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 + Logo preview + ) : ( + + )} +
+
+

{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 } +} +