diff --git a/apps/web/src/app/u/[handle]/page.tsx b/apps/web/src/app/u/[handle]/page.tsx
index 60f7d3d..69d7d27 100644
--- a/apps/web/src/app/u/[handle]/page.tsx
+++ b/apps/web/src/app/u/[handle]/page.tsx
@@ -258,19 +258,32 @@ 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;
+ // Supporter chip:
+ // - self path: fetch the full SupporterStatusDto via /v1/me/supporter
+ // (carries grace_until + timestamps the chip ignores; same shape
+ // SupporterChip already takes).
+ // - public / shared paths: read the public projection that
+ // `PublicSummaryResponse.supporter` now carries (state + tier +
+ // name_plate only — see `PublicSupporterInfo` server-side). The
+ // two shapes overlap on the three fields SupporterChip needs, so
+ // we feed either through the same `status` prop.
+ // Fail-soft to null on any error so the rest of the profile keeps
+ // rendering.
+ type ChipStatus = Pick<
+ SupporterStatusDto,
+ 'state' | 'name_plate' | 'current_tier_key'
+ >;
+ let chipStatus: ChipStatus | null = null;
if (view.kind === 'self' && token) {
try {
- supporter = await getSupporterStatus(token);
+ chipStatus = await getSupporterStatus(token);
} catch (e) {
logger.warn({ err: e }, 'self supporter status fetch failed');
}
+ } else if (view.kind === 'public' || view.kind === 'shared') {
+ // PublicSummaryResponse.supporter is `PublicSupporterInfo | null`;
+ // structurally identical to the SupporterChip status shape.
+ chipStatus = data.supporter ?? null;
}
return (
@@ -331,7 +344,7 @@ export default async function PublicProfilePage(props: PageProps) {
{profile && (
RSI verified
)}
-
+
{/* Sharing CTAs — context-sensitive deep links into /sharing.
diff --git a/crates/starstats-server/src/main.rs b/crates/starstats-server/src/main.rs
index dcd1bc7..c545c58 100644
--- a/crates/starstats-server/src/main.rs
+++ b/crates/starstats-server/src/main.rs
@@ -451,6 +451,10 @@ async fn main() -> anyhow::Result<()> {
let share_metadata_dyn: Arc =
share_metadata.clone();
let share_reports_dyn: Arc = share_reports.clone();
+ // Threaded through the request layer so sharing/discover handlers
+ // can look up supporter chip info by handle. Same dyn-cast pattern
+ // as share_metadata_dyn etc.
+ let supporter_store_dyn: Arc = supporter_store.clone();
// Roadmap pipeline (Phases 1-8). The store is always constructed
// so the public read API works even without GitHub credentials —
@@ -636,6 +640,7 @@ async fn main() -> anyhow::Result<()> {
.layer(Extension(health_pool))
.layer(Extension(spicedb))
.layer(Extension(public_access_checker))
+ .layer(Extension(supporter_store_dyn))
.layer(Extension(share_metadata_dyn))
.layer(Extension(share_reports_dyn))
.layer(Extension(admin_parser_submissions_store))
diff --git a/crates/starstats-server/src/openapi.rs b/crates/starstats-server/src/openapi.rs
index 622e850..6954006 100644
--- a/crates/starstats-server/src/openapi.rs
+++ b/crates/starstats-server/src/openapi.rs
@@ -484,6 +484,7 @@ impl Modify for SecurityAddon {
sharing_routes::SharedWithMeEntry,
sharing_routes::ListSharedWithMeResponse,
sharing_routes::PublicSummaryResponse,
+ sharing_routes::PublicSupporterInfo,
sharing_routes::PublicTypeCount,
sharing_routes::PublicTimelineResponse,
sharing_routes::PublicTimelineBucket,
diff --git a/crates/starstats-server/src/sharing_routes.rs b/crates/starstats-server/src/sharing_routes.rs
index 8bca4a2..7f8c615 100644
--- a/crates/starstats-server/src/sharing_routes.rs
+++ b/crates/starstats-server/src/sharing_routes.rs
@@ -29,6 +29,7 @@ use crate::share_reports::{
RATE_LIMIT_PER_WINDOW,
};
use crate::spicedb::{ObjectRef, SpicedbClient};
+use crate::supporters::{SupporterState, SupporterStatus, SupporterStore};
use crate::users::{PostgresUserStore, UserStore};
use crate::validation::{build_timeline_buckets, resolve_timeline_days, validate_handle};
use axum::{
@@ -326,6 +327,33 @@ pub struct PublicSummaryResponse {
pub claimed_handle: String,
pub total: u64,
pub by_type: Vec,
+ /// Supporter chip data for public + friend views. `None` when
+ /// the user has never donated (state = 'none' or no row) — the
+ /// web `` renders nothing in that case. Present
+ /// for both `active` and `lapsed` per the "pill stays —
+ /// recognition is permanent" design (see
+ /// `docs/REVOLUT-INTEGRATION-PLAN.md`). Only fields safe to
+ /// expose publicly: state + tier + plate. We deliberately
+ /// withhold `grace_until` / payment timestamps to limit
+ /// fingerprinting from a stranger.
+ pub supporter: Option,
+}
+
+/// Public-safe supporter projection for the profile chip. Mirrors
+/// the minimum fields the web `` needs and nothing
+/// else.
+#[derive(Debug, Serialize, Deserialize, ToSchema)]
+pub struct PublicSupporterInfo {
+ /// Either `active` or `lapsed`. `none` is never serialised here;
+ /// the outer `supporter` field is `None` instead.
+ pub state: String,
+ /// `coffee` / `standard` / `generous`. `None` when no completed
+ /// order exists yet (should not happen for live data but the
+ /// frontend renders a tier-less fallback rather than crashing).
+ pub current_tier_key: Option,
+ /// Optional 28-char display string; `None` when the user has not
+ /// set one.
+ pub name_plate: Option,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -1541,8 +1569,27 @@ where
}
}
-async fn render_summary(query: &Q, handle: &str) -> Response {
- render_summary_scoped(query, handle, None).await
+async fn render_summary(
+ query: &Q,
+ supporters: &dyn SupporterStore,
+ handle: &str,
+) -> Response {
+ render_summary_scoped(query, supporters, handle, None).await
+}
+
+/// Map a `SupporterStatus` (raw store shape, full field set) to a
+/// `PublicSupporterInfo` (chip projection, public-safe fields only).
+/// Returns `None` for `SupporterState::None` so callers can drop the
+/// `supporter:` field entirely without a conditional.
+fn supporter_to_public(status: SupporterStatus) -> Option {
+ match status.state {
+ SupporterState::Active | SupporterState::Lapsed => Some(PublicSupporterInfo {
+ state: status.state.as_str().to_string(),
+ current_tier_key: status.current_tier_key,
+ name_plate: status.name_plate,
+ }),
+ SupporterState::None => None,
+ }
}
/// Same as [`render_summary`] but applies a per-share scope clamp to
@@ -1551,6 +1598,7 @@ async fn render_summary(query: &Q, handle: &str) -> Response {
/// consistent (no `sum(by_type) != total` mismatch on the client).
async fn render_summary_scoped(
query: &Q,
+ supporters: &dyn SupporterStore,
handle: &str,
scope: Option<&ShareScope>,
) -> Response {
@@ -1565,6 +1613,18 @@ async fn render_summary_scoped(
} else {
(total, by_type)
};
+ // Supporter chip — fail-soft on lookup error so a
+ // supporter-store hiccup doesn't 5xx the whole summary
+ // (the chip is decorative; the by_type render is the
+ // load-bearing payload). Log the warn line so prod
+ // observability flags the degradation.
+ let supporter = match supporters.get_by_handle_public(handle).await {
+ Ok(opt) => opt.and_then(supporter_to_public),
+ Err(e) => {
+ tracing::warn!(error = %e, handle, "supporter chip lookup failed");
+ None
+ }
+ };
(
StatusCode::OK,
Json(PublicSummaryResponse {
@@ -1574,6 +1634,7 @@ async fn render_summary_scoped(
.into_iter()
.map(|(event_type, count)| PublicTypeCount { event_type, count })
.collect(),
+ supporter,
}),
)
.into_response()
@@ -1647,6 +1708,7 @@ async fn render_timeline_scoped(
pub async fn public_summary(
State(query): State>,
Extension(spicedb): Extension>>,
+ Extension(supporters): Extension>,
Path(handle): Path,
) -> Response {
if !validate_handle(&handle) {
@@ -1665,7 +1727,7 @@ pub async fn public_summary(
let check = check_public(client, &handle).await;
render_or_404(check, || async {
- render_summary(query.as_ref(), &handle).await
+ render_summary(query.as_ref(), supporters.as_ref(), &handle).await
})
.await
}
@@ -1731,6 +1793,7 @@ pub async fn friend_summary(
Extension(spicedb): Extension>>,
Extension(meta): Extension>,
Extension(audit): Extension>,
+ Extension(supporters): Extension>,
auth: AuthenticatedUser,
Path(handle): Path,
) -> Response {
@@ -1773,7 +1836,7 @@ pub async fn friend_summary(
}
render_or_404(check, || async {
emit_share_viewed(audit.as_ref(), &auth, &handle).await;
- render_summary_scoped(query.as_ref(), &handle, scope.as_ref()).await
+ render_summary_scoped(query.as_ref(), supporters.as_ref(), &handle, scope.as_ref()).await
})
.await
}
@@ -2410,6 +2473,7 @@ fn parse_scope_param(raw: Option<&str>) -> Result