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
This commit is contained in:
talatops 2026-04-02 17:07:41 +05:00
parent 7fe7ac9cac
commit ce5049a64f
7 changed files with 469 additions and 539 deletions

View File

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

View File

@ -1,6 +1,17 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma" 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=<shop-domain> * GET /api/storefront-chat/config?shop=<shop-domain>
* Public endpoint used by the storefront widget bootstrap. * Public endpoint used by the storefront widget bootstrap.
@ -11,25 +22,26 @@ export async function GET(req: Request) {
const shop = searchParams.get("shop") const shop = searchParams.get("shop")
if (!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 }, where: { shopifyDomain: shop },
select: { id: true, storefrontChatEnabled: true } select: { id: true, storefrontChatEnabled: true }
}) })
if (!store) { if (!store) {
// If we don't know the store, don't show the widget. // 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 enabled: store.storefrontChatEnabled
}) }))
} catch (error) { } catch (error) {
console.error("[STORE_FRONT_CHAT_CONFIG]", 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 }))
} }
} }

View File

@ -1,6 +1,13 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import {
getPublishedThemeId,
getThemeAsset,
putThemeAsset,
removeThemeLiquidInjection,
upsertThemeLiquidInjection
} from "@/lib/shopify/theme"
/** /**
* DELETE /api/stores/[storeId] * DELETE /api/stores/[storeId]
@ -140,8 +147,8 @@ export async function PATCH(
data: { storefrontChatEnabled } as any data: { storefrontChatEnabled } as any
}) })
// Best-effort: keep a Shopify ScriptTag in sync with the checkbox. // Best-effort: keep the PUBLISHED Shopify theme in sync with the checkbox.
// Even if ScriptTag operations fail (missing scopes), the widget still // Even if theme operations fail (missing scopes), the widget still
// won’t render because /api/storefront-chat/config returns the flag. // won’t render because /api/storefront-chat/config returns the flag.
try { try {
const apiVersion = "2024-01" const apiVersion = "2024-01"
@ -154,105 +161,238 @@ export async function PATCH(
const accessToken = store.shopifyAccessToken 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: <APP_URL>/api/shopify/app-proxy
if (cleanDomain && accessToken) { if (cleanDomain && accessToken) {
const injectorPath = `https://${cleanDomain}/apps/juno-chat/widget-injector.js` console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", { storeId, enabled: storefrontChatEnabled, shop: cleanDomain })
const injectorSrc = injectorPath
// Shopify's `src` filter is exact-match; we want to delete any older variants too. // Cleanup legacy ScriptTags from earlier implementations so storefront
// So we list a small page and filter by prefix instead. // stops loading old ngrok/app-proxy/widget-injector sources.
const listUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?limit=250&fields=id,src` const listLegacyTags = await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?limit=250&fields=id,src`, {
headers: {
const commonHeaders = {
"X-Shopify-Access-Token": accessToken, "X-Shopify-Access-Token": accessToken,
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}).then((r) => r.json().catch(() => ({}))).catch(() => ({}))
const listRes = await fetch(listUrl, { headers: commonHeaders }) const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags)
const listData = await listRes.json().catch(() => ({})) ? listLegacyTags.script_tags
const allTags: Array<{ id: number; src?: string }> = Array.isArray(listData?.script_tags)
? listData.script_tags
: [] : []
const existing = allTags.filter((t) => { const legacyMatches = legacyTags.filter((t) => {
const src = typeof t?.src === "string" ? t.src : "" const src = typeof t?.src === "string" ? t.src : ""
return src === injectorSrc || src.startsWith(injectorPath) return src.includes("/storefront-chat/widget-injector") || src.includes("/apps/juno-chat/widget-injector.js")
}) })
for (const tag of legacyMatches) {
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 if (!tag?.id) continue
const deleteUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json` await fetch(`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", method: "DELETE",
headers: { "X-Shopify-Access-Token": accessToken } headers: { "X-Shopify-Access-Token": accessToken }
}).catch(() => {}) }).catch(() => {})
} }
const createUrl = `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json` const published = await getPublishedThemeId(cleanDomain, accessToken, apiVersion)
const createBody = { if (!published.ok) {
script_tag: { console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, status: published.status, text: published.text })
event: "onload", } else {
src: injectorSrc, const themeId = published.data.themeId
display_scope: "online_store", const themeLiquidKey = "layout/theme.liquid"
cache: false 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 = `
(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);
} }
const createRes = await fetch(createUrl, { function openPanel() {
method: "POST", ensurePanel();
headers: commonHeaders, var panel = document.getElementById(panelId);
body: JSON.stringify(createBody) var backdrop = document.getElementById(backdropId);
}) if (panel) panel.style.display = "block";
if (backdrop) backdrop.style.display = "block";
if (!createRes.ok) {
const errText = await createRes.text().catch(() => "")
console.error("[STORE_FRONT_CHAT_SCRIPTTAG_CREATE]", { storeId, status: createRes.status, errText })
} }
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 { } else {
for (const tag of existing) { console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId, enabled: storefrontChatEnabled, themeId, changed: false })
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 { } else {
console.warn("[STORE_FRONT_CHAT_SCRIPTTAG_SKIP]", { console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
storeId, storeId,
cleanDomain, cleanDomain,
hasAccessToken: Boolean(accessToken) hasAccessToken: Boolean(accessToken)
}) })
} }
} catch (scriptError) { } catch (scriptError) {
console.error("[STORE_FRONT_CHAT_SCRIPTTAG_ERROR]", scriptError) console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError)
} }
return NextResponse.json({ return NextResponse.json({

View File

@ -186,7 +186,7 @@ export default function OnboardingClient() {
<AlertCircle className="w-6 h-6 text-indigo-400 shrink-0" /> <AlertCircle className="w-6 h-6 text-indigo-400 shrink-0" />
<div className="text-sm text-slate-300 leading-relaxed"> <div className="text-sm text-slate-300 leading-relaxed">
<p className="font-bold mb-1 text-white">How to get your credentials?</p> <p className="font-bold mb-1 text-white">How to get your credentials?</p>
Go to your Shopify Admin &gt; Settings &gt; Apps and sales channels &gt; Develop apps. Create an app and configure Admin API scopes. Go to your Shopify Admin &gt; Settings &gt; Apps and sales channels &gt; Develop apps. Create an app and configure Admin API scopes. For the storefront chat widget auto-injection, enable <span className="font-bold text-white">read_themes</span> and <span className="font-bold text-white">write_themes</span> too.
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

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

View File

@ -1,9 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { MessageSquare, Send, X } from "lucide-react" import { MessageSquare, Send } from "lucide-react"
type StorefrontConfig = { enabled: boolean }
type WidgetMessage = { type WidgetMessage = {
id: string id: string
@ -27,12 +25,6 @@ function getOrCreateVisitorId(shop: string) {
export default function StorefrontChatWidgetPage() { export default function StorefrontChatWidgetPage() {
const [shop, setShop] = useState<string>("") const [shop, setShop] = useState<string>("")
const [config, setConfig] = useState<StorefrontConfig>({ enabled: false })
const [configLoading, setConfigLoading] = useState(true)
const [configError, setConfigError] = useState<string | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [visitorId, setVisitorId] = useState<string>("") const [visitorId, setVisitorId] = useState<string>("")
const [messages, setMessages] = useState<WidgetMessage[]>([]) const [messages, setMessages] = useState<WidgetMessage[]>([])
@ -43,7 +35,28 @@ export default function StorefrontChatWidgetPage() {
const composerRef = useRef<HTMLTextAreaElement | null>(null) const composerRef = useRef<HTMLTextAreaElement | null>(null)
const messagesEndRef = useRef<HTMLDivElement | null>(null) const messagesEndRef = useRef<HTMLDivElement | null>(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(() => { useEffect(() => {
// Read query string on the client to avoid Next.js prerender warnings. // Read query string on the client to avoid Next.js prerender warnings.
@ -61,63 +74,9 @@ export default function StorefrontChatWidgetPage() {
setVisitorId(getOrCreateVisitorId(shop)) setVisitorId(getOrCreateVisitorId(shop))
}, [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<StorefrontConfig> & { 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(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, isOpen]) }, [messages])
const send = async () => { const send = async () => {
if (!shop || !visitorId) return if (!shop || !visitorId) return
@ -155,39 +114,14 @@ export default function StorefrontChatWidgetPage() {
} }
} }
// Widget is only visible if enabled for this store. // Require shop context; otherwise render nothing.
if (configLoading || !config.enabled) { if (!shop) {
return null return null
} }
return ( return (
<div className="w-full h-full relative"> <div className="w-screen h-screen overflow-hidden bg-white text-slate-900 flex flex-col">
{!isOpen ? ( <div className="p-3 border-b border-slate-200 bg-white flex items-center gap-3 flex-shrink-0">
<button
type="button"
onClick={() => setIsOpen(true)}
className="absolute bottom-5 right-5 z-[9999] w-14 h-14 rounded-2xl bg-indigo-600 shadow-xl hover:bg-indigo-700 transition-all flex items-center justify-center text-white"
aria-label="Open chat"
>
<MessageSquare className="w-6 h-6" />
</button>
) : (
<div className="fixed inset-0 z-[9999]">
{/* Backdrop + centering */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<div
className="absolute bottom-6 right-6 w-[360px] max-w-[calc(100vw-24px)] h-[520px] max-h-[calc(100vh-24px)] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col"
role="dialog"
aria-label="Chat widget"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center text-indigo-700"> <div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center text-indigo-700">
<MessageSquare className="w-5 h-5" /> <MessageSquare className="w-5 h-5" />
</div> </div>
@ -196,15 +130,6 @@ export default function StorefrontChatWidgetPage() {
<p className="text-[11px] text-slate-500">Ask about products</p> <p className="text-[11px] text-slate-500">Ask about products</p>
</div> </div>
</div> </div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-9 h-9 rounded-xl hover:bg-slate-100 border border-slate-200 flex items-center justify-center"
aria-label="Close chat"
>
<X className="w-4 h-4 text-slate-600" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-white to-slate-50"> <div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-white to-slate-50">
{sendError ? ( {sendError ? (
@ -236,15 +161,15 @@ export default function StorefrontChatWidgetPage() {
)} )}
</div> </div>
<div className="p-4 border-t bg-white flex-shrink-0"> <div className="p-3 border-t border-slate-200 bg-white flex-shrink-0">
<div className="flex items-end gap-3"> <div className="flex items-end gap-2">
<textarea <textarea
ref={composerRef} ref={composerRef}
rows={1} rows={1}
value={composer} value={composer}
onChange={(e) => setComposer(e.target.value)} onChange={(e) => setComposer(e.target.value)}
placeholder="Type a message..." placeholder="Type a message..."
className="flex-1 resize-none border rounded-2xl px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30" className="flex-1 resize-none border border-slate-200 bg-white rounded-2xl px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 outline-none focus:ring-2 focus:ring-indigo-500/30"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() e.preventDefault()
@ -256,24 +181,17 @@ export default function StorefrontChatWidgetPage() {
type="button" type="button"
onClick={() => void send()} onClick={() => void send()}
disabled={sending || !composer.trim()} disabled={sending || !composer.trim()}
className="w-11 h-11 rounded-2xl bg-indigo-600 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center transition-all" className="w-11 h-11 rounded-2xl bg-indigo-600 text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center transition-all"
aria-label="Send message" aria-label="Send message"
> >
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
</button> </button>
</div> </div>
{configError ? (
<p className="mt-2 text-[11px] text-slate-500">{configError}</p>
) : (
<p className="mt-2 text-[11px] text-slate-500"> <p className="mt-2 text-[11px] text-slate-500">
Customer messages are saved now. AI replies later. Customer messages are saved now. AI replies later.
</p> </p>
)}
</div> </div>
</div> </div>
</div>
)}
</div>
) )
} }

