AI Assistant
Online · trip planner
How can I help today?
Ask anything about destinations, itineraries, stays, and local recommendations.
FlightsStaysFoodItineraryBudgetWeather
Enter to send · Shift+Enter for new line
AI Chat Source
The full component behind this demo, background gradient included.
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Send,
Square,
X,
Sparkles,
SquarePen,
Copy,
Check,
ArrowDown,
Plane,
Hotel,
Utensils,
Compass,
Wallet,
Sun,
Paperclip,
Palette,
} from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import { Button } from '@/components/ui/button'
import GradFlow from '@/components/grad-flow'
import type { GradientType } from '@/types/gradient'
const GRADIENT_TYPES: GradientType[] = [
'linear',
'animated',
'conic',
'wave',
'silk',
'smoke',
'stripe',
'mesh',
'aurora',
]
type ChatMessage = {
id: string
role: 'user' | 'assistant'
content: string
image?: string
followups?: string[]
}
type Suggestion = { icon: React.ElementType; label: string; text: string }
const SUGGESTIONS: Suggestion[] = [
{
icon: Compass,
label: 'Itinerary',
text: 'Plan a 5-day itinerary for Lisbon',
},
{
icon: Hotel,
label: 'Stays',
text: 'Find boutique hotels near the coast under $200/night',
},
{
icon: Utensils,
label: 'Food',
text: 'Recommend local restaurants for authentic food',
},
]
const FAKE_REPLY = `Here's a quick plan to get you started:
- **Day 1-2**: Explore the old town, Alfama, and São Jorge Castle
- **Day 3**: Day trip to Sintra for the palaces and coastline
- **Day 4-5**: Belém, local food markets, and a sunset river cruise
Estimated budget: around **$650** per person for flights, stays, and food.
Want me to adjust this for a longer trip or a tighter budget?`
const FAKE_FOLLOWUPS = [
'Make it a 7-day trip',
'Suggest a lower budget option',
'Add more outdoor activities',
]
let idCounter = 0
const nextId = () => `msg-${++idCounter}-${Date.now()}`
function CopyButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current)
},
[],
)
const copy = useCallback(() => {
navigator.clipboard?.writeText(content).then(() => {
setCopied(true)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1500)
})
}, [content])
return (
<button
onClick={copy}
className='flex h-7 w-7 items-center justify-center rounded-md text-black/50 opacity-0 transition-all hover:bg-black/10 hover:text-black focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover:opacity-100'
aria-label={copied ? 'Copied' : 'Copy message'}
>
{copied ? (
<Check className='h-3.5 w-3.5 text-emerald-400' />
) : (
<Copy className='h-3.5 w-3.5' />
)}
</button>
)
}
function UserMessage({ content, image }: { content: string; image?: string }) {
return (
<div className='flex justify-end'>
<div className='max-w-[85%] space-y-2'>
{image && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt='Attached'
className='ml-auto block max-h-48 rounded-xl rounded-br-md object-cover shadow-sm'
/>
)}
{content && (
<div className='whitespace-pre-wrap rounded-2xl rounded-br-md border border-black/10 bg-black/85 px-4 py-2.5 text-sm leading-relaxed text-white shadow-sm backdrop-blur-xl'>
{content}
</div>
)}
</div>
</div>
)
}
function FollowupChips({
questions,
onSelect,
}: {
questions: string[]
onSelect: (q: string) => void
}) {
const [chosen, setChosen] = useState<string | null>(null)
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className='mt-3 flex flex-wrap gap-2 px-1'
>
{questions.map((q) => {
const isChosen = chosen === q
const locked = chosen !== null
return (
<button
key={q}
disabled={locked}
onClick={() => {
setChosen(q)
onSelect(q)
}}
className={`rounded-full border px-3 py-1.5 text-xs transition-all duration-200 ${
isChosen
? 'border-black/30 bg-black/10 text-black'
: locked
? 'cursor-default border-black/10 bg-black/5 text-black/30'
: 'border-black/10 bg-white/40 text-black/60 hover:border-black/20 hover:bg-white/60 hover:text-black'
}`}
>
{q}
</button>
)
})}
</motion.div>
)
}
function AssistantMessage({
message,
isLast,
isLoading,
onFollowup,
}: {
message: ChatMessage
isLast: boolean
isLoading: boolean
onFollowup: (text: string) => void
}) {
const showFollowups = isLast && !isLoading && !!message.followups?.length
return (
<div className='group flex flex-col'>
<div className='whitespace-pre-wrap py-1 text-sm leading-relaxed text-black'>
{message.content}
</div>
{message.content && (
<div className='mt-0.5 flex h-7 items-center'>
<CopyButton content={message.content} />
</div>
)}
{showFollowups && (
<FollowupChips questions={message.followups!} onSelect={onFollowup} />
)}
</div>
)
}
function TypingIndicator() {
return (
<div
className='flex items-center gap-1.5 py-2'
aria-label='Assistant is typing'
>
<span className='h-1.5 w-1.5 animate-bounce rounded-full bg-black/40 [animation-delay:0ms]' />
<span className='h-1.5 w-1.5 animate-bounce rounded-full bg-black/40 [animation-delay:150ms]' />
<span className='h-1.5 w-1.5 animate-bounce rounded-full bg-black/40 [animation-delay:300ms]' />
</div>
)
}
export function AiChat({
onCloseAction,
}: {
onCloseAction?: () => void
}) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [atBottom, setAtBottom] = useState(true)
const [gradientType, setGradientType] = useState<GradientType>('smoke')
const [image, setImage] = useState<{
dataUrl: string
mimeType: string
} | null>(null)
const cycleGradientType = useCallback(() => {
setGradientType((prev) => {
const idx = GRADIENT_TYPES.indexOf(prev)
return GRADIENT_TYPES[(idx + 1) % GRADIENT_TYPES.length]
})
}, [])
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<{ aborted: boolean } | null>(null)
const atBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
setAtBottom(distance < 80)
}, [])
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const el = scrollRef.current
if (el) el.scrollTo({ top: el.scrollHeight, behavior })
}, [])
useEffect(() => {
if (atBottom) scrollToBottom('smooth')
}, [messages, atBottom, scrollToBottom])
useEffect(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 160)}px`
}, [input])
useEffect(() => {
atBottomRef.current = atBottom
}, [atBottom])
const stop = useCallback(() => {
if (abortRef.current) abortRef.current.aborted = true
setIsLoading(false)
}, [])
const addMessage = useCallback(
(role: ChatMessage['role'], content: string, image?: string) => {
setMessages((prev) => [...prev, { id: nextId(), role, content, image }])
},
[],
)
// simulated streaming reply — UI-only demo, no backend
const send = useCallback(
(
text: string,
attachedImage?: { dataUrl: string; mimeType: string } | null,
) => {
const trimmed = text.trim()
const img = attachedImage ?? image
if ((!trimmed && !img) || isLoading) return
const token = { aborted: false }
abortRef.current = token
addMessage('user', trimmed, img?.dataUrl)
setInput('')
setImage(null)
setIsLoading(true)
setAtBottom(true)
const assistantId = nextId()
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant', content: '' },
])
let shown = 0
const step = () => {
if (token.aborted) return
shown = Math.min(FAKE_REPLY.length, shown + 3)
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: FAKE_REPLY.slice(0, shown) }
: m,
),
)
if (atBottomRef.current) scrollToBottom('auto')
if (shown < FAKE_REPLY.length) {
setTimeout(step, 12)
} else {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, followups: FAKE_FOLLOWUPS } : m,
),
)
setIsLoading(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}
setTimeout(step, 400)
},
[addMessage, image, isLoading, scrollToBottom],
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send(input)
}
}
const hasMessages = messages.length > 0
const lastMsgIdx = messages.length - 1
const waitingForFirstToken =
isLoading && messages[messages.length - 1]?.content === ''
return (
<div className='relative flex h-full w-full flex-col overflow-hidden '>
{/* full-bleed animated GradFlow background, blended into black at the edges */}
<div className='pointer-events-none absolute inset-0 z-0 mask-b-to-80%'>
<GradFlow
config={{
color1: { r: 14, g: 82, b: 255 },
color2: { r: 130, g: 220, b: 255 },
color3: { r: 255, g: 255, b: 255 },
speed: 0.4,
scale: 2,
type: gradientType,
noise: 0.16,
}}
/>
<div className='absolute inset-0' />
<div className='absolute inset-0 bg-white rounded-full blur-[100px] h-200 w-200 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2' />
</div>
<header className='relative z-10 flex shrink-0 items-center justify-between px-6 py-4'>
<div className='flex items-center gap-2.5 rounded-full border border-black/10 bg-white/40 py-1.5 pl-1.5 pr-4 backdrop-blur-xl'>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-black text-white shadow-sm'>
<Sparkles className='h-4 w-4' />
</div>
<div className='leading-tight'>
<p className='text-sm font-semibold text-black'>AI Assistant</p>
<div className='flex items-center gap-1.5'>
<span className='relative flex h-1.5 w-1.5'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60' />
<span className='relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400' />
</span>
<p className='text-[11px] text-black/60'>Online · trip planner</p>
</div>
</div>
</div>
<div className='flex items-center gap-1 rounded-full border border-black/10 bg-white/40 p-1 backdrop-blur-xl'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 rounded-full text-black/70 hover:bg-black/10 hover:text-black'
onClick={cycleGradientType}
aria-label='Change background gradient style'
title={`Gradient: ${gradientType}`}
>
<Palette className='h-4 w-4' />
</Button>
{hasMessages && (
<Button
variant='ghost'
size='icon'
className='h-8 w-8 rounded-full text-black/70 hover:bg-black/10 hover:text-black'
onClick={() => {
stop()
setMessages([])
}}
aria-label='New chat'
title='New chat'
>
<SquarePen className='h-4 w-4' />
</Button>
)}
{onCloseAction && (
<Button
variant='ghost'
size='icon'
className='h-8 w-8 rounded-full text-black/70 hover:bg-black/10 hover:text-black'
onClick={onCloseAction}
aria-label='Close chat'
>
<X className='h-4 w-4' />
</Button>
)}
</div>
</header>
<div
ref={scrollRef}
onScroll={handleScroll}
className='relative z-10 flex-1 overflow-y-auto scroll-smooth px-4 py-5'
>
<AnimatePresence mode='wait'>
{!hasMessages ? (
<motion.div
key='empty'
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12, scale: 0.98 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className='relative flex h-full flex-col items-center justify-center gap-8 pb-4'
>
<div className='relative z-10 flex flex-col items-center gap-4 text-center'>
<div className='relative flex h-16 w-16 items-center justify-center rounded-2xl border border-black/10 bg-white/50 text-black shadow-lg backdrop-blur-xl'>
<Sparkles className='h-8 w-8' />
<span className='absolute -right-1 -top-1 flex h-3.5 w-3.5'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75' />
<span className='relative inline-flex h-3.5 w-3.5 rounded-full bg-emerald-500' />
</span>
</div>
<div className='space-y-1.5'>
<p className='text-xl font-semibold tracking-tight text-black'>
How can I help today?
</p>
<p className='max-w-sm text-balance text-sm text-black/60'>
Ask anything about destinations, itineraries, stays, and
local recommendations.
</p>
</div>
</div>
<div className='relative z-10 flex flex-wrap justify-center gap-2'>
{[
{ icon: Plane, label: 'Flights' },
{ icon: Hotel, label: 'Stays' },
{ icon: Utensils, label: 'Food' },
{ icon: Compass, label: 'Itinerary' },
{ icon: Wallet, label: 'Budget' },
{ icon: Sun, label: 'Weather' },
].map(({ icon: Icon, label }) => (
<span
key={label}
className='flex items-center gap-1.5 rounded-full border border-black/10 bg-white/40 px-3 py-1.5 text-xs font-medium text-black/70 backdrop-blur-xl'
>
<Icon className='h-3.5 w-3.5' />
{label}
</span>
))}
</div>
<div className='relative z-10 w-full max-w-sm space-y-2'>
{SUGGESTIONS.map(({ icon: Icon, label, text }) => (
<button
key={text}
onClick={() => send(text)}
className='group flex w-full items-center gap-3 rounded-xl border border-black/10 bg-white/40 px-4 py-3 text-left backdrop-blur-xl transition-all hover:border-black/20 hover:bg-white/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20'
>
<span className='flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/10 text-black'>
<Icon className='h-3.5 w-3.5' />
</span>
<span className='min-w-0 flex-1 text-sm text-black/80 group-hover:text-black'>
{text}
</span>
<span className='sr-only'>{label}</span>
</button>
))}
</div>
</motion.div>
) : (
<motion.div
key='messages'
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className='mx-auto flex max-w-3xl flex-col gap-6'
role='log'
aria-live='polite'
aria-relevant='additions text'
>
{messages.map((m, idx) =>
m.role === 'user' ? (
<UserMessage key={m.id} content={m.content} image={m.image} />
) : (
<AssistantMessage
key={m.id}
message={m}
isLast={idx === lastMsgIdx}
isLoading={isLoading}
onFollowup={send}
/>
),
)}
{waitingForFirstToken && <TypingIndicator />}
</motion.div>
)}
</AnimatePresence>
</div>
<div className='relative z-10 shrink-0 px-4 pb-4 pt-2'>
<div className='relative mx-auto max-w-3xl'>
<AnimatePresence>
{hasMessages && !atBottom && (
<motion.button
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.15 }}
onClick={() => {
setAtBottom(true)
scrollToBottom('smooth')
}}
className='absolute -top-12 left-1/2 z-20 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-black/10 bg-white/70 px-3 py-1.5 text-xs font-medium text-black/70 shadow-md backdrop-blur-xl transition-colors hover:text-black'
aria-label='Scroll to latest message'
>
<ArrowDown className='h-3.5 w-3.5' />
Latest
</motion.button>
)}
</AnimatePresence>
<div className='rounded-2xl border border-black/10 bg-white/60 shadow-lg backdrop-blur-xl transition-colors focus-within:border-black/20 focus-within:ring-2 focus-within:ring-black/10'>
{image && (
<div className='relative w-fit px-3 pt-3'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.dataUrl}
alt='Attached'
className='h-20 rounded-lg object-cover'
/>
<button
onClick={() => setImage(null)}
className='absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-black text-white shadow'
aria-label='Remove image'
>
<X className='h-3 w-3' />
</button>
</div>
)}
<div className='flex items-end gap-2 p-2 pl-4'>
<textarea
ref={inputRef}
rows={1}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
const item = Array.from(e.clipboardData.items).find((i) =>
i.type.startsWith('image/'),
)
if (!item) return
e.preventDefault()
const file = item.getAsFile()
if (!file) return
const reader = new FileReader()
reader.onload = () =>
setImage({
dataUrl: reader.result as string,
mimeType: file.type,
})
reader.readAsDataURL(file)
}}
placeholder='Ask anything…'
aria-label='Message'
className='max-h-40 flex-1 resize-none self-center bg-transparent py-1.5 text-sm leading-5 text-black outline-none placeholder:text-black/40'
/>
<input
ref={fileInputRef}
type='file'
accept='image/*'
className='hidden'
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
setImage({
dataUrl: reader.result as string,
mimeType: file.type,
})
}
reader.readAsDataURL(file)
e.target.value = ''
}}
/>
{!isLoading && (
<button
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-black/60 transition-colors hover:bg-black/10 hover:text-black'
onClick={() => fileInputRef.current?.click()}
aria-label='Attach image'
>
<Paperclip className='h-4 w-4' />
</button>
)}
{isLoading ? (
<button
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-black text-white'
onClick={stop}
aria-label='Stop generating'
>
<Square className='h-3.5 w-3.5 fill-current' />
</button>
) : (
<button
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-black text-white transition-opacity disabled:opacity-40'
disabled={!input.trim() && !image}
onClick={() => send(input)}
aria-label='Send message'
>
<Send className='h-4 w-4' />
</button>
)}
</div>
</div>
<p className='mt-2 text-center text-[10px] text-black/50'>
<kbd className='rounded border border-black/10 bg-black/5 px-1 py-0.5 font-sans text-black/60'>
Enter
</kbd>{' '}
to send ·{' '}
<kbd className='rounded border border-black/10 bg-black/5 px-1 py-0.5 font-sans text-black/60'>
Shift
</kbd>
+
<kbd className='rounded border border-black/10 bg-black/5 px-1 py-0.5 font-sans text-black/60'>
Enter
</kbd>{' '}
for new line
</p>
</div>
</div>
</div>
)
}