diff --git a/prisma/migrations/20260403174000_storefront_chat_customization_fields/migration.sql b/prisma/migrations/20260403174000_storefront_chat_customization_fields/migration.sql
new file mode 100644
index 0000000..f5a0842
--- /dev/null
+++ b/prisma/migrations/20260403174000_storefront_chat_customization_fields/migration.sql
@@ -0,0 +1,7 @@
+-- Add vendor-configurable storefront chat branding fields.
+ALTER TABLE "Store"
+ADD COLUMN "storefrontChatName" TEXT,
+ADD COLUMN "storefrontChatTagline" TEXT,
+ADD COLUMN "storefrontChatBrandColor" TEXT,
+ADD COLUMN "storefrontChatFontFamily" TEXT,
+ADD COLUMN "storefrontChatLogoUrl" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 48ef43b..d21c117 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -109,6 +109,11 @@ model Store {
// Customer-facing storefront widget (floating chat) enablement.
storefrontChatEnabled Boolean @default(false)
+ storefrontChatName String?
+ storefrontChatTagline String?
+ storefrontChatBrandColor String?
+ storefrontChatFontFamily String?
+ storefrontChatLogoUrl String?
// Relations
connections Connection[]
diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts
index 59e8061..ecd44cb 100644
--- a/src/app/api/stores/[storeId]/route.ts
+++ b/src/app/api/stores/[storeId]/route.ts
@@ -110,106 +110,74 @@ 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 }> }
-) {
+const ALLOWED_FONTS = new Set([
+ "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
+ "Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
+ "Poppins, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
+ "Montserrat, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
+ "Nunito, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
+])
+
+const DEFAULT_CHAT_NAME = "JUNO Chat"
+const DEFAULT_TAGLINE = "Ask about products"
+const DEFAULT_BRAND_COLOR = "#4f46e5"
+const DEFAULT_FONT = "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif"
+
+function sanitizeColor(value: unknown) {
+ if (typeof value !== "string") return DEFAULT_BRAND_COLOR
+ const v = value.trim()
+ return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(v) ? v : DEFAULT_BRAND_COLOR
+}
+
+function sanitizeShortText(value: unknown, fallback: string, max = 64) {
+ if (typeof value !== "string") return fallback
+ const v = value.trim()
+ if (!v) return fallback
+ return v.slice(0, max)
+}
+
+function sanitizeFont(value: unknown) {
+ if (typeof value !== "string") return DEFAULT_FONT
+ const v = value.trim()
+ return ALLOWED_FONTS.has(v) ? v : DEFAULT_FONT
+}
+
+function sanitizeLogoUrl(value: unknown) {
+ if (typeof value !== "string") return null
+ const v = value.trim()
+ if (!v) return null
try {
- const session = await auth()
- if (!session?.user?.id) {
- return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
- }
+ const parsed = new URL(v)
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null
+ return parsed.toString().slice(0, 500)
+ } catch {
+ return null
+ }
+}
- const { storeId } = await params
- if (!storeId) {
- return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
- }
+function buildStorefrontScript(params: {
+ appBaseUrl: string
+ chatName: string
+ tagline: string
+ brandColor: string
+ fontFamily: string
+ logoUrl: string | null
+}) {
+ const { appBaseUrl, chatName, tagline, brandColor, fontFamily, logoUrl } = params
- 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 } as any
- })
-
- // Best-effort: keep the PUBLISHED Shopify theme in sync with the checkbox.
- // Even if theme operations fail (missing scopes), the widget still
- // won’t render because /api/storefront-chat/config returns the flag.
- try {
- const apiVersion = "2024-01"
-
- const cleanDomain = (store.shopifyDomain || "")
- .trim()
- .replace(/^https?:\/\//, "")
- .replace(/\/$/, "")
- .trim()
-
- const accessToken = store.shopifyAccessToken
-
- if (cleanDomain && accessToken) {
- console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", { storeId, enabled: storefrontChatEnabled, shop: cleanDomain })
-
- // Cleanup legacy ScriptTags from earlier implementations so storefront
- // stops loading old ngrok/app-proxy/widget-injector sources.
- const listLegacyTags = await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?limit=250&fields=id,src`, {
- headers: {
- "X-Shopify-Access-Token": accessToken,
- "Content-Type": "application/json"
- }
- }).then((r) => r.json().catch(() => ({}))).catch(() => ({}))
- const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags)
- ? listLegacyTags.script_tags
- : []
- const legacyMatches = legacyTags.filter((t) => {
- const src = typeof t?.src === "string" ? t.src : ""
- return src.includes("/storefront-chat/widget-injector") || src.includes("/apps/juno-chat/widget-injector.js")
- })
- for (const tag of legacyMatches) {
- if (!tag?.id) continue
- await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json`, {
- method: "DELETE",
- headers: { "X-Shopify-Access-Token": accessToken }
- }).catch(() => {})
- }
-
- const published = await getPublishedThemeId(cleanDomain, accessToken, apiVersion)
- if (!published.ok) {
- console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, status: published.status, text: published.text })
- } else {
- const themeId = published.data.themeId
- const themeLiquidKey = "layout/theme.liquid"
- const assetKey = "assets/juno-storefront-chat.js"
- const assetFilename = "juno-storefront-chat.js"
-
- const appBaseUrl = (
- process.env.NEXT_PUBLIC_APP_URL ||
- process.env.AUTH_URL ||
- process.env.NEXTAUTH_URL ||
- ""
- ).trim().replace(/\/$/, "")
-
- const js = `
+ return `
(function () {
try {
var appBaseUrl = ${JSON.stringify(appBaseUrl)};
if (!appBaseUrl) return;
var shop = (window.Shopify && (window.Shopify.shop || window.Shopify.myshopifyDomain)) || window.location.hostname;
+ var chatName = ${JSON.stringify(chatName)};
+ var tagline = ${JSON.stringify(tagline)};
+ var brandColor = ${JSON.stringify(brandColor)};
+ var fontFamily = ${JSON.stringify(fontFamily)};
+ var logoUrl = ${JSON.stringify(logoUrl)};
+
var containerId = "juno-storefront-chat-container";
var buttonId = "juno-storefront-chat-button";
var panelId = "juno-storefront-chat-panel";
@@ -248,6 +216,49 @@ export async function PATCH(
panel.style.boxShadow = "0 30px 60px rgba(2,6,23,0.28)";
panel.style.zIndex = "2147483646";
panel.style.display = "none";
+ panel.style.fontFamily = fontFamily;
+
+ var header = document.createElement("div");
+ header.style.height = "56px";
+ header.style.display = "flex";
+ header.style.alignItems = "center";
+ header.style.gap = "10px";
+ header.style.padding = "0 12px";
+ header.style.borderBottom = "1px solid rgba(15,23,42,0.08)";
+ header.style.background = "#fff";
+
+ var logoWrap = document.createElement("div");
+ logoWrap.style.width = "30px";
+ logoWrap.style.height = "30px";
+ logoWrap.style.borderRadius = "10px";
+ logoWrap.style.flexShrink = "0";
+ logoWrap.style.display = "flex";
+ logoWrap.style.alignItems = "center";
+ logoWrap.style.justifyContent = "center";
+ logoWrap.style.background = "rgba(79,70,229,0.12)";
+ logoWrap.style.border = "1px solid rgba(79,70,229,0.2)";
+ if (logoUrl) {
+ var logo = document.createElement("img");
+ logo.src = logoUrl;
+ logo.alt = chatName;
+ logo.style.width = "20px";
+ logo.style.height = "20px";
+ logo.style.objectFit = "contain";
+ logoWrap.appendChild(logo);
+ } else {
+ logoWrap.innerHTML = "J";
+ }
+ header.appendChild(logoWrap);
+
+ var headerText = document.createElement("div");
+ headerText.style.minWidth = "0";
+ headerText.innerHTML = "
" +
+ String(chatName || "Chat") +
+ "
" +
+ String(tagline || "") +
+ "
";
+ header.appendChild(headerText);
+ panel.appendChild(header);
var closeBtn = document.createElement("button");
closeBtn.id = closeId;
@@ -271,10 +282,10 @@ export async function PATCH(
var iframe = document.createElement("iframe");
iframe.id = iframeId;
- iframe.title = "JUNO Chat";
+ iframe.title = chatName || "Chat";
iframe.src = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop);
iframe.style.width = "100%";
- iframe.style.height = "100%";
+ iframe.style.height = "calc(100% - 56px)";
iframe.style.border = "0";
iframe.style.background = "white";
panel.appendChild(iframe);
@@ -324,80 +335,236 @@ export async function PATCH(
btn.style.border = "0";
btn.style.cursor = "pointer";
btn.style.boxShadow = "0 12px 28px rgba(0,0,0,0.20)";
- btn.style.background = "#4f46e5";
+ btn.style.background = brandColor || "#4f46e5";
btn.style.color = "#fff";
btn.style.display = "flex";
btn.style.alignItems = "center";
btn.style.justifyContent = "center";
- btn.style.fontFamily = "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
+ btn.style.fontFamily = fontFamily;
btn.style.fontWeight = "800";
btn.style.letterSpacing = "-0.02em";
btn.innerHTML = "Chat";
-
- btn.addEventListener("click", function () {
- openPanel();
- });
-
+ btn.addEventListener("click", function () { openPanel(); });
container.appendChild(btn);
}
function removeWidget() {
var container = document.getElementById(containerId);
+ var panel = document.getElementById(panelId);
+ var backdrop = document.getElementById(backdropId);
if (container && container.parentNode) container.parentNode.removeChild(container);
+ if (panel && panel.parentNode) panel.parentNode.removeChild(panel);
+ if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
}
- // Theme injection itself is the source of truth:
- // - ON: this script is injected in theme.liquid (show button)
- // - OFF: injected snippet is removed (button cannot render)
removeWidget();
upsertButton();
ensurePanel();
} catch (e) {}
})();
`.trimStart()
+}
- // Always upload/update the asset (safe & idempotent).
- const putAsset = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, assetKey, js)
- if (!putAsset.ok) {
- console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "put_asset", status: putAsset.status, text: putAsset.text })
- }
+async function syncStorefrontTheme(params: {
+ storeId: string
+ enabled: boolean
+ shopifyDomain: string
+ shopifyAccessToken: string
+ chatName: string
+ tagline: string
+ brandColor: string
+ fontFamily: string
+ logoUrl: string | null
+}) {
+ const apiVersion = "2024-01"
+ const cleanDomain = (params.shopifyDomain || "")
+ .trim()
+ .replace(/^https?:\/\//, "")
+ .replace(/\/$/, "")
+ .trim()
+ const accessToken = params.shopifyAccessToken
- const themeLiquid = await getThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey)
- if (!themeLiquid.ok) {
- console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "get_theme_liquid", status: themeLiquid.status, text: themeLiquid.text })
- } else {
- const updated = storefrontChatEnabled
- ? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
- : removeThemeLiquidInjection(themeLiquid.data.value)
+ if (!cleanDomain || !accessToken) {
+ console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
+ storeId: params.storeId,
+ cleanDomain,
+ hasAccessToken: Boolean(accessToken),
+ })
+ return
+ }
- if (!updated.updated && (updated as any).error) {
- console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "patch_theme_liquid", error: (updated as any).error })
- } else if (updated.updated) {
- const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
- if (!putThemeLiquid.ok) {
- console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "put_theme_liquid", status: putThemeLiquid.status, text: putThemeLiquid.text })
- } else {
- console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId, enabled: storefrontChatEnabled, themeId })
- }
- } else {
- console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId, enabled: storefrontChatEnabled, themeId, changed: false })
- }
- }
- }
- } else {
- console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
- storeId,
- cleanDomain,
- hasAccessToken: Boolean(accessToken)
- })
+ console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", {
+ storeId: params.storeId,
+ enabled: params.enabled,
+ shop: cleanDomain,
+ })
+
+ const listLegacyTags = await fetch(
+ `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?limit=250&fields=id,src`,
+ {
+ headers: {
+ "X-Shopify-Access-Token": accessToken,
+ "Content-Type": "application/json",
+ },
+ }
+ ).then((r) => r.json().catch(() => ({}))).catch(() => ({}))
+
+ const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags)
+ ? listLegacyTags.script_tags
+ : []
+ const legacyMatches = legacyTags.filter((t) => {
+ const src = typeof t?.src === "string" ? t.src : ""
+ return src.includes("/storefront-chat/widget-injector") || src.includes("/apps/juno-chat/widget-injector.js")
+ })
+ for (const tag of legacyMatches) {
+ if (!tag?.id) continue
+ await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json`, {
+ method: "DELETE",
+ headers: { "X-Shopify-Access-Token": accessToken },
+ }).catch(() => {})
+ }
+
+ const published = await getPublishedThemeId(cleanDomain, accessToken, apiVersion)
+ if (!published.ok) {
+ throw new Error(`Published theme fetch failed: ${published.status}`)
+ }
+
+ const themeId = published.data.themeId
+ const themeLiquidKey = "layout/theme.liquid"
+ const assetKey = "assets/juno-storefront-chat.js"
+ const assetFilename = "juno-storefront-chat.js"
+
+ const appBaseUrl = (
+ process.env.NEXT_PUBLIC_APP_URL ||
+ process.env.AUTH_URL ||
+ process.env.NEXTAUTH_URL ||
+ ""
+ ).trim().replace(/\/$/, "")
+
+ const js = buildStorefrontScript({
+ appBaseUrl,
+ chatName: params.chatName,
+ tagline: params.tagline,
+ brandColor: params.brandColor,
+ fontFamily: params.fontFamily,
+ logoUrl: params.logoUrl,
+ })
+
+ const putAsset = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, assetKey, js)
+ if (!putAsset.ok) {
+ throw new Error(`Asset update failed: ${putAsset.status}`)
+ }
+
+ const themeLiquid = await getThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey)
+ if (!themeLiquid.ok) {
+ throw new Error(`Theme liquid read failed: ${themeLiquid.status}`)
+ }
+
+ const updated = params.enabled
+ ? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
+ : removeThemeLiquidInjection(themeLiquid.data.value)
+ if (!updated.updated && (updated as any).error) {
+ throw new Error((updated as any).error)
+ }
+ if (updated.updated) {
+ const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
+ if (!putThemeLiquid.ok) {
+ throw new Error(`Theme liquid update failed: ${putThemeLiquid.status}`)
+ }
+ }
+
+ console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId: params.storeId, enabled: params.enabled, themeId })
+}
+
+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 payload = await request.json().catch(() => ({}))
+ const data: Record = (payload && typeof payload === "object") ? payload : {}
+
+ // 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 })
+ }
+
+ const nextEnabled = typeof data.storefrontChatEnabled === "boolean"
+ ? data.storefrontChatEnabled
+ : store.storefrontChatEnabled
+
+ const nextChatName = sanitizeShortText(data.storefrontChatName, store.storefrontChatName || DEFAULT_CHAT_NAME, 50)
+ const nextTagline = sanitizeShortText(data.storefrontChatTagline, store.storefrontChatTagline || DEFAULT_TAGLINE, 90)
+ const nextBrandColor = sanitizeColor(data.storefrontChatBrandColor ?? store.storefrontChatBrandColor)
+ const nextFont = sanitizeFont(data.storefrontChatFontFamily ?? store.storefrontChatFontFamily)
+ const nextLogoUrl = sanitizeLogoUrl(data.storefrontChatLogoUrl ?? store.storefrontChatLogoUrl)
+
+ const updatedStore = await prisma.store.update({
+ where: { id: storeId },
+ data: {
+ storefrontChatEnabled: nextEnabled,
+ storefrontChatName: nextChatName,
+ storefrontChatTagline: nextTagline,
+ storefrontChatBrandColor: nextBrandColor,
+ storefrontChatFontFamily: nextFont,
+ storefrontChatLogoUrl: nextLogoUrl,
+ },
+ select: {
+ storefrontChatEnabled: true,
+ storefrontChatName: true,
+ storefrontChatTagline: true,
+ storefrontChatBrandColor: true,
+ storefrontChatFontFamily: true,
+ storefrontChatLogoUrl: true,
+ }
+ })
+
+ // Explicit behavior change:
+ // - Enable: save only (no auto inject).
+ // - Disable: remove snippet immediately to hard-stop storefront widget.
+ if (!updatedStore.storefrontChatEnabled) {
+ try {
+ await syncStorefrontTheme({
+ storeId,
+ enabled: false,
+ shopifyDomain: store.shopifyDomain,
+ shopifyAccessToken: store.shopifyAccessToken,
+ chatName: updatedStore.storefrontChatName || DEFAULT_CHAT_NAME,
+ tagline: updatedStore.storefrontChatTagline || DEFAULT_TAGLINE,
+ brandColor: updatedStore.storefrontChatBrandColor || DEFAULT_BRAND_COLOR,
+ fontFamily: updatedStore.storefrontChatFontFamily || DEFAULT_FONT,
+ logoUrl: updatedStore.storefrontChatLogoUrl || null,
+ })
+ } catch (scriptError) {
+ console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError)
}
- } catch (scriptError) {
- console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError)
}
return NextResponse.json({
success: true,
- storefrontChatEnabled
+ storefrontChatEnabled: updatedStore.storefrontChatEnabled,
+ storefrontChatName: updatedStore.storefrontChatName,
+ storefrontChatTagline: updatedStore.storefrontChatTagline,
+ storefrontChatBrandColor: updatedStore.storefrontChatBrandColor,
+ storefrontChatFontFamily: updatedStore.storefrontChatFontFamily,
+ storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl,
})
} catch (error) {
console.error("Error updating store settings:", error)
@@ -407,3 +574,66 @@ export async function PATCH(
)
}
}
+
+/**
+ * POST /api/stores/[storeId]
+ * Inject storefront chat snippet + JS asset using saved customization.
+ */
+export async function POST(
+ _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 store = await prisma.store.findFirst({
+ where: {
+ id: storeId,
+ userId: session.user.id,
+ },
+ select: {
+ id: true,
+ storefrontChatEnabled: true,
+ storefrontChatName: true,
+ storefrontChatTagline: true,
+ storefrontChatBrandColor: true,
+ storefrontChatFontFamily: true,
+ storefrontChatLogoUrl: true,
+ shopifyDomain: true,
+ shopifyAccessToken: true,
+ },
+ })
+
+ if (!store) {
+ return NextResponse.json({ message: "Store not found" }, { status: 404 })
+ }
+ if (!store.storefrontChatEnabled) {
+ return NextResponse.json({ message: "Enable storefront chat first" }, { status: 400 })
+ }
+
+ await syncStorefrontTheme({
+ storeId: store.id,
+ enabled: true,
+ shopifyDomain: store.shopifyDomain,
+ shopifyAccessToken: store.shopifyAccessToken,
+ chatName: sanitizeShortText(store.storefrontChatName, DEFAULT_CHAT_NAME, 50),
+ tagline: sanitizeShortText(store.storefrontChatTagline, DEFAULT_TAGLINE, 90),
+ brandColor: sanitizeColor(store.storefrontChatBrandColor),
+ fontFamily: sanitizeFont(store.storefrontChatFontFamily),
+ logoUrl: sanitizeLogoUrl(store.storefrontChatLogoUrl),
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error("[STOREFRONT_CHAT_INJECT_ERR]", error)
+ return NextResponse.json({ message: "Failed to inject storefront chat" }, { status: 500 })
+ }
+}
diff --git a/src/app/api/stores/route.ts b/src/app/api/stores/route.ts
index 6c053fe..cd9af58 100644
--- a/src/app/api/stores/route.ts
+++ b/src/app/api/stores/route.ts
@@ -26,6 +26,11 @@ export async function GET() {
onboardingComplete: true,
isActive: true,
storefrontChatEnabled: true,
+ storefrontChatName: true,
+ storefrontChatTagline: true,
+ storefrontChatBrandColor: true,
+ storefrontChatFontFamily: true,
+ storefrontChatLogoUrl: true,
createdAt: true
},
orderBy: {
diff --git a/src/app/api/uploads/storefront-chat-logo/route.ts b/src/app/api/uploads/storefront-chat-logo/route.ts
new file mode 100644
index 0000000..dc0f9de
--- /dev/null
+++ b/src/app/api/uploads/storefront-chat-logo/route.ts
@@ -0,0 +1,103 @@
+import { NextResponse } from "next/server"
+import { auth } from "@/auth"
+import fs from "fs/promises"
+import path from "path"
+import { randomUUID } from "crypto"
+import { uploadStorefrontChatLogo } from "@/lib/s3"
+
+const LOCAL_UPLOAD_DIR =
+ process.env.STOREFRONT_CHAT_LOGOS_DIR || "public/uploads/storefront-chat-logos"
+
+async function ensureUploadDirForVendor(vendorId: string) {
+ const baseDir = path.resolve(LOCAL_UPLOAD_DIR)
+ const vendorDir = path.join(baseDir, vendorId)
+ await fs.mkdir(vendorDir, { recursive: true })
+ return vendorDir
+}
+
+function buildPublicUrl(absoluteFilePath: string) {
+ const publicRoot = path.resolve("public")
+ const normalized = absoluteFilePath.replace(/\\/g, "/")
+ const normalizedPublicRoot = publicRoot.replace(/\\/g, "/")
+
+ if (normalized.startsWith(normalizedPublicRoot)) {
+ const relative = normalized.slice(normalizedPublicRoot.length)
+ return relative.startsWith("/") ? relative : `/${relative}`
+ }
+
+ const baseDir = path.resolve(LOCAL_UPLOAD_DIR).replace(/\\/g, "/")
+ const relative = normalized.replace(baseDir, "").replace(/^\/?/, "/")
+ return relative
+}
+
+async function uploadLocally(params: {
+ vendorId: string
+ buffer: Buffer
+ fileName: string
+}) {
+ const extFromName = params.fileName.includes(".")
+ ? params.fileName.split(".").pop()
+ : undefined
+ const ext = extFromName || "bin"
+
+ const vendorDir = await ensureUploadDirForVendor(params.vendorId)
+ const fileNameOnDisk = `${randomUUID()}.${ext}`
+ const storagePath = path.join(vendorDir, fileNameOnDisk)
+
+ await fs.writeFile(storagePath, params.buffer)
+ const url = buildPublicUrl(storagePath)
+ return { url }
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await auth()
+ if (!session?.user?.id || session.user.role !== "VENDOR") {
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
+ }
+
+ const formData = await req.formData()
+ const file = formData.get("file")
+ if (!(file instanceof File)) {
+ return NextResponse.json({ message: "Missing file" }, { status: 400 })
+ }
+
+ if (!file.type.startsWith("image/")) {
+ return NextResponse.json({ message: "Only image files are allowed" }, { status: 400 })
+ }
+
+ const maxBytes = 3 * 1024 * 1024
+ if (file.size > maxBytes) {
+ return NextResponse.json({ message: "Image too large (max 3MB)" }, { status: 400 })
+ }
+
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ const originalName = file.name || "logo"
+
+ try {
+ const { url } = await uploadStorefrontChatLogo({
+ vendorId: session.user.id,
+ fileBuffer: buffer,
+ contentType: file.type || undefined,
+ originalName,
+ })
+ return NextResponse.json({ url })
+ } catch (s3Error) {
+ console.warn(
+ "[STOREFRONT_CHAT_LOGO_UPLOAD] S3 upload failed or not configured. Falling back to local storage.",
+ s3Error
+ )
+
+ const { url } = await uploadLocally({
+ vendorId: session.user.id,
+ buffer,
+ fileName: originalName,
+ })
+ return NextResponse.json({ url })
+ }
+ } catch (error) {
+ console.error("[STOREFRONT_CHAT_LOGO_UPLOAD]", error)
+ return NextResponse.json({ message: "Failed to upload logo image" }, { status: 500 })
+ }
+}
diff --git a/src/app/dashboard/stores/page.tsx b/src/app/dashboard/stores/page.tsx
index 25c612a..4326fb6 100644
--- a/src/app/dashboard/stores/page.tsx
+++ b/src/app/dashboard/stores/page.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { Store, Plus, Check, Loader2, AlertCircle, Building2, Trash2 } from "lucide-react"
import AddStoreModal from "@/components/AddStoreModal"
import DeleteStoreModal from "@/components/DeleteStoreModal"
+import StorefrontChatCustomizeModal, { type StorefrontChatCustomization } from "@/components/StorefrontChatCustomizeModal"
interface StoreData {
id: string
@@ -12,15 +13,30 @@ interface StoreData {
isActive: boolean
onboardingComplete: boolean
storefrontChatEnabled: boolean
+ storefrontChatName: string | null
+ storefrontChatTagline: string | null
+ storefrontChatBrandColor: string | null
+ storefrontChatFontFamily: string | null
+ storefrontChatLogoUrl: string | null
createdAt: string
}
+const DEFAULT_CUSTOMIZATION: StorefrontChatCustomization = {
+ storefrontChatName: "JUNO Chat",
+ storefrontChatTagline: "Ask about products",
+ storefrontChatBrandColor: "#4f46e5",
+ storefrontChatFontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
+ storefrontChatLogoUrl: "",
+}
+
export default function StoresPage() {
const [stores, setStores] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [switching, setSwitching] = useState(null)
const [togglingStorefrontChat, setTogglingStorefrontChat] = useState(null)
+ const [customizeStoreId, setCustomizeStoreId] = useState(null)
+ const [injectingStoreId, setInjectingStoreId] = useState(null)
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
/*
* Alignment Fix Details:
@@ -68,6 +84,9 @@ export default function StoresPage() {
await fetchStores()
window.dispatchEvent(new CustomEvent("storeUpdated"))
+ if (enabled) {
+ setCustomizeStoreId(storeId)
+ }
} catch (err) {
console.error("Toggle storefront chat error:", err)
alert("Could not update storefront chat setting. Please try again.")
@@ -76,6 +95,49 @@ export default function StoresPage() {
}
}
+ const openCustomize = (storeId: string) => {
+ setCustomizeStoreId(storeId)
+ }
+
+ const getCustomizationValues = (store: StoreData | undefined): StorefrontChatCustomization => {
+ if (!store) return DEFAULT_CUSTOMIZATION
+ return {
+ storefrontChatName: store.storefrontChatName || DEFAULT_CUSTOMIZATION.storefrontChatName,
+ storefrontChatTagline: store.storefrontChatTagline || DEFAULT_CUSTOMIZATION.storefrontChatTagline,
+ storefrontChatBrandColor: store.storefrontChatBrandColor || DEFAULT_CUSTOMIZATION.storefrontChatBrandColor,
+ storefrontChatFontFamily: store.storefrontChatFontFamily || DEFAULT_CUSTOMIZATION.storefrontChatFontFamily,
+ storefrontChatLogoUrl: store.storefrontChatLogoUrl || "",
+ }
+ }
+
+ const saveStorefrontCustomization = async (storeId: string, payload: StorefrontChatCustomization) => {
+ const res = await fetch(`/api/stores/${storeId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({ message: "Failed to save storefront customization" }))
+ throw new Error(data.message || "Failed to save storefront customization")
+ }
+ await fetchStores()
+ }
+
+ const injectStorefrontCustomization = async (storeId: string) => {
+ setInjectingStoreId(storeId)
+ try {
+ const res = await fetch(`/api/stores/${storeId}`, {
+ method: "POST",
+ })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({ message: "Failed to inject storefront chat" }))
+ throw new Error(data.message || "Failed to inject storefront chat")
+ }
+ } finally {
+ setInjectingStoreId(null)
+ }
+ }
+
const handleSwitchStore = async (storeId: string) => {
setSwitching(storeId)
try {
@@ -246,24 +308,31 @@ export default function StoresPage() {
- {!store.isActive ? (
+
+ {!store.isActive ? (
+
+ ) : null}
- ) : (
-
- )}
+
)
}
diff --git a/src/components/StorefrontChatCustomizeModal.tsx b/src/components/StorefrontChatCustomizeModal.tsx
new file mode 100644
index 0000000..463c9b8
--- /dev/null
+++ b/src/components/StorefrontChatCustomizeModal.tsx
@@ -0,0 +1,273 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import { AnimatePresence, motion } from "framer-motion"
+import { Loader2, Palette, Save, Upload, WandSparkles, X } from "lucide-react"
+
+export type StorefrontChatCustomization = {
+ storefrontChatName: string
+ storefrontChatTagline: string
+ storefrontChatBrandColor: string
+ storefrontChatFontFamily: string
+ storefrontChatLogoUrl: string
+}
+
+type Props = {
+ isOpen: boolean
+ storeName: string
+ initialValues: StorefrontChatCustomization
+ onClose: () => void
+ onSave: (payload: StorefrontChatCustomization) => Promise
+ onInject: () => Promise
+}
+
+const FONT_OPTIONS = [
+ { label: "System UI", value: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
+ { label: "Inter", value: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
+ { label: "Poppins", value: "Poppins, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
+ { label: "Montserrat", value: "Montserrat, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
+ { label: "Nunito", value: "Nunito, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
+]
+
+export default function StorefrontChatCustomizeModal({
+ isOpen,
+ storeName,
+ initialValues,
+ onClose,
+ onSave,
+ onInject,
+}: Props) {
+ const [form, setForm] = useState(initialValues)
+ const [saving, setSaving] = useState(false)
+ const [injecting, setInjecting] = useState(false)
+ const [uploadingLogo, setUploadingLogo] = useState(false)
+ const [error, setError] = useState(null)
+ const [saved, setSaved] = useState(false)
+
+ useEffect(() => {
+ if (!isOpen) return
+ setForm(initialValues)
+ setSaving(false)
+ setInjecting(false)
+ setUploadingLogo(false)
+ setError(null)
+ setSaved(false)
+ }, [isOpen, initialValues])
+
+ const canInject = useMemo(() => !saving && !injecting, [saving, injecting])
+
+ const update = (key: keyof StorefrontChatCustomization, value: string) => {
+ setForm((prev) => ({ ...prev, [key]: value }))
+ setSaved(false)
+ }
+
+ const handleLogoUpload = async (file: File | null) => {
+ if (!file) return
+ setUploadingLogo(true)
+ setError(null)
+ try {
+ const formData = new FormData()
+ formData.append("file", file)
+ const res = await fetch("/api/uploads/storefront-chat-logo", {
+ method: "POST",
+ body: formData,
+ })
+ const data = await res.json().catch(() => ({}))
+ if (!res.ok || !data?.url) {
+ throw new Error(data?.message || "Failed to upload logo")
+ }
+ update("storefrontChatLogoUrl", String(data.url))
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to upload logo")
+ } finally {
+ setUploadingLogo(false)
+ }
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ setError(null)
+ try {
+ await onSave(form)
+ setSaved(true)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save customization")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleInject = async () => {
+ setInjecting(true)
+ setError(null)
+ try {
+ if (!saved) {
+ await onSave(form)
+ setSaved(true)
+ }
+ await onInject()
+ onClose()
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to inject storefront chat")
+ } finally {
+ setInjecting(false)
+ }
+ }
+
+ return (
+
+ {isOpen ? (
+ <>
+
+
+
+
+
+
Customize Storefront Chat
+
{storeName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Logo Image (optional)
+
+
+ update("storefrontChatLogoUrl", e.target.value)}
+ placeholder="Or paste logo URL"
+ className="flex-1 min-w-[220px] h-10 rounded-xl bg-white/5 border border-white/10 px-3 text-sm text-white outline-none focus:ring-2 focus:ring-indigo-500/30"
+ />
+
+
Supported: image files up to 3MB.
+
+
+
+
Preview
+
+
+
+ {form.storefrontChatLogoUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ )}
+
+
+
{form.storefrontChatName || "JUNO Chat"}
+
{form.storefrontChatTagline || "Ask about products"}
+
+
+
Customer chat opens here...
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+
+
+
+
+
+
+
+ >
+ ) : null}
+
+ )
+}
diff --git a/src/lib/s3.ts b/src/lib/s3.ts
index ee37155..852889b 100644
--- a/src/lib/s3.ts
+++ b/src/lib/s3.ts
@@ -73,3 +73,38 @@ export async function uploadSupplierProductImage(params: {
return { key, url }
}
+export async function uploadStorefrontChatLogo(params: {
+ vendorId: string
+ fileBuffer: Buffer
+ contentType?: string
+ originalName?: string
+}) {
+ if (!BUCKET) {
+ throw new Error("S3 bucket is not configured for storefront chat logo uploads")
+ }
+
+ const extensionFromType =
+ params.contentType && params.contentType.includes("/")
+ ? params.contentType.split("/")[1]
+ : undefined
+
+ const ext =
+ (params.originalName && params.originalName.split(".").pop()) ||
+ extensionFromType ||
+ "bin"
+
+ const key = `storefront-chat-logos/${params.vendorId}/${crypto.randomUUID()}.${ext}`
+
+ const command = new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: key,
+ Body: params.fileBuffer,
+ ContentType: params.contentType
+ })
+
+ await s3Client.send(command)
+
+ const url = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`
+ return { key, url }
+}
+