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:
parent
5c8f84fca1
commit
665d875df1
2 changed files with 130 additions and 16 deletions
86
src/components/ProfilePreviewCard.tsx
Normal file
86
src/components/ProfilePreviewCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useSeoMeta } from '@unhead/react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { EditProfileForm } from '@/components/EditProfileForm';
|
import { EditProfileForm } from '@/components/EditProfileForm';
|
||||||
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
||||||
|
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
||||||
import { RelayListManager } from '@/components/RelayListManager';
|
import { RelayListManager } from '@/components/RelayListManager';
|
||||||
|
|
||||||
import { WalletModal } from '@/components/WalletModal';
|
import { WalletModal } from '@/components/WalletModal';
|
||||||
|
|
@ -17,8 +18,7 @@ import { DesktopNavManager } from '@/components/DesktopNavManager';
|
||||||
import { MyDataTab } from '@/components/MyDataTab';
|
import { MyDataTab } from '@/components/MyDataTab';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { Button } from '@/components/ui/button';
|
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'];
|
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 }) {
|
function ProfileScreen({ onBack }: { onBack: () => void }) {
|
||||||
const { user, metadata } = useCurrentUser();
|
const { user, metadata, isLoadingProfile } = useCurrentUser();
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
@ -169,26 +199,24 @@ function ProfileScreen({ onBack }: { onBack: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Quick link to the user's own public profile page */}
|
{/* How everyone else sees you (last saved profile). */}
|
||||||
<Link
|
<ProfilePreviewCard />
|
||||||
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"
|
{/* 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. */}
|
||||||
<span className="flex items-center gap-2 text-sm font-medium">
|
{!isLoadingProfile && !metadata && (
|
||||||
<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 && (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-2xl bg-yellow-950/30 border border-yellow-500/40">
|
<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" />
|
<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't find a profile on the relays. If you'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">
|
<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')} />
|
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ProfileChecklist metadata={metadata} />
|
||||||
<SkaterDirectoryCard />
|
<SkaterDirectoryCard />
|
||||||
<EditProfileForm />
|
<EditProfileForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue