Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/web/e2e/helpers/api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}
Expand All @@ -465,3 +470,34 @@ export const emptyChangelog = {
status: 200,
body: { entries: [] as Array<unknown> },
};

/** 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,
},
});
28 changes: 23 additions & 5 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,18 +71,25 @@ 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/<handle>.
// 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
// page anyway — pulling it here saves the per-page fetch in
// 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 {
Expand Down Expand Up @@ -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 (
Expand All @@ -130,6 +147,7 @@ export default async function RootLayout({
location?.entered_at_is_lower_bound ?? false
}
locationCatalog={locationCatalog}
supporter={supporter}
/>
<LeftRail
handle={session.claimedHandle}
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/shell/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Link from 'next/link';
import type { ResolvedLocation } from '@/lib/api';
import type { ResolvedLocation, SupporterStatusDto } from '@/lib/api';
import type { ReferenceCatalog } from '@/lib/reference-types';
import { CompassStar } from '@/components/CompassStar';
import { LocationChip } from '@/components/LocationPill';
import { SupporterChip } from '@/components/SupporterChip';
import { DrawerToggle } from './DrawerToggle';

interface Props {
Expand Down Expand Up @@ -36,6 +37,16 @@ interface Props {
* text links to `/kb/location/{slug}` with the EntityHoverCard.
* Pass from the layout's `getCategoryBundle('location').catalog`. */
locationCatalog?: ReferenceCatalog;
/**
* Caller's own supporter status. When state is `active` or
* `lapsed`, a compact `<SupporterChip size="sm">` 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;
}

/**
Expand All @@ -51,6 +62,7 @@ export function TopBar({
dwellStart = null,
dwellIsLowerBound = false,
locationCatalog,
supporter = null,
}: Props) {
return (
<header className="ss-topbar">
Expand Down Expand Up @@ -115,6 +127,10 @@ export function TopBar({
@{handle}
</span>
)}
{/* 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. */}
<SupporterChip status={supporter} size="sm" />
</header>
);
}
Loading