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:
parent
ee97a1c5ca
commit
5f5a954ce6
5 changed files with 227 additions and 51 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
||||
|
|
|
|||
67
src/components/ProfileBadges.tsx
Normal file
67
src/components/ProfileBadges.tsx
Normal 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
77
src/lib/badges.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue