feat(storefront-chat): add customer widget + vendor toggle

Made-with: Cursor
This commit is contained in:
talatops 2026-04-02 14:31:35 +05:00
parent d3122d44bf
commit 8af711a6c9
12 changed files with 803 additions and 0 deletions

View File

@ -0,0 +1,24 @@
<div style="position: fixed; bottom: 16px; right: 16px; z-index: 2147483647;">
<iframe
title="JUNO Storefront Chat"
style="border: 0; width: 420px; height: 640px; max-width: calc(100vw - 32px); max-height: calc(100vh - 32px); border-radius: 16px; overflow: hidden;"
allow="clipboard-write; fullscreen"
src="{{ settings.widget_base_url | append: '/storefront-chat/widget?shop=' | append: shop.permanent_domain }}"
></iframe>
</div>
{% schema %}
{
"name": "JUNO Storefront Chat",
"target": "body",
"settings": [
{
"type": "url",
"id": "widget_base_url",
"label": "JUNO app base URL",
"default": "https://precontributive-tribally-dione.ngrok-free.dev"
}
]
}
{% endschema %}

View File

@ -0,0 +1,7 @@
{
"name": "juno-storefront-chat-widget",
"version": "0.0.0",
"description": "JUNO Storefront Chat widget theme app extension",
"private": true
}

View File

@ -0,0 +1,7 @@
api_version = "2024-07"
[[extensions]]
name = "JUNO Storefront Chat"
handle = "juno-storefront-chat"
type = "theme"

View File

@ -0,0 +1,94 @@
-- Storefront chat widget enablement + minimal message persistence (v1, plain text)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'StorefrontChatSenderType') THEN
CREATE TYPE "StorefrontChatSenderType" AS ENUM ('CUSTOMER', 'AI');
END IF;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
ALTER TABLE "Store"
ADD COLUMN IF NOT EXISTS "storefrontChatEnabled" BOOLEAN NOT NULL DEFAULT false;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'StorefrontChatConversation'
) THEN
CREATE TABLE "StorefrontChatConversation" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"visitorId" TEXT NOT NULL,
"customerEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StorefrontChatConversation_pkey" PRIMARY KEY ("id")
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'StorefrontChatMessage'
) THEN
CREATE TABLE "StorefrontChatMessage" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"senderType" "StorefrontChatSenderType" NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StorefrontChatMessage_pkey" PRIMARY KEY ("id")
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'StorefrontChatConversation_storeId_fkey') THEN
ALTER TABLE "StorefrontChatConversation"
ADD CONSTRAINT "StorefrontChatConversation_storeId_fkey"
FOREIGN KEY ("storeId") REFERENCES "Store"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'StorefrontChatMessage_conversationId_fkey') THEN
ALTER TABLE "StorefrontChatMessage"
ADD CONSTRAINT "StorefrontChatMessage_conversationId_fkey"
FOREIGN KEY ("conversationId") REFERENCES "StorefrontChatConversation"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'StorefrontChatConversation_storeId_visitorId_key') THEN
CREATE UNIQUE INDEX "StorefrontChatConversation_storeId_visitorId_key"
ON "StorefrontChatConversation"("storeId", "visitorId");
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'StorefrontChatConversation_storeId_idx') THEN
CREATE INDEX "StorefrontChatConversation_storeId_idx"
ON "StorefrontChatConversation"("storeId");
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'StorefrontChatMessage_conversationId_createdAt_idx') THEN
CREATE INDEX "StorefrontChatMessage_conversationId_createdAt_idx"
ON "StorefrontChatMessage"("conversationId", "createdAt");
END IF;
END $$;

View File

