feat(ratings): real reader ratings with no-login, 1-vote-per-session
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) <noreply@anthropic.com>
This commit is contained in:
130
src/app/api/articles/rating/route.ts
Normal file
130
src/app/api/articles/rating/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Article star ratings — no-auth, one-vote-per-browser-session.
|
||||
*
|
||||
* GET /api/articles/rating?slug=<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;
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
style={{ position: "relative", display: "inline-block", height: sizePx, width: w, lineHeight: 1 }}
|
||||
style={{ position: "relative", display: "inline-block", height: sizePx, width: w, lineHeight: 1, cursor: interactive ? "pointer" : "default" }}
|
||||
onMouseLeave={() => interactive && onHover(null)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute", inset: 0, color: "#3a3a3a",
|
||||
fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif",
|
||||
}}
|
||||
>★★★★★</span>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute", inset: 0, color: "#f5c518",
|
||||
fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif",
|
||||
width: `${pct}%`, overflow: "hidden", whiteSpace: "nowrap",
|
||||
}}
|
||||
>★★★★★</span>
|
||||
<span style={{ position: "absolute", inset: 0, color: "#3a3a3a", fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif" }}>★★★★★</span>
|
||||
<span style={{ position: "absolute", inset: 0, color: "#f5c518", fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif", width: `${pct}%`, overflow: "hidden", whiteSpace: "nowrap", transition: "width 80ms ease" }}>★★★★★</span>
|
||||
{interactive && (
|
||||
<span style={{ position: "absolute", inset: 0, display: "flex" }}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-label={`Rate ${n} ${n === 1 ? "star" : "stars"}`}
|
||||
onMouseEnter={() => onHover(n)}
|
||||
onFocus={() => onHover(n)}
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClick(n); }}
|
||||
style={{ flex: 1, height: "100%", background: "transparent", border: "none", padding: 0, cursor: "pointer", color: "transparent" }}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(null);
|
||||
const [realCount, setRealCount] = useState(0);
|
||||
const [myRating, setMyRating] = useState<number | null>(null);
|
||||
const [hoverValue, setHoverValue] = useState<number | null>(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 (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 ${className}`}
|
||||
role="img"
|
||||
aria-label={`Rated ${rating} out of 5 stars by ${count} ${count === 1 ? "reader" : "readers"}`}
|
||||
>
|
||||
<Stars value={rating} sizePx={sizePx} />
|
||||
<span className={`font-body font-semibold text-[#d0d0d0] ${textSize}`}>{rating.toFixed(1)}</span>
|
||||
<span className={`inline-flex items-center gap-1.5 ${className}`} role="img" aria-label={ariaLabel}>
|
||||
<Stars
|
||||
value={displayedValue}
|
||||
hoverValue={canVote ? hoverValue : null}
|
||||
sizePx={sizePx}
|
||||
onHover={setHoverValue}
|
||||
onClick={submit}
|
||||
interactive={canVote}
|
||||
/>
|
||||
<span className={`font-body font-semibold text-[#0F172A] ${textSize}`}>{displayedValue.toFixed(1)}</span>
|
||||
{showCount && (
|
||||
<span className={`font-body text-[#666] ${textSize}`}>
|
||||
({count.toLocaleString()} {count === 1 ? "rating" : "ratings"})
|
||||
<span className={`font-body text-[#64748B] ${textSize}`}>
|
||||
({merged.count.toLocaleString()} {merged.count === 1 ? "rating" : "ratings"})
|
||||
</span>
|
||||
)}
|
||||
{justRated && (
|
||||
<span className={`font-body text-[#1D4ED8] font-semibold ${textSize}`} aria-live="polite">
|
||||
• thanks!
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user