feat(storefront-chat): add vendor customization modal and manual inject flow

Introduce per-store chat branding (name, tagline, color, font, logo), open customization on enable, and separate Save vs INJECT actions so theme sync is explicitly vendor-triggered while preserving safe disable behavior.

Made-with: Cursor
This commit is contained in:
talatops 2026-04-03 16:49:35 +05:00
parent 9f5cd954e0
commit 7b2cb3485e
8 changed files with 895 additions and 153 deletions

View File

@ -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;

View File

@ -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[]

View File

@ -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 = "<span style=\\"font-weight:700;color:#4338ca;font-size:12px\\">J</span>";
}
header.appendChild(logoWrap);
var headerText = document.createElement("div");
headerText.style.minWidth = "0";
headerText.innerHTML = "<div style=\\"font-size:13px;font-weight:800;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
String(chatName || "Chat") +
"</div><div style=\\"font-size:11px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
String(tagline || "") +
"</div>";
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()
}
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
if (!cleanDomain || !accessToken) {
console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
storeId: params.storeId,
cleanDomain,
hasAccessToken: Boolean(accessToken),
})
return
}
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,
})
// 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 })
throw new Error(`Asset update failed: ${putAsset.status}`)
}
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
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) {
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "patch_theme_liquid", error: (updated as any).error })
} else if (updated.updated) {
throw new Error((updated as any).error)
}
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 })
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<string, unknown> = (payload && typeof payload === "object") ? payload : {}
// Verify store belongs to the user
const store = await prisma.store.findFirst({
where: {
id: storeId,
userId: session.user.id
}
} else {
console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
storeId,
cleanDomain,
hasAccessToken: Boolean(accessToken)
})
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)
}
}
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 })
}
}

View File

@ -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: {

View File

@ -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 })
}
}

View File

@ -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<StoreData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [switching, setSwitching] = useState<string | null>(null)
const [togglingStorefrontChat, setTogglingStorefrontChat] = useState<string | null>(null)
const [customizeStoreId, setCustomizeStoreId] = useState<string | null>(null)
const [injectingStoreId, setInjectingStoreId] = useState<string | null>(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,6 +308,7 @@ export default function StoresPage() {
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 flex items-center gap-2">
{!store.isActive ? (
<button
onClick={() => handleSwitchStore(store.id)}
@ -261,9 +324,15 @@ export default function StoresPage() {
"Switch to this store"
)}
</button>
) : (
<div className="flex-1" />
)}
) : null}
<button
onClick={() => openCustomize(store.id)}
disabled={!store.storefrontChatEnabled || injectingStoreId === store.id}
className="h-9 md:h-10 px-3 rounded-xl bg-indigo-500/20 text-indigo-200 text-xs font-bold hover:bg-indigo-500/30 border border-indigo-400/30 transition-all disabled:opacity-50"
>
{injectingStoreId === store.id ? "Injecting..." : "Customize"}
</button>
</div>
<button
onClick={() => handleDeleteClick(store)}
className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/10 hover:bg-red-500/20 border border-white/10 hover:border-red-500/30 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 shrink-0"
@ -291,6 +360,21 @@ export default function StoresPage() {
storeName={deleteModal.storeName}
isActive={deleteModal.isActive}
/>
<StorefrontChatCustomizeModal
isOpen={Boolean(customizeStoreId)}
storeName={stores.find((s) => s.id === customizeStoreId)?.businessName || "Store"}
initialValues={getCustomizationValues(stores.find((s) => s.id === customizeStoreId))}
onClose={() => setCustomizeStoreId(null)}
onSave={async (payload) => {
if (!customizeStoreId) return
await saveStorefrontCustomization(customizeStoreId, payload)
}}
onInject={async () => {
if (!customizeStoreId) return
await injectStorefrontCustomization(customizeStoreId)
}}
/>
</div>
)
}

View File