@ -106,12 +106,16 @@ model Store {
onboardingComplete Boolean @default(false) onboardingComplete Boolean @default(false)
isActive Boolean @default(true) // Track if this is the active store isActive Boolean @default(true) // Track if this is the active store
// Customer-facing storefront widget (floating chat) enablement.
storefrontChatEnabled Boolean @default(false)
// Relations // Relations
connections Connection[] connections Connection[]
cachedProducts CachedProduct[] cachedProducts CachedProduct[]
cachedOrders CachedOrder[] cachedOrders CachedOrder[]
cachedCustomers CachedCustomer[] cachedCustomers CachedCustomer[]
storefrontChatConversations StorefrontChatConversation[]
syncMetadata SyncMetadata[] syncMetadata SyncMetadata[]
invitations Invitation[] invitations Invitation[]
productSyncs ProductSync[] productSyncs ProductSync[]
@ -582,6 +586,40 @@ model ChatMessage {
@@index([senderId, receiverId]) @@index([senderId, receiverId])
@@index([receiverId, senderId]) @@index([receiverId, senderId])
} }
enum StorefrontChatSenderType {
CUSTOMER
AI
}
model StorefrontChatConversation {
id String @id @default(cuid())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
visitorId String
customerEmail String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages StorefrontChatMessage[]
@@unique([storeId, visitorId])
@@index([storeId])
}
model StorefrontChatMessage {
id String @id @default(cuid())
conversationId String
conversation StorefrontChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
senderType StorefrontChatSenderType
content String @db.Text
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
}
model PushSubscription { model PushSubscription {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String

View File

@ -0,0 +1,35 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
/**
* GET /api/storefront-chat/config?shop=<shop-domain>
* Public endpoint used by the storefront widget bootstrap.
*/
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url)
const shop = searchParams.get("shop")
if (!shop) {
return NextResponse.json({ message: "Missing shop" }, { status: 400 })
}
const store = await prisma.store.findFirst({
where: { shopifyDomain: shop },
select: { id: true, storefrontChatEnabled: true }
})
if (!store) {
// If we don't know the store, don't show the widget.
return NextResponse.json({ enabled: false }, { status: 200 })
}
return NextResponse.json({
enabled: store.storefrontChatEnabled
})
} catch (error) {
console.error("[STORE_FRONT_CHAT_CONFIG]", error)
return NextResponse.json({ message: "Internal Error" }, { status: 500 })
}
}

View File

@ -0,0 +1,104 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
type StorefrontMessagePayload = {
shop: string
visitorId: string
content: string
customerEmail?: string | null
}
export async function POST(req: Request) {
try {
const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload>
const { shop, visitorId, content } = payload
if (typeof shop !== "string" || !shop.trim()) {
return NextResponse.json({ message: "Missing shop" }, { status: 400 })
}
if (typeof visitorId !== "string" || !visitorId.trim()) {
return NextResponse.json({ message: "Missing visitorId" }, { status: 400 })
}
if (typeof content !== "string") {
return NextResponse.json({ message: "Missing content" }, { status: 400 })
}
const trimmedContent = content.trim()
const trimmedVisitorId = visitorId.trim()
const trimmedShop = shop.trim()
// Guardrails (v1): keep it small, avoid empty spam.
if (trimmedContent.length < 1) {
return NextResponse.json({ message: "Empty message" }, { status: 400 })
}
if (trimmedContent.length > 2000) {
return NextResponse.json({ message: "Message too long" }, { status: 400 })
}
if (trimmedVisitorId.length > 300) {
return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 })
}
const store = await prisma.store.findFirst({
where: { shopifyDomain: trimmedShop },
select: { id: true, storefrontChatEnabled: true }
})
if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 })
}
if (!store.storefrontChatEnabled) {
return NextResponse.json({ message: "Widget disabled" }, { status: 403 })
}
// Find or create conversation for this anonymous visitor.
let conversation = await prisma.storefrontChatConversation.findFirst({
where: { storeId: store.id, visitorId: trimmedVisitorId },
select: { id: true }
})
if (conversation) {
// Very lightweight rate limit: max 5 messages per 30 seconds per conversation.
const windowMs = 30_000
const recentCount = await prisma.storefrontChatMessage.count({
where: {
conversationId: conversation.id,
createdAt: { gte: new Date(Date.now() - windowMs) }
}
})
if (recentCount >= 5) {
return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 })
}
} else {
conversation = await prisma.storefrontChatConversation.create({
data: {
storeId: store.id,
visitorId: trimmedVisitorId,
customerEmail: payload.customerEmail ?? null
},
select: { id: true }
})
}
const message = await prisma.storefrontChatMessage.create({
data: {
conversationId: conversation.id,
senderType: "CUSTOMER",
content: trimmedContent
},
select: { id: true, createdAt: true }
})
return NextResponse.json({
success: true,
message: {
id: message.id,
createdAt: message.createdAt
}
})
} catch (error) {
console.error("[STORE_FRONT_CHAT_MESSAGES_POST]", error)
return NextResponse.json({ message: "Internal Error" }, { status: 500 })
}
}

