From 9fee3a4d722942c7de897d9b5613bf792fc211df Mon Sep 17 00:00:00 2001 From: Nigel Tatschner Date: Mon, 1 Jun 2026 02:09:39 +0100 Subject: [PATCH] feat(supporter): render compact supporter chip in TopBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the supporter pill on every signed-in page (not just /u/ self view) by piggybacking on the existing layout-level Promise.allSettled scaffold. `getSupporterStatus` joins the existing location / shared / catalog fan-out so the chip costs one extra fetch per shell render — request-level cached by React anyway. Web changes: - layout.tsx adds getSupporterStatus to the Promise.allSettled block, fail-soft to null on error so a /v1/me/supporter hiccup doesn't blank the chrome (same posture as the other shell fetches). - TopBar.tsx accepts an optional `supporter` prop, renders next to the @handle pill. Compact palette size mirrors the existing topbar density. E2E fixture (required per `Playwright fixture default rule`): - api-mock.ts gets a default `/v1/me/supporter` fixture pointing at state=none so existing scenarios that don't care about supporter logic don't 599 on the new layout fetch. - New `supporterStatus(tier, plate)` helper for tests that DO want to exercise the chip. Self-profile chip from PR #143 continues to work; the topbar chip just makes the recognition visible on /dashboard, /journey, /sharing etc. without forcing the user to navigate to their profile page. Follow-up: public/friend profile chip (#145), discover/profiles chip (#146). --- apps/web/e2e/helpers/api-mock.ts | 36 ++++++++++++++++++++++++ apps/web/src/app/layout.tsx | 28 ++++++++++++++---- apps/web/src/components/shell/TopBar.tsx | 18 +++++++++++- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/apps/web/e2e/helpers/api-mock.ts b/apps/web/e2e/helpers/api-mock.ts index 1782fb94..0bab2225 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 fa738e1b..995b1fe9 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. */} +
); }