From deb8fe19952c2344984ac9ad7de6a392a7384144 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 2 Sep 2026 22:08:49 +0700 Subject: [PATCH] Profile polish: NIP-05 field UX, share+QR, at-a-glance stats, cleaner empty states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-05 (the field you couldn't find — it existed but was a bare input): - Promote to its own prominent, self-explaining row (out of the cramped grid) - Live verification: checks the handle resolves back to your own key, with checking/verified/mismatch/unreachable states, an inline status icon, plain-language copy, and a "How?" help link Share + QR: - New ShareProfileDialog: QR of the profile URL (great for IRL/stickers), copy-link, and native share-sheet fallback - "Share" button added to every profile header At-a-glance stats: - Compact stat strip near the top (Spots / Shops / Crews / Gear / Zaps + "Member since") - Removed the now-redundant sidebar "Contributions" card Cleaner empty states: - Spots/Shops sections no longer show dead "nothing here" cards on other people's profiles — hidden entirely when empty - Own empty Spots state is now an actionable nudge linking to /create-spot Co-authored-by: shakespeare.diy --- src/components/EditProfileForm.tsx | 153 ++++++++++++--- src/components/ShareProfileDialog.tsx | 126 ++++++++++++ src/pages/NIP19Page.tsx | 271 ++++++++++++++++---------- 3 files changed, 415 insertions(+), 135 deletions(-) create mode 100644 src/components/ShareProfileDialog.tsx diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index c02f654..4b37d52 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -18,10 +18,11 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot } from 'lucide-react'; +import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle } from 'lucide-react'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; +import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; export const EditProfileForm: React.FC = () => { @@ -335,34 +336,27 @@ export const EditProfileForm: React.FC = () => { /> -
- ( - - Website - - - - - - )} - /> - ( - - NIP-05 identifier - - - - - - )} - /> -
+ ( + + Website + + + + + + )} + /> + + {/* NIP-05 — the verified-handle field. Given real prominence + live + verification feedback so people know if it's actually working. */} + } + /> {/* ── Section: Bitcoin & Payments ── */} @@ -614,6 +608,107 @@ const FieldRow: React.FC<{ ); +/** + * The NIP-05 identifier field — a verified, human-readable handle + * (`you@yourdomain.com`) that proves you control both the Nostr key and the + * domain. This is the closest thing Nostr has to a "verified" checkmark, so it + * gets a prominent, self-explaining row with LIVE verification feedback that + * checks the handle actually resolves back to the logged-in key. + */ +const Nip05Field: React.FC<{ + value: string; + onChange: (v: string) => void; + pubkey: string | undefined; +}> = ({ value, onChange, pubkey }) => { + const trimmed = value.trim().toLowerCase(); + const looksValid = /^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(trimmed); + + // Only hit the network once it's a plausible address. + const { data: result, isLoading } = useCrewNip05(looksValid ? trimmed : undefined, pubkey); + + let status: { kind: 'hint' | 'ok' | 'error' | 'warn' | 'checking'; text: string }; + if (!trimmed) { + status = { kind: 'hint', text: 'A verified handle like you@yourdomain.com — Nostr’s version of a blue check. Shows a ✓ badge on your profile and unlocks the “Verified” filter in the Skaters directory.' }; + } else if (!looksValid) { + status = { kind: 'error', text: 'Use an email-like handle, e.g. you@yourdomain.com — no “https://” or spaces.' }; + } else if (isLoading) { + status = { kind: 'checking', text: 'Checking your domain…' }; + } else if (result?.status === 'verified') { + status = { kind: 'ok', text: 'Verified — your domain confirms this handle belongs to you. ✓' }; + } else if (result?.status === 'mismatch') { + status = { kind: 'error', text: 'Your domain maps this handle to a DIFFERENT key. Fix your .well-known/nostr.json so it points to this account.' }; + } else if (result?.status === 'unreachable') { + status = { kind: 'warn', text: 'Couldn’t reach your domain’s /.well-known/nostr.json yet. Saved handles still work once your domain is set up correctly.' }; + } else { + status = { kind: 'hint', text: 'Enter your NIP-05 handle.' }; + } + + return ( +
+ +
+ onChange(e.target.value)} + spellCheck={false} + autoCapitalize="none" + className="font-mono text-sm pr-9" + /> + {looksValid && ( + + {isLoading ? ( + + ) : result?.status === 'verified' ? ( + + ) : result?.status === 'mismatch' ? ( + + ) : null} + + )} +
+ {status.kind === 'error' ? ( +

+ + {status.text} +

+ ) : status.kind === 'ok' ? ( +

+ + {status.text} +

+ ) : status.kind === 'warn' ? ( +

+ + {status.text} +

+ ) : status.kind === 'checking' ? ( +

+ + {status.text} +

+ ) : ( +

{status.text}

+ )} +
+ ); +}; + // Reusable component for image upload fields interface ImageUploadFieldProps { field: { diff --git a/src/components/ShareProfileDialog.tsx b/src/components/ShareProfileDialog.tsx new file mode 100644 index 0000000..8d28edc --- /dev/null +++ b/src/components/ShareProfileDialog.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Check, Copy, Share2, Loader2 } from 'lucide-react'; +import { useToast } from '@/hooks/useToast'; + +interface ShareProfileDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The npub (or nprofile) to build the shareable URL from. */ + npub: string; + /** Display name, used in the share sheet + QR caption. */ + displayName: string; +} + +/** + * Share a skater's profile IRL or online. Skateboarding is a physical, local + * community — a QR you can flash at a spot (or slap on a sticker) is worth more + * than a raw npub. Falls back gracefully to copy-link when the native share + * sheet isn't available. + */ +export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: ShareProfileDialogProps) { + const { toast } = useToast(); + const [qr, setQr] = useState(null); + const [copied, setCopied] = useState(false); + + // Prefer the deployed public URL so the link works anywhere; fall back to the + // current origin (dev / self-hosted). + const origin = typeof window !== 'undefined' ? window.location.origin : 'https://skate-spots.org'; + const shareUrl = `${origin}/${npub}`; + + useEffect(() => { + if (!open) return; + let active = true; + QRCode.toDataURL(shareUrl, { + width: 320, + margin: 1, + color: { dark: '#000000', light: '#ffffff' }, + errorCorrectionLevel: 'M', + }) + .then((url) => { if (active) setQr(url); }) + .catch(() => { if (active) setQr(null); }); + return () => { active = false; }; + }, [open, shareUrl]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ title: 'Copy failed', description: 'Could not copy the link.', variant: 'destructive' }); + } + }; + + const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share; + + const handleNativeShare = async () => { + try { + await navigator.share({ + title: `${displayName} on SkateSpots`, + text: `Check out ${displayName} on SkateSpots 🛹`, + url: shareUrl, + }); + } catch { + // user cancelled or unsupported — no-op + } + }; + + return ( + + + + + + Share profile + + + Point a phone camera at the code to open {displayName}'s profile. + + + + {/* QR */} +
+
+ {qr ? ( + Profile QR code + ) : ( +
+ +
+ )} +
+ + {/* URL + copy */} + + +
+ + {canNativeShare && ( + + )} +
+
+
+
+ ); +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 916d664..e68036d 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -33,6 +33,7 @@ import { Check, ShieldCheck, Instagram, + Share2, Users, Crown, Bot, @@ -48,8 +49,47 @@ import { Home, Clock, } from 'lucide-react'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; +import { ShareProfileDialog } from '@/components/ShareProfileDialog'; +import { useRiderStats } from '@/hooks/useRiderStats'; + +/** Format a "member since" year from a kind-0 event timestamp (seconds). */ +function profileEventCreatedAt(createdAt: number | undefined): string | undefined { + if (!createdAt) return undefined; + return new Date(createdAt * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short' }); +} + +/** A compact stat pill for the at-a-glance strip. */ +function StatPill({ + value, + label, + color, + loading, + icon, +}: { + value: number; + label: string; + color: string; + loading?: boolean; + icon?: ReactNode; +}) { + return ( +
+
+ {loading ? ( + + ) : ( +

+ {icon} + {value} +

+ )} +

{label}

+
+
+ ); +} function ProfilePage({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); @@ -84,10 +124,15 @@ function ProfilePage({ pubkey }: { pubkey: string }) { : []; const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined; const [spCopied, setSpCopied] = useState(false); + const [shareOpen, setShareOpen] = useState(false); const npub = nip19.npubEncode(pubkey); const isOwnProfile = user?.pubkey === pubkey; + // At-a-glance skater stats for the summary strip. + const { data: riderStats } = useRiderStats(pubkey); + const memberSince = profileEventCreatedAt(author.data?.event?.created_at); + // Skate layer (kind 35000). Fields fall back to kind-0 when the skater // profile leaves them empty — kind-0 stays the single source of truth for // shared fields (bio/image/website), 35000 only carries skate-specific data. @@ -216,6 +261,16 @@ function ProfilePage({ pubkey }: { pubkey: string }) { {/* Actions */}
+ {/* Share — available on every profile */} + {isOwnProfile && (
+ {/* ── At-a-glance stats strip ── */} +
+ + + + + {(riderStats?.zapsReceived ?? 0) > 0 && ( + } /> + )} + {memberSince && ( +
+ +
+

Member since

+

{memberSince}

+
+
+ )} +
+ {/* Profile Info */}
{/* Main Content */} @@ -382,91 +457,93 @@ function ProfilePage({ pubkey }: { pubkey: string }) { ) )} - {/* Spots by this user */} -
-
-

- - Spots Added - {spots && spots.length > 0 && ( - - {spots.length} - - )} -

+ {/* Spots by this user — hidden entirely for others when empty */} + {(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && ( +
+
+

+ + Spots Added + {spots && spots.length > 0 && ( + + {spots.length} + + )} +

+
+ + {spotsLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : spots && spots.length > 0 ? ( +
+ {spots.slice(0, 4).map((spot) => ( + + ))} +
+ ) : ( + // Only shown on your own profile — an actionable nudge, not a dead end. + + +
+

You haven't added any spots yet

+

Put your local spot on the map →

+
+ + )}
+ )} - {spotsLoading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} + {/* Shops by this user — only render when there's something, or it's you */} + {(shopsLoading || (shops && shops.length > 0)) && ( +
+
+

+ + Shops Added + {shops && shops.length > 0 && ( + + {shops.length} + + )} +

- ) : spots && spots.length > 0 ? ( -
- {spots.slice(0, 4).map((spot) => ( - - ))} -
- ) : ( - - - -

No spots added yet

-
-
- )} -
- {/* Shops by this user */} -
-
-

- - Shops Added - {shops && shops.length > 0 && ( - - {shops.length} - - )} -

+ {shopsLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {shops!.slice(0, 4).map((shop) => ( + + + +
+ +
+
+

{shop.name}

+ {shop.location && ( +

{shop.location}

+ )} +
+
+
+ + ))} +
+ )}
- - {shopsLoading ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : shops && shops.length > 0 ? ( -
- {shops.slice(0, 4).map((shop) => ( - - - -
- -
-
-

{shop.name}

- {shop.location && ( -

{shop.location}

- )} -
-
-
- - ))} -
- ) : ( - - - -

No shops added yet

-
-
- )} -
+ )} {/* Public Stash — gear this skater chose to show publicly */} {(publicStashLoading || (publicStash && publicStash.length > 0)) && ( @@ -734,31 +811,6 @@ function ProfilePage({ pubkey }: { pubkey: string }) { )} - {/* Stats Card */} - - -

Contributions

-
-
-

{spots?.length || 0}

-

Spots

-
-
-

{shops?.length || 0}

-

Shops

-
-
-

{crews.length}

-

Crews

-
-
-

{publicStash?.length || 0}

-

Gear

-
-
-
-
- {/* Tip Card */} {!isOwnProfile && (profileEvent || nofferCode) && ( @@ -789,6 +841,13 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
+ + ); }