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