--- /** * Island: the reviews panel. * * Prerendered with the counts the API already knows, so the panel occupies its final * space and shows real numbers with JavaScript off. Review bodies live on Nostr, so * the list itself hydrates from relays on load. * * Used by both a mint page and a federation page, unchanged. What is being reviewed * arrives as a `ReviewSubject` — which `k`, which `d`, which `u` — and nothing below * this line asks which ecosystem produced it. See `lib/review-subject.ts`. */ import type { RatingDistribution } from '@cashumints/shared'; import type { ReviewSubject } from '../lib/review-subject'; import { renderBones } from 'boneyard-js'; import WriteForm from './WriteForm.astro'; import reviewsBones from '../bones/reviews-panel.bones.json'; import { BONE_COLOR } from '../lib/skeleton'; import { useI18n } from '../i18n'; import { formatters } from '../i18n/format'; import { pageLocale } from '../i18n/paths'; interface Props { /** * What is being reviewed: the tags a review carries and the filters that find them. * * Null on the 404 page alone, which renders this panel for a mint it has not resolved * yet: the resolver writes the subject onto the root and tells the island to start. * Every prerendered page passes a real one. */ subject: ReviewSubject | null; /** The counts the API already knows, prerendered so the panel is honest without JS. */ reviewCount: number; distribution: RatingDistribution; /** Named in the write dialog's heading. The thing's own name, never translated. */ name: string; } const { subject, reviewCount, distribution, name } = Astro.props; const { locale } = pageLocale(Astro); const t = useI18n(locale); const f = formatters(t); const dist = distribution; /* * The placeholder asks about the thing being reviewed, which is the one sentence in * this panel that cannot be ecosystem-neutral without going vague: "how did minting and * melting work out" means nothing about a federation, and "how did it go" means nothing * about anything. One key per ecosystem, falling back to the Cashu wording for a type * whose own has not been written yet. */ const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject?.type ?? 'cashu'}`; const bodyPlaceholder = t.has(placeholderKey) ? t(placeholderKey) : t('reviews.dialog.bodyPlaceholder.cashu'); const critical = dist['1'] + dist['2']; const fiveStar = dist['5']; /** * Bones are drawn into the prerendered HTML, not by the island. * * `renderBones` is a pure string builder, so it runs here at build time just as well * as in the browser. Doing it here means the skeleton is on screen at first paint * instead of after the island's chunk has downloaded, which on a slow connection is * the difference between a skeleton and a second of loading text followed by a jump. * * One block per captured breakpoint, picked by media query in global.css, because * bone heights differ per width and the server cannot know the viewport. */ const boneBlocks = Object.entries(reviewsBones.breakpoints) .map(([width, skeleton]) => ({ width: Number(width), html: renderBones(skeleton, BONE_COLOR, false) })) .sort((a, b) => a.width - b.width); // Bones stand in for reviews the API has already counted. With none counted there is // nothing to wait for, and the empty state below is the truthful thing to prerender. const showBoneBlocks = reviewCount > 0 && boneBlocks.length > 0; /** * Without JavaScript the relays are never queried, so the bones would sit there for * good. Drop them and say what is actually going on. * * Emitted as raw HTML on purpose: Astro hoists every ` + `

${escapeText(t('reviews.noscript'))}

`; /** This block goes in as raw HTML, so the one translated string in it is escaped. */ function escapeText(value: string): string { return value .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } --- {/* The island's root is `data-reviews-panel`, not `data-reviews`: a mint card already carries that one, as its review count, and this island now looks for its root on every page the router brings in, not only on a mint page. */}

{t('reviews.title')}

{/* A protocol name, not a label: NIP-87 is NIP-87 in every language. */} NIP-87
{/* Not a filter but styled as one: hides reviews AND ratings from keys with no kind-0 profile. Hidden until the island knows how many that is; without JavaScript there is nothing it could hide anyway. */}
{/* One line of consequence when the toggle above is on: how much is showing. */} {/* Filled by the island with one line summarising every review that carried a rating and no words. Empty (and invisible) until the relays answer, and on a mint where every review has a body it stays empty. */}
{/* Only the panel body is skeletoned: the header, badge, filter chips and their counts above are prerendered and stay put. `data-boneyard` marks the capture target for `pnpm bones`, which snapshots this element's single child. */}
{/* `data-boneyard` only when there is something to measure. `pnpm bones` captures two pages, and the second is a /mint/ URL with no prerendered page — which now carries this panel too, with no subject and nothing in it. Marked as a capture target there, it would overwrite the reviews bones taken from the real mint page a moment earlier with the bones of an empty box. */}
{ showBoneBlocks ? (