ui(settings): give Edit Profile a real preview, honest reload, and a finish checklist

The profile screen was a bare 'view my public profile' link, a banner that
wrongly said 'Profile not loaded' whenever the user simply had no name yet,
a directory card, then the form.

- ProfilePreviewCard: banner + avatar + handle + bio + badges, so you can see
  how you look to everyone else without leaving the page. The public-profile
  link moves into it.
- The reload banner now only shows when we have NO metadata at all, and says so
  honestly instead of keying off a missing name.
- ProfileChecklist: a compact nudge listing only genuinely empty essentials
  (name, photo, bio, lightning address, handle); hidden once complete.
This commit is contained in:
SkateSpots 2026-09-29 14:27:20 +07:00
parent 5c8f84fca1
commit 665d875df1
2 changed files with 130 additions and 16 deletions

View file

@ -0,0 +1,86 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ExternalLink, ShieldCheck } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ProfileBadges } from '@/components/ProfileBadges';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isSkateSpotsNip05, profileBadges } from '@/lib/badges';
import { cn } from '@/lib/utils';
/**
* A live preview of how the signed-in user appears to everyone else — banner,
* avatar, handle, bio and badges — shown at the top of the Edit Profile screen
* so "how do I look?" has an answer without leaving the page.
*
* Reads the same kind-0 metadata the form edits, so it reflects the last SAVED
* profile (the form's unsaved edits aren't shown; the sticky Save button commits).
*/
export function ProfilePreviewCard({ className }: { className?: string }) {
const { user, metadata, isLoadingProfile } = useCurrentUser();
const [bannerOk, setBannerOk] = useState(true);
if (!user) return null;
const name = metadata?.display_name || metadata?.name || '';
const initials = (name || user.pubkey.slice(0, 2)).slice(0, 2).toUpperCase();
const nip05 = metadata?.nip05;
const about = metadata?.about?.trim();
const badges = profileBadges(metadata);
return (
<div className={cn('overflow-hidden rounded-2xl border border-white/10 bg-white/2', className)}>
{/* Banner (falls back to the brand gradient) */}
<div className="relative h-28 w-full bg-gradient-to-r from-[#ff0040]/25 via-[#ff6600]/15 to-[#ccff00]/20">
{metadata?.banner && bannerOk && (
<img
src={metadata.banner}
alt=""
className="h-full w-full object-cover"
onError={() => setBannerOk(false)}
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
<Link
to={`/${nip19.npubEncode(user.pubkey)}`}
className="absolute right-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-3 py-1.5 text-xs font-bold text-white backdrop-blur transition-colors hover:bg-black/75"
>
View public
<ExternalLink className="h-3 w-3" />
</Link>
</div>
{/* Identity */}
<div className="px-4 pb-4">
<div className="flex items-end gap-3">
<Avatar className="-mt-10 h-20 w-20 border-4 border-background shadow-lg">
{metadata?.picture && <AvatarImage src={metadata.picture} alt={name} />}
<AvatarFallback className="text-lg font-black">{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 pb-1">
<p className="truncate text-lg font-black leading-tight text-white">
{name || (isLoadingProfile ? 'Loading…' : 'Unnamed skater')}
</p>
{nip05 ? (
<span
className={cn(
'mt-0.5 inline-flex items-center gap-1 text-xs font-bold',
isSkateSpotsNip05(nip05) ? 'text-[#ccff00]' : 'text-blue-400',
)}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">{nip05}</span>
</span>
) : (
<span className="mt-0.5 block text-xs text-muted-foreground">No handle yet</span>
)}
</div>
</div>
{about && <p className="mt-3 line-clamp-3 text-sm leading-relaxed text-muted-foreground">{about}</p>}
{badges.length > 0 && <ProfileBadges badges={badges} size="sm" className="mt-3" />}
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useSeoMeta } from '@unhead/react';
import { Link, useSearchParams } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
import { RelayListManager } from '@/components/RelayListManager';
import { WalletModal } from '@/components/WalletModal';
@ -17,8 +18,7 @@ import { DesktopNavManager } from '@/components/DesktopNavManager';
import { MyDataTab } from '@/components/MyDataTab';
import { useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useState } from 'react';
import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button';
@ -128,8 +128,38 @@ function MenuGroup({ title, children }: { title: string; children: React.ReactNo
const PROFILE_FALLBACK_RELAYS = ['wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol'];
/**
* A short "what's missing" nudge. Deliberately only lists genuinely empty
* essentials — a new skater sees it, a finished profile never does — so it can't
* read as nagging.
*/
function ProfileChecklist({ metadata }: { metadata?: NostrMetadata }) {
const items: string[] = [];
if (!metadata?.name && !metadata?.display_name) items.push('a display name');
if (!metadata?.picture) items.push('a profile photo');
if (!metadata?.about?.trim()) items.push('a short bio');
if (!metadata?.lud16 && !metadata?.lud06) items.push('a Lightning address so people can zap you');
if (!metadata?.nip05) items.push('a free @skate-spots.org handle');
if (items.length === 0) return null;
return (
<div className="rounded-2xl border border-[#ccff00]/25 bg-[#ccff00]/5 p-4">
<p className="text-sm font-bold text-white">Finish your profile</p>
<ul className="mt-2 space-y-1">
{items.map((item) => (
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground">
<span className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#ccff00]" />
Add {item}
</li>
))}
</ul>
</div>
);
}
function ProfileScreen({ onBack }: { onBack: () => void }) {
const { user, metadata } = useCurrentUser();
const { user, metadata, isLoadingProfile } = useCurrentUser();
const { nostr } = useNostr();
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
@ -169,26 +199,24 @@ function ProfileScreen({ onBack }: { onBack: () => void }) {
</div>
) : (
<div className="space-y-4">
{/* Quick link to the user's own public profile page */}
<Link
to={`/${nip19.npubEncode(user.pubkey)}`}
className="flex items-center justify-between gap-3 p-3 rounded-2xl bg-muted/50 border border-border hover:bg-muted transition-colors"
>
<span className="flex items-center gap-2 text-sm font-medium">
<User className="h-4 w-4 text-[#ff0040]" />
View my public profile
</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</Link>
{!metadata?.name && (
{/* How everyone else sees you (last saved profile). */}
<ProfilePreviewCard />
{/* Only warn when we truly have NOTHING — an account with a name but no
bio is not "not loaded", and the checklist below covers that case. */}
{!isLoadingProfile && !metadata && (
<div className="flex items-center gap-3 p-3 rounded-2xl bg-yellow-950/30 border border-yellow-500/40">
<AlertCircle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
<p className="text-xs text-yellow-200 flex-1">Profile not loaded from relays.</p>
<p className="text-xs text-yellow-200 flex-1">
We couldn&apos;t find a profile on the relays. If you&apos;ve set one before, try refreshing.
</p>
<Button onClick={handleRefresh} disabled={isRefreshing} size="sm" variant="outline" className="border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 flex-shrink-0 h-8">
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
)}
<ProfileChecklist metadata={metadata} />
<SkaterDirectoryCard />
<EditProfileForm />
</div>