diff --git a/Cargo.lock b/Cargo.lock
index 25824e77..e4a90a6b 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 8aef3f6c..db8ac3d4 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"]
diff --git a/apps/web/src/app/u/[handle]/page.tsx b/apps/web/src/app/u/[handle]/page.tsx
index a9547805..60f7d3de 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 00000000..29afdd8b
--- /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 00000000..940b849d
--- /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 && (
+
+ · {status.name_plate}
+
+ )}
+
+ );
+}
diff --git a/crates/starstats-server/src/supporter_routes.rs b/crates/starstats-server/src/supporter_routes.rs
index 6826f079..95592be4 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 e6294fb5..ebe38043 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 f3784aed..6490de15 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 */