Skip to content
Open
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
36 changes: 36 additions & 0 deletions apps/web/src/app/discover/_components/DiscoverLoadMore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,42 @@ function ClientGridAppendix({
a.appendChild(dn);
}

// Supporter chip — load-more path is imperative DOM, so we
// build a simplified pill here rather than mounting the
// React `<SupporterChip>` component. Tier-specific palette
// and accessible-label work appears on the server-rendered
// first page; the load-more chip uses the standard accent
// palette for all tiers. Cleaner than maintaining two
// tier-palette mappings (one TS, one imperative DOM string).
if (p.supporter) {
const wrap = document.createElement('div');
wrap.style.marginTop = '2px';
const chip = document.createElement('span');
chip.className = 'mono';
Object.assign(chip.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '2px 8px',
fontSize: '11px',
fontWeight: '500',
color: 'var(--accent)',
background:
'color-mix(in oklab, var(--accent) 12%, transparent)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-pill)',
});
const label =
p.supporter.state === 'lapsed'
? 'Supporter (lapsed)'
: 'Supporter';
chip.textContent = p.supporter.name_plate
? `${label} · ${p.supporter.name_plate}`
: label;
wrap.appendChild(chip);
a.appendChild(wrap);
}

const rel = formatRelative(p.last_active_at);
if (rel) {
const t = document.createElement('div');
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type DiscoverProfilesResponse,
} from '@/lib/api';
import { logger } from '@/lib/logger';
import { SupporterChip } from '@/components/SupporterChip';
import { DiscoverLoadMore } from './_components/DiscoverLoadMore';

// Default request size on the initial render. Mirrors the server-
Expand Down Expand Up @@ -189,6 +190,11 @@ export default async function DiscoverPage() {
{p.display_name}
</div>
) : null}
{p.supporter ? (
<div style={{ marginTop: 2 }}>
<SupporterChip status={p.supporter} size="sm" />
</div>
) : null}
{relative ? (
<div
style={{
Expand Down
62 changes: 62 additions & 0 deletions crates/starstats-server/src/discover_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
//! a Postgres + Memory pair so route-layer tests stay self-contained.

use crate::api_error::ApiErrorBody;
use crate::sharing_routes::PublicSupporterInfo;
use crate::spicedb::SpicedbClient;
use crate::supporters::{SupporterState, SupporterStore};
use async_trait::async_trait;
use axum::{
extract::{Query, State},
Expand Down Expand Up @@ -102,6 +104,12 @@ pub struct DiscoverProfile {
/// timestamp (every event row carries one or the field is left
/// NULL for lines that parsed structurally but lacked a stamp).
pub last_active_at: Option<String>,
/// Supporter chip data — same public-safe projection used by the
/// summary endpoint (see `sharing_routes::PublicSupporterInfo`).
/// `None` for non-supporters; present (with tier + plate) for
/// `active`/`lapsed` rows. Bulk-fetched alongside the profile
/// list so the discover page renders chips without N+1 queries.
pub supporter: Option<crate::sharing_routes::PublicSupporterInfo>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
Expand Down Expand Up @@ -242,6 +250,12 @@ impl DiscoverStore for PostgresDiscoverStore {
display_name,
joined_at: Some(created_at.to_rfc3339()),
last_active_at: last_active_at.map(|t| t.to_rfc3339()),
// Supporter info is bulk-fetched by the handler
// AFTER the listing query returns — keeps this
// store method focused on the user/profile join
// and avoids growing this SQL with a third
// optional source.
supporter: None,
},
)
.collect())
Expand Down Expand Up @@ -274,6 +288,7 @@ pub fn routes(store: Arc<PostgresDiscoverStore>) -> Router {
pub async fn list_discover_profiles(
State(store): State<Arc<dyn DiscoverStore>>,
Extension(spicedb): Extension<Arc<Option<SpicedbClient>>>,
Extension(supporters): Extension<Arc<dyn SupporterStore>>,
Query(q): Query<DiscoverQuery>,
) -> Response {
let Some(client) = spicedb.as_ref() else {
Expand Down Expand Up @@ -360,6 +375,49 @@ pub async fn list_discover_profiles(
None
};

// Bulk-fetch supporter chip data for everyone on this page in a
// single SQL round-trip. Failing soft means a transient supporter-
// store error degrades to "no chips on the list" rather than
// 5xx'ing the whole discover page (chips are decorative; the
// listing is the load-bearing payload). Logged as a warn so ops
// sees the degradation.
if !rows.is_empty() {
let handles: Vec<String> = rows.iter().map(|p| p.handle.clone()).collect();
match supporters.get_many_public_by_handle(&handles).await {
Ok(supporter_map) => {
for profile in rows.iter_mut() {
let key = profile.handle.to_ascii_lowercase();
if let Some(status) = supporter_map.get(&key) {
// Same projection logic as
// sharing_routes::supporter_to_public; we
// can't reuse that helper directly because
// it's a private fn in sharing_routes (and
// adding a pub re-export for one call site
// is more coupling than the duplication).
let info = match status.state {
SupporterState::Active | SupporterState::Lapsed => {
Some(PublicSupporterInfo {
state: status.state.as_str().to_string(),
current_tier_key: status.current_tier_key.clone(),
name_plate: status.name_plate.clone(),
})
}
SupporterState::None => None,
};
profile.supporter = info;
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
count = handles.len(),
"discover supporter bulk fetch failed; chips will be absent"
);
}
}
}

(
StatusCode::OK,
Json(DiscoverProfilesResponse {
Expand Down Expand Up @@ -466,6 +524,10 @@ mod tests {
display_name: Some(format!("{handle} Display")),
joined_at: Some("2026-01-01T00:00:00+00:00".to_owned()),
last_active_at: None,
// No supporter data in these tests — the listing-store
// tests target the SQL-side filter/sort, not the chip
// enrichment which is a separate handler step.
supporter: None,
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/starstats-server/src/sharing_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ pub struct PublicSummaryResponse {
/// Public-safe supporter projection for the profile chip. Mirrors
/// the minimum fields the web `<SupporterChip>` needs and nothing
/// else.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PublicSupporterInfo {
/// Either `active` or `lapsed`. `none` is never serialised here;
/// the outer `supporter` field is `None` instead.
Expand Down
Loading
Loading