diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts index c7e8fd1..84977bf 100644 --- a/src/app/api/stores/[storeId]/route.ts +++ b/src/app/api/stores/[storeId]/route.ts @@ -137,11 +137,90 @@ export async function PATCH( await prisma.store.update({ where: { id: storeId }, - data: { - storefrontChatEnabled - } + 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 + // won’t render because /api/storefront-chat/config returns the flag. + try { + const appBaseUrl = (process.env.NEXT_PUBLIC_APP_URL || "").trim() + const apiVersion = "2024-01" + const injectorBase = appBaseUrl ? appBaseUrl.replace(/\/$/, "") : "" + + const cleanDomain = (store.shopifyDomain || "") + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/$/, "") + .trim() + + const accessToken = store.shopifyAccessToken + + if (injectorBase && cleanDomain && accessToken) { + const injectorSrc = `${injectorBase}/storefront-chat/widget-injector` + const listUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?src=${encodeURIComponent(injectorSrc)}` + + const commonHeaders = { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json" + } + + const listRes = await fetch(listUrl, { headers: commonHeaders }) + const listData = await listRes.json().catch(() => ({})) + const existing: Array<{ id: number }> = Array.isArray(listData?.script_tags) + ? listData.script_tags + : [] + + if (storefrontChatEnabled) { + if (existing.length === 0) { + 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 }) + } + } + } 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 } + }) + + if (!delRes.ok) { + const errText = await delRes.text().catch(() => "") + console.error("[STORE_FRONT_CHAT_SCRIPTTAG_DELETE]", { storeId, tagId: tag.id, status: delRes.status, errText }) + } + } + } + } else { + console.warn("[STORE_FRONT_CHAT_SCRIPTTAG_SKIP]", { + storeId, + hasInjectorBase: Boolean(appBaseUrl), + cleanDomain, + hasAccessToken: Boolean(accessToken) + }) + } + } catch (scriptError) { + console.error("[STORE_FRONT_CHAT_SCRIPTTAG_ERROR]", scriptError) + } + return NextResponse.json({ success: true, storefrontChatEnabled diff --git a/src/app/storefront-chat/widget-injector/route.ts b/src/app/storefront-chat/widget-injector/route.ts new file mode 100644 index 0000000..04cbb6f --- /dev/null +++ b/src/app/storefront-chat/widget-injector/route.ts @@ -0,0 +1,84 @@ +/** + * 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; + var shop = window.location.hostname; + + var containerId = "juno-storefront-chat-container"; + var iframeId = "juno-storefront-chat-iframe"; + + function upsertIframe() { + 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 = "420px"; + container.style.maxWidth = "calc(100vw - 32px)"; + container.style.height = "640px"; + container.style.maxHeight = "calc(100vh - 32px)"; + container.style.borderRadius = "16px"; + document.body.appendChild(container); + } + + var existing = document.getElementById(iframeId); + if (existing) return; + + var iframe = document.createElement("iframe"); + iframe.id = iframeId; + iframe.title = "JUNO Storefront Chat"; + iframe.style.border = "0"; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.borderRadius = "16px"; + iframe.style.overflow = "hidden"; + iframe.allow = "clipboard-write; fullscreen"; + iframe.referrerPolicy = "no-referrer"; + iframe.src = baseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop); + container.appendChild(iframe); + } + + 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) upsertIframe(); + 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" + } + }) +} +