From ce5049a64fc8a6b4a2a8077eb33ec3b68454640d Mon Sep 17 00:00:00 2001 From: talatops Date: Thu, 2 Apr 2026 17:07:41 +0500 Subject: [PATCH] refactor(storefront-chat): switch to theme asset injection flow Replace ScriptTag/App Proxy storefront injection with Theme API asset injection, add theme helper utilities, and polish the iframe chat panel UI/contrast. Also clean up legacy injector routes and add onboarding guidance for required theme scopes. Made-with: Cursor --- .../api/shopify/app-proxy/[...path]/route.ts | 163 --------- src/app/api/storefront-chat/config/route.ts | 24 +- src/app/api/stores/[storeId]/route.ts | 310 +++++++++++++----- src/app/onboarding/onboarding-client.tsx | 2 +- .../storefront-chat/widget-injector/route.ts | 109 ------ src/app/storefront-chat/widget/page.tsx | 268 ++++++--------- src/lib/shopify/theme.ts | 132 ++++++++ 7 files changed, 469 insertions(+), 539 deletions(-) delete mode 100644 src/app/api/shopify/app-proxy/[...path]/route.ts delete mode 100644 src/app/storefront-chat/widget-injector/route.ts create mode 100644 src/lib/shopify/theme.ts diff --git a/src/app/api/shopify/app-proxy/[...path]/route.ts b/src/app/api/shopify/app-proxy/[...path]/route.ts deleted file mode 100644 index 7bb6896..0000000 --- a/src/app/api/shopify/app-proxy/[...path]/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" -import crypto from "crypto" - -function verifyShopifyProxyHmac(params: URLSearchParams, secret: string) { - const provided = params.get("hmac") || "" - if (!provided) return false - - const pairs: string[] = [] - params.forEach((value, key) => { - if (key === "hmac" || key === "signature") return - pairs.push(`${key}=${value}`) - }) - pairs.sort() - const message = pairs.join("&") - - const digest = crypto.createHmac("sha256", secret).update(message).digest("hex") - return crypto.timingSafeEqual(Buffer.from(digest, "utf8"), Buffer.from(provided, "utf8")) -} - -function getAppBaseUrl() { - return ( - process.env.NEXT_PUBLIC_APP_URL || - process.env.AUTH_URL || - process.env.NEXTAUTH_URL || - "" - ) - .trim() - .replace(/\/$/, "") -} - -export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }) { - try { - const { path } = await params - const pathname = (path || []).join("/") - const url = new URL(req.url) - const shop = (url.searchParams.get("shop") || "").trim() - - if (!shop) { - return new NextResponse("Missing shop", { status: 400 }) - } - - // Lookup store by shopifyDomain (myshopify domain). - const store = await prisma.store.findFirst({ - where: { shopifyDomain: shop }, - select: { id: true, shopifyApiSecret: true }, - }) - - if (!store?.shopifyApiSecret) { - return new NextResponse("Unknown store", { status: 404 }) - } - - // Verify app proxy signature (prevents third parties from hitting this endpoint). - if (!verifyShopifyProxyHmac(url.searchParams, store.shopifyApiSecret)) { - return new NextResponse("Invalid signature", { status: 401 }) - } - - // Only serve injector JS for now. - if (pathname !== "widget-injector.js") { - return new NextResponse("Not found", { status: 404 }) - } - - const appBaseUrl = getAppBaseUrl() - if (!appBaseUrl) { - return new NextResponse("App base URL not configured", { status: 500 }) - } - - // App proxy returns JS that opens our hosted widget in a popup/new tab. - const js = ` -(function () { - try { - var appBaseUrl = ${JSON.stringify(appBaseUrl)}; - var shop = ${JSON.stringify(shop)}; - - var containerId = "juno-storefront-chat-container"; - var buttonId = "juno-storefront-chat-button"; - - function upsertButton() { - var container = document.getElementById(containerId); - if (!container) { - container = document.createElement("div"); - container.id = containerId; - container.style.position = "fixed"; - container.style.bottom = "16px"; - container.style.right = "16px"; - container.style.zIndex = "2147483647"; - container.style.width = "56px"; - container.style.height = "56px"; - document.body.appendChild(container); - } - - var existing = document.getElementById(buttonId); - if (existing) return; - - var btn = document.createElement("button"); - btn.id = buttonId; - btn.type = "button"; - btn.setAttribute("aria-label", "Open chat"); - btn.style.width = "56px"; - btn.style.height = "56px"; - btn.style.borderRadius = "16px"; - 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.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.fontWeight = "800"; - btn.style.letterSpacing = "-0.02em"; - btn.innerHTML = "Chat"; - - btn.addEventListener("click", function () { - try { - var url = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop); - var w = Math.min(420, Math.max(320, window.innerWidth)); - var h = Math.min(640, Math.max(480, window.innerHeight)); - var left = Math.max(0, Math.round(window.screenX + (window.outerWidth - w))); - var top = Math.max(0, Math.round(window.screenY + (window.outerHeight - h))); - var popup = window.open(url, "juno_storefront_chat", "popup=yes,width=" + w + ",height=" + h + ",left=" + left + ",top=" + top); - if (!popup) window.open(url, "_blank"); - } catch (e) { - window.open(appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop), "_blank"); - } - }); - - container.appendChild(btn); - } - - function removeWidget() { - var container = document.getElementById(containerId); - if (container && container.parentNode) container.parentNode.removeChild(container); - } - - fetch(appBaseUrl + "/api/storefront-chat/config?shop=" + encodeURIComponent(shop)) - .then(function (r) { return r.json().catch(function(){ return {}; }); }) - .then(function (data) { - var enabled = !!(data && data.enabled); - if (enabled) upsertButton(); - else removeWidget(); - }) - .catch(function () { removeWidget(); }); - } catch (e) {} -})(); -`.trimStart() - - // If disabled, still return JS (it will remove/avoid rendering). - // This keeps the ScriptTag simple and lets our own config control visibility. - return new NextResponse(js, { - status: 200, - headers: { - "Content-Type": "application/javascript; charset=utf-8", - "Cache-Control": "public, max-age=60", - }, - }) - } catch (error) { - console.error("[APP_PROXY_WIDGET_INJECTOR]", error) - return new NextResponse("Internal Error", { status: 500 }) - } -} - diff --git a/src/app/api/storefront-chat/config/route.ts b/src/app/api/storefront-chat/config/route.ts index 078bf2e..ef93923 100644 --- a/src/app/api/storefront-chat/config/route.ts +++ b/src/app/api/storefront-chat/config/route.ts @@ -1,6 +1,17 @@ import { NextResponse } from "next/server" import { prisma } from "@/lib/prisma" +function withCors(res: NextResponse) { + res.headers.set("Access-Control-Allow-Origin", "*") + res.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS") + res.headers.set("Access-Control-Allow-Headers", "Content-Type") + return res +} + +export async function OPTIONS() { + return withCors(new NextResponse(null, { status: 204 })) +} + /** * GET /api/storefront-chat/config?shop= * Public endpoint used by the storefront widget bootstrap. @@ -11,25 +22,26 @@ export async function GET(req: Request) { const shop = searchParams.get("shop") if (!shop) { - return NextResponse.json({ message: "Missing shop" }, { status: 400 }) + return withCors(NextResponse.json({ message: "Missing shop" }, { status: 400 })) } - const store = await prisma.store.findFirst({ + const p = prisma as any + const store = await p.store.findFirst({ where: { shopifyDomain: shop }, select: { id: true, storefrontChatEnabled: true } }) if (!store) { // If we don't know the store, don't show the widget. - return NextResponse.json({ enabled: false }, { status: 200 }) + return withCors(NextResponse.json({ enabled: false }, { status: 200 })) } - return NextResponse.json({ + return withCors(NextResponse.json({ enabled: store.storefrontChatEnabled - }) + })) } catch (error) { console.error("[STORE_FRONT_CHAT_CONFIG]", error) - return NextResponse.json({ message: "Internal Error" }, { status: 500 }) + return withCors(NextResponse.json({ message: "Internal Error" }, { status: 500 })) } } diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts index 4db6dc8..59e8061 100644 --- a/src/app/api/stores/[storeId]/route.ts +++ b/src/app/api/stores/[storeId]/route.ts @@ -1,6 +1,13 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { + getPublishedThemeId, + getThemeAsset, + putThemeAsset, + removeThemeLiquidInjection, + upsertThemeLiquidInjection +} from "@/lib/shopify/theme" /** * DELETE /api/stores/[storeId] @@ -140,8 +147,8 @@ export async function PATCH( data: { storefrontChatEnabled } as any }) - // Best-effort: keep a Shopify ScriptTag in sync with the checkbox. - // Even if ScriptTag operations fail (missing scopes), the widget still + // 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" @@ -154,105 +161,238 @@ export async function PATCH( const accessToken = store.shopifyAccessToken - // Use Shopify App Proxy URL for the ScriptTag src to avoid CSP/ORB/ngrok issues. - // Requires one-time App Proxy setup in Shopify admin for the app: - // - App proxy prefix: apps - // - Subpath: juno-chat - // - Proxy URL: /api/shopify/app-proxy if (cleanDomain && accessToken) { - const injectorPath = `https://${cleanDomain}/apps/juno-chat/widget-injector.js` - const injectorSrc = injectorPath + console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", { storeId, enabled: storefrontChatEnabled, shop: cleanDomain }) - // Shopify's `src` filter is exact-match; we want to delete any older variants too. - // So we list a small page and filter by prefix instead. - const listUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?limit=250&fields=id,src` - - const commonHeaders = { - "X-Shopify-Access-Token": accessToken, - "Content-Type": "application/json" + // 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 listRes = await fetch(listUrl, { headers: commonHeaders }) - const listData = await listRes.json().catch(() => ({})) - const allTags: Array<{ id: number; src?: string }> = Array.isArray(listData?.script_tags) - ? listData.script_tags - : [] - const existing = allTags.filter((t) => { - const src = typeof t?.src === "string" ? t.src : "" - return src === injectorSrc || src.startsWith(injectorPath) - }) - - if (storefrontChatEnabled) { - // Ensure exactly one matching tag exists; clean up duplicates/old variants. - if (existing.length > 1) { - for (const tag of existing.slice(1)) { - if (!tag?.id) continue - const deleteUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json` - await fetch(deleteUrl, { - method: "DELETE", - headers: { "X-Shopify-Access-Token": accessToken } - }).catch(() => {}) - } - } - - // If the remaining tag isn't the exact injectorSrc (ngrok skip param), replace it. - const hasExact = existing.some((t) => typeof t?.src === "string" && t.src === injectorSrc) - if (!hasExact) { - // Delete old variant(s) - for (const tag of existing) { - if (!tag?.id) continue - const deleteUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json` - await fetch(deleteUrl, { - method: "DELETE", - headers: { "X-Shopify-Access-Token": accessToken } - }).catch(() => {}) - } - - const createUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json` - const createBody = { - script_tag: { - event: "onload", - src: injectorSrc, - display_scope: "online_store", - cache: false - } - } - - const createRes = await fetch(createUrl, { - method: "POST", - headers: commonHeaders, - body: JSON.stringify(createBody) - }) - - if (!createRes.ok) { - const errText = await createRes.text().catch(() => "") - console.error("[STORE_FRONT_CHAT_SCRIPTTAG_CREATE]", { storeId, status: createRes.status, errText }) - } - } + 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 { - for (const tag of existing) { - if (!tag?.id) continue - const deleteUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json` - const delRes = await fetch(deleteUrl, { - method: "DELETE", - headers: { "X-Shopify-Access-Token": accessToken } - }) + const themeId = published.data.themeId + const themeLiquidKey = "layout/theme.liquid" + const assetKey = "assets/juno-storefront-chat.js" + const assetFilename = "juno-storefront-chat.js" - if (!delRes.ok) { - const errText = await delRes.text().catch(() => "") - console.error("[STORE_FRONT_CHAT_SCRIPTTAG_DELETE]", { storeId, tagId: tag.id, status: delRes.status, errText }) + const appBaseUrl = ( + process.env.NEXT_PUBLIC_APP_URL || + process.env.AUTH_URL || + process.env.NEXTAUTH_URL || + "" + ).trim().replace(/\/$/, "") + + const js = ` +(function () { + try { + var appBaseUrl = ${JSON.stringify(appBaseUrl)}; + if (!appBaseUrl) return; + var shop = (window.Shopify && (window.Shopify.shop || window.Shopify.myshopifyDomain)) || window.location.hostname; + + var containerId = "juno-storefront-chat-container"; + var buttonId = "juno-storefront-chat-button"; + var panelId = "juno-storefront-chat-panel"; + var backdropId = "juno-storefront-chat-backdrop"; + var closeId = "juno-storefront-chat-close"; + var iframeId = "juno-storefront-chat-iframe"; + + function ensurePanel() { + var existingPanel = document.getElementById(panelId); + if (existingPanel) return; + + var backdrop = document.createElement("div"); + backdrop.id = backdropId; + backdrop.style.position = "fixed"; + backdrop.style.inset = "0"; + backdrop.style.background = "rgba(2,6,23,0.28)"; + backdrop.style.backdropFilter = "blur(2px)"; + backdrop.style.zIndex = "2147483645"; + backdrop.style.display = "none"; + backdrop.addEventListener("click", function () { closePanel(); }); + document.body.appendChild(backdrop); + + var panel = document.createElement("div"); + panel.id = panelId; + panel.style.position = "fixed"; + panel.style.bottom = "86px"; + panel.style.right = "16px"; + panel.style.width = "380px"; + panel.style.maxWidth = "calc(100vw - 24px)"; + panel.style.height = "620px"; + panel.style.maxHeight = "calc(100vh - 110px)"; + panel.style.background = "#fff"; + panel.style.border = "1px solid rgba(15,23,42,0.12)"; + panel.style.borderRadius = "16px"; + panel.style.overflow = "hidden"; + panel.style.boxShadow = "0 30px 60px rgba(2,6,23,0.28)"; + panel.style.zIndex = "2147483646"; + panel.style.display = "none"; + + var closeBtn = document.createElement("button"); + closeBtn.id = closeId; + closeBtn.type = "button"; + closeBtn.innerHTML = "Close"; + closeBtn.setAttribute("aria-label", "Close chat"); + closeBtn.style.position = "absolute"; + closeBtn.style.top = "10px"; + closeBtn.style.right = "10px"; + closeBtn.style.zIndex = "2"; + closeBtn.style.border = "1px solid rgba(255,255,255,0.5)"; + closeBtn.style.borderRadius = "10px"; + closeBtn.style.padding = "6px 10px"; + closeBtn.style.fontSize = "12px"; + closeBtn.style.fontWeight = "700"; + closeBtn.style.color = "#ffffff"; + closeBtn.style.background = "rgba(15,23,42,0.45)"; + closeBtn.style.cursor = "pointer"; + closeBtn.addEventListener("click", function () { closePanel(); }); + panel.appendChild(closeBtn); + + var iframe = document.createElement("iframe"); + iframe.id = iframeId; + iframe.title = "JUNO Chat"; + iframe.src = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop); + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "0"; + iframe.style.background = "white"; + panel.appendChild(iframe); + + document.body.appendChild(panel); + } + + function openPanel() { + ensurePanel(); + var panel = document.getElementById(panelId); + var backdrop = document.getElementById(backdropId); + if (panel) panel.style.display = "block"; + if (backdrop) backdrop.style.display = "block"; + } + + function closePanel() { + var panel = document.getElementById(panelId); + var backdrop = document.getElementById(backdropId); + if (panel) panel.style.display = "none"; + if (backdrop) backdrop.style.display = "none"; + } + + function upsertButton() { + var container = document.getElementById(containerId); + if (!container) { + container = document.createElement("div"); + container.id = containerId; + container.style.position = "fixed"; + container.style.bottom = "16px"; + container.style.right = "16px"; + container.style.zIndex = "2147483647"; + container.style.width = "56px"; + container.style.height = "56px"; + document.body.appendChild(container); + } + + var existing = document.getElementById(buttonId); + if (existing) return; + + var btn = document.createElement("button"); + btn.id = buttonId; + btn.type = "button"; + btn.setAttribute("aria-label", "Open chat"); + btn.style.width = "56px"; + btn.style.height = "56px"; + btn.style.borderRadius = "16px"; + 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.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.fontWeight = "800"; + btn.style.letterSpacing = "-0.02em"; + btn.innerHTML = "Chat"; + + btn.addEventListener("click", function () { + openPanel(); + }); + + container.appendChild(btn); + } + + function removeWidget() { + var container = document.getElementById(containerId); + if (container && container.parentNode) container.parentNode.removeChild(container); + } + + // 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 }) + } + + 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 (!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("[STORE_FRONT_CHAT_SCRIPTTAG_SKIP]", { + console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", { storeId, cleanDomain, hasAccessToken: Boolean(accessToken) }) } } catch (scriptError) { - console.error("[STORE_FRONT_CHAT_SCRIPTTAG_ERROR]", scriptError) + console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError) } return NextResponse.json({ diff --git a/src/app/onboarding/onboarding-client.tsx b/src/app/onboarding/onboarding-client.tsx index 2de4568..9c9d1f3 100644 --- a/src/app/onboarding/onboarding-client.tsx +++ b/src/app/onboarding/onboarding-client.tsx @@ -186,7 +186,7 @@ export default function OnboardingClient() {

How to get your credentials?

- Go to your Shopify Admin > Settings > Apps and sales channels > Develop apps. Create an app and configure Admin API scopes. + Go to your Shopify Admin > Settings > Apps and sales channels > Develop apps. Create an app and configure Admin API scopes. For the storefront chat widget auto-injection, enable read_themes and write_themes too.
diff --git a/src/app/storefront-chat/widget-injector/route.ts b/src/app/storefront-chat/widget-injector/route.ts deleted file mode 100644 index a8b68a7..0000000 --- a/src/app/storefront-chat/widget-injector/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Shopify ScriptTag loader (v1). - * - * Shopify injects this JS onto the ONLINE_STORE. - * The script then checks our backend config for the current store and, if enabled, - * mounts an iframe pointing to `/storefront-chat/widget?shop=...`. - */ -export async function GET() { - const js = ` -(function () { - try { - var script = document.currentScript; - var baseUrl = script && script.src ? new URL(script.src).origin : window.location.origin; - // Prefer Shopify-provided shop identifier when available. - // On custom domains, window.location.hostname won't match the shop's myshopify domain. - var shop = - (window.Shopify && (window.Shopify.shop || window.Shopify.myshopifyDomain)) || - window.location.hostname; - - var containerId = "juno-storefront-chat-container"; - var buttonId = "juno-storefront-chat-button"; - - function upsertButton() { - var container = document.getElementById(containerId); - if (!container) { - container = document.createElement("div"); - container.id = containerId; - container.style.position = "fixed"; - container.style.bottom = "16px"; - container.style.right = "16px"; - container.style.zIndex = "2147483647"; - container.style.width = "56px"; - container.style.height = "56px"; - document.body.appendChild(container); - } - - var existing = document.getElementById(buttonId); - if (existing) return; - - var btn = document.createElement("button"); - btn.id = buttonId; - btn.type = "button"; - btn.setAttribute("aria-label", "Open chat"); - btn.style.width = "56px"; - btn.style.height = "56px"; - btn.style.borderRadius = "16px"; - 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.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.fontWeight = "800"; - btn.style.letterSpacing = "-0.02em"; - btn.innerHTML = "Chat"; - - btn.addEventListener("click", function () { - try { - var url = baseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop); - // Try popup first (nice for testing), fallback to new tab. - var w = Math.min(420, Math.max(320, window.innerWidth)); - var h = Math.min(640, Math.max(480, window.innerHeight)); - var left = Math.max(0, Math.round(window.screenX + (window.outerWidth - w))); - var top = Math.max(0, Math.round(window.screenY + (window.outerHeight - h))); - var popup = window.open(url, "juno_storefront_chat", "popup=yes,width=" + w + ",height=" + h + ",left=" + left + ",top=" + top); - if (!popup) window.open(url, "_blank"); - } catch (e) { - window.open(baseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop), "_blank"); - } - }); - - container.appendChild(btn); - } - - function removeWidget() { - var container = document.getElementById(containerId); - if (container && container.parentNode) container.parentNode.removeChild(container); - } - - // Avoid multiple config calls if Shopify re-runs script. - if (window.__junoStorefrontChatConfigPromise) return; - window.__junoStorefrontChatConfigPromise = fetch(baseUrl + "/api/storefront-chat/config?shop=" + encodeURIComponent(shop)) - .then(function (r) { return r.json().catch(function(){ return {}; }); }) - .then(function (data) { - var enabled = !!(data && data.enabled); - if (enabled) upsertButton(); - else removeWidget(); - }) - .catch(function () { - // Fail closed: don't render widget if config check fails. - removeWidget(); - }); - } catch (e) { - // Fail closed: don't render widget. - } -})(); -`.trimStart() - - return new Response(js, { - headers: { - "Content-Type": "application/javascript; charset=utf-8", - "Cache-Control": "public, max-age=300" - } - }) -} - diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index a63048e..863bccb 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -1,9 +1,7 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" -import { MessageSquare, Send, X } from "lucide-react" - -type StorefrontConfig = { enabled: boolean } +import { useEffect, useRef, useState } from "react" +import { MessageSquare, Send } from "lucide-react" type WidgetMessage = { id: string @@ -27,12 +25,6 @@ function getOrCreateVisitorId(shop: string) { export default function StorefrontChatWidgetPage() { const [shop, setShop] = useState("") - - const [config, setConfig] = useState({ enabled: false }) - const [configLoading, setConfigLoading] = useState(true) - const [configError, setConfigError] = useState(null) - - const [isOpen, setIsOpen] = useState(false) const [visitorId, setVisitorId] = useState("") const [messages, setMessages] = useState([]) @@ -43,7 +35,28 @@ export default function StorefrontChatWidgetPage() { const composerRef = useRef(null) const messagesEndRef = useRef(null) - const apiShopEncoded = useMemo(() => encodeURIComponent(shop), [shop]) + useEffect(() => { + // This page is rendered inside a storefront iframe. Force a neutral + // full-height canvas to avoid inheriting dark app-shell backgrounds. + const html = document.documentElement + const body = document.body + const prevHtmlBg = html.style.backgroundColor + const prevBodyBg = body.style.backgroundColor + const prevBodyMargin = body.style.margin + const prevBodyMinHeight = body.style.minHeight + + html.style.backgroundColor = "#ffffff" + body.style.backgroundColor = "#ffffff" + body.style.margin = "0" + body.style.minHeight = "100vh" + + return () => { + html.style.backgroundColor = prevHtmlBg + body.style.backgroundColor = prevBodyBg + body.style.margin = prevBodyMargin + body.style.minHeight = prevBodyMinHeight + } + }, []) useEffect(() => { // Read query string on the client to avoid Next.js prerender warnings. @@ -61,63 +74,9 @@ export default function StorefrontChatWidgetPage() { setVisitorId(getOrCreateVisitorId(shop)) }, [shop]) - useEffect(() => { - if (!shop) { - setConfigLoading(false) - setConfigError("Missing shop parameter") - return - } - - let cancelled = false - const run = async () => { - try { - setConfigLoading(true) - setConfigError(null) - const res = await fetch(`/api/storefront-chat/config?shop=${apiShopEncoded}`) - const data = (await res.json().catch(() => ({}))) as Partial & { message?: string } - if (!res.ok) { - throw new Error(data.message || "Failed to fetch config") - } - if (!cancelled) { - setConfig({ enabled: !!data.enabled }) - } - } catch (e: any) { - if (!cancelled) { - setConfigError(e?.message || "Failed to load storefront chat config") - setConfig({ enabled: false }) - } - } finally { - if (!cancelled) setConfigLoading(false) - } - } - - run() - return () => { - cancelled = true - } - }, [shop, apiShopEncoded]) - - useEffect(() => { - if (!isOpen) return - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsOpen(false) - } - window.addEventListener("keydown", onKeyDown) - return () => window.removeEventListener("keydown", onKeyDown) - }, [isOpen]) - - useEffect(() => { - if (!isOpen) return - const prevOverflow = document.body.style.overflow - document.body.style.overflow = "hidden" - return () => { - document.body.style.overflow = prevOverflow - } - }, [isOpen]) - useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) - }, [messages, isOpen]) + }, [messages]) const send = async () => { if (!shop || !visitorId) return @@ -155,124 +114,83 @@ export default function StorefrontChatWidgetPage() { } } - // Widget is only visible if enabled for this store. - if (configLoading || !config.enabled) { + // Require shop context; otherwise render nothing. + if (!shop) { return null } return ( -
- {!isOpen ? ( - - ) : ( -
- {/* Backdrop + centering */} -
setIsOpen(false)} - aria-hidden="true" - /> +
+
+
+ +
+
+

JUNO Chat

+

Ask about products

+
+
-
e.stopPropagation()} - > -
-
-
- -
-
-

JUNO Chat

-

Ask about products

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

Start a conversation

-

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

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