MOHAN d165883d9c 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>
2026-06-12 20:59:04 +05:30

201 lines
8.5 KiB
JavaScript

import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} 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() {
const data = useLoaderData();
const shop = data?.shop || null;
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://cdn.shopify.com/" />
<link
rel="stylesheet"
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
/>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ChatWidget shop={shop} />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}