From 38eb6ec0da0ee49cd46de55ea1e626458383d024 Mon Sep 17 00:00:00 2001 From: Nigel Tatschner Date: Sun, 31 May 2026 18:55:55 +0100 Subject: [PATCH 1/2] chore: bump platform to v1.8.15 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25824e7..e4a90a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "starstats-core" -version = "1.8.13" +version = "1.8.15" dependencies = [ "chrono", "once_cell", @@ -6515,7 +6515,7 @@ dependencies = [ [[package]] name = "starstats-server" -version = "1.8.13" +version = "1.8.15" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 8aef3f6..db8ac3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "1.8.14" +version = "1.8.15" edition = "2021" license = "MPL-2.0" authors = ["StarStats contributors"] From 16f0ae39bdd243b558e996200016aaeac0911ab1 Mon Sep 17 00:00:00 2001 From: Nigel Tatschner Date: Sun, 31 May 2026 22:09:12 +0100 Subject: [PATCH 2/2] feat(supporter): render tier-styled chip on self profile view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the supporter recognition pill promised on /support ("A supporter pill on your public profile") to the owner-viewing-self path of /u/[handle]. Tier-specific styling distinguishes the three TIERS: coffee (warm brown), standard (accent), generous (gold). Lapsed states render with the same chip shape but muted colours per the design promise — "the pill stays — recognition is permanent — but accent perks revert to free-tier until the next payment lands". Server changes: - `SupporterStatus` gains `current_tier_key: Option`, derived at read time by a LATERAL join to the most-recent completed `revolut_orders` row. Schema header for supporter_status deliberately keeps the tier off the table (rename-safety + denorm-drift avoidance) so the read-side join is the canonical path. - `SupporterStatusDto` (returned by `GET /v1/me/supporter`) gains the same field. Backward-compatible — existing callers that ignore the new optional field still work. Web changes: - New `SupporterChip` component with three tier palettes built off CSS custom properties (theme-respecting), an `active`/`lapsed` variant for each, and a `size` prop (`sm` for compact surfaces like the future TopBar wire). Returns `null` for `state === 'none'` so callers don't need a conditional render. - Wired into `/u/[handle]/page.tsx` self path only. The chip appears alongside the existing "You" badge in the header. Public/ friend paths don't get the chip in this PR — extending `PublicSummaryResponse` with supporter info needs threading the supporter store through `render_summary` + a handle->user_id lookup, deferred to a follow-up PR. Test coverage: - 10 new SupporterChip tests: null status, none state, three tier labels, name plate appendage, lapsed text marker, unknown-tier fallback, accessible label composition, size variants. - Existing 4 supporter store tests updated for the new field + asserting tier_key round-trips through the seed path. Follow-ups: - PR for topbar chip (trivial — layout.tsx already has the Promise.allSettled scaffold; add getSupporterStatus + thread through TopBar prop). - PR for public/friend profile chip (needs PublicSupporterInfo + PublicSummaryResponse extension + render_summary supporter threading + UserStore handle->id lookup). - PR for /discover/profiles chip (needs bulk-fetch SupporterStore method to avoid N+1 queries). --- apps/web/src/app/u/[handle]/page.tsx | 19 +++ .../web/src/components/SupporterChip.test.tsx | 133 +++++++++++++++ apps/web/src/components/SupporterChip.tsx | 152 ++++++++++++++++++ .../starstats-server/src/supporter_routes.rs | 7 + crates/starstats-server/src/supporters.rs | 42 ++++- .../api-client-ts/src/generated/schema.ts | 8 + 6 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/SupporterChip.test.tsx create mode 100644 apps/web/src/components/SupporterChip.tsx diff --git a/apps/web/src/app/u/[handle]/page.tsx b/apps/web/src/app/u/[handle]/page.tsx index a954780..60f7d3d 100644 --- a/apps/web/src/app/u/[handle]/page.tsx +++ b/apps/web/src/app/u/[handle]/page.tsx @@ -26,9 +26,11 @@ import { getPublicShareScopes, getPublicSummary, getSummary, + getSupporterStatus, type ProfileResponse, type PublicSummaryResponse, type ShareScope, + type SupporterStatusDto, type WidgetShareScopesApi, } from '@/lib/api'; import { formatEventType } from '@/lib/event-types'; @@ -44,6 +46,7 @@ import { type RenderedWidget, } from '@/app/_components/widgets/SortableProfileWidgets'; import { ProfileCard } from '@/components/ProfileCard'; +import { SupporterChip } from '@/components/SupporterChip'; interface PageProps { params: Promise<{ handle: string }>; @@ -255,6 +258,21 @@ export default async function PublicProfilePage(props: PageProps) { } } + // Self-path supporter chip. We only fetch + render it for the owner + // viewing their own page (kind === 'self') because the supporter + // data isn't currently exposed on the public/friend summary + // endpoints — extending PublicSummaryResponse with supporter info + // is a follow-up. Fail-soft to null on any error so the rest of + // the profile keeps rendering. + let supporter: SupporterStatusDto | null = null; + if (view.kind === 'self' && token) { + try { + supporter = await getSupporterStatus(token); + } catch (e) { + logger.warn({ err: e }, 'self supporter status fetch failed'); + } + } + return (
RSI verified )} +
{/* Sharing CTAs — context-sensitive deep links into /sharing. diff --git a/apps/web/src/components/SupporterChip.test.tsx b/apps/web/src/components/SupporterChip.test.tsx new file mode 100644 index 0000000..29afdd8 --- /dev/null +++ b/apps/web/src/components/SupporterChip.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SupporterChip } from './SupporterChip'; +import type { SupporterStatusDto } from '@/lib/api'; + +function status( + overrides: Partial, +): SupporterStatusDto { + return { + state: 'none', + name_plate: null, + became_supporter_at: null, + last_payment_at: null, + grace_until: null, + cancelled_at: null, + current_tier_key: null, + ...overrides, + }; +} + +describe('SupporterChip', () => { + it('renders nothing for a null status (no supporter row yet)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing for state="none"', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders an active coffee chip with the tier label', () => { + render( + , + ); + expect(screen.getByText('Coffee supporter')).toBeInTheDocument(); + }); + + it('renders an active standard chip', () => { + render( + , + ); + expect(screen.getByText('Supporter')).toBeInTheDocument(); + }); + + it('renders an active generous chip', () => { + render( + , + ); + expect(screen.getByText('Generous supporter')).toBeInTheDocument(); + }); + + it('appends the name plate when present', () => { + render( + , + ); + expect(screen.getByText(/Coffee supporter/)).toBeInTheDocument(); + expect(screen.getByText(/Caelum/)).toBeInTheDocument(); + }); + + it('marks lapsed status visibly different (label includes "lapsed")', () => { + render( + , + ); + expect(screen.getByText(/Supporter \(lapsed\)/)).toBeInTheDocument(); + }); + + it('falls back to standard label when tier_key is unknown', () => { + render( + , + ); + expect(screen.getByText('Supporter')).toBeInTheDocument(); + }); + + it('exposes an accessible label combining tier + plate', () => { + render( + , + ); + const chip = screen.getByRole('status'); + expect(chip).toHaveAttribute( + 'aria-label', + 'Generous supporter — Caelum', + ); + }); + + it('uses a tighter padding for size="sm"', () => { + const { rerender } = render( + , + ); + const small = screen.getByRole('status') as HTMLElement; + const smPadding = small.style.padding; + + rerender( + , + ); + const md = screen.getByRole('status') as HTMLElement; + expect(md.style.padding).not.toEqual(smPadding); + }); +}); diff --git a/apps/web/src/components/SupporterChip.tsx b/apps/web/src/components/SupporterChip.tsx new file mode 100644 index 0000000..940b849 --- /dev/null +++ b/apps/web/src/components/SupporterChip.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import type { SupporterStatusDto } from '@/lib/api'; + +/** + * Supporter status chip. Renders the "Supporter" pill (plus optional + * 28-char name plate) for users whose `supporter_status.state` is + * `active` or `lapsed`. Returns `null` when state is `none` so the + * caller can drop the component without a conditional render. + * + * Tier-specific styling (per user-chosen design): coffee = warm + * brown; standard = accent; generous = gold. Lapsed states mute + * those colours per the design promise — "the pill stays — + * recognition is permanent — but accent perks revert to free-tier + * until the next payment lands" (see `docs/REVOLUT-INTEGRATION-PLAN.md`). + * + * When `tier_key` is `null` (no completed order yet, defensive + * fallback) the chip uses the standard accent palette so it still + * renders. + */ + +export type SupporterChipSize = 'sm' | 'md'; + +type TierKey = 'coffee' | 'standard' | 'generous'; + +interface TierPalette { + fg: string; + bg: string; + border: string; +} + +// Each tier resolves to a small palette built off CSS custom +// properties so the colours respect the active theme (Stanton / Pyro +// / future themes). The `color-mix` literals scale alpha against the +// base hue so callers don't have to think about contrast separately. +const TIER_PALETTES: Record = { + coffee: { + active: { + fg: 'var(--supporter-coffee-fg, #b87333)', + bg: 'color-mix(in oklab, var(--supporter-coffee-fg, #b87333) 14%, transparent)', + border: 'var(--supporter-coffee-fg, #b87333)', + }, + lapsed: { + fg: 'var(--fg-muted)', + bg: 'color-mix(in oklab, var(--supporter-coffee-fg, #b87333) 6%, transparent)', + border: 'var(--border)', + }, + }, + standard: { + active: { + fg: 'var(--accent)', + bg: 'color-mix(in oklab, var(--accent) 12%, transparent)', + border: 'var(--accent)', + }, + lapsed: { + fg: 'var(--fg-muted)', + bg: 'color-mix(in oklab, var(--accent) 6%, transparent)', + border: 'var(--border)', + }, + }, + generous: { + active: { + fg: 'var(--supporter-generous-fg, #d4af37)', + bg: 'color-mix(in oklab, var(--supporter-generous-fg, #d4af37) 14%, transparent)', + border: 'var(--supporter-generous-fg, #d4af37)', + }, + lapsed: { + fg: 'var(--fg-muted)', + bg: 'color-mix(in oklab, var(--supporter-generous-fg, #d4af37) 6%, transparent)', + border: 'var(--border)', + }, + }, +}; + +const TIER_LABELS: Record = { + coffee: 'Coffee supporter', + standard: 'Supporter', + generous: 'Generous supporter', +}; + +function isKnownTier(value: string | null | undefined): value is TierKey { + return value === 'coffee' || value === 'standard' || value === 'generous'; +} + +interface Props { + status: Pick< + SupporterStatusDto, + 'state' | 'name_plate' | 'current_tier_key' + > | null; + /** Compact (sm) vs full (md). Defaults to md. */ + size?: SupporterChipSize; +} + +export function SupporterChip({ status, size = 'md' }: Props) { + if (!status) return null; + if (status.state !== 'active' && status.state !== 'lapsed') return null; + + // Unknown / null tier falls back to the standard palette + generic + // "Supporter" label. Live data should always carry a tier when + // state is active/lapsed, but the chip renders something useful + // rather than nothing if the join row is missing. + const tier: TierKey = isKnownTier(status.current_tier_key) + ? status.current_tier_key + : 'standard'; + const variant = status.state === 'active' ? 'active' : 'lapsed'; + const palette = TIER_PALETTES[tier][variant]; + const label = + status.state === 'lapsed' ? `${TIER_LABELS[tier]} (lapsed)` : TIER_LABELS[tier]; + + const padY = size === 'sm' ? 2 : 4; + const padX = size === 'sm' ? 8 : 10; + const fontSize = size === 'sm' ? 11 : 12; + const gap = size === 'sm' ? 6 : 8; + + return ( + + {label} + {status.name_plate && ( + + )} + + ); +} diff --git a/crates/starstats-server/src/supporter_routes.rs b/crates/starstats-server/src/supporter_routes.rs index 6826f07..95592be 100644 --- a/crates/starstats-server/src/supporter_routes.rs +++ b/crates/starstats-server/src/supporter_routes.rs @@ -35,6 +35,12 @@ pub struct SupporterStatusDto { pub last_payment_at: Option>, pub grace_until: Option>, pub cancelled_at: Option>, + /// `tier_key` of the most-recent completed donation + /// (`coffee` / `standard` / `generous`). Used by the web UI to + /// pick the supporter-chip styling. `None` when the user has + /// never had a completed order — combined with `state` the + /// frontend can distinguish "never paid" from "paid (any tier)". + pub current_tier_key: Option, } fn err(status: StatusCode, code: &'static str) -> Response { @@ -78,6 +84,7 @@ pub async fn get_me( last_payment_at: s.last_payment_at, grace_until: s.grace_until, cancelled_at: s.cancelled_at, + current_tier_key: s.current_tier_key, }), ) .into_response(), diff --git a/crates/starstats-server/src/supporters.rs b/crates/starstats-server/src/supporters.rs index e6294fb..ebe3804 100644 --- a/crates/starstats-server/src/supporters.rs +++ b/crates/starstats-server/src/supporters.rs @@ -61,6 +61,15 @@ pub struct SupporterStatus { /// for stale-row checks, not by the read DTO. #[allow(dead_code)] pub updated_at: DateTime, + /// `tier_key` of the most-recent completed Revolut order. Derived + /// at read time from `revolut_orders` because the schema header + /// for `supporter_status` deliberately keeps the dollar amount + /// off this table (tier rename safety + denorm-drift avoidance). + /// `None` when the user has never had a completed order — even + /// if `state` is `active` for a state-only path (which today + /// can't actually happen since the only writer is the webhook). + /// Powers the tier-specific styling on the supporter chip. + pub current_tier_key: Option, } impl SupporterStatus { @@ -77,6 +86,7 @@ impl SupporterStatus { grace_until: None, cancelled_at: None, updated_at: Utc::now(), + current_tier_key: None, } } } @@ -125,6 +135,13 @@ impl PostgresSupporterStore { #[async_trait] impl SupporterStore for PostgresSupporterStore { async fn get(&self, user_id: Uuid) -> Result { + // Single-row read joined to the user's most-recent completed + // revolut_orders row for the `current_tier_key` derivation. + // `LEFT JOIN LATERAL ... ON true` keeps the supporter_status + // row even when no completed orders exist (state could be + // `active` from a future state-only path; today every active + // row has at least one order but we don't want the join to + // suppress the supporter row if a backfill case ever exists). let row: Option<( String, Option, @@ -133,12 +150,21 @@ impl SupporterStore for PostgresSupporterStore { Option>, Option>, DateTime, + Option, )> = sqlx::query_as( - "SELECT state, name_plate, - became_supporter_at, last_payment_at, - grace_until, cancelled_at, updated_at - FROM supporter_status - WHERE user_id = $1", + "SELECT s.state, s.name_plate, + s.became_supporter_at, s.last_payment_at, + s.grace_until, s.cancelled_at, s.updated_at, + o.tier_key + FROM supporter_status s + LEFT JOIN LATERAL ( + SELECT tier_key + FROM revolut_orders + WHERE user_id = s.user_id AND state = 'completed' + ORDER BY completed_at DESC NULLS LAST, created_at DESC + LIMIT 1 + ) o ON true + WHERE s.user_id = $1", ) .bind(user_id) .fetch_optional(&self.pool) @@ -146,7 +172,7 @@ impl SupporterStore for PostgresSupporterStore { Ok(match row { None => SupporterStatus::empty(user_id), - Some((state, name_plate, became, last_pay, grace, cancelled, updated)) => { + Some((state, name_plate, became, last_pay, grace, cancelled, updated, tier_key)) => { SupporterStatus { user_id, state: SupporterState::parse(&state).unwrap_or(SupporterState::None), @@ -156,6 +182,7 @@ impl SupporterStore for PostgresSupporterStore { grace_until: grace, cancelled_at: cancelled, updated_at: updated, + current_tier_key: tier_key, } } }) @@ -244,6 +271,7 @@ pub mod test_support { grace_until: None, cancelled_at: None, updated_at: now, + current_tier_key: None, }); entry.state = SupporterState::Active; if let Some(plate) = name_plate { @@ -323,9 +351,11 @@ mod tests { grace_until: None, cancelled_at: None, updated_at: Utc::now(), + current_tier_key: Some("coffee".into()), }); let s = store.get(user_id).await.unwrap(); assert_eq!(s.state, SupporterState::Active); assert_eq!(s.name_plate.as_deref(), Some("Caelum")); + assert_eq!(s.current_tier_key.as_deref(), Some("coffee")); } } diff --git a/packages/api-client-ts/src/generated/schema.ts b/packages/api-client-ts/src/generated/schema.ts index f3784ae..6490de1 100644 --- a/packages/api-client-ts/src/generated/schema.ts +++ b/packages/api-client-ts/src/generated/schema.ts @@ -4550,6 +4550,14 @@ export interface components { became_supporter_at?: string | null; /** Format: date-time */ cancelled_at?: string | null; + /** + * @description `tier_key` of the most-recent completed donation + * (`coffee` / `standard` / `generous`). Used by the web UI to + * pick the supporter-chip styling. `None` when the user has + * never had a completed order — combined with `state` the + * frontend can distinguish "never paid" from "paid (any tier)". + */ + current_tier_key?: string | null; /** Format: date-time */ grace_until?: string | null; /** Format: date-time */