feat(storefront): script tag toggle injection
Made-with: Cursor
This commit is contained in:
parent
8af711a6c9
commit
ec600012d1
@ -137,11 +137,90 @@ export async function PATCH(
|
|||||||
|
|
||||||
await prisma.store.update({
|
await prisma.store.update({
|
||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
data: {
|
data: { storefrontChatEnabled } as any
|
||||||
storefrontChatEnabled
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
storefrontChatEnabled
|
storefrontChatEnabled
|
||||||
|
|||||||
84
src/app/storefront-chat/widget-injector/route.ts
Normal file
84
src/app/storefront-chat/widget-injector/route.ts
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user