Inbox: Google reviews (read and reply) and YouTube comments
- 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>
This commit is contained in:
parent
c4d8c6474c
commit
21e49b442f
@ -47,6 +47,28 @@ export default {
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Latest reviews on the chosen location (Business Profile API v4). */
|
||||
async reviews(tokens, target, { pageSize = 30 } = {}) {
|
||||
const STARS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 };
|
||||
const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${target.id}/reviews`, { token: tokens.accessToken, query: { pageSize, orderBy: "updateTime desc" } });
|
||||
return {
|
||||
average: data.averageRating ?? null, total: data.totalReviewCount ?? null,
|
||||
reviews: (data.reviews || []).map((r) => ({
|
||||
id: r.name, author: r.reviewer?.isAnonymous ? "A Google user" : r.reviewer?.displayName || "A Google user", avatar: r.reviewer?.profilePhotoUrl || null,
|
||||
stars: STARS[r.starRating] ?? null, text: r.comment || "", at: r.createTime, updatedAt: r.updateTime,
|
||||
reply: r.reviewReply ? { text: r.reviewReply.comment, at: r.reviewReply.updateTime } : null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async replyReview(tokens, reviewName, comment) {
|
||||
const text = String(comment || "").trim();
|
||||
if (!text) throw new Error("Write a reply first.");
|
||||
if (text.length > 4096) throw new Error("Google allows replies up to 4,096 characters.");
|
||||
const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${reviewName}/reply`, { token: tokens.accessToken, method: "PUT", json: { comment: text } });
|
||||
return { text: data.comment, at: data.updateTime };
|
||||
},
|
||||
|
||||
async publish({ tokens, conn, post, opts, caption }) {
|
||||
const location = conn.target?.id;
|
||||
if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first.");
|
||||
|
||||
@ -110,6 +110,31 @@ export async function postStats(platform, client, ids) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Google reviews for a client's Business Profile location. */
|
||||
export async function gbpReviews(client) {
|
||||
const conn = vault.connectionFor(client, "gbp");
|
||||
if (!conn) throw err("Google Business Profile isn't connected for this client. Connect it on the Connections page.");
|
||||
if (!conn.target) throw err("Choose the business location on the Connections page first.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
return { location: conn.target.name, ...(await adapter("gbp").reviews(tokens, conn.target)) };
|
||||
}
|
||||
export async function gbpReply(client, reviewId, text) {
|
||||
const conn = vault.connectionFor(client, "gbp");
|
||||
if (!conn?.target || !String(reviewId).startsWith(`${conn.target.id}/reviews/`)) throw err("That review isn't on this client's location.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
return adapter("gbp").replyReview(tokens, reviewId, text);
|
||||
}
|
||||
/** Latest YouTube comments on the client's channel. */
|
||||
export async function youtubeComments(client) {
|
||||
const conn = vault.connectionFor(client, "youtube");
|
||||
if (!conn) throw err("YouTube isn't connected for this client.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
quota.ensure("youtube", null, "read");
|
||||
const out = await adapter("youtube").comments(tokens, conn.account);
|
||||
quota.record("youtube", null, "read");
|
||||
return { channel: conn.account?.name, comments: out };
|
||||
}
|
||||
|
||||
/** Re-read the account (name, followers) to check the connection still works. */
|
||||
export async function checkConnection(connId) {
|
||||
const { conn, tokens } = await tokensFor(connId);
|
||||
|
||||
@ -32,6 +32,15 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
/** Latest comments across the channel's videos (read-only: replying needs a broader scope, so reply on YouTube). */
|
||||
async comments(tokens, account, { max = 30 } = {}) {
|
||||
const { data } = await request("YouTube", `${API}/commentThreads`, { token: tokens.accessToken, query: { part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } });
|
||||
return (data.items || []).map((t) => {
|
||||
const c = t.snippet?.topLevelComment?.snippet || {};
|
||||
return { id: t.id, author: c.authorDisplayName, avatar: c.authorProfileImageUrl || null, text: c.textOriginal || "", at: c.publishedAt, likes: c.likeCount ?? 0, replies: t.snippet?.totalReplyCount ?? 0, videoId: t.snippet?.videoId, url: `https://www.youtube.com/watch?v=${t.snippet?.videoId}&lc=${t.id}` };
|
||||
});
|
||||
},
|
||||
|
||||
/** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */
|
||||
async stats(tokens, ids) {
|
||||
if (!ids.length) return {};
|
||||
|
||||
@ -346,3 +346,28 @@ test("platform errors include the code and a plain fix", async () => {
|
||||
route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ error: { code: "unaudited_client_can_only_post_to_private_accounts", message: "Please review our integration guidelines" } }, 403));
|
||||
await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY" } } }), /Private account.*unaudited_client_can_only_post_to_private_accounts/);
|
||||
});
|
||||
|
||||
test("Google reviews: list with stars and replies, reply only to this location's reviews", async () => {
|
||||
reset();
|
||||
route("GET", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews", json({ averageRating: 4.6, totalReviewCount: 52, reviews: [
|
||||
{ name: "accounts/1/locations/5/reviews/r1", reviewer: { displayName: "Sam" }, starRating: "FIVE", comment: "Best croissants", createTime: "2026-10-01T10:00:00Z" },
|
||||
{ name: "accounts/1/locations/5/reviews/r2", reviewer: { isAnonymous: true }, starRating: "TWO", createTime: "2026-10-02T10:00:00Z", reviewReply: { comment: "Sorry!", updateTime: "2026-10-03T10:00:00Z" } },
|
||||
] }));
|
||||
const r = await P.gbpReviews("acme");
|
||||
assert.equal(r.average, 4.6);
|
||||
assert.deepEqual(r.reviews.map((x) => [x.stars, x.author, Boolean(x.reply)]), [[5, "Sam", false], [2, "A Google user", true]]);
|
||||
route("PUT", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews/r1/reply", json({ comment: "Thanks Sam!", updateTime: "2026-10-04T10:00:00Z" }));
|
||||
assert.equal((await P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", "Thanks Sam!")).text, "Thanks Sam!");
|
||||
await assert.rejects(P.gbpReply("acme", "accounts/9/locations/9/reviews/x", "hi"), /isn't on this client's location/);
|
||||
await assert.rejects(P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", " "), /Write a reply/);
|
||||
});
|
||||
|
||||
test("YouTube comments: latest threads on the channel with a link to reply", async () => {
|
||||
reset();
|
||||
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "yt-new", expires_in: 3600 }));
|
||||
route("GET", "https://www.googleapis.com/youtube/v3/commentThreads", json({ items: [{ id: "t1", snippet: { videoId: "v1", totalReplyCount: 2, topLevelComment: { snippet: { authorDisplayName: "Ana", textOriginal: "Love this", publishedAt: "2026-10-05T10:00:00Z", likeCount: 3 } } } }] }));
|
||||
const r = await P.youtubeComments("acme");
|
||||
assert.equal(r.comments[0].author, "Ana");
|
||||
assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1");
|
||||
assert.match(sent(/commentThreads/)[0].url, /allThreadsRelatedToChannelId=/);
|
||||
});
|
||||
|
||||
@ -425,6 +425,9 @@ app.get("/api/library/file", h(async (req, res) => {
|
||||
}));
|
||||
|
||||
// ---------- engagement: comment inbox ----------
|
||||
app.get("/api/inbox/reviews", h(async (req) => extra.gbpReviews(String(req.query.client || ""))));
|
||||
app.post("/api/inbox/reviews/reply", h(async (req) => ({ reply: await extra.gbpReply(String(req.body?.client || ""), String(req.body?.reviewId || ""), req.body?.text) })));
|
||||
app.get("/api/inbox/youtube", h(async (req) => extra.youtubeComments(String(req.query.client || ""))));
|
||||
app.get("/api/inbox", h(async (req) => {
|
||||
const slug = req.query.client; clients.safeSlug(slug);
|
||||
return { comments: await meta.commentInbox(await clientAccount(slug), { days: Number(req.query.days || 14) }) };
|
||||
|
||||
@ -1,9 +1,36 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { EyeOff, Eye, MessageCircle, RefreshCw, Send } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, Tabs, timeAgo } from "../ui.jsx";
|
||||
import { GoogleReviews, YouTubeComments } from "./InboxMore.jsx";
|
||||
|
||||
export default function Inbox() {
|
||||
const [tab, setTab] = useState("comments");
|
||||
const [all, setAll] = useState([]);
|
||||
const [other, setOther] = useState("");
|
||||
useEffect(() => { api("/api/overview").then((r) => { setAll(r.clients); setOther((c) => c || r.clients[0]?.slug || ""); }).catch(() => {}); }, []);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={tab} onChange={setTab} tabs={[{ value: "comments", label: "Instagram & Facebook" }, { value: "reviews", label: "Google reviews" }, { value: "youtube", label: "YouTube comments" }]} />
|
||||
{tab === "comments" ? <MetaComments /> : (
|
||||
<>
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">{tab === "reviews" ? "Google reviews" : "YouTube comments"}</h1>
|
||||
<p className="text-muted mt-0.5">{tab === "reviews" ? "Reviews on the client's Business Profile. Replying to every review helps local ranking." : "Latest comments on the client's YouTube videos."}</p>
|
||||
</div>
|
||||
<select id="inbox2-client" aria-label="Client" className="input h-9 w-auto" value={other} onChange={(e) => setOther(e.target.value)}>
|
||||
{all.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
</header>
|
||||
{tab === "reviews" ? <GoogleReviews client={other} /> : <YouTubeComments client={other} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaComments() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [client, setClient] = useState("");
|
||||
const [days, setDays] = useState(14);
|
||||
|
||||
120
dashboard/web/src/pages/InboxMore.jsx
Normal file
120
dashboard/web/src/pages/InboxMore.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user