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.
This commit is contained in:
SkateSpots 2026-09-28 19:40:19 +07:00
parent ee97a1c5ca
commit 5f5a954ce6
5 changed files with 227 additions and 51 deletions

View file

@ -493,6 +493,17 @@ session with **no other memory of this project**. So:
(`useBatchTrustScores`) are kind-agnostic; status/photo signals are spot-only.
Status/go-lab/crew queues are still out — profiles are less spam-prone and
hiding a crew hides a community.
- **Profile badges (2026-09-28):** `lib/badges.ts` + `components/ProfileBadges`.
Two sources: **(1) the built-in SkateSpots badge** — *derived* from a `nip05`
ending `@skate-spots.org` (app logo → `https://skate-spots.org/`), so every
issued handle gets it with no storage and it works for existing users; **(2)
user badges** from a kind-0 **`badges`** array (`{label, url?}`), rendered as
clickable chips (external links). The legacy newline **`sponsors`** field is
carried forward into badges (`parseBadges` reads it; `EditProfileForm` now
writes `badges` and deletes `sponsors`). Shown on the profile page
(`NIP19Page`): the app badge in the header, user badges in a "Badges" card.
⚠️ `NostrMetadata` has no `badges` field, so it's read via a cast
(`metadata as … & Record<string, unknown>`), like `sponsors`/`cordn` before it.
- **Report → action + a publish floor (2026-09-28):** the one-tap "not a skate
spot" report used to only land in the admin queue (invisible effect). It now
**shows on the spot page to everyone** ("Reported as not a skate spot by N —

View file

@ -55,10 +55,11 @@ export const EditProfileForm: React.FC = () => {
const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed);
const spValid = isValidSilentPayment(spTrimmed);
// Sponsorships — one entry per line. Stored as newline-separated text in the
// custom kind-0 field `sponsors`; edited here as a dynamic list of rows.
const [sponsorList, setSponsorList] = useState<string[]>(['']);
const cleanSponsors = sponsorList.map((s) => s.trim()).filter(Boolean);
// Badges — a label + optional link each, stored as a `badges` array in kind-0.
// Used for sponsors/backers, and rendered as clickable chips on the profile.
const [badgeList, setBadgeList] = useState<Array<{ label: string; url: string }>>([
{ label: '', url: '' },
]);
// Cordn contact link — private (MLS-encrypted) group messaging you can own.
// A URL others can use to reach out to you on Cordn (cordn.net).
@ -98,9 +99,19 @@ export const EditProfileForm: React.FC = () => {
if (typeof raw.noffer === 'string') setNoffer(raw.noffer);
if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16);
if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment);
if (typeof raw.sponsors === 'string') {
// Badges: prefer the structured array; fall back to legacy `sponsors` lines.
const rawBadges = Array.isArray(raw.badges) ? raw.badges : null;
if (rawBadges) {
const rows = rawBadges
.map((b) => (b && typeof b === 'object' ? (b as Record<string, unknown>) : {}))
.map((b) => ({ label: String(b.label ?? ''), url: String(b.url ?? '') }))
.filter((b) => b.label.trim());
setBadgeList(rows.length ? rows : [{ label: '', url: '' }]);
} else if (typeof raw.sponsors === 'string') {
const lines = raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean);
setSponsorList(lines.length ? lines : ['']);
setBadgeList(
lines.length ? lines.map((label) => ({ label, url: '' })) : [{ label: '', url: '' }],
);
}
if (typeof raw.cordn === 'string') setCordn(raw.cordn);
}
@ -204,12 +215,18 @@ export const EditProfileForm: React.FC = () => {
delete data.bitcoin_silent_payment;
}
// Sponsorships highlight (custom field) — one entry per line.
if (cleanSponsors.length) {
data.sponsors = cleanSponsors.join('\n');
// Badges (custom field) — label + optional link, shown as chips on profile.
const cleanBadges = badgeList
.map((b) => ({ label: b.label.trim(), url: b.url.trim() }))
.filter((b) => b.label)
.map((b) => (b.url ? { label: b.label, url: b.url } : { label: b.label }));
if (cleanBadges.length) {
data.badges = cleanBadges;
} else {
delete data.sponsors;
delete data.badges;
}
// Retire the legacy field now that badges supersede it.
delete data.sponsors;
// Cordn contact link (custom field).
if (cordnTrimmed) {
@ -429,46 +446,49 @@ export const EditProfileForm: React.FC = () => {
description="Reppin’ your sponsors and how the crew reaches you."
accent="from-pink-500 to-fuchsia-500"
>
{/* Sponsors — one per line */}
{/* Badges — sponsor/backer chips, each with an optional link */}
<div className="space-y-2.5">
<Label className="flex items-center gap-2 text-sm font-semibold">
<Award className="h-4 w-4 text-pink-500" />
Sponsors &amp; support
<span className="text-xs font-normal text-muted-foreground">(one per line)</span>
Badges
<span className="text-xs font-normal text-muted-foreground">(label + optional link)</span>
</Label>
<div className="space-y-2">
{sponsorList.map((value, i) => (
{badgeList.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-pink-500/60 text-sm font-black w-4 text-center flex-shrink-0">•</span>
<Input
value={value}
placeholder={i === 0 ? 'e.g. Independent Trucks' : 'Add another sponsor…'}
value={row.label}
placeholder={i === 0 ? 'e.g. Independent Trucks' : 'Badge label…'}
onChange={(e) => {
const next = [...sponsorList];
next[i] = e.target.value;
setSponsorList(next);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (i === sponsorList.length - 1 && value.trim()) {
setSponsorList([...sponsorList, '']);
}
}
const next = [...badgeList];
next[i] = { ...next[i], label: e.target.value };
setBadgeList(next);
}}
className="flex-1"
/>
<Input
value={row.url}
placeholder="https://… (optional)"
onChange={(e) => {
const next = [...badgeList];
next[i] = { ...next[i], url: e.target.value };
setBadgeList(next);
}}
className="flex-1"
spellCheck={false}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0 text-muted-foreground hover:text-red-500"
onClick={() => {
const next = sponsorList.filter((_, idx) => idx !== i);
setSponsorList(next.length ? next : ['']);
const next = badgeList.filter((_, idx) => idx !== i);
setBadgeList(next.length ? next : [{ label: '', url: '' }]);
}}
aria-label="Remove sponsor"
disabled={sponsorList.length === 1 && !value}
aria-label="Remove badge"
disabled={badgeList.length === 1 && !row.label && !row.url}
>
<X className="h-4 w-4" />
</Button>
@ -480,13 +500,15 @@ export const EditProfileForm: React.FC = () => {
variant="outline"
size="sm"
className="gap-1.5 border-pink-500/40 text-pink-600 dark:text-pink-400 hover:bg-pink-500/10"
onClick={() => setSponsorList([...sponsorList, ''])}
onClick={() => setBadgeList([...badgeList, { label: '', url: '' }])}
>
<Plus className="h-3.5 w-3.5" />
Add sponsor
Add badge
</Button>
<p className="text-xs text-muted-foreground">
Brands, shops or people who back your skating. Each line shows as its own badge on your profile.
Sponsors, shops or people who back your skating — and anything else you want to show off. Each
becomes a clickable chip on your profile. Your{' '}
<span className="text-[#ccff00]">@skate-spots.org</span> member badge is added automatically.
</p>
</div>

View file

@ -0,0 +1,67 @@
import { BadgeCheck, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
/** The app logo is already served with the site. */
const SKATESPOTS_LOGO = '/images/logo-icon.png';
const isApp = (b: ProfileBadge) => b.label === 'SkateSpots' && b.url === SKATESPOTS_SITE;
/**
* ProfileBadges — the row of badges on a profile (own or someone else's).
* Clicking a badge with a link opens it in a new tab.
*/
export function ProfileBadges({
badges,
size = 'md',
className,
}: {
badges: ProfileBadge[];
size?: 'sm' | 'md';
className?: string;
}) {
if (badges.length === 0) return null;
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{badges.map((b, i) => {
const app = isApp(b);
const cls = cn(
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm',
app
? 'border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00] hover:bg-[#ccff00]/20'
: 'border-pink-500/25 bg-pink-500/10 text-gray-100 hover:bg-pink-500/20',
);
const inner = (
<>
{app ? (
<img src={SKATESPOTS_LOGO} alt="" className="h-4 w-4 rounded-full object-contain" />
) : (
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
)}
<span className="max-w-[12rem] truncate">{b.label}</span>
{b.url && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
</>
);
return b.url ? (
<a
key={`${b.label}-${i}`}
href={b.url}
target="_blank"
rel="noopener noreferrer"
title={b.url}
className={cls}
>
{inner}
</a>
) : (
<span key={`${b.label}-${i}`} className={cls}>
{inner}
</span>
);
})}
</div>
);
}

77
src/lib/badges.ts Normal file
View file

@ -0,0 +1,77 @@
/**
* 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;
}

View file

@ -30,6 +30,8 @@ import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, Sh
import { useState, type ReactNode } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
import { ProfileBadges } from '@/components/ProfileBadges';
import { SKATESPOTS_BADGE, isSkateSpotsNip05, parseBadges } from '@/lib/badges';
import { useRiderStats } from '@/hooks/useRiderStats';
/** Format a "member since" year from a kind-0 event timestamp (seconds). */
@ -130,9 +132,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
// Extra custom profile fields.
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
const sponsorItems = typeof raw?.sponsors === 'string'
? raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean)
: [];
const userBadges = parseBadges(metadata);
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
const [spCopied, setSpCopied] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
@ -278,6 +278,15 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</span>
</p>
)}
{/* SkateSpots member badge — the app logo, linking to the site.
Shown for any handle issued by our own domain (name@skate-spots.org). */}
{isSkateSpotsNip05(nip05) && (
<ProfileBadges
badges={[SKATESPOTS_BADGE]}
size="sm"
className="mt-1.5 justify-center sm:justify-start"
/>
)}
</div>
{/* Actions */}
@ -343,8 +352,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
)}
{/* ── Sponsors & Support — surfaced up top; reppin' is a point of pride ── */}
{sponsorItems.length > 0 && (
{/* ── Badges — reppin' sponsors & backers; each links where the skater chose ── */}
{userBadges.length > 0 && (
<div className="relative overflow-hidden rounded-2xl border-2 border-pink-500/30 bg-gradient-to-r from-pink-500/15 via-black to-fuchsia-500/5 p-4 sm:p-5 mb-6">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-pink-500 opacity-10 rounded-full blur-3xl pointer-events-none" />
<div className="relative flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
@ -353,21 +362,11 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
<Award className="h-5 w-5 text-pink-400" />
</div>
<div className="leading-tight">
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-pink-400">Backed by</p>
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-pink-400">Badges</p>
<p className="text-xs text-gray-400">Sponsors &amp; support</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 min-w-0">
{sponsorItems.map((s, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-pink-500/10 border border-pink-500/25 text-sm font-bold text-gray-100 break-words [overflow-wrap:anywhere]"
>
<span className="h-1.5 w-1.5 rounded-full bg-pink-400 flex-shrink-0" />
{s}
</span>
))}
</div>
<ProfileBadges badges={userBadges} className="min-w-0" />
</div>
</div>
)}