From 5f5a954ce6bbd02fba4486220e431719da7c30ab Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 19:40:19 +0700 Subject: [PATCH] =?UTF-8?q?feat(profile):=20badges=20=E2=80=94=20clickable?= =?UTF-8?q?=20chips=20+=20a=20SkateSpots=20member=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 11 ++++ src/components/EditProfileForm.tsx | 90 +++++++++++++++++++----------- src/components/ProfileBadges.tsx | 67 ++++++++++++++++++++++ src/lib/badges.ts | 77 +++++++++++++++++++++++++ src/pages/NIP19Page.tsx | 33 ++++++----- 5 files changed, 227 insertions(+), 51 deletions(-) create mode 100644 src/components/ProfileBadges.tsx create mode 100644 src/lib/badges.ts diff --git a/AGENTS.md b/AGENTS.md index 6a4bc93..a30592f 100644 --- a/AGENTS.md +++ b/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`), 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 — diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index 870837e..7884f40 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -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(['']); - 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>([ + { 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) : {})) + .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 */}
- {sponsorList.map((value, i) => ( + {badgeList.map((row, i) => (
• { - 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" /> + { + const next = [...badgeList]; + next[i] = { ...next[i], url: e.target.value }; + setBadgeList(next); + }} + className="flex-1" + spellCheck={false} + /> @@ -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: '' }])} > - Add sponsor + Add badge

- 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{' '} + @skate-spots.org member badge is added automatically.

diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx new file mode 100644 index 0000000..4629ec9 --- /dev/null +++ b/src/components/ProfileBadges.tsx @@ -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 ( +
+ {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 ? ( + + ) : ( + + )} + {b.label} + {b.url && !app && } + + ); + + return b.url ? ( + + {inner} + + ) : ( + + {inner} + + ); + })} +
+ ); +} diff --git a/src/lib/badges.ts b/src/lib/badges.ts new file mode 100644 index 0000000..4ad685f --- /dev/null +++ b/src/lib/badges.ts @@ -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) | 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; + 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; +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 32c0e35..4d6ec4d 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -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 }) {

)} + {/* 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) && ( + + )}
{/* Actions */} @@ -343,8 +352,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)} - {/* ── 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 && (
@@ -353,21 +362,11 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
-

Backed by

+

Badges

Sponsors & support

-
- {sponsorItems.map((s, i) => ( - - - {s} - - ))} -
+
)}