- Inbox tabs: Instagram & Facebook / Google reviews / YouTube comments - Google reviews: average rating, needs-reply filter, reply or edit reply (public on Google), only on the client's location - YouTube comments: latest on the channel, open the comment to reply on YouTube (reply needs a broader scope) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
7.4 KiB
JavaScript
121 lines
7.4 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { ExternalLink, MessageSquareText, Send, Star } from "lucide-react";
|
|
import { api } from "../api.js";
|
|
import { Badge, Button, cx, Empty, ErrorNote, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
|
|
|
|
function Stars({ n }) {
|
|
if (n == null) return null;
|
|
return (
|
|
<span className="inline-flex items-center gap-0.5" role="img" aria-label={`${n} out of 5 stars`}>
|
|
{[1, 2, 3, 4, 5].map((i) => <Star key={i} className={cx("size-3.5", i <= n ? "fill-[#f5a623] text-[#f5a623]" : "text-line-strong")} aria-hidden="true" />)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function NotConnected({ platform, client, error }) {
|
|
return (
|
|
<Empty icon={MessageSquareText} title={`${platform} isn't ready for this client`}
|
|
action={<Link to={`/connections?client=${client}`} className="text-brand-deep underline text-[13px]">Open Connections</Link>}>
|
|
{error?.message || `Connect ${platform} on the Connections page first.`}
|
|
</Empty>
|
|
);
|
|
}
|
|
|
|
function ReviewRow({ r, client, onReplied }) {
|
|
const [text, setText] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const send = async () => {
|
|
setBusy(true); setError(null);
|
|
try { const { reply } = await api("/api/inbox/reviews/reply", { method: "POST", body: { client, reviewId: r.id, text } }); onReplied(reply); setOpen(false); setText(""); }
|
|
catch (e) { setError(e); }
|
|
setBusy(false);
|
|
};
|
|
return (
|
|
<li className="px-4 py-3.5 space-y-1.5">
|
|
<div className="flex items-center gap-2 flex-wrap text-[13px]">
|
|
<span className="font-semibold">{r.author}</span><Stars n={r.stars} /><span className="text-muted text-[12px]">{timeAgo(r.at)}</span>
|
|
{!r.reply && <Badge tone={r.stars != null && r.stars <= 3 ? "bad" : "warn"}>No reply yet</Badge>}
|
|
</div>
|
|
{r.text ? <p className="text-[13.5px] text-ink-2 whitespace-pre-line">{r.text}</p> : <p className="text-[12.5px] text-muted italic">Rating only, no text.</p>}
|
|
{r.reply && <div className="text-[13px] rounded-md bg-canvas border border-line px-3 py-2"><span className="text-[12px] text-muted">Your reply · {timeAgo(r.reply.at)}</span><p className="whitespace-pre-line">{r.reply.text}</p></div>}
|
|
{!open ? (
|
|
<Button size="sm" variant={r.reply ? "ghost" : "secondary"} onClick={() => { setOpen(true); setText(r.reply?.text || ""); }}>{r.reply ? "Edit reply" : "Reply"}</Button>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<label htmlFor={`rv-${r.id}`} className="sr-only">Reply to {r.author}</label>
|
|
<textarea id={`rv-${r.id}`} className="input h-24 py-2" value={text} onChange={(e) => setText(e.target.value)} placeholder={r.stars != null && r.stars <= 3 ? "Thank them, own the problem, and offer a way to make it right (phone or email)." : "Thank them by name and mention what they liked."} />
|
|
<div className="flex gap-2">
|
|
<Button size="sm" variant="primary" icon={Send} loading={busy} disabled={!text.trim()} onClick={send}>Post reply on Google</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
|
</div>
|
|
<p className="text-[12px] text-muted">Replies are public on Google Maps and Search, under the business name.</p>
|
|
<ErrorNote error={error} />
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function GoogleReviews({ client }) {
|
|
const [data, setData] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [view, setView] = useState("unreplied");
|
|
const load = useCallback(() => { setData(null); setError(null); api(`/api/inbox/reviews?client=${client}`).then(setData).catch((e) => { setError(e); setData(false); }); }, [client]);
|
|
useEffect(() => { if (client) load(); }, [load, client]);
|
|
const list = useMemo(() => (data?.reviews || []).filter((r) => view === "all" || !r.reply), [data, view]);
|
|
const patch = (id, reply) => setData((d) => ({ ...d, reviews: d.reviews.map((r) => (r.id === id ? { ...r, reply } : r)) }));
|
|
|
|
if (data === null) return <Panel><Spinner label="Reading Google reviews…" /></Panel>;
|
|
if (data === false) return <Panel><NotConnected platform="Google Business Profile" client={client} error={error} /></Panel>;
|
|
const unreplied = data.reviews.filter((r) => !r.reply).length;
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 flex-wrap text-[13px]">
|
|
<span className="font-medium">{data.location}</span>
|
|
{data.average != null && <span className="inline-flex items-center gap-1.5"><Stars n={Math.round(data.average)} /><b className="num">{data.average.toFixed(1)}</b><span className="text-muted">from {fmt(data.total)} reviews</span></span>}
|
|
<span className="ml-auto flex gap-1">
|
|
{[["unreplied", `Needs reply (${unreplied})`], ["all", "All"]].map(([k, l]) => (
|
|
<button key={k} type="button" onClick={() => setView(k)} className={cx("h-8 px-3 rounded-md text-[13px] font-medium", view === k ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>{l}</button>
|
|
))}
|
|
</span>
|
|
</div>
|
|
<Panel>
|
|
{!list.length ? <Empty icon={Star} title={view === "unreplied" ? "Every review has a reply" : "No reviews yet"}>Replying to every review, good and bad, helps the business rank in Google Maps.</Empty>
|
|
: <ul className="divide-y divide-line">{list.map((r) => <ReviewRow key={r.id} r={r} client={client} onReplied={(reply) => patch(r.id, reply)} />)}</ul>}
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function YouTubeComments({ client }) {
|
|
const [data, setData] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
useEffect(() => { if (!client) return; setData(null); setError(null); api(`/api/inbox/youtube?client=${client}`).then(setData).catch((e) => { setError(e); setData(false); }); }, [client]);
|
|
if (data === null) return <Panel><Spinner label="Reading YouTube comments…" /></Panel>;
|
|
if (data === false) return <Panel><NotConnected platform="YouTube" client={client} error={error} /></Panel>;
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[13px] text-muted">Latest comments on {data.channel}'s videos. Reply on YouTube for now: the button opens the comment.</p>
|
|
<Panel>
|
|
{!data.comments.length ? <Empty icon={MessageSquareText} title="No comments yet" /> : (
|
|
<ul className="divide-y divide-line">
|
|
{data.comments.map((c) => (
|
|
<li key={c.id} className="px-4 py-3 flex gap-3 items-start">
|
|
{c.avatar ? <img src={c.avatar} alt="" className="size-8 rounded-full" /> : <span className="size-8 rounded-full bg-canvas" aria-hidden="true" />}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[13px]"><span className="font-semibold">{c.author}</span> <span className="text-muted text-[12px]">{timeAgo(c.at)}{c.replies ? ` · ${c.replies} repl${c.replies === 1 ? "y" : "ies"}` : ""}{c.likes ? ` · ${c.likes} likes` : ""}</span></div>
|
|
<p className="text-[13.5px] text-ink-2 whitespace-pre-line">{c.text}</p>
|
|
</div>
|
|
<a href={c.url} target="_blank" rel="noreferrer" className="shrink-0 inline-flex items-center gap-1 text-[12.5px] text-brand-deep hover:underline">Reply on YouTube <ExternalLink className="size-3.5" /></a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|