"use client" import { useEffect, useRef, useState } from "react" import { formatDistanceToNow } from "date-fns" import { Send } from "lucide-react" type WidgetMessage = { id: string content: string createdAt: string senderType: "CUSTOMER" | "AI" } 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("") const [visitorId, setVisitorId] = useState("") const [messages, setMessages] = useState([]) const [composer, setComposer] = useState("") const [sending, setSending] = useState(false) const [sendError, setSendError] = useState(null) const [loadingHistory, setLoadingHistory] = useState(false) const [verifyEmail, setVerifyEmail] = useState("") const [otpCode, setOtpCode] = useState("") const [otpSending, setOtpSending] = useState(false) const [otpBusy, setOtpBusy] = useState(false) const [otpMessage, setOtpMessage] = useState(null) const [sessionUntil, setSessionUntil] = useState(null) /** collect_email → enter_code (after send) → verified */ const [otpPhase, setOtpPhase] = useState<"collect_email" | "enter_code" | "verified">( "collect_email", ) const composerRef = useRef(null) const messagesEndRef = useRef(null) const messagesScrollRef = useRef(null) const otpInputRef = useRef(null) useEffect(() => { // Embedded in a storefront iframe: one scroll surface only (the message list). // If html/body can scroll, the wheel often moves the page scrollbar instead of chat. const html = document.documentElement const body = document.body const prevHtmlBg = html.style.backgroundColor const prevBodyBg = body.style.backgroundColor const prevBodyMargin = body.style.margin const prevBodyMinHeight = body.style.minHeight const prevHtmlH = html.style.height const prevHtmlOverflow = html.style.overflow const prevBodyH = body.style.height const prevBodyOverflow = body.style.overflow html.style.backgroundColor = "#ffffff" body.style.backgroundColor = "#ffffff" body.style.margin = "0" html.style.height = "100%" html.style.overflow = "hidden" body.style.height = "100%" body.style.minHeight = "100%" body.style.overflow = "hidden" return () => { html.style.backgroundColor = prevHtmlBg body.style.backgroundColor = prevBodyBg body.style.margin = prevBodyMargin body.style.minHeight = prevBodyMinHeight html.style.height = prevHtmlH html.style.overflow = prevHtmlOverflow body.style.height = prevBodyH body.style.overflow = prevBodyOverflow } }, []) 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 || !visitorId) return let cancelled = false const run = async () => { try { setLoadingHistory(true) const res = await fetch( `/api/storefront-chat/messages?shop=${encodeURIComponent(shop)}&visitorId=${encodeURIComponent(visitorId)}` ) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.message || "Failed to load history") } const nextMessages = Array.isArray(data?.messages) ? data.messages : [] if (!cancelled) { setMessages( nextMessages.map((m: any) => ({ id: String(m.id), content: String(m.content || ""), senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", createdAt: String(m.createdAt || new Date().toISOString()) })) ) } } catch (e: any) { if (!cancelled) { setSendError(e?.message || "Failed to load chat history") } } finally { if (!cancelled) { setLoadingHistory(false) } } } void run() return () => { cancelled = true } }, [shop, visitorId]) useEffect(() => { const el = messagesScrollRef.current if (el) { el.scrollTop = el.scrollHeight } else { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) } }, [messages]) useEffect(() => { if (otpPhase === "enter_code") { setTimeout(() => otpInputRef.current?.focus(), 0) } }, [otpPhase]) /** Parent storefront often receives wheel events before the iframe; capture + stop on the chat scroller. */ useEffect(() => { if (!shop) return const el = messagesScrollRef.current if (!el) return const stopWheelBubble = (e: WheelEvent) => { e.stopPropagation() } el.addEventListener("wheel", stopWheelBubble, { capture: true }) return () => el.removeEventListener("wheel", stopWheelBubble, { capture: true }) }, [shop, messages.length]) const sendOtp = async () => { if (!shop || !visitorId) return const email = verifyEmail.trim() if (!email) { setOtpMessage("Enter your email first.") return } try { setOtpMessage(null) setOtpSending(true) const res = await fetch("/api/storefront-chat/otp/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shop, visitorId, email }), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.message || "Could not send code") } setOtpCode("") setOtpPhase("enter_code") setOtpMessage(null) } catch (e: unknown) { setOtpMessage(e instanceof Error ? e.message : "Failed to send code") } finally { setOtpSending(false) } } const backToEmailStep = () => { setOtpPhase("collect_email") setOtpCode("") setOtpMessage(null) } const verifyOtp = async () => { if (!shop || !visitorId) return const email = verifyEmail.trim() const code = otpCode.trim() if (!email || !/^\d{6}$/.test(code)) { setOtpMessage("Enter email and 6-digit code.") return } try { setOtpMessage(null) setOtpBusy(true) const res = await fetch("/api/storefront-chat/otp/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shop, visitorId, email, code }), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.message || "Invalid code") } const expiresRaw = typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null setSessionUntil(expiresRaw) setOtpCode("") setOtpPhase("verified") const expiresAt = expiresRaw ? new Date(expiresRaw) : null const distanceOk = expiresAt && !Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() > Date.now() setOtpMessage( distanceOk ? `You’re verified. This session stays active ${formatDistanceToNow(expiresAt!, { addSuffix: true })}. You can ask about your orders until then.` : "You’re verified. You can ask about your orders until this session expires.", ) } catch (e: unknown) { setOtpMessage(e instanceof Error ? e.message : "Verification failed") } finally { setOtpBusy(false) } } 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") } const returned = Array.isArray(data?.messages) ? data.messages : [] if (returned.length) { setMessages(prev => [ ...prev, ...returned.map((m: any) => ({ id: String(m.id || `local_${Date.now()}`), content: String(m.content || ""), senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", createdAt: String(m.createdAt || new Date().toISOString()) })) ]) } else { setMessages(prev => [ ...prev, { id: `local_${Date.now()}`, content: text, senderType: "CUSTOMER", createdAt: new Date().toISOString() } ]) } setComposer("") setTimeout(() => composerRef.current?.focus(), 0) } catch (e: any) { setSendError(e?.message || "Failed to send message") } finally { setSending(false) } } // Require shop context; otherwise render nothing. if (!shop) { return null } return (
{sendError ? (
{sendError}
) : null} {loadingHistory ? (

Loading chat...

) : messages.length === 0 ? (

Start a conversation

Send a message about any product on this store.

) : (
{messages.map((m) => (
{m.content}
))}
)}
Order help — verify your email
{otpPhase === "verified" ? (

Email verified

{otpMessage}

{sessionUntil ? (

Session active until{" "} {new Date(sessionUntil).toLocaleString()}

) : null}
) : ( <> {otpPhase === "collect_email" ? ( <>

For order status and account-specific questions, enter the email you used at checkout. We'll send a one-time code.

setVerifyEmail(e.target.value)} placeholder="you@example.com" autoComplete="email" className="w-full rounded-lg border border-slate-200 px-2 py-1.5 text-sm" /> ) : ( <>

Code sent

We emailed a 6-digit code to{" "} {verifyEmail.trim()} . Enter it below (check spam).

setOtpCode( e.target.value.replace(/\D/g, "").slice(0, 6), ) } placeholder="000000" className="w-32 rounded-lg border border-slate-200 px-2 py-2 text-sm font-semibold tracking-[0.2em]" />
)} {otpMessage ? (

{otpMessage}

) : null} )}