From ca317f26a9918c35eba95d6ef8f07de2ca730005 Mon Sep 17 00:00:00 2001 From: Local Administrator Date: Thu, 4 Jun 2026 19:32:28 +0000 Subject: [PATCH] feat(ratings): real reader ratings with no-login, 1-vote-per-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a no-auth star rating system backed by a centralized cross-property table on supabase01. Database: - distribute.article_ratings (id, property, article_slug, rating 1..5, session_id, ip_hash, user_agent, created_at) with UNIQUE (property, article_slug, session_id). property column means every RMP site shares one table; AV Beat writes 'avbeat', BB writes 'bb', etc. - distribute.article_rating_aggregates view rolls up real_count + real_avg per (property, slug). API (/api/articles/rating): - GET ?slug=X → { avg, count, my_rating } — reads aggregate, plus the current session's vote if any. - POST { slug, rating } → inserts the vote; unique_violation on duplicate is silently swallowed (one vote per session). Returns updated summary. - Session lives in `av_rate_sid` cookie (uuid, 1y, sameSite=lax). Cookie is minted by the API on first request, so the client never has to know about it. StarRating component: - Now interactive — hover preview, click to submit, "thanks!" affordance. - Merges the existing deterministic baseline (slug-hash → 4.0–5.0 + age- scaled count) with the real aggregate using the baseline as a Bayesian prior, so a single real rating doesn't crater the displayed average. - Once the current session has voted, stars show "your rating" and hover/click are disabled. - Text colors updated #d0d0d0 → #0F172A so the rating number is readable on AV Beat's light backgrounds. Property identifier hardcoded as "avbeat" at the top of the route — when porting to BB / SNS / BLB / etc., change that constant and keep everything else identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/articles/rating/route.ts | 130 ++++++++++++++++ src/components/StarRating.tsx | 215 +++++++++++++++++++++------ 2 files changed, 296 insertions(+), 49 deletions(-) create mode 100644 src/app/api/articles/rating/route.ts diff --git a/src/app/api/articles/rating/route.ts b/src/app/api/articles/rating/route.ts new file mode 100644 index 0000000..2f29930 --- /dev/null +++ b/src/app/api/articles/rating/route.ts @@ -0,0 +1,130 @@ +/** + * Article star ratings — no-auth, one-vote-per-browser-session. + * + * GET /api/articles/rating?slug= + * → { avg: number|null, count: number, my_rating: number|null } + * + * POST /api/articles/rating + * body { slug: string, rating: 1..5 } + * → { ok: true, avg, count, my_rating } + * + * Storage: distribute.article_ratings (centralized RMP cross-property table). + * Each property writes with its own `property` value so the same table + * powers BB / AV Beat / SNS / etc. — see the top-level constant below. + * + * Session: a uuid stored in the `av_rate_sid` cookie (1y) — the (property, + * slug, session_id) tuple is UNIQUE so a duplicate POST from the same + * browser session for the same article is a no-op. Clearing cookies / + * opening incognito creates a new session, which is acceptable per the + * "one per computer within a session" spec. + */ +import { NextRequest, NextResponse } from "next/server"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { randomUUID, createHash } from "crypto"; + +export const dynamic = "force-dynamic"; + +const PROPERTY = "avbeat"; +const SESSION_COOKIE = "av_rate_sid"; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 365; // 1 year + +function getOrMintSession(req: NextRequest): { sid: string; mint: boolean } { + const existing = req.cookies.get(SESSION_COOKIE)?.value; + if (existing && /^[0-9a-f-]{30,40}$/i.test(existing)) { + return { sid: existing, mint: false }; + } + return { sid: randomUUID(), mint: true }; +} + +function hashIp(req: NextRequest): string | null { + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || req.headers.get("x-real-ip") + || null; + if (!ip) return null; + // SHA-256 truncated — enough for soft analytics, not reversible + return createHash("sha256").update(ip).digest("hex").slice(0, 32); +} + +function attachSessionCookie(res: NextResponse, sid: string) { + res.cookies.set(SESSION_COOKIE, sid, { + maxAge: SESSION_TTL_SECONDS, + sameSite: "lax", + path: "/", + httpOnly: false, // client JS may read it for optimistic UI + }); +} + +async function loadSummary(slug: string, sid: string) { + const sb = createAdminClient("distribute"); + const [agg, mine] = await Promise.all([ + sb.from("article_rating_aggregates") + .select("real_count, real_avg") + .eq("property", PROPERTY) + .eq("article_slug", slug) + .maybeSingle(), + sb.from("article_ratings") + .select("rating") + .eq("property", PROPERTY) + .eq("article_slug", slug) + .eq("session_id", sid) + .maybeSingle(), + ]); + return { + avg: agg.data?.real_avg ?? null, + count: agg.data?.real_count ?? 0, + my_rating: mine.data?.rating ?? null, + }; +} + +export async function GET(req: NextRequest) { + const slug = new URL(req.url).searchParams.get("slug"); + if (!slug) { + return NextResponse.json({ error: "slug required" }, { status: 400 }); + } + const { sid, mint } = getOrMintSession(req); + const summary = await loadSummary(slug, sid); + const res = NextResponse.json(summary); + if (mint) attachSessionCookie(res, sid); + return res; +} + +export async function POST(req: NextRequest) { + let body: { slug?: unknown; rating?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + const slug = typeof body.slug === "string" ? body.slug : null; + const rating = typeof body.rating === "number" ? Math.round(body.rating) : null; + if (!slug || !rating || rating < 1 || rating > 5) { + return NextResponse.json({ error: "slug + rating (1..5) required" }, { status: 400 }); + } + + const { sid, mint } = getOrMintSession(req); + const sb = createAdminClient("distribute"); + + // Insert; on conflict (property, slug, session_id) we silently no-op + // because the spec says one rating per session. We then return the + // CURRENT summary so the client renders what's actually stored. + const { error } = await sb + .from("article_ratings") + .insert({ + property: PROPERTY, + article_slug: slug, + rating, + session_id: sid, + ip_hash: hashIp(req), + user_agent: req.headers.get("user-agent")?.slice(0, 200) ?? null, + }); + + // 23505 = unique_violation — duplicate vote from same session, expected + if (error && (error as { code?: string }).code !== "23505") { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const summary = await loadSummary(slug, sid); + const res = NextResponse.json({ ok: true, ...summary }); + if (mint) attachSessionCookie(res, sid); + return res; +} diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx index 8bf29fd..2e93052 100644 --- a/src/components/StarRating.tsx +++ b/src/components/StarRating.tsx @@ -1,19 +1,42 @@ /** - * StarRating — deterministic, displays as ★★★★½ 4.7 (212 ratings) + * StarRating — interactive reader ratings. * - * Rating + vote count are derived from a stable hash of the article's - * slug, so the same article always renders the same numbers across views. - * Rating range is intentionally constrained to 4.0–5.0 (always positive) - * per editorial spec; vote count scales with article age when a date is - * supplied. + * Display: ★★★★½ 4.7 (212 ratings) + * + * Two layers of state: + * 1. A deterministic baseline derived from the article's slug (4.0–5.0 + * rating, plausibility-scaled vote count by article age). This shows + * immediately on render and gives every story some social-proof + * density even before real ratings come in. + * 2. A real aggregate fetched from /api/articles/rating?slug=… on mount. + * When real ratings exist, they MERGE with the baseline using the + * baseline as a synthetic prior — see mergeRatings() — so a single + * contrarian vote can't crater an article's average. + * + * Rating gate: + * - No login required. + * - One vote per browser session per article — enforced server-side + * via a UNIQUE (property, slug, session_id) index. The session_id + * lives in the `av_rate_sid` cookie minted by the API on first + * request. + * - Once a session has voted, the stars become non-interactive and + * show "your rating: N" alongside the aggregate. + * + * The widget can render in three sizes (sm/md/lg) and can hide the + * "(212 ratings)" count via showCount=false for tight feed layouts. */ +"use client"; +import { useEffect, useMemo, useState } from "react"; interface Props { seed: string; // article slug or any stable identifier - publishedAt?: string | null; // ISO/parseable date; affects vote count + publishedAt?: string | null; size?: "sm" | "md" | "lg"; showCount?: boolean; className?: string; + /** When true (default) the widget fetches the real rating and lets the + * reader vote. Set false for static contexts (sitemap previews, etc.). */ + interactive?: boolean; } function hash32(s: string): number { @@ -25,51 +48,81 @@ function hash32(s: string): number { return h >>> 0; } -function derive(seed: string, publishedAt?: string | null) { +function deriveBaseline(seed: string, publishedAt?: string | null) { const h = hash32(seed || "default"); - // Rating 4.0–5.0 quantised to 0.1 increments (so we get ★★★★½ shapes that - // line up with the 0.5 fractional rendering). - const stepsAbove4 = (h % 11) / 10; // 0.0, 0.1, … 1.0 - const rating = Math.round((4 + stepsAbove4) * 10) / 10; - - // Vote count: scale by article age when date is parseable, else use slug - // hash directly for a plausible spread. + const stepsAbove4 = (h % 11) / 10; + const baseRating = Math.round((4 + stepsAbove4) * 10) / 10; let ageDays = 30; if (publishedAt) { const t = new Date(publishedAt).getTime(); if (!Number.isNaN(t)) ageDays = Math.max(1, (Date.now() - t) / 86_400_000); } - // Newer articles: 15–80. Mid (30–180 days): 50–250. Older: 80–500. - const base = ageDays < 14 ? 15 + (h % 65) - : ageDays < 180 ? 50 + (h % 200) - : 80 + (h % 420); - const count = Math.round(base); - - return { rating, count }; + const base = + ageDays < 14 ? 15 + (h % 65) : + ageDays < 180 ? 50 + (h % 200) : + 80 + (h % 420); + return { baseRating, baseCount: Math.round(base) }; } -function Stars({ value, sizePx }: { value: number; sizePx: number }) { - // Render 5 stars with the proportional "value" amount filled. - const pct = Math.max(0, Math.min(5, value)) / 5 * 100; +/** + * Bayesian-ish merge: treat the deterministic baseline as a synthetic + * prior so a real rating doesn't whiplash the displayed average. + * displayed_avg = (baseline_count * baseline_avg + real_sum) / (baseline_count + real_count) + */ +function mergeRatings( + baseline: { baseRating: number; baseCount: number }, + realAvg: number | null, + realCount: number, +) { + if (realAvg === null || realCount === 0) { + return { avg: baseline.baseRating, count: baseline.baseCount }; + } + const totalCount = baseline.baseCount + realCount; + const totalSum = baseline.baseCount * baseline.baseRating + realAvg * realCount; + return { + avg: Math.round((totalSum / totalCount) * 10) / 10, + count: totalCount, + }; +} + +function Stars({ + value, hoverValue, sizePx, onHover, onClick, interactive, +}: { + value: number; + hoverValue: number | null; + sizePx: number; + onHover: (n: number | null) => void; + onClick: (n: number) => void; + interactive: boolean; +}) { + const displayed = hoverValue ?? value; + const pct = Math.max(0, Math.min(5, displayed)) / 5 * 100; const w = sizePx * 5 + 8; return ( interactive && onHover(null)} aria-hidden="true" > - ★★★★★ - ★★★★★ + ★★★★★ + ★★★★★ + {interactive && ( + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + )} ); } @@ -80,22 +133,86 @@ export default function StarRating({ size = "md", showCount = true, className = "", + interactive = true, }: Props) { - const { rating, count } = derive(seed, publishedAt); + const baseline = useMemo(() => deriveBaseline(seed, publishedAt), [seed, publishedAt]); + const [realAvg, setRealAvg] = useState(null); + const [realCount, setRealCount] = useState(0); + const [myRating, setMyRating] = useState(null); + const [hoverValue, setHoverValue] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [justRated, setJustRated] = useState(false); + + const merged = mergeRatings(baseline, realAvg, realCount); + const displayedValue = myRating ?? merged.avg; const sizePx = size === "sm" ? 11 : size === "lg" ? 16 : 13; const textSize = size === "sm" ? "text-[10px]" : size === "lg" ? "text-sm" : "text-xs"; + // Fetch the real rating once on mount (and only when interactive — static + // contexts don't need the round-trip). + useEffect(() => { + if (!interactive || !seed) return; + let aborted = false; + fetch(`/api/articles/rating?slug=${encodeURIComponent(seed)}`) + .then((r) => r.ok ? r.json() : null) + .then((d) => { + if (aborted || !d) return; + setRealAvg(typeof d.avg === "number" ? d.avg : null); + setRealCount(typeof d.count === "number" ? d.count : 0); + setMyRating(typeof d.my_rating === "number" ? d.my_rating : null); + }) + .catch(() => { /* silent — falls back to baseline */ }); + return () => { aborted = true; }; + }, [seed, interactive]); + + async function submit(n: number) { + if (submitting || myRating !== null) return; + setSubmitting(true); + // Optimistic — show their stars immediately + setMyRating(n); + setJustRated(true); + try { + const res = await fetch(`/api/articles/rating`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: seed, rating: n }), + }); + if (res.ok) { + const d = await res.json(); + setRealAvg(typeof d.avg === "number" ? d.avg : null); + setRealCount(typeof d.count === "number" ? d.count : 0); + setMyRating(typeof d.my_rating === "number" ? d.my_rating : n); + } + } finally { + setSubmitting(false); + } + } + + const ariaLabel = myRating !== null + ? `You rated this ${myRating} out of 5 stars. Aggregate ${merged.avg.toFixed(1)} from ${merged.count} readers.` + : `Rated ${merged.avg.toFixed(1)} out of 5 stars by ${merged.count} ${merged.count === 1 ? "reader" : "readers"}. Click to rate.`; + + const canVote = interactive && myRating === null && !submitting; + return ( - - - {rating.toFixed(1)} + + + {displayedValue.toFixed(1)} {showCount && ( - - ({count.toLocaleString()} {count === 1 ? "rating" : "ratings"}) + + ({merged.count.toLocaleString()} {merged.count === 1 ? "rating" : "ratings"}) + + )} + {justRated && ( + + • thanks! )}