From 8af711a6c971c4c8a4dc3a9e5f529332d7e81da4 Mon Sep 17 00:00:00 2001
From: talatops
Date: Thu, 2 Apr 2026 14:31:35 +0500
Subject: [PATCH] feat(storefront-chat): add customer widget + vendor toggle
Made-with: Cursor
---
.../blocks/app-embed-block.liquid | 24 ++
.../juno-storefront-chat-widget/package.json | 7 +
.../shopify.extension.toml | 7 +
.../migration.sql | 94 ++++++
prisma/schema.prisma | 38 +++
src/app/api/storefront-chat/config/route.ts | 35 +++
src/app/api/storefront-chat/messages/route.ts | 104 +++++++
src/app/api/stores/[storeId]/route.ts | 56 ++++
src/app/api/stores/route.ts | 1 +
src/app/dashboard/storefront-chat/page.tsx | 111 +++++++
src/app/dashboard/stores/page.tsx | 47 +++
src/app/storefront-chat/widget/page.tsx | 279 ++++++++++++++++++
12 files changed, 803 insertions(+)
create mode 100644 extensions/juno-storefront-chat-widget/blocks/app-embed-block.liquid
create mode 100644 extensions/juno-storefront-chat-widget/package.json
create mode 100644 extensions/juno-storefront-chat-widget/shopify.extension.toml
create mode 100644 prisma/migrations/20260402190000_storefront_chat_widget/migration.sql
create mode 100644 src/app/api/storefront-chat/config/route.ts
create mode 100644 src/app/api/storefront-chat/messages/route.ts
create mode 100644 src/app/dashboard/storefront-chat/page.tsx
create mode 100644 src/app/storefront-chat/widget/page.tsx
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 ? (