feat(storefront-chat): add vendor customization modal and manual inject flow
Introduce per-store chat branding (name, tagline, color, font, logo), open customization on enable, and separate Save vs INJECT actions so theme sync is explicitly vendor-triggered while preserving safe disable behavior. Made-with: Cursor
This commit is contained in:
parent
9f5cd954e0
commit
7b2cb3485e
@ -0,0 +1,7 @@
|
|||||||
|
-- Add vendor-configurable storefront chat branding fields.
|
||||||
|
ALTER TABLE "Store"
|
||||||
|
ADD COLUMN "storefrontChatName" TEXT,
|
||||||
|
ADD COLUMN "storefrontChatTagline" TEXT,
|
||||||
|
ADD COLUMN "storefrontChatBrandColor" TEXT,
|
||||||
|
ADD COLUMN "storefrontChatFontFamily" TEXT,
|
||||||
|
ADD COLUMN "storefrontChatLogoUrl" TEXT;
|
||||||
@ -109,6 +109,11 @@ model Store {
|
|||||||
|
|
||||||
// Customer-facing storefront widget (floating chat) enablement.
|
// Customer-facing storefront widget (floating chat) enablement.
|
||||||
storefrontChatEnabled Boolean @default(false)
|
storefrontChatEnabled Boolean @default(false)
|
||||||
|
storefrontChatName String?
|
||||||
|
storefrontChatTagline String?
|
||||||
|
storefrontChatBrandColor String?
|
||||||
|
storefrontChatFontFamily String?
|
||||||
|
storefrontChatLogoUrl String?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
connections Connection[]
|
connections Connection[]
|
||||||
|
|||||||
@ -110,106 +110,74 @@ export async function DELETE(
|
|||||||
* PATCH /api/stores/[storeId]
|
* PATCH /api/stores/[storeId]
|
||||||
* Update per-store configuration (vendor only).
|
* Update per-store configuration (vendor only).
|
||||||
*/
|
*/
|
||||||
export async function PATCH(
|
const ALLOWED_FONTS = new Set([
|
||||||
request: Request,
|
"system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
|
||||||
{ params }: { params: Promise<{ storeId: string }> }
|
"Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
|
||||||
) {
|
"Poppins, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
|
||||||
|
"Montserrat, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
|
||||||
|
"Nunito, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
|
||||||
|
])
|
||||||
|
|
||||||
|
const DEFAULT_CHAT_NAME = "JUNO Chat"
|
||||||
|
const DEFAULT_TAGLINE = "Ask about products"
|
||||||
|
const DEFAULT_BRAND_COLOR = "#4f46e5"
|
||||||
|
const DEFAULT_FONT = "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif"
|
||||||
|
|
||||||
|
function sanitizeColor(value: unknown) {
|
||||||
|
if (typeof value !== "string") return DEFAULT_BRAND_COLOR
|
||||||
|
const v = value.trim()
|
||||||
|
return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(v) ? v : DEFAULT_BRAND_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeShortText(value: unknown, fallback: string, max = 64) {
|
||||||
|
if (typeof value !== "string") return fallback
|
||||||
|
const v = value.trim()
|
||||||
|
if (!v) return fallback
|
||||||
|
return v.slice(0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFont(value: unknown) {
|
||||||
|
if (typeof value !== "string") return DEFAULT_FONT
|
||||||
|
const v = value.trim()
|
||||||
|
return ALLOWED_FONTS.has(v) ? v : DEFAULT_FONT
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeLogoUrl(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
const v = value.trim()
|
||||||
|
if (!v) return null
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const parsed = new URL(v)
|
||||||
if (!session?.user?.id) {
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null
|
||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return parsed.toString().slice(0, 500)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storeId } = await params
|
function buildStorefrontScript(params: {
|
||||||
if (!storeId) {
|
appBaseUrl: string
|
||||||
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
chatName: string
|
||||||
}
|
tagline: string
|
||||||
|
brandColor: string
|
||||||
|
fontFamily: string
|
||||||
|
logoUrl: string | null
|
||||||
|
}) {
|
||||||
|
const { appBaseUrl, chatName, tagline, brandColor, fontFamily, logoUrl } = params
|
||||||
|
|
||||||
const { storefrontChatEnabled } = await request.json().catch(() => ({}))
|
return `
|
||||||
if (typeof storefrontChatEnabled !== "boolean") {
|
|
||||||
return NextResponse.json({ message: "Invalid storefrontChatEnabled" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify store belongs to the user
|
|
||||||
const store = await prisma.store.findFirst({
|
|
||||||
where: {
|
|
||||||
id: storeId,
|
|
||||||
userId: session.user.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!store) {
|
|
||||||
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.store.update({
|
|
||||||
where: { id: storeId },
|
|
||||||
data: { storefrontChatEnabled } as any
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
|
|
||||||
const cleanDomain = (store.shopifyDomain || "")
|
|
||||||
.trim()
|
|
||||||
.replace(/^https?:\/\//, "")
|
|
||||||
.replace(/\/$/, "")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
const accessToken = store.shopifyAccessToken
|
|
||||||
|
|
||||||
if (cleanDomain && accessToken) {
|
|
||||||
console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", { storeId, enabled: storefrontChatEnabled, shop: cleanDomain })
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}).then((r) => r.json().catch(() => ({}))).catch(() => ({}))
|
|
||||||
const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags)
|
|
||||||
? listLegacyTags.script_tags
|
|
||||||
: []
|
|
||||||
const legacyMatches = legacyTags.filter((t) => {
|
|
||||||
const src = typeof t?.src === "string" ? t.src : ""
|
|
||||||
return src.includes("/storefront-chat/widget-injector") || src.includes("/apps/juno-chat/widget-injector.js")
|
|
||||||
})
|
|
||||||
for (const tag of legacyMatches) {
|
|
||||||
if (!tag?.id) continue
|
|
||||||
await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "X-Shopify-Access-Token": accessToken }
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var appBaseUrl = ${JSON.stringify(appBaseUrl)};
|
var appBaseUrl = ${JSON.stringify(appBaseUrl)};
|
||||||
if (!appBaseUrl) return;
|
if (!appBaseUrl) return;
|
||||||
var shop = (window.Shopify && (window.Shopify.shop || window.Shopify.myshopifyDomain)) || window.location.hostname;
|
var shop = (window.Shopify && (window.Shopify.shop || window.Shopify.myshopifyDomain)) || window.location.hostname;
|
||||||
|
|
||||||
|
var chatName = ${JSON.stringify(chatName)};
|
||||||
|
var tagline = ${JSON.stringify(tagline)};
|
||||||
|
var brandColor = ${JSON.stringify(brandColor)};
|
||||||
|
var fontFamily = ${JSON.stringify(fontFamily)};
|
||||||
|
var logoUrl = ${JSON.stringify(logoUrl)};
|
||||||
|
|
||||||
var containerId = "juno-storefront-chat-container";
|
var containerId = "juno-storefront-chat-container";
|
||||||
var buttonId = "juno-storefront-chat-button";
|
var buttonId = "juno-storefront-chat-button";
|
||||||
var panelId = "juno-storefront-chat-panel";
|
var panelId = "juno-storefront-chat-panel";
|
||||||
@ -248,6 +216,49 @@ export async function PATCH(
|
|||||||
panel.style.boxShadow = "0 30px 60px rgba(2,6,23,0.28)";
|
panel.style.boxShadow = "0 30px 60px rgba(2,6,23,0.28)";
|
||||||
panel.style.zIndex = "2147483646";
|
panel.style.zIndex = "2147483646";
|
||||||
panel.style.display = "none";
|
panel.style.display = "none";
|
||||||
|
panel.style.fontFamily = fontFamily;
|
||||||
|
|
||||||
|
var header = document.createElement("div");
|
||||||
|
header.style.height = "56px";
|
||||||
|
header.style.display = "flex";
|
||||||
|
header.style.alignItems = "center";
|
||||||
|
header.style.gap = "10px";
|
||||||
|
header.style.padding = "0 12px";
|
||||||
|
header.style.borderBottom = "1px solid rgba(15,23,42,0.08)";
|
||||||
|
header.style.background = "#fff";
|
||||||
|
|
||||||
|
var logoWrap = document.createElement("div");
|
||||||
|
logoWrap.style.width = "30px";
|
||||||
|
logoWrap.style.height = "30px";
|
||||||
|
logoWrap.style.borderRadius = "10px";
|
||||||
|
logoWrap.style.flexShrink = "0";
|
||||||
|
logoWrap.style.display = "flex";
|
||||||
|
logoWrap.style.alignItems = "center";
|
||||||
|
logoWrap.style.justifyContent = "center";
|
||||||
|
logoWrap.style.background = "rgba(79,70,229,0.12)";
|
||||||
|
logoWrap.style.border = "1px solid rgba(79,70,229,0.2)";
|
||||||
|
if (logoUrl) {
|
||||||
|
var logo = document.createElement("img");
|
||||||
|
logo.src = logoUrl;
|
||||||
|
logo.alt = chatName;
|
||||||
|
logo.style.width = "20px";
|
||||||
|
logo.style.height = "20px";
|
||||||
|
logo.style.objectFit = "contain";
|
||||||
|
logoWrap.appendChild(logo);
|
||||||
|
} else {
|
||||||
|
logoWrap.innerHTML = "<span style=\\"font-weight:700;color:#4338ca;font-size:12px\\">J</span>";
|
||||||
|
}
|
||||||
|
header.appendChild(logoWrap);
|
||||||
|
|
||||||
|
var headerText = document.createElement("div");
|
||||||
|
headerText.style.minWidth = "0";
|
||||||
|
headerText.innerHTML = "<div style=\\"font-size:13px;font-weight:800;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
|
||||||
|
String(chatName || "Chat") +
|
||||||
|
"</div><div style=\\"font-size:11px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
|
||||||
|
String(tagline || "") +
|
||||||
|
"</div>";
|
||||||
|
header.appendChild(headerText);
|
||||||
|
panel.appendChild(header);
|
||||||
|
|
||||||
var closeBtn = document.createElement("button");
|
var closeBtn = document.createElement("button");
|
||||||
closeBtn.id = closeId;
|
closeBtn.id = closeId;
|
||||||
@ -271,10 +282,10 @@ export async function PATCH(
|
|||||||
|
|
||||||
var iframe = document.createElement("iframe");
|
var iframe = document.createElement("iframe");
|
||||||
iframe.id = iframeId;
|
iframe.id = iframeId;
|
||||||
iframe.title = "JUNO Chat";
|
iframe.title = chatName || "Chat";
|
||||||
iframe.src = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop);
|
iframe.src = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop);
|
||||||
iframe.style.width = "100%";
|
iframe.style.width = "100%";
|
||||||
iframe.style.height = "100%";
|
iframe.style.height = "calc(100% - 56px)";
|
||||||
iframe.style.border = "0";
|
iframe.style.border = "0";
|
||||||
iframe.style.background = "white";
|
iframe.style.background = "white";
|
||||||
panel.appendChild(iframe);
|
panel.appendChild(iframe);
|
||||||
@ -324,80 +335,236 @@ export async function PATCH(
|
|||||||
btn.style.border = "0";
|
btn.style.border = "0";
|
||||||
btn.style.cursor = "pointer";
|
btn.style.cursor = "pointer";
|
||||||
btn.style.boxShadow = "0 12px 28px rgba(0,0,0,0.20)";
|
btn.style.boxShadow = "0 12px 28px rgba(0,0,0,0.20)";
|
||||||
btn.style.background = "#4f46e5";
|
btn.style.background = brandColor || "#4f46e5";
|
||||||
btn.style.color = "#fff";
|
btn.style.color = "#fff";
|
||||||
btn.style.display = "flex";
|
btn.style.display = "flex";
|
||||||
btn.style.alignItems = "center";
|
btn.style.alignItems = "center";
|
||||||
btn.style.justifyContent = "center";
|
btn.style.justifyContent = "center";
|
||||||
btn.style.fontFamily = "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
|
btn.style.fontFamily = fontFamily;
|
||||||
btn.style.fontWeight = "800";
|
btn.style.fontWeight = "800";
|
||||||
btn.style.letterSpacing = "-0.02em";
|
btn.style.letterSpacing = "-0.02em";
|
||||||
btn.innerHTML = "Chat";
|
btn.innerHTML = "Chat";
|
||||||
|
btn.addEventListener("click", function () { openPanel(); });
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
openPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeWidget() {
|
function removeWidget() {
|
||||||
var container = document.getElementById(containerId);
|
var container = document.getElementById(containerId);
|
||||||
|
var panel = document.getElementById(panelId);
|
||||||
|
var backdrop = document.getElementById(backdropId);
|
||||||
if (container && container.parentNode) container.parentNode.removeChild(container);
|
if (container && container.parentNode) container.parentNode.removeChild(container);
|
||||||
|
if (panel && panel.parentNode) panel.parentNode.removeChild(panel);
|
||||||
|
if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
removeWidget();
|
||||||
upsertButton();
|
upsertButton();
|
||||||
ensurePanel();
|
ensurePanel();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
`.trimStart()
|
`.trimStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncStorefrontTheme(params: {
|
||||||
|
storeId: string
|
||||||
|
enabled: boolean
|
||||||
|
shopifyDomain: string
|
||||||
|
shopifyAccessToken: string
|
||||||
|
chatName: string
|
||||||
|
tagline: string
|
||||||
|
brandColor: string
|
||||||
|
fontFamily: string
|
||||||
|
logoUrl: string | null
|
||||||
|
}) {
|
||||||
|
const apiVersion = "2024-01"
|
||||||
|
const cleanDomain = (params.shopifyDomain || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
.trim()
|
||||||
|
const accessToken = params.shopifyAccessToken
|
||||||
|
|
||||||
|
if (!cleanDomain || !accessToken) {
|
||||||
|
console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
|
||||||
|
storeId: params.storeId,
|
||||||
|
cleanDomain,
|
||||||
|
hasAccessToken: Boolean(accessToken),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", {
|
||||||
|
storeId: params.storeId,
|
||||||
|
enabled: params.enabled,
|
||||||
|
shop: cleanDomain,
|
||||||
|
})
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).then((r) => r.json().catch(() => ({}))).catch(() => ({}))
|
||||||
|
|
||||||
|
const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags)
|
||||||
|
? listLegacyTags.script_tags
|
||||||
|
: []
|
||||||
|
const legacyMatches = legacyTags.filter((t) => {
|
||||||
|
const src = typeof t?.src === "string" ? t.src : ""
|
||||||
|
return src.includes("/storefront-chat/widget-injector") || src.includes("/apps/juno-chat/widget-injector.js")
|
||||||
|
})
|
||||||
|
for (const tag of legacyMatches) {
|
||||||
|
if (!tag?.id) continue
|
||||||
|
await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "X-Shopify-Access-Token": accessToken },
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const published = await getPublishedThemeId(cleanDomain, accessToken, apiVersion)
|
||||||
|
if (!published.ok) {
|
||||||
|
throw new Error(`Published theme fetch failed: ${published.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = buildStorefrontScript({
|
||||||
|
appBaseUrl,
|
||||||
|
chatName: params.chatName,
|
||||||
|
tagline: params.tagline,
|
||||||
|
brandColor: params.brandColor,
|
||||||
|
fontFamily: params.fontFamily,
|
||||||
|
logoUrl: params.logoUrl,
|
||||||
|
})
|
||||||
|
|
||||||
// Always upload/update the asset (safe & idempotent).
|
|
||||||
const putAsset = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, assetKey, js)
|
const putAsset = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, assetKey, js)
|
||||||
if (!putAsset.ok) {
|
if (!putAsset.ok) {
|
||||||
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "put_asset", status: putAsset.status, text: putAsset.text })
|
throw new Error(`Asset update failed: ${putAsset.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeLiquid = await getThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey)
|
const themeLiquid = await getThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey)
|
||||||
if (!themeLiquid.ok) {
|
if (!themeLiquid.ok) {
|
||||||
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "get_theme_liquid", status: themeLiquid.status, text: themeLiquid.text })
|
throw new Error(`Theme liquid read failed: ${themeLiquid.status}`)
|
||||||
} else {
|
}
|
||||||
const updated = storefrontChatEnabled
|
|
||||||
|
const updated = params.enabled
|
||||||
? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
|
? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
|
||||||
: removeThemeLiquidInjection(themeLiquid.data.value)
|
: removeThemeLiquidInjection(themeLiquid.data.value)
|
||||||
|
|
||||||
if (!updated.updated && (updated as any).error) {
|
if (!updated.updated && (updated as any).error) {
|
||||||
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "patch_theme_liquid", error: (updated as any).error })
|
throw new Error((updated as any).error)
|
||||||
} else if (updated.updated) {
|
}
|
||||||
|
if (updated.updated) {
|
||||||
const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
|
const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
|
||||||
if (!putThemeLiquid.ok) {
|
if (!putThemeLiquid.ok) {
|
||||||
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", { storeId, step: "put_theme_liquid", status: putThemeLiquid.status, text: putThemeLiquid.text })
|
throw new Error(`Theme liquid update failed: ${putThemeLiquid.status}`)
|
||||||
} else {
|
|
||||||
console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId, enabled: storefrontChatEnabled, themeId })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId, enabled: storefrontChatEnabled, themeId, changed: false })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId: params.storeId, enabled: params.enabled, themeId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await request.json().catch(() => ({}))
|
||||||
|
const data: Record<string, unknown> = (payload && typeof payload === "object") ? payload : {}
|
||||||
|
|
||||||
|
// Verify store belongs to the user
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", {
|
|
||||||
storeId,
|
|
||||||
cleanDomain,
|
|
||||||
hasAccessToken: Boolean(accessToken)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextEnabled = typeof data.storefrontChatEnabled === "boolean"
|
||||||
|
? data.storefrontChatEnabled
|
||||||
|
: store.storefrontChatEnabled
|
||||||
|
|
||||||
|
const nextChatName = sanitizeShortText(data.storefrontChatName, store.storefrontChatName || DEFAULT_CHAT_NAME, 50)
|
||||||
|
const nextTagline = sanitizeShortText(data.storefrontChatTagline, store.storefrontChatTagline || DEFAULT_TAGLINE, 90)
|
||||||
|
const nextBrandColor = sanitizeColor(data.storefrontChatBrandColor ?? store.storefrontChatBrandColor)
|
||||||
|
const nextFont = sanitizeFont(data.storefrontChatFontFamily ?? store.storefrontChatFontFamily)
|
||||||
|
const nextLogoUrl = sanitizeLogoUrl(data.storefrontChatLogoUrl ?? store.storefrontChatLogoUrl)
|
||||||
|
|
||||||
|
const updatedStore = await prisma.store.update({
|
||||||
|
where: { id: storeId },
|
||||||
|
data: {
|
||||||
|
storefrontChatEnabled: nextEnabled,
|
||||||
|
storefrontChatName: nextChatName,
|
||||||
|
storefrontChatTagline: nextTagline,
|
||||||
|
storefrontChatBrandColor: nextBrandColor,
|
||||||
|
storefrontChatFontFamily: nextFont,
|
||||||
|
storefrontChatLogoUrl: nextLogoUrl,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
storefrontChatEnabled: true,
|
||||||
|
storefrontChatName: true,
|
||||||
|
storefrontChatTagline: true,
|
||||||
|
storefrontChatBrandColor: true,
|
||||||
|
storefrontChatFontFamily: true,
|
||||||
|
storefrontChatLogoUrl: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Explicit behavior change:
|
||||||
|
// - Enable: save only (no auto inject).
|
||||||
|
// - Disable: remove snippet immediately to hard-stop storefront widget.
|
||||||
|
if (!updatedStore.storefrontChatEnabled) {
|
||||||
|
try {
|
||||||
|
await syncStorefrontTheme({
|
||||||
|
storeId,
|
||||||
|
enabled: false,
|
||||||
|
shopifyDomain: store.shopifyDomain,
|
||||||
|
shopifyAccessToken: store.shopifyAccessToken,
|
||||||
|
chatName: updatedStore.storefrontChatName || DEFAULT_CHAT_NAME,
|
||||||
|
tagline: updatedStore.storefrontChatTagline || DEFAULT_TAGLINE,
|
||||||
|
brandColor: updatedStore.storefrontChatBrandColor || DEFAULT_BRAND_COLOR,
|
||||||
|
fontFamily: updatedStore.storefrontChatFontFamily || DEFAULT_FONT,
|
||||||
|
logoUrl: updatedStore.storefrontChatLogoUrl || null,
|
||||||
|
})
|
||||||
} catch (scriptError) {
|
} catch (scriptError) {
|
||||||
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError)
|
console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
storefrontChatEnabled
|
storefrontChatEnabled: updatedStore.storefrontChatEnabled,
|
||||||
|
storefrontChatName: updatedStore.storefrontChatName,
|
||||||
|
storefrontChatTagline: updatedStore.storefrontChatTagline,
|
||||||
|
storefrontChatBrandColor: updatedStore.storefrontChatBrandColor,
|
||||||
|
storefrontChatFontFamily: updatedStore.storefrontChatFontFamily,
|
||||||
|
storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating store settings:", error)
|
console.error("Error updating store settings:", error)
|
||||||
@ -407,3 +574,66 @@ export async function PATCH(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stores/[storeId]
|
||||||
|
* Inject storefront chat snippet + JS asset using saved customization.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
storefrontChatEnabled: true,
|
||||||
|
storefrontChatName: true,
|
||||||
|
storefrontChatTagline: true,
|
||||||
|
storefrontChatBrandColor: true,
|
||||||
|
storefrontChatFontFamily: true,
|
||||||
|
storefrontChatLogoUrl: true,
|
||||||
|
shopifyDomain: true,
|
||||||
|
shopifyAccessToken: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (!store.storefrontChatEnabled) {
|
||||||
|
return NextResponse.json({ message: "Enable storefront chat first" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncStorefrontTheme({
|
||||||
|
storeId: store.id,
|
||||||
|
enabled: true,
|
||||||
|
shopifyDomain: store.shopifyDomain,
|
||||||
|
shopifyAccessToken: store.shopifyAccessToken,
|
||||||
|
chatName: sanitizeShortText(store.storefrontChatName, DEFAULT_CHAT_NAME, 50),
|
||||||
|
tagline: sanitizeShortText(store.storefrontChatTagline, DEFAULT_TAGLINE, 90),
|
||||||
|
brandColor: sanitizeColor(store.storefrontChatBrandColor),
|
||||||
|
fontFamily: sanitizeFont(store.storefrontChatFontFamily),
|
||||||
|
logoUrl: sanitizeLogoUrl(store.storefrontChatLogoUrl),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[STOREFRONT_CHAT_INJECT_ERR]", error)
|
||||||
|
return NextResponse.json({ message: "Failed to inject storefront chat" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -26,6 +26,11 @@ export async function GET() {
|
|||||||
onboardingComplete: true,
|
onboardingComplete: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
storefrontChatEnabled: true,
|
storefrontChatEnabled: true,
|
||||||
|
storefrontChatName: true,
|
||||||
|
storefrontChatTagline: true,
|
||||||
|
storefrontChatBrandColor: true,
|
||||||
|
storefrontChatFontFamily: true,
|
||||||
|
storefrontChatLogoUrl: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
|
|||||||
103
src/app/api/uploads/storefront-chat-logo/route.ts
Normal file
103
src/app/api/uploads/storefront-chat-logo/route.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
import { uploadStorefrontChatLogo } from "@/lib/s3"
|
||||||
|
|
||||||
|
const LOCAL_UPLOAD_DIR =
|
||||||
|
process.env.STOREFRONT_CHAT_LOGOS_DIR || "public/uploads/storefront-chat-logos"
|
||||||
|
|
||||||
|
async function ensureUploadDirForVendor(vendorId: string) {
|
||||||
|
const baseDir = path.resolve(LOCAL_UPLOAD_DIR)
|
||||||
|
const vendorDir = path.join(baseDir, vendorId)
|
||||||
|
await fs.mkdir(vendorDir, { recursive: true })
|
||||||
|
return vendorDir
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicUrl(absoluteFilePath: string) {
|
||||||
|
const publicRoot = path.resolve("public")
|
||||||
|
const normalized = absoluteFilePath.replace(/\\/g, "/")
|
||||||
|
const normalizedPublicRoot = publicRoot.replace(/\\/g, "/")
|
||||||
|
|
||||||
|
if (normalized.startsWith(normalizedPublicRoot)) {
|
||||||
|
const relative = normalized.slice(normalizedPublicRoot.length)
|
||||||
|
return relative.startsWith("/") ? relative : `/${relative}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = path.resolve(LOCAL_UPLOAD_DIR).replace(/\\/g, "/")
|
||||||
|
const relative = normalized.replace(baseDir, "").replace(/^\/?/, "/")
|
||||||
|
return relative
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLocally(params: {
|
||||||
|
vendorId: string
|
||||||
|
buffer: Buffer
|
||||||
|
fileName: string
|
||||||
|
}) {
|
||||||
|
const extFromName = params.fileName.includes(".")
|
||||||
|
? params.fileName.split(".").pop()
|
||||||
|
: undefined
|
||||||
|
const ext = extFromName || "bin"
|
||||||
|
|
||||||
|
const vendorDir = await ensureUploadDirForVendor(params.vendorId)
|
||||||
|
const fileNameOnDisk = `${randomUUID()}.${ext}`
|
||||||
|
const storagePath = path.join(vendorDir, fileNameOnDisk)
|
||||||
|
|
||||||
|
await fs.writeFile(storagePath, params.buffer)
|
||||||
|
const url = buildPublicUrl(storagePath)
|
||||||
|
return { url }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get("file")
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ message: "Missing file" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return NextResponse.json({ message: "Only image files are allowed" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = 3 * 1024 * 1024
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
return NextResponse.json({ message: "Image too large (max 3MB)" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
const originalName = file.name || "logo"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url } = await uploadStorefrontChatLogo({
|
||||||
|
vendorId: session.user.id,
|
||||||
|
fileBuffer: buffer,
|
||||||
|
contentType: file.type || undefined,
|
||||||
|
originalName,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ url })
|
||||||
|
} catch (s3Error) {
|
||||||
|
console.warn(
|
||||||
|
"[STOREFRONT_CHAT_LOGO_UPLOAD] S3 upload failed or not configured. Falling back to local storage.",
|
||||||
|
s3Error
|
||||||
|
)
|
||||||
|
|
||||||
|
const { url } = await uploadLocally({
|
||||||
|
vendorId: session.user.id,
|
||||||
|
buffer,
|
||||||
|
fileName: originalName,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ url })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[STOREFRONT_CHAT_LOGO_UPLOAD]", error)
|
||||||
|
return NextResponse.json({ message: "Failed to upload logo image" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
|
|||||||
import { Store, Plus, Check, Loader2, AlertCircle, Building2, Trash2 } from "lucide-react"
|
import { Store, Plus, Check, Loader2, AlertCircle, Building2, Trash2 } from "lucide-react"
|
||||||
import AddStoreModal from "@/components/AddStoreModal"
|
import AddStoreModal from "@/components/AddStoreModal"
|
||||||
import DeleteStoreModal from "@/components/DeleteStoreModal"
|
import DeleteStoreModal from "@/components/DeleteStoreModal"
|
||||||
|
import StorefrontChatCustomizeModal, { type StorefrontChatCustomization } from "@/components/StorefrontChatCustomizeModal"
|
||||||
|
|
||||||
interface StoreData {
|
interface StoreData {
|
||||||
id: string
|
id: string
|
||||||
@ -12,15 +13,30 @@ interface StoreData {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
onboardingComplete: boolean
|
onboardingComplete: boolean
|
||||||
storefrontChatEnabled: boolean
|
storefrontChatEnabled: boolean
|
||||||
|
storefrontChatName: string | null
|
||||||
|
storefrontChatTagline: string | null
|
||||||
|
storefrontChatBrandColor: string | null
|
||||||
|
storefrontChatFontFamily: string | null
|
||||||
|
storefrontChatLogoUrl: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CUSTOMIZATION: StorefrontChatCustomization = {
|
||||||
|
storefrontChatName: "JUNO Chat",
|
||||||
|
storefrontChatTagline: "Ask about products",
|
||||||
|
storefrontChatBrandColor: "#4f46e5",
|
||||||
|
storefrontChatFontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
|
||||||
|
storefrontChatLogoUrl: "",
|
||||||
|
}
|
||||||
|
|
||||||
export default function StoresPage() {
|
export default function StoresPage() {
|
||||||
const [stores, setStores] = useState<StoreData[]>([])
|
const [stores, setStores] = useState<StoreData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [switching, setSwitching] = useState<string | null>(null)
|
const [switching, setSwitching] = useState<string | null>(null)
|
||||||
const [togglingStorefrontChat, setTogglingStorefrontChat] = useState<string | null>(null)
|
const [togglingStorefrontChat, setTogglingStorefrontChat] = useState<string | null>(null)
|
||||||
|
const [customizeStoreId, setCustomizeStoreId] = useState<string | null>(null)
|
||||||
|
const [injectingStoreId, setInjectingStoreId] = useState<string | null>(null)
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
|
||||||
/*
|
/*
|
||||||
* Alignment Fix Details:
|
* Alignment Fix Details:
|
||||||
@ -68,6 +84,9 @@ export default function StoresPage() {
|
|||||||
|
|
||||||
await fetchStores()
|
await fetchStores()
|
||||||
window.dispatchEvent(new CustomEvent("storeUpdated"))
|
window.dispatchEvent(new CustomEvent("storeUpdated"))
|
||||||
|
if (enabled) {
|
||||||
|
setCustomizeStoreId(storeId)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Toggle storefront chat error:", err)
|
console.error("Toggle storefront chat error:", err)
|
||||||
alert("Could not update storefront chat setting. Please try again.")
|
alert("Could not update storefront chat setting. Please try again.")
|
||||||
@ -76,6 +95,49 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openCustomize = (storeId: string) => {
|
||||||
|
setCustomizeStoreId(storeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomizationValues = (store: StoreData | undefined): StorefrontChatCustomization => {
|
||||||
|
if (!store) return DEFAULT_CUSTOMIZATION
|
||||||
|
return {
|
||||||
|
storefrontChatName: store.storefrontChatName || DEFAULT_CUSTOMIZATION.storefrontChatName,
|
||||||
|
storefrontChatTagline: store.storefrontChatTagline || DEFAULT_CUSTOMIZATION.storefrontChatTagline,
|
||||||
|
storefrontChatBrandColor: store.storefrontChatBrandColor || DEFAULT_CUSTOMIZATION.storefrontChatBrandColor,
|
||||||
|
storefrontChatFontFamily: store.storefrontChatFontFamily || DEFAULT_CUSTOMIZATION.storefrontChatFontFamily,
|
||||||
|
storefrontChatLogoUrl: store.storefrontChatLogoUrl || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStorefrontCustomization = async (storeId: string, payload: StorefrontChatCustomization) => {
|
||||||
|
const res = await fetch(`/api/stores/${storeId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ message: "Failed to save storefront customization" }))
|
||||||
|
throw new Error(data.message || "Failed to save storefront customization")
|
||||||
|
}
|
||||||
|
await fetchStores()
|
||||||
|
}
|
||||||
|
|
||||||
|
const injectStorefrontCustomization = async (storeId: string) => {
|
||||||
|
setInjectingStoreId(storeId)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/stores/${storeId}`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ message: "Failed to inject storefront chat" }))
|
||||||
|
throw new Error(data.message || "Failed to inject storefront chat")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setInjectingStoreId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSwitchStore = async (storeId: string) => {
|
const handleSwitchStore = async (storeId: string) => {
|
||||||
setSwitching(storeId)
|
setSwitching(storeId)
|
||||||
try {
|
try {
|
||||||
@ -246,6 +308,7 @@ export default function StoresPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{!store.isActive ? (
|
{!store.isActive ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSwitchStore(store.id)}
|
onClick={() => handleSwitchStore(store.id)}
|
||||||
@ -261,9 +324,15 @@ export default function StoresPage() {
|
|||||||
"Switch to this store"
|
"Switch to this store"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
<div className="flex-1" />
|
<button
|
||||||
)}
|
onClick={() => openCustomize(store.id)}
|
||||||
|
disabled={!store.storefrontChatEnabled || injectingStoreId === store.id}
|
||||||
|
className="h-9 md:h-10 px-3 rounded-xl bg-indigo-500/20 text-indigo-200 text-xs font-bold hover:bg-indigo-500/30 border border-indigo-400/30 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{injectingStoreId === store.id ? "Injecting..." : "Customize"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteClick(store)}
|
onClick={() => handleDeleteClick(store)}
|
||||||
className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/10 hover:bg-red-500/20 border border-white/10 hover:border-red-500/30 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 shrink-0"
|
className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/10 hover:bg-red-500/20 border border-white/10 hover:border-red-500/30 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
@ -291,6 +360,21 @@ export default function StoresPage() {
|
|||||||
storeName={deleteModal.storeName}
|
storeName={deleteModal.storeName}
|
||||||
isActive={deleteModal.isActive}
|
isActive={deleteModal.isActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StorefrontChatCustomizeModal
|
||||||
|
isOpen={Boolean(customizeStoreId)}
|
||||||
|
storeName={stores.find((s) => s.id === customizeStoreId)?.businessName || "Store"}
|
||||||
|
initialValues={getCustomizationValues(stores.find((s) => s.id === customizeStoreId))}
|
||||||
|
onClose={() => setCustomizeStoreId(null)}
|
||||||
|
onSave={async (payload) => {
|
||||||
|
if (!customizeStoreId) return
|
||||||
|
await saveStorefrontCustomization(customizeStoreId, payload)
|
||||||
|
}}
|
||||||
|
onInject={async () => {
|
||||||
|
if (!customizeStoreId) return
|
||||||
|
await injectStorefrontCustomization(customizeStoreId)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
273
src/components/StorefrontChatCustomizeModal.tsx
Normal file
273
src/components/StorefrontChatCustomizeModal.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { Loader2, Palette, Save, Upload, WandSparkles, X } from "lucide-react"
|
||||||
|
|
||||||
|
export type StorefrontChatCustomization = {
|
||||||
|
storefrontChatName: string
|
||||||
|
storefrontChatTagline: string
|
||||||
|
storefrontChatBrandColor: string
|
||||||
|
storefrontChatFontFamily: string
|
||||||
|
storefrontChatLogoUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean
|
||||||
|
storeName: string
|
||||||
|
initialValues: StorefrontChatCustomization
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (payload: StorefrontChatCustomization) => Promise<void>
|
||||||
|
onInject: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_OPTIONS = [
|
||||||
|
{ label: "System UI", value: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
|
||||||
|
{ label: "Inter", value: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
|
||||||
|
{ label: "Poppins", value: "Poppins, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
|
||||||
|
{ label: "Montserrat", value: "Montserrat, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
|
||||||
|
{ label: "Nunito", value: "Nunito, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function StorefrontChatCustomizeModal({
|
||||||
|
isOpen,
|
||||||
|
storeName,
|
||||||
|
initialValues,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onInject,
|
||||||
|
}: Props) {
|
||||||
|
const [form, setForm] = useState<StorefrontChatCustomization>(initialValues)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [injecting, setInjecting] = useState(false)
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setForm(initialValues)
|
||||||
|
setSaving(false)
|
||||||
|
setInjecting(false)
|
||||||
|
setUploadingLogo(false)
|
||||||
|
setError(null)
|
||||||
|
setSaved(false)
|
||||||
|
}, [isOpen, initialValues])
|
||||||
|
|
||||||
|
const canInject = useMemo(() => !saving && !injecting, [saving, injecting])
|
||||||
|
|
||||||
|
const update = (key: keyof StorefrontChatCustomization, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
setSaved(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogoUpload = async (file: File | null) => {
|
||||||
|
if (!file) return
|
||||||
|
setUploadingLogo(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
const res = await fetch("/api/uploads/storefront-chat-logo", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok || !data?.url) {
|
||||||
|
throw new Error(data?.message || "Failed to upload logo")
|
||||||
|
}
|
||||||
|
update("storefrontChatLogoUrl", String(data.url))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to upload logo")
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onSave(form)
|
||||||
|
setSaved(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save customization")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInject = async () => {
|
||||||
|
setInjecting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (!saved) {
|
||||||
|
await onSave(form)
|
||||||
|
setSaved(true)
|
||||||
|
}
|
||||||
|
await onInject()
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to inject storefront chat")
|
||||||
|
} finally {
|
||||||
|
setInjecting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen ? (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
className="w-full max-w-2xl rounded-2xl border border-white/10 bg-slate-900 shadow-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-white font-black text-lg">Customize Storefront Chat</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">{storeName}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="w-8 h-8 rounded-lg bg-white/10 hover:bg-white/15 text-slate-300 flex items-center justify-center">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 grid md:grid-cols-2 gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-slate-300">Chat Name</span>
|
||||||
|
<input
|
||||||
|
value={form.storefrontChatName}
|
||||||
|
onChange={(e) => update("storefrontChatName", e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-xl bg-white/5 border border-white/10 px-3 text-sm text-white outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-slate-300">Tagline</span>
|
||||||
|
<input
|
||||||
|
value={form.storefrontChatTagline}
|
||||||
|
onChange={(e) => update("storefrontChatTagline", e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-xl bg-white/5 border border-white/10 px-3 text-sm text-white outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-slate-300">Brand Color</span>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form.storefrontChatBrandColor}
|
||||||
|
onChange={(e) => update("storefrontChatBrandColor", e.target.value)}
|
||||||
|
className="w-12 h-10 rounded-lg bg-transparent border border-white/10"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={form.storefrontChatBrandColor}
|
||||||
|
onChange={(e) => update("storefrontChatBrandColor", e.target.value)}
|
||||||
|
className="flex-1 h-10 rounded-xl bg-white/5 border border-white/10 px-3 text-sm text-white outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-bold text-slate-300">Font</span>
|
||||||
|
<select
|
||||||
|
value={form.storefrontChatFontFamily}
|
||||||
|
onChange={(e) => update("storefrontChatFontFamily", e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-xl bg-white/5 border border-white/10 px-3 text-sm text-white outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
|
>
|
||||||
|
{FONT_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value} className="text-slate-900">
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="block md:col-span-2">
|
||||||
|
<span className="text-xs font-bold text-slate-300">Logo Image (optional)</span>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
<label className="h-10 px-3 rounded-xl bg-white/10 text-slate-200 hover:bg-white/15 border border-white/10 cursor-pointer font-bold text-sm inline-flex items-center gap-2">
|
||||||
|
{uploadingLogo ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload image
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0] ?? null
|
||||||
|
void handleLogoUpload(file)
|
||||||
|
e.currentTarget.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.storefrontChatLogoUrl}
|
||||||
|
onChange={(e) => update("storefrontChatLogoUrl", e.target.value)}
|
||||||
|
placeholder="Or paste logo URL"
|
||||||
|
className="flex-1 min-w-[220px] h-10 rounded-xl bg-white/5 border border-white/10 px-3 text-sm text-white outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">Supported: image files up to 3MB.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<p className="text-[11px] font-bold text-slate-400 mb-2">Preview</p>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||||
|
<div className="h-12 border-b border-slate-200 flex items-center gap-2 px-3" style={{ fontFamily: form.storefrontChatFontFamily }}>
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${form.storefrontChatBrandColor}22` }}>
|
||||||
|
{form.storefrontChatLogoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={form.storefrontChatLogoUrl} alt="Logo preview" className="w-5 h-5 object-contain rounded" />
|
||||||
|
) : (
|
||||||
|
<Palette className="w-4 h-4" style={{ color: form.storefrontChatBrandColor }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-slate-900">{form.storefrontChatName || "JUNO Chat"}</p>
|
||||||
|
<p className="text-[10px] text-slate-500">{form.storefrontChatTagline || "Ask about products"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 px-3 py-2 text-xs text-slate-400">Customer chat opens here...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="px-5 pb-2 text-xs text-red-300">{error}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="px-5 py-4 border-t border-white/10 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || injecting}
|
||||||
|
className="h-10 px-4 rounded-xl bg-white/10 text-slate-200 hover:bg-white/15 disabled:opacity-50 font-bold text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleInject}
|
||||||
|
disabled={!canInject}
|
||||||
|
className="h-10 px-4 rounded-xl bg-indigo-500 text-white hover:bg-indigo-600 disabled:opacity-50 font-bold text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{injecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <WandSparkles className="w-4 h-4" />}
|
||||||
|
INJECT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -73,3 +73,38 @@ export async function uploadSupplierProductImage(params: {
|
|||||||
return { key, url }
|
return { key, url }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadStorefrontChatLogo(params: {
|
||||||
|
vendorId: string
|
||||||
|
fileBuffer: Buffer
|
||||||
|
contentType?: string
|
||||||
|
originalName?: string
|
||||||
|
}) {
|
||||||
|
if (!BUCKET) {
|
||||||
|
throw new Error("S3 bucket is not configured for storefront chat logo uploads")
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionFromType =
|
||||||
|
params.contentType && params.contentType.includes("/")
|
||||||
|
? params.contentType.split("/")[1]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
(params.originalName && params.originalName.split(".").pop()) ||
|
||||||
|
extensionFromType ||
|
||||||
|
"bin"
|
||||||
|
|
||||||
|
const key = `storefront-chat-logos/${params.vendorId}/${crypto.randomUUID()}.${ext}`
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: key,
|
||||||
|
Body: params.fileBuffer,
|
||||||
|
ContentType: params.contentType
|
||||||
|
})
|
||||||
|
|
||||||
|
await s3Client.send(command)
|
||||||
|
|
||||||
|
const url = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`
|
||||||
|
return { key, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user