View File

@ -98,3 +98,59 @@ export async function DELETE(
) )
} }
} }
/**
* PATCH /api/stores/[storeId]
* Update per-store configuration (vendor only).
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ storeId: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { storeId } = await params
if (!storeId) {
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
}
const { storefrontChatEnabled } = await request.json().catch(() => ({}))
if (typeof storefrontChatEnabled !== "boolean") {
return NextResponse.json({ message: "Invalid storefrontChatEnabled" }, { status: 400 })
}
// Verify store belongs to the user
const store = await prisma.store.findFirst({
where: {
id: storeId,
userId: session.user.id
}
})
if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 })
}
await prisma.store.update({
where: { id: storeId },
data: {
storefrontChatEnabled
}
})
return NextResponse.json({
success: true,
storefrontChatEnabled
})
} catch (error) {
console.error("Error updating store settings:", error)
return NextResponse.json(
{ message: "Failed to update store setting" },
{ status: 500 }
)
}
}

View File

@ -25,6 +25,7 @@ export async function GET() {
shopifyStoreName: true, shopifyStoreName: true,
onboardingComplete: true, onboardingComplete: true,
isActive: true, isActive: true,
storefrontChatEnabled: true,
createdAt: true createdAt: true
}, },
orderBy: { orderBy: {

View File

@ -0,0 +1,111 @@
"use server"
import { prisma } from "@/lib/prisma"
import { auth } from "@/auth.vendor"
import { redirect } from "next/navigation"
export default async function StorefrontChatInboxPage() {
const session = await auth()
if (!session?.user?.id) {
redirect("/login")
}
const p = prisma as any
const stores = await prisma.store.findMany({
where: { userId: session.user.id, onboardingComplete: true },
select: { id: true, businessName: true, shopifyDomain: true, isActive: true }
})
const store = stores.find((s) => s.isActive) ?? stores[0]
if (!store) {
redirect("/onboarding")
}
const conversations = await p.storefrontChatConversation.findMany({
where: { storeId: store.id },
orderBy: { updatedAt: "desc" },
take: 25,
select: { id: true, visitorId: true, customerEmail: true, updatedAt: true }
})
const conversationIds = conversations.map((c: { id: string }) => c.id)
const messages = await p.storefrontChatMessage.findMany({
where: conversationIds.length ? { conversationId: { in: conversationIds } } : undefined,
orderBy: { createdAt: "desc" },
take: 200,
select: {
id: true,
content: true,
createdAt: true,
senderType: true,
conversation: {
select: {
visitorId: true,
customerEmail: true
}
}
}
})
const sorted = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
return (
<div className="flex flex-col gap-4 p-1">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-xl font-black text-white">Storefront Chat Inbox</h1>
<p className="text-sm text-slate-400 mt-1 truncate">
{store.businessName} · {store.shopifyDomain}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-black text-white">{conversations.length} visitors</p>
<p className="text-xs text-slate-500 mt-1">{sorted.length} messages</p>
</div>
</div>
{sorted.length === 0 ? (
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-slate-300">
<p className="font-black">No storefront chat messages yet.</p>
<p className="text-sm text-slate-400 mt-2">When customers send a message from the widget, it will show here.</p>
</div>
) : (
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<p className="text-sm font-black text-white">Recent activity</p>
<p className="text-xs text-slate-400">Read-only</p>
</div>
<div className="max-h-[70vh] overflow-y-auto p-4 space-y-3">
{sorted.map((m) => (
<div key={m.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate">
{m.senderType === "CUSTOMER" ? "CUSTOMER" : m.senderType}
{" · "}
{m.conversation?.visitorId || "visitor"}
</p>
{m.conversation?.customerEmail ? (
<p className="text-[11px] text-slate-500 truncate">{m.conversation.customerEmail}</p>
) : null}
</div>
<p className="text-[11px] text-slate-500 shrink-0">
{new Date(m.createdAt).toLocaleString()}
</p>
</div>
<div className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 border border-white/10 rounded-2xl px-4 py-3">
{m.content}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -11,6 +11,7 @@ interface StoreData {
shopifyDomain: string shopifyDomain: string
isActive: boolean isActive: boolean
onboardingComplete: boolean onboardingComplete: boolean
storefrontChatEnabled: boolean
createdAt: string createdAt: string
} }
@ -19,6 +20,7 @@ export default function StoresPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState("") const [error, setError] = useState("")
const [switching, setSwitching] = useState<string | null>(null) const [switching, setSwitching] = useState<string | null>(null)
const [togglingStorefrontChat, setTogglingStorefrontChat] = useState<string | null>(null)
const [isAddModalOpen, setIsAddModalOpen] = useState(false) const [isAddModalOpen, setIsAddModalOpen] = useState(false)
/* /*
* Alignment Fix Details: * Alignment Fix Details:
@ -50,6 +52,30 @@ export default function StoresPage() {
} }
} }
const handleToggleStorefrontChat = async (storeId: string, enabled: boolean) => {
setTogglingStorefrontChat(storeId)
try {
const res = await fetch(`/api/stores/${storeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ storefrontChatEnabled: enabled })
})
if (!res.ok) {
const data = await res.json().catch(() => ({ message: "Failed to update store setting" }))
throw new Error(data.message || "Failed to update store setting")
}
await fetchStores()
window.dispatchEvent(new CustomEvent("storeUpdated"))
} catch (err) {
console.error("Toggle storefront chat error:", err)
alert("Could not update storefront chat setting. Please try again.")
} finally {
setTogglingStorefrontChat(null)
}
}
const handleSwitchStore = async (storeId: string) => { const handleSwitchStore = async (storeId: string) => {
setSwitching(storeId) setSwitching(storeId)
try { try {
@ -198,6 +224,27 @@ export default function StoresPage() {
</p> </p>
</div> </div>
<div className="flex items-center justify-between gap-3 mb-3">
<div className="min-w-0">
<p className="text-xs md:text-sm text-white font-bold truncate">Storefront Chat (Customers)</p>
<p className="text-[10px] md:text-[11px] text-slate-400">
Show chat widget on the storefront
</p>
</div>
<div className="shrink-0 flex items-center gap-3">
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={store.storefrontChatEnabled}
disabled={togglingStorefrontChat === store.id}
onChange={(e) => handleToggleStorefrontChat(store.id, e.target.checked)}
className="h-4 w-4 rounded border-white/20 accent-indigo-600"
aria-label="Enable storefront chat"
/>
</label>
</div>
</div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{!store.isActive ? ( {!store.isActive ? (
<button <button

View File

@ -0,0 +1,279 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { MessageSquare, Send, X } from "lucide-react"
type StorefrontConfig = { enabled: boolean }
type WidgetMessage = {
id: string
content: string
createdAt: string
}
function getOrCreateVisitorId(shop: string) {
const key = `juno_storefront_visitor_id_${shop}`
try {
const existing = localStorage.getItem(key)
if (existing) return existing
const id = crypto.randomUUID()
localStorage.setItem(key, id)
return id
} catch {
// Fallback if storage/crypto fails.
return `visitor_${Math.random().toString(16).slice(2)}`
}
}
export default function StorefrontChatWidgetPage() {
const [shop, setShop] = useState<string>("")
const [config, setConfig] = useState<StorefrontConfig>({ enabled: false })
const [configLoading, setConfigLoading] = useState(true)
const [configError, setConfigError] = useState<string | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [visitorId, setVisitorId] = useState<string>("")
const [messages, setMessages] = useState<WidgetMessage[]>([])
const [composer, setComposer] = useState("")
const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const apiShopEncoded = useMemo(() => encodeURIComponent(shop), [shop])
useEffect(() => {
// Read query string on the client to avoid Next.js prerender warnings.
try {
const params = new URLSearchParams(window.location.search)
const nextShop = params.get("shop")?.trim() || ""
setShop(nextShop)
} catch {
setShop("")
}
}, [])
useEffect(() => {
if (!shop) return
setVisitorId(getOrCreateVisitorId(shop))
}, [shop])
useEffect(() => {
if (!shop) {
setConfigLoading(false)
setConfigError("Missing shop parameter")
return
}
let cancelled = false
const run = async () => {
try {
setConfigLoading(true)
setConfigError(null)
const res = await fetch(`/api/storefront-chat/config?shop=${apiShopEncoded}`)
const data = (await res.json().catch(() => ({}))) as Partial<StorefrontConfig> & { message?: string }
if (!res.ok) {
throw new Error(data.message || "Failed to fetch config")
}
if (!cancelled) {
setConfig({ enabled: !!data.enabled })
}
} catch (e: any) {
if (!cancelled) {
setConfigError(e?.message || "Failed to load storefront chat config")
setConfig({ enabled: false })
}
} finally {
if (!cancelled) setConfigLoading(false)
}
}
run()
return () => {
cancelled = true
}
}, [shop, apiShopEncoded])
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [isOpen])
useEffect(() => {
if (!isOpen) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = prevOverflow
}
}, [isOpen])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, isOpen])
const send = async () => {
if (!shop || !visitorId) return
const text = composer.trim()
if (!text) return
try {
setSendError(null)
setSending(true)
const res = await fetch("/api/storefront-chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shop, visitorId, content: text })
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data?.message || "Failed to send message")
}
setMessages(prev => [
...prev,
{
id: data?.message?.id || `local_${Date.now()}`,
content: text,
createdAt: new Date().toISOString()
}
])
setComposer("")
setTimeout(() => composerRef.current?.focus(), 0)
} catch (e: any) {
setSendError(e?.message || "Failed to send message")
} finally {
setSending(false)
}
}
// Widget is only visible if enabled for this store.
if (configLoading || !config.enabled) {
return null
}
return (
<div className="w-full h-full relative">
{!isOpen ? (
<button
type="button"
onClick={() => setIsOpen(true)}
className="absolute bottom-5 right-5 z-[9999] w-14 h-14 rounded-2xl bg-indigo-600 shadow-xl hover:bg-indigo-700 transition-all flex items-center justify-center text-white"
aria-label="Open chat"
>
<MessageSquare className="w-6 h-6" />
</button>
) : (
<div className="fixed inset-0 z-[9999]">
{/* Backdrop + centering */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<div
className="absolute bottom-6 right-6 w-[360px] max-w-[calc(100vw-24px)] h-[520px] max-h-[calc(100vh-24px)] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col"
role="dialog"
aria-label="Chat widget"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center text-indigo-700">
<MessageSquare className="w-5 h-5" />
</div>
<div className="min-w-0">
<p className="font-black text-sm truncate">JUNO Chat</p>
<p className="text-[11px] text-slate-500">Ask about products</p>
</div>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-9 h-9 rounded-xl hover:bg-slate-100 border border-slate-200 flex items-center justify-center"
aria-label="Close chat"
>
<X className="w-4 h-4 text-slate-600" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-white to-slate-50">
{sendError ? (
<div className="mb-3 px-3 py-2 rounded-xl bg-red-500/10 border border-red-500/20 text-red-600 text-xs font-medium">
{sendError}
</div>
) : null}
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-center p-4">
<div>
<p className="font-black text-slate-900">Start a conversation</p>
<p className="text-xs text-slate-500 mt-1">
Send a message about any product on this store.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{messages.map((m) => (
<div key={m.id} className="flex justify-end">
<div className="max-w-[85%] px-3 py-2 rounded-2xl bg-indigo-600 text-white text-sm font-medium">
{m.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
<div className="p-4 border-t bg-white flex-shrink-0">
<div className="flex items-end gap-3">
<textarea
ref={composerRef}
rows={1}
value={composer}
onChange={(e) => setComposer(e.target.value)}
placeholder="Type a message..."
className="flex-1 resize-none border rounded-2xl px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
void send()
}
}}
/>
<button
type="button"
onClick={() => void send()}
disabled={sending || !composer.trim()}
className="w-11 h-11 rounded-2xl bg-indigo-600 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center transition-all"
aria-label="Send message"
>
<Send className="w-4 h-4" />
</button>
</div>
{configError ? (
<p className="mt-2 text-[11px] text-slate-500">{configError}</p>
) : (
<p className="mt-2 text-[11px] text-slate-500">
Customer messages are saved now. AI replies later.
</p>
)}
</div>
</div>
</div>
)}
</div>
)
}