diff --git a/extensions/juno-storefront-chat-widget/blocks/app-embed-block.liquid b/extensions/juno-storefront-chat-widget/blocks/app-embed-block.liquid new file mode 100644 index 0000000..b3b5475 --- /dev/null +++ b/extensions/juno-storefront-chat-widget/blocks/app-embed-block.liquid @@ -0,0 +1,24 @@ +
+ +
+ +{% 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 %} + diff --git a/extensions/juno-storefront-chat-widget/package.json b/extensions/juno-storefront-chat-widget/package.json new file mode 100644 index 0000000..7e0cac0 --- /dev/null +++ b/extensions/juno-storefront-chat-widget/package.json @@ -0,0 +1,7 @@ +{ + "name": "juno-storefront-chat-widget", + "version": "0.0.0", + "description": "JUNO Storefront Chat widget theme app extension", + "private": true +} + diff --git a/extensions/juno-storefront-chat-widget/shopify.extension.toml b/extensions/juno-storefront-chat-widget/shopify.extension.toml new file mode 100644 index 0000000..6153fa3 --- /dev/null +++ b/extensions/juno-storefront-chat-widget/shopify.extension.toml @@ -0,0 +1,7 @@ +api_version = "2024-07" + +[[extensions]] +name = "JUNO Storefront Chat" +handle = "juno-storefront-chat" +type = "theme" + diff --git a/prisma/migrations/20260402190000_storefront_chat_widget/migration.sql b/prisma/migrations/20260402190000_storefront_chat_widget/migration.sql new file mode 100644 index 0000000..cfa7740 --- /dev/null +++ b/prisma/migrations/20260402190000_storefront_chat_widget/migration.sql @@ -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 $$; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 353ce93..48ef43b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/storefront-chat/config/route.ts b/src/app/api/storefront-chat/config/route.ts new file mode 100644 index 0000000..078bf2e --- /dev/null +++ b/src/app/api/storefront-chat/config/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/storefront-chat/config?shop= + * 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 }) + } +} + diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts new file mode 100644 index 0000000..2497edd --- /dev/null +++ b/src/app/api/storefront-chat/messages/route.ts @@ -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 + 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 }) + } +} + diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts index 494a0b8..c7e8fd1 100644 --- a/src/app/api/stores/[storeId]/route.ts +++ b/src/app/api/stores/[storeId]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/stores/route.ts b/src/app/api/stores/route.ts index 054f8c1..6c053fe 100644 --- a/src/app/api/stores/route.ts +++ b/src/app/api/stores/route.ts @@ -25,6 +25,7 @@ export async function GET() { shopifyStoreName: true, onboardingComplete: true, isActive: true, + storefrontChatEnabled: true, createdAt: true }, orderBy: { diff --git a/src/app/dashboard/storefront-chat/page.tsx b/src/app/dashboard/storefront-chat/page.tsx new file mode 100644 index 0000000..82bf185 --- /dev/null +++ b/src/app/dashboard/storefront-chat/page.tsx @@ -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 ( +
+
+
+

Storefront Chat Inbox

+

+ {store.businessName} · {store.shopifyDomain} +

+
+
+

{conversations.length} visitors

+

{sorted.length} messages

+
+
+ + {sorted.length === 0 ? ( +
+

No storefront chat messages yet.

+

When customers send a message from the widget, it will show here.

+
+ ) : ( +
+
+

Recent activity

+

Read-only

+
+
+ {sorted.map((m) => ( +
+
+
+

+ {m.senderType === "CUSTOMER" ? "CUSTOMER" : m.senderType} + {" · "} + {m.conversation?.visitorId || "visitor"} +

+ {m.conversation?.customerEmail ? ( +

{m.conversation.customerEmail}

+ ) : null} +
+

+ {new Date(m.createdAt).toLocaleString()} +

+
+
+ {m.content} +
+
+ ))} +
+
+ )} +
+ ) +} + diff --git a/src/app/dashboard/stores/page.tsx b/src/app/dashboard/stores/page.tsx index 1aca9b6..25c612a 100644 --- a/src/app/dashboard/stores/page.tsx +++ b/src/app/dashboard/stores/page.tsx @@ -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(null) + const [togglingStorefrontChat, setTogglingStorefrontChat] = useState(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() {

+
+
+

Storefront Chat (Customers)

+

+ Show chat widget on the storefront +

+
+
+ +
+
+
{!store.isActive ? ( + ) : ( +
+ {/* Backdrop + centering */} +
setIsOpen(false)} + aria-hidden="true" + /> + +
e.stopPropagation()} + > +
+
+
+ +
+
+

JUNO Chat

+

Ask about products

+
+
+ +
+ +
+ {sendError ? ( +
+ {sendError} +
+ ) : null} + + {messages.length === 0 ? ( +
+
+

Start a conversation

+

+ Send a message about any product on this store. +

+
+
+ ) : ( +
+ {messages.map((m) => ( +
+
+ {m.content} +
+
+ ))} +
+
+ )} +
+ +
+
+