diff --git a/apps/web/e2e/helpers/api-mock.ts b/apps/web/e2e/helpers/api-mock.ts index 1782fb9..0bab222 100644 --- a/apps/web/e2e/helpers/api-mock.ts +++ b/apps/web/e2e/helpers/api-mock.ts @@ -450,6 +450,11 @@ export function scenarioFor( // that hit those routes render without 599s. 'GET /v1/roadmap': emptyRoadmapListing, 'GET /v1/roadmap/changelog': emptyChangelog, + // Supporter chip in TopBar fans out to /v1/me/supporter on every + // signed-in render. Default to state=none so scenarios that + // don't exercise supporter logic don't hit a 599; supporter + // tests override via `supporterStatus(...)`. + 'GET /v1/me/supporter': supporterStatusNone, }; return { __id: id, routes: { ...base, ...overrides } }; } @@ -465,3 +470,34 @@ export const emptyChangelog = { status: 200, body: { entries: [] as Array }, }; + +/** Non-supporter status — used in scenarioFor's base map. */ +export const supporterStatusNone = { + status: 200, + body: { + state: 'none', + name_plate: null, + became_supporter_at: null, + last_payment_at: null, + grace_until: null, + cancelled_at: null, + current_tier_key: null, + }, +}; + +/** Helper for tests that want an active supporter state with a tier. */ +export const supporterStatus = ( + tier: 'coffee' | 'standard' | 'generous', + namePlate: string | null = null, +) => ({ + status: 200, + body: { + state: 'active', + name_plate: namePlate, + became_supporter_at: '2026-05-31T22:00:00Z', + last_payment_at: '2026-05-31T22:00:00Z', + grace_until: '2026-06-30T22:00:00Z', + cancelled_at: null, + current_tier_key: tier, + }, +}); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index fa738e1..995b1fe 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,8 +2,10 @@ import type { Metadata, Route } from 'next'; import Link from 'next/link'; import { getCurrentLocation, + getSupporterStatus, listSharedWithMe, type ResolvedLocation, + type SupporterStatusDto, } from '@/lib/api'; import { logger } from '@/lib/logger'; import { getCategoryBundle } from '@/lib/reference'; @@ -69,6 +71,11 @@ export default async function RootLayout({ // making a >24h stay render as a frozen "here 23h 57m". let location: ResolvedLocation | null = null; let inboundShareCount = 0; + // Caller's own supporter status — drives the TopBar chip so the + // user sees their recognition on every page, not just /u/. + // Fail-soft to null so a hiccup on /v1/me/supporter doesn't blank + // the chrome. + let supporter: SupporterStatusDto | null = null; // Pulled at the layout level so the TopBar's LocationChip can link // its location text to /kb/location/{slug}. The endpoint caches // server-side for 1h and is fetched (cached) by every signed-in @@ -76,11 +83,13 @@ export default async function RootLayout({ // aggregate. Failures degrade to plain-text chip rendering. let locationCatalog: ReferenceCatalog = EMPTY_CATEGORY_BUNDLE.catalog; if (session) { - const [locResult, sharedResult, catalogResult] = await Promise.allSettled([ - getCurrentLocation(session.token), - listSharedWithMe(session.token), - getCategoryBundle('location'), - ]); + const [locResult, sharedResult, catalogResult, supporterResult] = + await Promise.allSettled([ + getCurrentLocation(session.token), + listSharedWithMe(session.token), + getCategoryBundle('location'), + getSupporterStatus(session.token), + ]); if (locResult.status === 'fulfilled') { location = locResult.value; } else { @@ -111,6 +120,14 @@ export default async function RootLayout({ 'topbar location catalog fetch failed', ); } + if (supporterResult.status === 'fulfilled') { + supporter = supporterResult.value; + } else { + logger.warn( + { err: supporterResult.reason }, + 'topbar supporter status fetch failed', + ); + } } return ( @@ -130,6 +147,7 @@ export default async function RootLayout({ location?.entered_at_is_lower_bound ?? false } locationCatalog={locationCatalog} + supporter={supporter} /> ` renders next to + * the handle pill so the user sees their recognition on every + * page. `null` (default) hides the chip — same posture as the + * existing handle / location chip pattern. Sourced at the layout + * level (`Promise.allSettled` with the other shell fetches) so + * the TopBar stays presentational. + */ + supporter?: SupporterStatusDto | null; } /** @@ -51,6 +62,7 @@ export function TopBar({ dwellStart = null, dwellIsLowerBound = false, locationCatalog, + supporter = null, }: Props) { return (
@@ -115,6 +127,10 @@ export function TopBar({ @{handle} )} + {/* Supporter chip (compact size) sits to the right of the + handle. Returns null for state=none so non-supporters see + nothing — same posture as the existing handle render. */} +
); }