Skip to content

feat(supporter): render tier-styled chip on self profile view#143

Merged
ntatschner merged 2 commits into
nextfrom
feat/supporter-chip-self-profile
May 31, 2026
Merged

feat(supporter): render tier-styled chip on self profile view#143
ntatschner merged 2 commits into
nextfrom
feat/supporter-chip-self-profile

Conversation

@ntatschner
Copy link
Copy Markdown
Collaborator

Summary

Lights up the supporter recognition pill promised on /support ("A supporter pill on your public profile") for 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".

Scope choice — self-path only

The original ask was a three-surface chip (profile + topbar + discover) but each surface has different invasiveness:

  • Self profile (this PR): trivial. Existing /v1/me/supporter already returns the data; just adds current_tier_key.
  • Public/friend profile (follow-up): needs PublicSupporterInfo + extending PublicSummaryResponse + threading the supporter store through render_summary + a handle→user_id lookup. Real plumbing.
  • TopBar (follow-up): trivial extension on top of this PR — layout.tsx already does Promise.allSettled, add getSupporterStatus + thread to a TopBar prop.
  • Discover listing (follow-up): needs a bulk-fetch SupporterStore method to avoid N+1 queries across the paginated profile list.

Splitting now means each follow-up can ship + promote independently with focused review.

Server changes

  • SupporterStatus gains current_tier_key: Option<String>, derived at read time via a LEFT JOIN LATERAL to the most-recent completed revolut_orders row. Schema header for supporter_status deliberately keeps the tier off the table (tier-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 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.

Test plan

  • cargo test -p starstats-server config::tests supporters:: — all green (4 existing supporter tests passed against the new field shape after seed-row update)
  • cargo fmt -p starstats-server clean
  • cargo clippy -p starstats-server --bin starstats-server -- -D warnings clean
  • pnpm --filter web run test:run — 40 tests pass (10 new in SupporterChip.test.tsx covering null/none gating, three tier labels, name plate, lapsed marker, unknown-tier fallback, accessible label, size variants)
  • pnpm --filter web run typecheck clean
  • pnpm --filter web run lint — no new warnings (3 pre-existing remain in unrelated files)
  • Smoke after platform promote: visit /u/<your-handle> while signed in as yourself — chip renders with your current tier and name plate. Sign out, visit same URL — no chip (public path doesn't fetch supporter yet, expected).

Follow-ups

  • TopBar chip wire (small)
  • Public/friend profile chip + PublicSummaryResponse extension
  • /discover/profiles chip + bulk-fetch

Nigel Tatschner added 2 commits May 31, 2026 18:55
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<String>`,
    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).
@ntatschner ntatschner merged commit 4c7263e into next May 31, 2026
11 checks passed
@ntatschner ntatschner deleted the feat/supporter-chip-self-profile branch May 31, 2026 21:33
ntatschner pushed a commit that referenced this pull request Jun 1, 2026
Surfaces the supporter pill on every signed-in page (not just
/u/<handle> 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
    <SupporterChip size="sm" /> 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).
ntatschner pushed a commit that referenced this pull request Jun 1, 2026
Extends the supporter recognition pill (PR #143 self-only, PR #144
topbar) to anyone visiting a public profile or accepted-share view.
Continuation of the chip rollout: #143#144 → **#145 (this PR)** →
#146 (discover bulk).

Server changes:

  - `SupporterStore` gains `get_by_handle_public(handle)` —
    single-query JOIN of `users` + `supporter_status` + LATERAL most-
    recent completed `revolut_orders`. Returns `Ok(None)` for unknown
    handles, missing rows, or `state=none`; returns `Ok(Some(_))`
    only for `active`/`lapsed`. Case-insensitive handle match
    mirrors the rest of the project.

  - `PublicSummaryResponse` gains `supporter: Option<PublicSupporterInfo>`.
    `PublicSupporterInfo` is a deliberate projection of the store
    shape — only `state` + `current_tier_key` + `name_plate` are
    surfaced. `grace_until`, payment timestamps, and `cancelled_at`
    are explicitly NOT exposed to strangers (fingerprinting + churn
    leak avoidance).

  - `render_summary` + `render_summary_scoped` now take a
    `&dyn SupporterStore` and populate `supporter` on every public
    summary response. Fail-soft on lookup error (warn log; chip
    just doesn't render). Three handler call sites updated:
    `public_summary`, `friend_summary`, `preview_summary`. Each
    takes `Extension(Arc<dyn SupporterStore>)`; same dyn-cast
    pattern as `share_metadata_dyn`.

  - `main.rs` wires `supporter_store_dyn` + adds the Extension
    layer alongside `share_metadata_dyn`.

  - `openapi.rs` registers `PublicSupporterInfo` so the TS client
    regen picks it up.

Web changes:

  - `/u/[handle]/page.tsx` extends the supporter wiring from PR #143.
    Self path keeps fetching `/v1/me/supporter` for full DTO. Public
    + shared paths now read `data.supporter` from the extended
    PublicSummaryResponse. Both feed the same `<SupporterChip>` via
    a `ChipStatus` type alias (the three-field overlap between
    `SupporterStatusDto` and `PublicSupporterInfo`).

Tests:

  - 4 new SupporterStore tests on the memstore: unknown handle,
    unbound handle (row exists but no handle binding — simulates
    Postgres JOIN miss), bound handle returns active row with
    case-insensitive lookup, state=none filtered out.
  - `MemorySupporterStore` gains `bind_handle(handle, user_id)` so
    tests can simulate the users-table side of the JOIN.
  - All existing supporter + sharing tests still pass (35 sharing,
    8 supporter, 666 filtered).

Follow-up: PR #146 — discover/profiles chip (needs bulk
`get_many_public(user_ids)` to avoid N+1).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant