feat(storefront-chat): add customer widget + vendor toggle
Made-with: Cursor
This commit is contained in:
parent
d3122d44bf
commit
8af711a6c9
@ -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 %}
|
||||
|
||||
7
extensions/juno-storefront-chat-widget/package.json
Normal file
7
extensions/juno-storefront-chat-widget/package.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "juno-storefront-chat-widget",
|
||||
"version": "0.0.0",
|
||||
"description": "JUNO Storefront Chat widget theme app extension",
|
||||
"private": true
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
api_version = "2024-07"
|
||||
|
||||
[[extensions]]
|
||||
name = "JUNO Storefront Chat"
|
||||
handle = "juno-storefront-chat"
|
||||
type = "theme"
|
||||
|
||||
@ -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 $$;
|
||||
|
||||
@ -106,12 +106,16 @@ model Store {
|
||||
|
||||
onboardingComplete Boolean @default(false)
|
||||
isActive Boolean @default(true) // Track if this is the active store
|
||||
|
||||
// Customer-facing storefront widget (floating chat) enablement.
|
||||
storefrontChatEnabled Boolean @default(false)
|
||||
|
||||
// Relations
|
||||
connections Connection[]
|
||||
cachedProducts CachedProduct[]
|
||||
cachedOrders CachedOrder[]
|
||||
cachedCustomers CachedCustomer[]
|
||||
storefrontChatConversations StorefrontChatConversation[]
|
||||
syncMetadata SyncMetadata[]
|
||||
invitations Invitation[]
|
||||
productSyncs ProductSync[]
|
||||
@ -582,6 +586,40 @@ model ChatMessage {
|
||||
@@index([senderId, receiverId])
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
35
src/app/api/storefront-chat/config/route.ts
Normal file
35
src/app/api/storefront-chat/config/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
104
src/app/api/storefront-chat/messages/route.ts
Normal file
104
src/app/api/storefront-chat/messages/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ export async function GET() {
|
||||
shopifyStoreName: true,
|
||||
onboardingComplete: true,
|
||||
isActive: true,
|
||||
storefrontChatEnabled: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
111
src/app/dashboard/storefront-chat/page.tsx
Normal file
111
src/app/dashboard/storefront-chat/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ interface StoreData {
|
||||
shopifyDomain: string
|
||||
isActive: boolean
|
||||
onboardingComplete: boolean
|
||||
storefrontChatEnabled: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@ -19,6 +20,7 @@ export default function StoresPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState("")
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
const [togglingStorefrontChat, setTogglingStorefrontChat] = useState<string | null>(null)
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
|
||||
/*
|
||||
* 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) => {
|
||||
setSwitching(storeId)
|
||||
try {
|
||||
@ -198,6 +224,27 @@ export default function StoresPage() {
|
||||
</p>
|
||||
</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">
|
||||
{!store.isActive ? (
|
||||
<button
|
||||
|
||||
279
src/app/storefront-chat/widget/page.tsx
Normal file
279
src/app/storefront-chat/widget/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user