SkateSpots/src/lib/badges.ts
SkateSpots 5f5a954ce6 feat(profile): badges — clickable chips + a SkateSpots member badge
- lib/badges.ts + ProfileBadges: a profile shows (1) the built-in SkateSpots
  badge, derived from a nip05 ending @skate-spots.org (app logo linking to
  https://skate-spots.org/ — no storage, works for existing users), and (2) user
  badges from a kind-0 badges array ({label,url?}) rendered as clickable chips.
- NIP19Page: app badge in the header, user badges in a 'Badges' card (replaces
  the plain Sponsors section).
- EditProfileForm: Sponsors editor becomes a Badges editor (label + optional
  link); saves the badges array and retires the legacy newline sponsors field.
  Existing sponsors are read back as label-only badges, so nothing disappears.
2026-09-28 19:40:19 +07:00

77 lines
2.8 KiB
TypeScript

/**
* Profile badges — the small logos/chips on a profile.
*
* Two sources:
* 1. The built-in **SkateSpots** badge: any handle issued by our own domain
* (`name@skate-spots.org`) earns the app-logo badge, linking to the site.
* Derived — never stored — so it works for every existing user.
* 2. User badges: a `badges` array in the kind-0 metadata
* (`{ label, url? }`), e.g. sponsors a skater represents. Clicking one opens
* the link the skater chose.
*
* Back-compat: the old `sponsors` newline-string is carried forward as
* label-only badges, so nothing disappears for people who set it before.
*/
import type { NostrMetadata } from '@nostrify/nostrify';
export interface ProfileBadge {
label: string;
/** Optional link the badge opens. */
url?: string;
}
export const SKATESPOTS_SITE = 'https://skate-spots.org/';
/** The app badge every `@skate-spots.org` handle earns (logo → the site). */
export const SKATESPOTS_BADGE: ProfileBadge = {
label: 'SkateSpots',
url: SKATESPOTS_SITE,
};
/** True when a handle is issued by our own domain. */
export function isSkateSpotsNip05(nip05?: string | null): boolean {
return !!nip05 && nip05.trim().toLowerCase().endsWith('@skate-spots.org');
}
/** A safe external link, or undefined. */
function safeUrl(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const url = value.trim();
return /^https?:\/\//i.test(url) ? url : undefined;
}
/** Parse the profile's own badges from kind-0 metadata (defensive). */
export function parseBadges(metadata: NostrMetadata | undefined): ProfileBadge[] {
const raw = metadata as (NostrMetadata & Record<string, unknown>) | undefined;
const out: ProfileBadge[] = [];
if (Array.isArray(raw?.badges)) {
for (const entry of raw.badges) {
if (!entry || typeof entry !== 'object') continue;
const { label, url } = entry as Record<string, unknown>;
if (typeof label !== 'string' || !label.trim()) continue;
out.push({ label: label.trim().slice(0, 40), url: safeUrl(url) });
if (out.length >= 12) break;
}
}
// Legacy `sponsors` (newline-separated) → label-only badges.
if (typeof raw?.sponsors === 'string') {
for (const line of raw.sponsors.split('\n')) {
const label = line.trim();
if (!label || out.some((b) => b.label === label)) continue;
out.push({ label: label.slice(0, 40) });
if (out.length >= 12) break;
}
}
return out;
}
/** The badges to render for a profile: the app badge first, then the user's own. */
export function profileBadges(metadata: NostrMetadata | undefined): ProfileBadge[] {
const own = parseBadges(metadata);
const nip05 = (metadata as { nip05?: string } | undefined)?.nip05;
return isSkateSpotsNip05(nip05) ? [SKATESPOTS_BADGE, ...own] : own;
}