@ -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<void>
onInject: () => Promise<void>
}
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<StorefrontChatCustomization>(initialValues)
const [saving, setSaving] = useState(false)
const [injecting, setInjecting] = useState(false)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [error, setError] = useState<string | null>(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 (
<AnimatePresence>
{isOpen ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm"
onClick={onClose}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
className="w-full max-w-2xl rounded-2xl border border-white/10 bg-slate-900 shadow-2xl overflow-hidden"
>
<div className="px-5 py-4 border-b border-white/10 flex items-center justify-between">
<div className="min-w-0">
<p className="text-white font-black text-lg">Customize Storefront Chat</p>
<p className="text-xs text-slate-400 truncate">{storeName}</p>
</div>
<button onClick={onClose} className="w-8 h-8 rounded-lg bg-white/10 hover:bg-white/15 text-slate-300 flex items-center justify-center">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 grid md:grid-cols-2 gap-4">
<label className="block">
<span className="text-xs font-bold text-slate-300">Chat Name</span>
<input
value={form.storefrontChatName}
onChange={(e) => update("storefrontChatName", e.target.value)}
className="mt-1 w-full 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"
/>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-300">Tagline</span>
<input
value={form.storefrontChatTagline}
onChange={(e) => update("storefrontChatTagline", e.target.value)}
className="mt-1 w-full 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"
/>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-300">Brand Color</span>
<div className="mt-1 flex gap-2">
<input
type="color"
value={form.storefrontChatBrandColor}
onChange={(e) => update("storefrontChatBrandColor", e.target.value)}
className="w-12 h-10 rounded-lg bg-transparent border border-white/10"
/>
<input
value={form.storefrontChatBrandColor}
onChange={(e) => update("storefrontChatBrandColor", e.target.value)}
className="flex-1 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"
/>
</div>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-300">Font</span>
<select
value={form.storefrontChatFontFamily}
onChange={(e) => update("storefrontChatFontFamily", e.target.value)}
className="mt-1 w-full 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"
>
{FONT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} className="text-slate-900">
{opt.label}
</option>
))}
</select>
</label>
<div className="block md:col-span-2">
<span className="text-xs font-bold text-slate-300">Logo Image (optional)</span>
<div className="mt-1 flex flex-wrap gap-2">
<label className="h-10 px-3 rounded-xl bg-white/10 text-slate-200 hover:bg-white/15 border border-white/10 cursor-pointer font-bold text-sm inline-flex items-center gap-2">
{uploadingLogo ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload image
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0] ?? null
void handleLogoUpload(file)
e.currentTarget.value = ""
}}
/>
</label>
<input
value={form.storefrontChatLogoUrl}
onChange={(e) => 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"
/>
</div>
<p className="text-[11px] text-slate-500 mt-1">Supported: image files up to 3MB.</p>
</div>
<div className="md:col-span-2 rounded-xl border border-white/10 bg-white/5 p-3">
<p className="text-[11px] font-bold text-slate-400 mb-2">Preview</p>
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="h-12 border-b border-slate-200 flex items-center gap-2 px-3" style={{ fontFamily: form.storefrontChatFontFamily }}>
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${form.storefrontChatBrandColor}22` }}>
{form.storefrontChatLogoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={form.storefrontChatLogoUrl} alt="Logo preview" className="w-5 h-5 object-contain rounded" />
) : (
<Palette className="w-4 h-4" style={{ color: form.storefrontChatBrandColor }} />
)}
</div>
<div>
<p className="text-xs font-black text-slate-900">{form.storefrontChatName || "JUNO Chat"}</p>
<p className="text-[10px] text-slate-500">{form.storefrontChatTagline || "Ask about products"}</p>
</div>
</div>
<div className="h-16 px-3 py-2 text-xs text-slate-400">Customer chat opens here...</div>
</div>
</div>
</div>
{error ? (
<div className="px-5 pb-2 text-xs text-red-300">{error}</div>
) : null}
<div className="px-5 py-4 border-t border-white/10 flex items-center justify-end gap-2">
<button
onClick={handleSave}
disabled={saving || injecting}
className="h-10 px-4 rounded-xl bg-white/10 text-slate-200 hover:bg-white/15 disabled:opacity-50 font-bold text-sm flex items-center gap-2"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
<button
onClick={handleInject}
disabled={!canInject}
className="h-10 px-4 rounded-xl bg-indigo-500 text-white hover:bg-indigo-600 disabled:opacity-50 font-bold text-sm flex items-center gap-2"
>
{injecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <WandSparkles className="w-4 h-4" />}
INJECT
</button>
</div>
</motion.div>
</div>
</>
) : null}
</AnimatePresence>
)
}

View File

@ -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 }
}