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:
parent
7fe7ac9cac
commit
ce5049a64f
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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=<shop-domain>
|
||||
* 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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: <APP_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 = {
|
||||
// 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"
|
||||
}
|
||||
|
||||
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
|
||||
}).then((r) => r.json().catch(() => ({}))).catch(() => ({}))
|
||||
const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags)
|
||||
? listLegacyTags.script_tags
|
||||
: []
|
||||
const existing = allTags.filter((t) => {
|
||||
const legacyMatches = legacyTags.filter((t) => {
|
||||
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")
|
||||
})
|
||||
|
||||
if (storefrontChatEnabled) {
|
||||
// Ensure exactly one matching tag exists; clean up duplicates/old variants.
|
||||
if (existing.length > 1) {
|
||||
for (const tag of existing.slice(1)) {
|
||||
for (const tag of legacyMatches) {
|
||||
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, {
|
||||
await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json`, {
|
||||
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 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 = `
|
||||
(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, {
|
||||
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 })
|
||||
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 {
|
||||
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 })
|
||||
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({
|
||||
|
||||
@ -186,7 +186,7 @@ export default function OnboardingClient() {
|
||||
<AlertCircle className="w-6 h-6 text-indigo-400 shrink-0" />
|
||||
<div className="text-sm text-slate-300 leading-relaxed">
|
||||
<p className="font-bold mb-1 text-white">How to get your credentials?</p>
|
||||
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 <span className="font-bold text-white">read_themes</span> and <span className="font-bold text-white">write_themes</span> too.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@ -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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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<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 [messages, setMessages] = useState<WidgetMessage[]>([])
|
||||
@ -43,7 +35,28 @@ export default function StorefrontChatWidgetPage() {
|
||||
const composerRef = useRef<HTMLTextAreaElement | 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(() => {
|
||||
// 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<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(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, isOpen])
|
||||
}, [messages])
|
||||
|
||||
const send = async () => {
|
||||
if (!shop || !visitorId) return
|
||||
@ -155,39 +114,14 @@ 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 (
|
||||
<div className="w-full h-full relative">
|
||||
{!isOpen ? (
|
||||
<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-screen h-screen overflow-hidden bg-white text-slate-900 flex flex-col">
|
||||
<div className="p-3 border-b border-slate-200 bg-white flex items-center gap-3 flex-shrink-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">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
@ -196,15 +130,6 @@ export default function StorefrontChatWidgetPage() {
|
||||
<p className="text-[11px] text-slate-500">Ask about products</p>
|
||||
</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">
|
||||
{sendError ? (
|
||||
@ -236,15 +161,15 @@ export default function StorefrontChatWidgetPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t bg-white flex-shrink-0">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="p-3 border-t border-slate-200 bg-white flex-shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
rows={1}
|
||||
value={composer}
|
||||
onChange={(e) => setComposer(e.target.value)}
|
||||
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) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
@ -256,24 +181,17 @@ export default function StorefrontChatWidgetPage() {
|
||||
type="button"
|
||||
onClick={() => void send()}
|
||||
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"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{configError ? (
|
||||
<p className="mt-2 text-[11px] text-slate-500">{configError}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
Customer messages are saved now. AI replies later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
132
src/lib/shopify/theme.ts
Normal file
132
src/lib/shopify/theme.ts
Normal 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 } }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user