feat: embed chat support widget directly in app root layout
Renders a floating chat button on every app page without any Shopify ScriptTag API or permissions — merchants see it as soon as they open the app. Shop domain is read from the loader session so messages are correctly scoped per shop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
eefb2e612a
commit
d165883d9c
170
app/root.jsx
170
app/root.jsx
@ -4,9 +4,178 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
|
useLoaderData,
|
||||||
} from "@remix-run/react";
|
} from "@remix-run/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const { authenticate } = await import("./shopify.server");
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
return json({ shop: session?.shop || null });
|
||||||
|
} catch {
|
||||||
|
return json({ shop: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Chat Widget ──────────────────────────────────────────────────────────────
|
||||||
|
function ChatWidget({ shop }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const msgsRef = useRef(null);
|
||||||
|
const pollRef = useRef(null);
|
||||||
|
const lastTsRef = useRef("");
|
||||||
|
const BASE = "https://backend.data4autos.com";
|
||||||
|
|
||||||
|
const visitorId = (() => {
|
||||||
|
try {
|
||||||
|
let v = localStorage.getItem("d4a_vid");
|
||||||
|
if (!v) { v = "v" + Math.random().toString(36).slice(2); localStorage.setItem("d4a_vid", v); }
|
||||||
|
return v;
|
||||||
|
} catch { return "anon"; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const loadMessages = async (scrollToBottom = false) => {
|
||||||
|
if (!shop) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${BASE}/chat/${encodeURIComponent(shop)}`);
|
||||||
|
const d = await r.json();
|
||||||
|
const msgs = d.messages || [];
|
||||||
|
const latest = msgs[msgs.length - 1]?.timestamp || "";
|
||||||
|
const isNew = latest && latest !== lastTsRef.current;
|
||||||
|
lastTsRef.current = latest;
|
||||||
|
setMessages(msgs);
|
||||||
|
if (!open && isNew && msgs[msgs.length - 1]?.from === "admin") {
|
||||||
|
setUnread(u => u + 1);
|
||||||
|
}
|
||||||
|
if (scrollToBottom || (msgsRef.current && msgsRef.current.scrollHeight - msgsRef.current.scrollTop - msgsRef.current.clientHeight < 80)) {
|
||||||
|
setTimeout(() => { if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight; }, 50);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shop) return;
|
||||||
|
loadMessages(false);
|
||||||
|
pollRef.current = setInterval(() => loadMessages(false), 4000);
|
||||||
|
return () => clearInterval(pollRef.current);
|
||||||
|
}, [shop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setUnread(0);
|
||||||
|
loadMessages(true);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!input.trim() || !shop || sending) return;
|
||||||
|
const text = input.trim();
|
||||||
|
setInput("");
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE}/chat/${encodeURIComponent(shop)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text, visitorId }),
|
||||||
|
});
|
||||||
|
await loadMessages(true);
|
||||||
|
} catch {}
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shop) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
#d4a-widget-fab{position:fixed;bottom:24px;right:24px;width:54px;height:54px;background:linear-gradient(135deg,#2563eb,#4f46e5);border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 20px rgba(37,99,235,0.4);z-index:99999;border:none;transition:transform .15s;}
|
||||||
|
#d4a-widget-fab:hover{transform:scale(1.08);}
|
||||||
|
#d4a-widget-badge{position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;background:#ef4444;border-radius:9px;font-size:10px;font-weight:700;color:#fff;display:flex;align-items:center;justify-content:center;padding:0 3px;border:2px solid #fff;}
|
||||||
|
#d4a-widget-win{position:fixed;bottom:90px;right:24px;width:340px;max-height:500px;background:#fff;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,0.18);z-index:99998;display:flex;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;}
|
||||||
|
#d4a-widget-hdr{background:linear-gradient(135deg,#2563eb,#4f46e5);padding:14px 18px;display:flex;align-items:center;gap:10px;flex-shrink:0;}
|
||||||
|
#d4a-widget-msgs{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;background:#f8fafc;min-height:200px;max-height:330px;}
|
||||||
|
.d4a-bub{max-width:82%;padding:9px 13px;font-size:13px;line-height:1.5;word-break:break-word;}
|
||||||
|
.d4a-bub.admin{background:#fff;color:#0f172a;border:1px solid #e2e8f0;align-self:flex-start;border-radius:12px 12px 12px 3px;box-shadow:0 1px 3px rgba(0,0,0,0.06);}
|
||||||
|
.d4a-bub.cust{background:#2563eb;color:#fff;align-self:flex-end;border-radius:12px 12px 3px 12px;}
|
||||||
|
.d4a-time{font-size:10px;color:#94a3b8;margin-top:3px;}
|
||||||
|
#d4a-widget-inp-row{padding:10px 12px;border-top:1px solid #e2e8f0;display:flex;gap:8px;background:#fff;flex-shrink:0;}
|
||||||
|
#d4a-widget-inp{flex:1;border:1.5px solid #e2e8f0;border-radius:8px;padding:8px 11px;font-size:13px;font-family:inherit;outline:none;resize:none;max-height:80px;}
|
||||||
|
#d4a-widget-inp:focus{border-color:#2563eb;}
|
||||||
|
#d4a-widget-send{background:#2563eb;border:none;border-radius:8px;color:#fff;padding:8px 15px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;}
|
||||||
|
#d4a-widget-send:hover{background:#1d4ed8;}
|
||||||
|
#d4a-widget-send:disabled{background:#93c5fd;cursor:not-allowed;}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Floating button */}
|
||||||
|
<button
|
||||||
|
id="d4a-widget-fab"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
title="Chat with Data4Autos Support"
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
)}
|
||||||
|
{unread > 0 && (
|
||||||
|
<div id="d4a-widget-badge">{unread > 9 ? "9+" : unread}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Chat window */}
|
||||||
|
{open && (
|
||||||
|
<div id="d4a-widget-win">
|
||||||
|
<div id="d4a-widget-hdr">
|
||||||
|
<div style={{width:36,height:36,background:"rgba(255,255,255,.15)",borderRadius:10,display:"flex",alignItems:"center",justifyContent:"center",fontSize:18,flexShrink:0}}>💬</div>
|
||||||
|
<div>
|
||||||
|
<div style={{fontSize:14,fontWeight:700,color:"#fff"}}>Data4Autos Support</div>
|
||||||
|
<div style={{fontSize:11,color:"rgba(255,255,255,.75)",marginTop:1}}>We typically reply within a few hours</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="d4a-widget-msgs" ref={msgsRef}>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div style={{textAlign:"center",color:"#94a3b8",fontSize:13,paddingTop:20}}>
|
||||||
|
Send us a message and we'll get back to you!
|
||||||
|
</div>
|
||||||
|
) : messages.map(m => (
|
||||||
|
<div key={m.id} style={{display:"flex",flexDirection:"column",alignItems:m.from==="admin"?"flex-start":"flex-end"}}>
|
||||||
|
<div className={`d4a-bub ${m.from === "admin" ? "admin" : "cust"}`}>{m.text}</div>
|
||||||
|
<div className="d4a-time" style={{textAlign:m.from==="admin"?"left":"right"}}>
|
||||||
|
{new Date(m.timestamp).toLocaleTimeString(undefined,{hour:"2-digit",minute:"2-digit"})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="d4a-widget-inp-row">
|
||||||
|
<textarea
|
||||||
|
id="d4a-widget-inp"
|
||||||
|
rows={1}
|
||||||
|
placeholder="Type a message…"
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
|
||||||
|
/>
|
||||||
|
<button id="d4a-widget-send" onClick={sendMessage} disabled={sending}>
|
||||||
|
{sending ? "…" : "Send"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const data = useLoaderData();
|
||||||
|
const shop = data?.shop || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@ -22,6 +191,7 @@ export default function App() {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<ChatWidget shop={shop} />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user