feat: Customizable storefront chat

This commit is contained in:
talatops 2026-04-03 18:19:20 +05:00
parent 7b2cb3485e
commit fae9e9ce7d
4 changed files with 184 additions and 56 deletions

View File

@ -146,6 +146,9 @@ function sanitizeLogoUrl(value: unknown) {
if (typeof value !== "string") return null
const v = value.trim()
if (!v) return null
if (v.startsWith("/") && !v.includes("..")) {
return v.slice(0, 500)
}
try {
const parsed = new URL(v)
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null
@ -155,6 +158,19 @@ function sanitizeLogoUrl(value: unknown) {
}
}
/** Storefront script runs on Shopify origin; relative /uploads/... must become absolute JUNO app URLs. */
function resolveLogoUrlForInject(appBaseUrl: string, logoUrl: string | null) {
if (!logoUrl) return null
const v = logoUrl.trim()
if (!v) return null
if (/^https?:\/\//i.test(v)) return v
if (v.startsWith("/") && !v.includes("..")) {
const base = appBaseUrl.replace(/\/$/, "")
return `${base}${v}`
}
return null
}
function buildStorefrontScript(params: {
appBaseUrl: string
chatName: string
@ -332,18 +348,33 @@ function buildStorefrontScript(params: {
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 = brandColor || "#4f46e5";
btn.style.color = "#fff";
btn.style.display = "flex";
btn.style.alignItems = "center";
btn.style.justifyContent = "center";
btn.style.fontFamily = fontFamily;
btn.style.fontWeight = "800";
btn.style.letterSpacing = "-0.02em";
btn.innerHTML = "Chat";
btn.style.padding = "0";
btn.style.overflow = "hidden";
if (logoUrl) {
btn.style.background = "#ffffff";
btn.style.border = "2px solid " + (brandColor || "#4f46e5");
var fabImg = document.createElement("img");
fabImg.src = logoUrl;
fabImg.alt = chatName || "Chat";
fabImg.style.width = "36px";
fabImg.style.height = "36px";
fabImg.style.objectFit = "contain";
fabImg.style.display = "block";
btn.appendChild(fabImg);
} else {
btn.style.border = "0";
btn.style.background = brandColor || "#4f46e5";
btn.style.color = "#fff";
btn.innerHTML = "Chat";
}
btn.addEventListener("click", function () { openPanel(); });
container.appendChild(btn);
}
@ -447,7 +478,7 @@ async function syncStorefrontTheme(params: {
tagline: params.tagline,
brandColor: params.brandColor,
fontFamily: params.fontFamily,
logoUrl: params.logoUrl,
logoUrl: resolveLogoUrlForInject(appBaseUrl, params.logoUrl),
})
const putAsset = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, assetKey, js)

View File

@ -5,6 +5,7 @@ import { Store, Plus, Check, Loader2, AlertCircle, Building2, Trash2 } from "luc
import AddStoreModal from "@/components/AddStoreModal"
import DeleteStoreModal from "@/components/DeleteStoreModal"
import StorefrontChatCustomizeModal, { type StorefrontChatCustomization } from "@/components/StorefrontChatCustomizeModal"
import { useTheme } from "@/components/ThemeProvider"
interface StoreData {
id: string
@ -38,6 +39,8 @@ export default function StoresPage() {
const [customizeStoreId, setCustomizeStoreId] = useState<string | null>(null)
const [injectingStoreId, setInjectingStoreId] = useState<string | null>(null)
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const { theme } = useTheme()
const isLight = theme === "light"
/*
* Alignment Fix Details:
* - Use React `createPortal` to render the modal backdrop and content at the end of `document.body`.
@ -261,17 +264,32 @@ export default function StoresPage() {
{stores.map((store) => (
<div
key={store.id}
className={`bg-white/5 backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border transition-all group relative ${store.isActive
? "border-indigo-500/30 bg-indigo-500/10"
: "border-white/10 hover:bg-white/[0.07]"
}`}
className={`backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border transition-all group relative ${
store.isActive
? isLight
? "border-indigo-600/25 bg-indigo-600/5"
: "border-indigo-500/30 bg-indigo-500/10"
: isLight
? "border-slate-200/70 bg-white/80 hover:bg-white/95"
: "border-white/10 hover:bg-white/[0.07]"
}`}
>
<div className="flex items-start justify-between mb-3 md:mb-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-lg md:rounded-xl bg-indigo-500/20 border border-indigo-400/20 flex items-center justify-center shrink-0">
<Store className="w-5 h-5 md:w-6 md:h-6 text-indigo-400" />
<div
className={`w-10 h-10 md:w-12 md:h-12 rounded-lg md:rounded-xl flex items-center justify-center shrink-0 ${
isLight
? "bg-indigo-100 border border-indigo-200"
: "bg-indigo-500/20 border border-indigo-400/20"
}`}
>
<Store className={`w-5 h-5 md:w-6 md:h-6 ${isLight ? "text-indigo-700" : "text-indigo-400"}`} />
</div>
{store.isActive && (
<span className="px-2 md:px-2.5 py-1 bg-indigo-500/30 text-indigo-300 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-wider flex items-center gap-1 border border-indigo-400/20">
<span
className={`px-2 md:px-2.5 py-1 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-wider flex items-center gap-1 border ${
isLight ? "bg-indigo-600/10 text-indigo-700 border-indigo-600/20" : "bg-indigo-500/30 text-indigo-300 border-indigo-400/20"
}`}
>
<Check className="w-2.5 h-2.5 md:w-3 md:h-3" />
Active
</span>
@ -279,17 +297,23 @@ export default function StoresPage() {
</div>
<div className="space-y-1.5 md:space-y-2 mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-black text-white truncate">{store.businessName}</h3>
<p className="text-xs text-slate-400 font-medium truncate">{store.shopifyDomain}</p>
<p className="text-[10px] text-slate-500">
<h3 className={`text-base md:text-lg font-black truncate ${isLight ? "text-slate-900" : "text-white"}`}>
{store.businessName}
</h3>
<p className={`text-xs font-medium truncate ${isLight ? "text-slate-600" : "text-slate-400"}`}>
{store.shopifyDomain}
</p>
<p className={`text-[10px] ${isLight ? "text-slate-500" : "text-slate-500"}`}>
Connected {new Date(store.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center justify-between gap-3 mb-3">
<div className="min-w-0">
<p className="text-xs md:text-sm text-white font-bold truncate">Storefront Chat (Customers)</p>
<p className="text-[10px] md:text-[11px] text-slate-400">
<p className={`text-xs md:text-sm font-bold truncate ${isLight ? "text-slate-900" : "text-white"}`}>
Storefront Chat (Customers)
</p>
<p className={`text-[10px] md:text-[11px] ${isLight ? "text-slate-600" : "text-slate-400"}`}>
Show chat widget on the storefront
</p>
</div>
@ -313,7 +337,11 @@ export default function StoresPage() {
<button
onClick={() => handleSwitchStore(store.id)}
disabled={switching === store.id}
className="flex-1 h-9 md:h-10 rounded-xl bg-white/10 text-white text-xs md:text-sm font-bold hover:bg-white/15 border border-white/10 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
className={`flex-1 h-9 md:h-10 rounded-xl text-xs md:text-sm font-bold transition-all disabled:opacity-50 flex items-center justify-center gap-2 ${
isLight
? "bg-indigo-600 text-white hover:bg-indigo-700 border border-indigo-600/20"
: "bg-white/10 text-white hover:bg-white/15 border border-white/10"
}`}
>
{switching === store.id ? (
<>
@ -328,18 +356,28 @@ export default function StoresPage() {
<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"
className={`h-9 md:h-10 px-3 rounded-xl text-xs font-bold transition-all disabled:opacity-50 ${
isLight
? "bg-indigo-700 text-white hover:bg-indigo-800 border border-indigo-600/25"
: "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30 border border-indigo-400/30"
}`}
>
{injectingStoreId === store.id ? "Injecting..." : "Customize"}
</button>
</div>
<button
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 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 shrink-0 ${
isLight ? "bg-red-50 border border-red-200 hover:bg-red-100 hover:border-red-300" : "bg-white/10 hover:bg-red-500/20 border border-white/10 hover:border-red-500/30"
}`}
title="Delete store"
aria-label="Delete store"
>
<Trash2 className="w-4 h-4 md:w-5 md:h-5 text-slate-400 group-hover:text-red-400 transition-colors" />
<Trash2
className={`w-4 h-4 md:w-5 md:h-5 ${
isLight ? "text-slate-500 group-hover:text-red-600" : "text-slate-400 group-hover:text-red-400"
} transition-colors`}
/>
</button>
</div>
</div>

View File

@ -179,16 +179,6 @@ export default function StorefrontChatWidgetPage() {
return (
<div className="w-screen h-screen overflow-hidden bg-white text-slate-900 flex flex-col">
<div className="p-3 border-b border-slate-200 bg-white flex items-center gap-3 flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center text-indigo-700">
<MessageSquare className="w-5 h-5" />
</div>
<div className="min-w-0">
<p className="font-black text-sm truncate">JUNO Chat</p>
<p className="text-[11px] text-slate-500">Ask about products</p>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-white to-slate-50">
{sendError ? (
<div className="mb-3 px-3 py-2 rounded-xl bg-red-500/10 border border-red-500/20 text-red-600 text-xs font-medium">

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { Loader2, Palette, Save, Upload, WandSparkles, X } from "lucide-react"
import { useTheme } from "@/components/ThemeProvider"
export type StorefrontChatCustomization = {
storefrontChatName: string
@ -37,6 +38,9 @@ export default function StorefrontChatCustomizeModal({
onSave,
onInject,
}: Props) {
const { theme } = useTheme()
const isLight = theme === "light"
const [form, setForm] = useState<StorefrontChatCustomization>(initialValues)
const [saving, setSaving] = useState(false)
const [injecting, setInjecting] = useState(false)
@ -122,68 +126,105 @@ export default function StorefrontChatCustomizeModal({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm"
className={`fixed inset-0 z-50 backdrop-blur-sm ${isLight ? "bg-slate-900/35" : "bg-black/70"}`}
onClick={onClose}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<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"
className={`pointer-events-auto w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden ${
isLight
? "border border-slate-200 bg-white"
: "border border-white/10 bg-slate-900"
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-white/10 flex items-center justify-between">
<div
className={`px-5 py-4 flex items-center justify-between border-b ${
isLight ? "border-slate-200 bg-white" : "border-white/10"
}`}
>
<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>
<p className={`font-black text-lg ${isLight ? "text-slate-900" : "text-white"}`}>
Customize Storefront Chat
</p>
<p className={`text-xs truncate ${isLight ? "text-slate-500" : "text-slate-400"}`}>{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">
<button
type="button"
onClick={onClose}
className={`w-8 h-8 rounded-lg flex items-center justify-center ${
isLight
? "bg-slate-100 hover:bg-slate-200 text-slate-600"
: "bg-white/10 hover:bg-white/15 text-slate-300"
}`}
>
<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>
<span className={`text-xs font-bold ${isLight ? "text-slate-700" : "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"
className={`mt-1 w-full h-10 rounded-xl border px-3 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30 ${
isLight
? "bg-white border-slate-200 text-slate-900 placeholder:text-slate-400"
: "bg-white/5 border-white/10 text-white"
}`}
/>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-300">Tagline</span>
<span className={`text-xs font-bold ${isLight ? "text-slate-700" : "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"
className={`mt-1 w-full h-10 rounded-xl border px-3 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30 ${
isLight
? "bg-white border-slate-200 text-slate-900 placeholder:text-slate-400"
: "bg-white/5 border-white/10 text-white"
}`}
/>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-300">Brand Color</span>
<span className={`text-xs font-bold ${isLight ? "text-slate-700" : "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"
className={`w-12 h-10 rounded-lg bg-transparent border ${
isLight ? "border-slate-200" : "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"
className={`flex-1 h-10 rounded-xl border px-3 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30 ${
isLight
? "bg-white border-slate-200 text-slate-900"
: "bg-white/5 border-white/10 text-white"
}`}
/>
</div>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-300">Font</span>
<span className={`text-xs font-bold ${isLight ? "text-slate-700" : "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"
className={`mt-1 w-full h-10 rounded-xl border px-3 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30 ${
isLight
? "bg-white border-slate-200 text-slate-900"
: "bg-white/5 border-white/10 text-white"
}`}
>
{FONT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} className="text-slate-900">
@ -194,9 +235,17 @@ export default function StorefrontChatCustomizeModal({
</label>
<div className="block md:col-span-2">
<span className="text-xs font-bold text-slate-300">Logo Image (optional)</span>
<span className={`text-xs font-bold ${isLight ? "text-slate-700" : "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">
<label
className={`h-10 px-3 rounded-xl cursor-pointer font-bold text-sm inline-flex items-center gap-2 border ${
isLight
? "bg-indigo-50 text-indigo-800 hover:bg-indigo-100 border-indigo-200"
: "bg-white/10 text-slate-200 hover:bg-white/15 border-white/10"
}`}
>
{uploadingLogo ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload image
<input
@ -214,14 +263,26 @@ export default function StorefrontChatCustomizeModal({
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"
className={`flex-1 min-w-[220px] h-10 rounded-xl border px-3 text-sm outline-none focus:ring-2 focus:ring-indigo-500/30 ${
isLight
? "bg-white border-slate-200 text-slate-900 placeholder:text-slate-400"
: "bg-white/5 border-white/10 text-white"
}`}
/>
</div>
<p className="text-[11px] text-slate-500 mt-1">Supported: image files up to 3MB.</p>
<p className={`text-[11px] mt-1 ${isLight ? "text-slate-500" : "text-slate-500"}`}>
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={`md:col-span-2 rounded-xl border p-3 ${
isLight ? "border-slate-200 bg-slate-50" : "border-white/10 bg-white/5"
}`}
>
<p className={`text-[11px] font-bold mb-2 ${isLight ? "text-slate-600" : "text-slate-400"}`}>
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` }}>
@ -243,14 +304,22 @@ export default function StorefrontChatCustomizeModal({
</div>
{error ? (
<div className="px-5 pb-2 text-xs text-red-300">{error}</div>
<div className={`px-5 pb-2 text-xs ${isLight ? "text-red-600" : "text-red-300"}`}>{error}</div>
) : null}
<div className="px-5 py-4 border-t border-white/10 flex items-center justify-end gap-2">
<div
className={`px-5 py-4 flex items-center justify-end gap-2 border-t ${
isLight ? "border-slate-200 bg-white" : "border-white/10"
}`}
>
<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"
className={`h-10 px-4 rounded-xl disabled:opacity-50 font-bold text-sm flex items-center gap-2 ${
isLight
? "bg-slate-100 text-slate-800 hover:bg-slate-200 border border-slate-200"
: "bg-white/10 text-slate-200 hover:bg-white/15"
}`}
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save