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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "1.8.14"
version = "1.8.15"
edition = "2021"
license = "MPL-2.0"
authors = ["StarStats contributors"]
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/app/u/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 }>;
Expand Down Expand Up @@ -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 (
<div
className="ss-screen-enter"
Expand Down Expand Up @@ -313,6 +331,7 @@ export default async function PublicProfilePage(props: PageProps) {
{profile && (
<span className="ss-badge ss-badge--ok">RSI verified</span>
)}
<SupporterChip status={supporter} />
</div>
</div>
{/* Sharing CTAs — context-sensitive deep links into /sharing.
Expand Down
133 changes: 133 additions & 0 deletions apps/web/src/components/SupporterChip.test.tsx
Original file line number Diff line number Diff line change
@@ -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>,
): 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(<SupporterChip status={null} />);
expect(container.firstChild).toBeNull();
});

it('renders nothing for state="none"', () => {
const { container } = render(
<SupporterChip status={status({ state: 'none' })} />,
);
expect(container.firstChild).toBeNull();
});

it('renders an active coffee chip with the tier label', () => {
render(
<SupporterChip
status={status({ state: 'active', current_tier_key: 'coffee' })}
/>,
);
expect(screen.getByText('Coffee supporter')).toBeInTheDocument();
});

it('renders an active standard chip', () => {
render(
<SupporterChip
status={status({ state: 'active', current_tier_key: 'standard' })}
/>,
);
expect(screen.getByText('Supporter')).toBeInTheDocument();
});

it('renders an active generous chip', () => {
render(
<SupporterChip
status={status({ state: 'active', current_tier_key: 'generous' })}
/>,
);
expect(screen.getByText('Generous supporter')).toBeInTheDocument();
});

it('appends the name plate when present', () => {
render(
<SupporterChip
status={status({
state: 'active',
current_tier_key: 'coffee',
name_plate: 'Caelum',
})}
/>,
);
expect(screen.getByText(/Coffee supporter/)).toBeInTheDocument();
expect(screen.getByText(/Caelum/)).toBeInTheDocument();
});

it('marks lapsed status visibly different (label includes "lapsed")', () => {
render(
<SupporterChip
status={status({ state: 'lapsed', current_tier_key: 'standard' })}
/>,
);
expect(screen.getByText(/Supporter \(lapsed\)/)).toBeInTheDocument();
});

it('falls back to standard label when tier_key is unknown', () => {
render(
<SupporterChip
status={status({
state: 'active',
current_tier_key: 'mystery-future-tier',
})}
/>,
);
expect(screen.getByText('Supporter')).toBeInTheDocument();
});

it('exposes an accessible label combining tier + plate', () => {
render(
<SupporterChip
status={status({
state: 'active',
current_tier_key: 'generous',
name_plate: 'Caelum',
})}
/>,
);
const chip = screen.getByRole('status');
expect(chip).toHaveAttribute(
'aria-label',
'Generous supporter — Caelum',
);
});

it('uses a tighter padding for size="sm"', () => {
const { rerender } = render(
<SupporterChip
status={status({ state: 'active', current_tier_key: 'standard' })}
size="sm"
/>,
);
const small = screen.getByRole('status') as HTMLElement;
const smPadding = small.style.padding;

rerender(
<SupporterChip
status={status({ state: 'active', current_tier_key: 'standard' })}
size="md"
/>,
);
const md = screen.getByRole('status') as HTMLElement;
expect(md.style.padding).not.toEqual(smPadding);
});
});
152 changes: 152 additions & 0 deletions apps/web/src/components/SupporterChip.tsx
Original file line number Diff line number Diff line change
@@ -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<TierKey, { active: TierPalette; lapsed: TierPalette }> = {
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<TierKey, string> = {
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 (
<span
role="status"
aria-label={status.name_plate ? `${label} — ${status.name_plate}` : label}
className="mono"
style={{
display: 'inline-flex',
alignItems: 'center',
gap,
padding: `${padY}px ${padX}px`,
fontSize,
fontWeight: 500,
lineHeight: 1.2,
color: palette.fg,
background: palette.bg,
border: `1px solid ${palette.border}`,
borderRadius: 'var(--r-pill)',
whiteSpace: 'nowrap',
}}
>
<span>{label}</span>
{status.name_plate && (
<span
aria-hidden="true"
style={{
opacity: 0.55,
fontWeight: 400,
// The dot keeps the plate visually anchored to the label
// without needing another border / divider element.
// Reads cleanly in screen readers because the aria-label
// on the parent already spells out the relationship.
}}
>
· {status.name_plate}
</span>
)}
</span>
);
}
7 changes: 7 additions & 0 deletions crates/starstats-server/src/supporter_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ pub struct SupporterStatusDto {
pub last_payment_at: Option<DateTime<Utc>>,
pub grace_until: Option<DateTime<Utc>>,
pub cancelled_at: Option<DateTime<Utc>>,
/// `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<String>,
}

fn err(status: StatusCode, code: &'static str) -> Response {
Expand Down Expand Up @@ -78,6 +84,7 @@ pub async fn get_me<S: SupporterStore>(
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(),
Expand Down
Loading
Loading