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
|
* Display: ★★★★½ 4.7 (212 ratings)
|
||||||
* slug, so the same article always renders the same numbers across views.
|
*
|
||||||
* Rating range is intentionally constrained to 4.0–5.0 (always positive)
|
* Two layers of state:
|
||||||
* per editorial spec; vote count scales with article age when a date is
|
* 1. A deterministic baseline derived from the article's slug (4.0–5.0
|
||||||
* supplied.
|
* 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 {
|
interface Props {
|
||||||
seed: string; // article slug or any stable identifier
|
seed: string; // article slug or any stable identifier
|
||||||
publishedAt?: string | null; // ISO/parseable date; affects vote count
|
publishedAt?: string | null;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
showCount?: boolean;
|
showCount?: boolean;
|
||||||
className?: string;
|
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 {
|
function hash32(s: string): number {
|
||||||
@@ -25,51 +48,81 @@ function hash32(s: string): number {
|
|||||||
return h >>> 0;
|
return h >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function derive(seed: string, publishedAt?: string | null) {
|
function deriveBaseline(seed: string, publishedAt?: string | null) {
|
||||||
const h = hash32(seed || "default");
|
const h = hash32(seed || "default");
|
||||||
// Rating 4.0–5.0 quantised to 0.1 increments (so we get ★★★★½ shapes that
|
const stepsAbove4 = (h % 11) / 10;
|
||||||
// line up with the 0.5 fractional rendering).
|
const baseRating = Math.round((4 + stepsAbove4) * 10) / 10;
|
||||||
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.
|
|
||||||
let ageDays = 30;
|
let ageDays = 30;
|
||||||
if (publishedAt) {
|
if (publishedAt) {
|
||||||
const t = new Date(publishedAt).getTime();
|
const t = new Date(publishedAt).getTime();
|
||||||
if (!Number.isNaN(t)) ageDays = Math.max(1, (Date.now() - t) / 86_400_000);
|
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 =
|
||||||
const base = ageDays < 14 ? 15 + (h % 65)
|
ageDays < 14 ? 15 + (h % 65) :
|
||||||
: ageDays < 180 ? 50 + (h % 200)
|
ageDays < 180 ? 50 + (h % 200) :
|
||||||
: 80 + (h % 420);
|
80 + (h % 420);
|
||||||
const count = Math.round(base);
|
return { baseRating, baseCount: Math.round(base) };
|
||||||
|
|
||||||
return { rating, count };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stars({ value, sizePx }: { value: number; sizePx: number }) {
|
/**
|
||||||
// Render 5 stars with the proportional "value" amount filled.
|
* Bayesian-ish merge: treat the deterministic baseline as a synthetic
|
||||||
const pct = Math.max(0, Math.min(5, value)) / 5 * 100;
|
* 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;
|
const w = sizePx * 5 + 8;
|
||||||
return (
|
return (
|
||||||
<span
|
<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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span
|
<span style={{ position: "absolute", inset: 0, color: "#3a3a3a", fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif" }}>★★★★★</span>
|
||||||
style={{
|
<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>
|
||||||
position: "absolute", inset: 0, color: "#3a3a3a",
|
{interactive && (
|
||||||
fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif",
|
<span style={{ position: "absolute", inset: 0, display: "flex" }}>
|
||||||
}}
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
>★★★★★</span>
|
<button
|
||||||
<span
|
key={n}
|
||||||
style={{
|
type="button"
|
||||||
position: "absolute", inset: 0, color: "#f5c518",
|
aria-label={`Rate ${n} ${n === 1 ? "star" : "stars"}`}
|
||||||
fontSize: sizePx, letterSpacing: "1px", fontFamily: "Arial, sans-serif",
|
onMouseEnter={() => onHover(n)}
|
||||||
width: `${pct}%`, overflow: "hidden", whiteSpace: "nowrap",
|
onFocus={() => onHover(n)}
|
||||||
}}
|
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClick(n); }}
|
||||||
>★★★★★</span>
|
style={{ flex: 1, height: "100%", background: "transparent", border: "none", padding: 0, cursor: "pointer", color: "transparent" }}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,22 +133,86 @@ export default function StarRating({
|
|||||||
size = "md",
|
size = "md",
|
||||||
showCount = true,
|
showCount = true,
|
||||||
className = "",
|
className = "",
|
||||||
|
interactive = true,
|
||||||
}: Props) {
|
}: 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 sizePx = size === "sm" ? 11 : size === "lg" ? 16 : 13;
|
||||||
const textSize = size === "sm" ? "text-[10px]" : size === "lg" ? "text-sm" : "text-xs";
|
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 (
|
return (
|
||||||
<span
|
<span className={`inline-flex items-center gap-1.5 ${className}`} role="img" aria-label={ariaLabel}>
|
||||||
className={`inline-flex items-center gap-1.5 ${className}`}
|
<Stars
|
||||||
role="img"
|
value={displayedValue}
|
||||||
aria-label={`Rated ${rating} out of 5 stars by ${count} ${count === 1 ? "reader" : "readers"}`}
|
hoverValue={canVote ? hoverValue : null}
|
||||||
>
|
sizePx={sizePx}
|
||||||
<Stars value={rating} sizePx={sizePx} />
|
onHover={setHoverValue}
|
||||||
<span className={`font-body font-semibold text-[#d0d0d0] ${textSize}`}>{rating.toFixed(1)}</span>
|
onClick={submit}
|
||||||
|
interactive={canVote}
|
||||||
|
/>
|
||||||
|
<span className={`font-body font-semibold text-[#0F172A] ${textSize}`}>{displayedValue.toFixed(1)}</span>
|
||||||
{showCount && (
|
{showCount && (
|
||||||
<span className={`font-body text-[#666] ${textSize}`}>
|
<span className={`font-body text-[#64748B] ${textSize}`}>
|
||||||
({count.toLocaleString()} {count === 1 ? "rating" : "ratings"})
|
({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>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user