Profile polish: NIP-05 field UX, share+QR, at-a-glance stats, cleaner empty states

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 22:08:49 +07:00
parent a6c90941d2
commit deb8fe1995
3 changed files with 415 additions and 135 deletions

View file

@ -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 = () => {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormControl>
<Input placeholder="https://yourwebsite.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nip05"
render={({ field }) => (
<FormItem>
<FormLabel>NIP-05 identifier</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormControl>
<Input placeholder="https://yourwebsite.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* NIP-05 — the verified-handle field. Given real prominence + live
verification feedback so people know if it's actually working. */}
<FormField
control={form.control}
name="nip05"
render={({ field }) => <Nip05Field value={field.value ?? ''} onChange={field.onChange} pubkey={user?.pubkey} />}
/>
</FormSection>
{/* ── Section: Bitcoin & Payments ── */}
@ -614,6 +608,107 @@ const FieldRow: React.FC<{
</div>
);
/**
* 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 (
<div className="space-y-2 rounded-xl border bg-muted/20 p-4">
<Label htmlFor="profile-nip05" className="flex items-center gap-2 text-sm font-semibold">
<span className="h-6 w-6 rounded-lg flex items-center justify-center text-[#00a3ff] bg-[#00a3ff]/10">
<BadgeCheck className="h-4 w-4" />
</span>
NIP-05 verified handle
<a
href="https://nostr.how/en/guides/get-verified"
target="_blank"
rel="noopener noreferrer"
className="ml-auto inline-flex items-center gap-1 text-xs font-normal text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-3.5 w-3.5" />
How?
</a>
</Label>
<div className="relative">
<Input
id="profile-nip05"
placeholder="you@yourdomain.com"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm pr-9"
/>
{looksValid && (
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : result?.status === 'verified' ? (
<BadgeCheck className="h-4 w-4 text-green-500" />
) : result?.status === 'mismatch' ? (
<AlertCircle className="h-4 w-4 text-red-500" />
) : null}
</span>
)}
</div>
{status.kind === 'error' ? (
<p className="flex items-start gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : status.kind === 'ok' ? (
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
<BadgeCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : status.kind === 'warn' ? (
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : status.kind === 'checking' ? (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 shrink-0 mt-px animate-spin" />
{status.text}
</p>
) : (
<p className="text-xs text-muted-foreground">{status.text}</p>
)}
</div>
);
};
// Reusable component for image upload fields
interface ImageUploadFieldProps {
field: {

View file

@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm bg-black border-white/10">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 uppercase font-black">
<Share2 className="h-5 w-5 text-[#ff0040]" />
Share profile
</DialogTitle>
<DialogDescription className="text-gray-400">
Point a phone camera at the code to open {displayName}'s profile.
</DialogDescription>
</DialogHeader>
{/* QR */}
<div className="flex flex-col items-center gap-4 py-2">
<div className="rounded-2xl bg-white p-3 shadow-lg">
{qr ? (
<img src={qr} alt="Profile QR code" className="w-56 h-56" />
) : (
<div className="w-56 h-56 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
)}
</div>
{/* URL + copy */}
<button
onClick={handleCopy}
className="group w-full flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 hover:border-[#ff0040]/40 transition-colors"
>
<span className="flex-1 min-w-0 text-left text-sm text-gray-300 truncate font-mono">
{shareUrl.replace(/^https?:\/\//, '')}
</span>
{copied ? (
<Check className="h-4 w-4 text-[#ccff00] flex-shrink-0" />
) : (
<Copy className="h-4 w-4 text-gray-500 group-hover:text-white flex-shrink-0" />
)}
</button>
<div className="grid grid-cols-1 gap-2 w-full">
<Button onClick={handleCopy} variant="outline" className="w-full border-white/15 font-bold">
{copied ? <><Check className="h-4 w-4 mr-2 text-[#ccff00]" />Copied!</> : <><Copy className="h-4 w-4 mr-2" />Copy link</>}
</Button>
{canNativeShare && (
<Button onClick={handleNativeShare} className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase">
<Share2 className="h-4 w-4 mr-2" />
Share…
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<div className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/5 px-3.5 py-2.5 min-w-[84px]">
<div className="leading-none">
{loading ? (
<Skeleton className="h-6 w-8" />
) : (
<p className="text-xl sm:text-2xl font-black flex items-center gap-1" style={{ color }}>
{icon}
{value}
</p>
)}
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold mt-0.5">{label}</p>
</div>
</div>
);
}
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 */}
<div className="flex items-center gap-2">
{/* Share — available on every profile */}
<Button
variant="outline"
onClick={() => setShareOpen(true)}
className="gap-2 border-white/15 text-gray-200 hover:bg-white/10"
aria-label="Share profile"
>
<Share2 className="h-4 w-4" />
<span className="hidden sm:inline">Share</span>
</Button>
{isOwnProfile && (
<Link to="/settings?section=profile">
<Button variant="outline" className="gap-2 border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10">
@ -241,6 +296,26 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
</div>
{/* ── At-a-glance stats strip ── */}
<div className="flex flex-wrap items-stretch gap-2 sm:gap-3 mb-6">
<StatPill value={spots?.length ?? 0} label="Spots" color="#ff0040" loading={spotsLoading} />
<StatPill value={shops?.length ?? 0} label="Shops" color="#ccff00" loading={shopsLoading} />
<StatPill value={crews.length} label="Crews" color="#2dd4bf" loading={crewsLoading} />
<StatPill value={publicStash?.length ?? 0} label="Gear" color="#ff6600" loading={publicStashLoading} />
{(riderStats?.zapsReceived ?? 0) > 0 && (
<StatPill value={riderStats!.zapsReceived} label="Zaps" color="#facc15" icon={<Zap className="h-3.5 w-3.5" />} />
)}
{memberSince && (
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3.5 py-2.5 ml-auto">
<Clock className="h-4 w-4 text-gray-500 flex-shrink-0" />
<div className="leading-tight">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Member since</p>
<p className="text-sm font-black text-white">{memberSince}</p>
</div>
</div>
)}
</div>
{/* Profile Info */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Main Content */}
@ -382,91 +457,93 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)
)}
{/* Spots by this user */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<MapPin className="h-5 w-5 text-[#ff0040]" />
Spots Added
{spots && spots.length > 0 && (
<Badge className="bg-[#ff0040]/20 text-[#ff0040] border-[#ff0040]/50">
{spots.length}
</Badge>
)}
</h2>
{/* Spots by this user — hidden entirely for others when empty */}
{(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<MapPin className="h-5 w-5 text-[#ff0040]" />
Spots Added
{spots && spots.length > 0 && (
<Badge className="bg-[#ff0040]/20 text-[#ff0040] border-[#ff0040]/50">
{spots.length}
</Badge>
)}
</h2>
</div>
{spotsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, i) => (
<SpotCardSkeleton key={i} />
))}
</div>
) : spots && spots.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{spots.slice(0, 4).map((spot) => (
<SpotCard key={spot.id} spot={spot} />
))}
</div>
) : (
// Only shown on your own profile — an actionable nudge, not a dead end.
<Link
to="/create-spot"
className="group flex items-center gap-3 rounded-xl border-2 border-dashed border-gray-800 hover:border-[#ff0040]/50 hover:bg-[#ff0040]/5 transition-all p-6"
>
<MapPin className="h-8 w-8 text-gray-600 group-hover:text-[#ff0040] transition-colors" />
<div>
<p className="text-gray-300 font-bold">You haven't added any spots yet</p>
<p className="text-sm text-gray-500">Put your local spot on the map →</p>
</div>
</Link>
)}
</div>
)}
{spotsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, i) => (
<SpotCardSkeleton key={i} />
))}
{/* Shops by this user — only render when there's something, or it's you */}
{(shopsLoading || (shops && shops.length > 0)) && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Store className="h-5 w-5 text-[#ccff00]" />
Shops Added
{shops && shops.length > 0 && (
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/50">
{shops.length}
</Badge>
)}
</h2>
</div>
) : spots && spots.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{spots.slice(0, 4).map((spot) => (
<SpotCard key={spot.id} spot={spot} />
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-800">
<CardContent className="py-8 text-center">
<MapPin className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-500">No spots added yet</p>
</CardContent>
</Card>
)}
</div>
{/* Shops by this user */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Store className="h-5 w-5 text-[#ccff00]" />
Shops Added
{shops && shops.length > 0 && (
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/50">
{shops.length}
</Badge>
)}
</h2>
{shopsLoading ? (
<div className="grid grid-cols-1 gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
) : (
<div className="space-y-3">
{shops!.slice(0, 4).map((shop) => (
<Link key={shop.id} to={`/shop/${shop.pubkey}/${shop.identifier}`}>
<Card className="bg-black/90 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all">
<CardContent className="p-4 flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
<Store className="h-6 w-6 text-[#ccff00]" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate">{shop.name}</h3>
{shop.location && (
<p className="text-sm text-gray-400 truncate">{shop.location}</p>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
{shopsLoading ? (
<div className="grid grid-cols-1 gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
) : shops && shops.length > 0 ? (
<div className="space-y-3">
{shops.slice(0, 4).map((shop) => (
<Link key={shop.id} to={`/shop/${shop.pubkey}/${shop.identifier}`}>
<Card className="bg-black/90 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all">
<CardContent className="p-4 flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
<Store className="h-6 w-6 text-[#ccff00]" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate">{shop.name}</h3>
{shop.location && (
<p className="text-sm text-gray-400 truncate">{shop.location}</p>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-800">
<CardContent className="py-8 text-center">
<Store className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-500">No shops added yet</p>
</CardContent>
</Card>
)}
</div>
)}
{/* Public Stash — gear this skater chose to show publicly */}
{(publicStashLoading || (publicStash && publicStash.length > 0)) && (
@ -734,31 +811,6 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</Card>
)}
{/* Stats Card */}
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
<CardContent className="p-4">
<h3 className="text-sm font-bold uppercase text-gray-400 mb-3">Contributions</h3>
<div className="grid grid-cols-4 gap-2">
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ff0040]">{spots?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Spots</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ccff00]">{shops?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Shops</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-teal-400">{crews.length}</p>
<p className="text-[10px] text-gray-500 uppercase">Crews</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ff6600]">{publicStash?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Gear</p>
</div>
</div>
</CardContent>
</Card>
{/* Tip Card */}
{!isOwnProfile && (profileEvent || nofferCode) && (
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
@ -789,6 +841,13 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
</div>
</div>
<ShareProfileDialog
open={shareOpen}
onOpenChange={setShareOpen}
npub={npub}
displayName={displayName}
/>
</Layout>
);
}