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 (
+
+
+
+
+
+ NIP-05 verified handle
+
+
+ How?
+
+
+
+
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 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* URL + copy */}
+
+
+ {shareUrl.replace(/^https?:\/\//, '')}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {copied ? <> Copied!> : <> Copy link>}
+
+ {canNativeShare && (
+
+
+ Share…
+
+ )}
+
+
+
+
+ );
+}
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 */}
+ setShareOpen(true)}
+ className="gap-2 border-white/15 text-gray-200 hover:bg-white/10"
+ aria-label="Share profile"
+ >
+
+ Share
+
{isOwnProfile && (
@@ -241,6 +296,26 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
+ {/* ── 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 }) {
+
+
);
}