132
src/lib/shopify/theme.ts Normal file
View File

@ -0,0 +1,132 @@
export const JUNO_STOREFRONT_CHAT_MARKER_START = "<!-- JUNO_STOREFRONT_CHAT_START -->"
export const JUNO_STOREFRONT_CHAT_MARKER_END = "<!-- JUNO_STOREFRONT_CHAT_END -->"
export function buildThemeInjectionSnippet(assetFilename: string) {
return [
JUNO_STOREFRONT_CHAT_MARKER_START,
`<script src="{{ '${assetFilename}' | asset_url }}" defer></script>`,
JUNO_STOREFRONT_CHAT_MARKER_END,
].join("\n")
}
export function upsertThemeLiquidInjection(themeLiquid: string, assetFilename: string) {
const startIdx = themeLiquid.indexOf(JUNO_STOREFRONT_CHAT_MARKER_START)
const endIdx = themeLiquid.indexOf(JUNO_STOREFRONT_CHAT_MARKER_END)
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
return { updated: false, next: themeLiquid }
}
const snippet = buildThemeInjectionSnippet(assetFilename) + "\n"
const headClose = themeLiquid.lastIndexOf("</head>")
if (headClose !== -1) {
const next = themeLiquid.slice(0, headClose) + snippet + themeLiquid.slice(headClose)
return { updated: true, next }
}
const bodyClose = themeLiquid.lastIndexOf("</body>")
if (bodyClose !== -1) {
const next = themeLiquid.slice(0, bodyClose) + snippet + themeLiquid.slice(bodyClose)
return { updated: true, next }
}
// Give up safely: do not mutate unknown layouts.
return { updated: false, next: themeLiquid, error: "Could not find </head> or </body> in theme.liquid" }
}
export function removeThemeLiquidInjection(themeLiquid: string) {
const startIdx = themeLiquid.indexOf(JUNO_STOREFRONT_CHAT_MARKER_START)
const endIdx = themeLiquid.indexOf(JUNO_STOREFRONT_CHAT_MARKER_END)
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
return { updated: false, next: themeLiquid }
}
const afterEnd = endIdx + JUNO_STOREFRONT_CHAT_MARKER_END.length
// Remove trailing newline if present to avoid leaving blank lines.
const next = themeLiquid.slice(0, startIdx) + themeLiquid.slice(afterEnd).replace(/^\s*\n/, "")
return { updated: true, next }
}
export async function shopifyFetchJson<T>(
shopDomain: string,
accessToken: string,
apiVersion: string,
path: string,
init?: RequestInit
): Promise<{ ok: true; data: T } | { ok: false; status: number; text: string }> {
const url = `https://${shopDomain}/admin/api/${apiVersion}${path}`
const res = await fetch(url, {
...init,
headers: {
"X-Shopify-Access-Token": accessToken,
"Content-Type": "application/json",
...(init?.headers || {}),
},
})
if (!res.ok) {
const text = await res.text().catch(() => "")
return { ok: false, status: res.status, text }
}
const data = (await res.json().catch(() => ({}))) as T
return { ok: true, data }
}
export async function getPublishedThemeId(shopDomain: string, accessToken: string, apiVersion: string) {
const out = await shopifyFetchJson<{ themes?: Array<{ id: number; role?: string }> }>(
shopDomain,
accessToken,
apiVersion,
"/themes.json?fields=id,role"
)
if (!out.ok) return out
const themes = Array.isArray(out.data.themes) ? out.data.themes : []
const main = themes.find((t) => t?.role === "main")
if (!main?.id) {
return { ok: false as const, status: 404, text: "No published theme found (role=main)" }
}
return { ok: true as const, data: { themeId: main.id } }
}
export async function getThemeAsset(
shopDomain: string,
accessToken: string,
apiVersion: string,
themeId: number,
assetKey: string
) {
const out = await shopifyFetchJson<{ asset?: { key?: string; value?: string } }>(
shopDomain,
accessToken,
apiVersion,
`/themes/${themeId}/assets.json?asset[key]=${encodeURIComponent(assetKey)}`
)
if (!out.ok) return out
const value = out.data?.asset?.value
if (typeof value !== "string") {
return { ok: false as const, status: 404, text: `Asset ${assetKey} not found or not readable` }
}
return { ok: true as const, data: { value } }
}
export async function putThemeAsset(
shopDomain: string,
accessToken: string,
apiVersion: string,
themeId: number,
assetKey: string,
assetValue: string
) {
return await shopifyFetchJson<{ asset?: { key?: string } }>(
shopDomain,
accessToken,
apiVersion,
`/themes/${themeId}/assets.json`,
{
method: "PUT",
body: JSON.stringify({ asset: { key: assetKey, value: assetValue } }),
}
)
}