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:
parent
a6c90941d2
commit
deb8fe1995
3 changed files with 415 additions and 135 deletions
|
|
@ -18,10 +18,11 @@ import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
|
import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
||||||
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
|
||||||
|
|
||||||
export const EditProfileForm: React.FC = () => {
|
export const EditProfileForm: React.FC = () => {
|
||||||
|
|
@ -335,7 +336,6 @@ export const EditProfileForm: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="website"
|
name="website"
|
||||||
|
|
@ -349,20 +349,14 @@ export const EditProfileForm: React.FC = () => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* NIP-05 — the verified-handle field. Given real prominence + live
|
||||||
|
verification feedback so people know if it's actually working. */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="nip05"
|
name="nip05"
|
||||||
render={({ field }) => (
|
render={({ field }) => <Nip05Field value={field.value ?? ''} onChange={field.onChange} pubkey={user?.pubkey} />}
|
||||||
<FormItem>
|
|
||||||
<FormLabel>NIP-05 identifier</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="you@example.com" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* ── Section: Bitcoin & Payments ── */}
|
{/* ── Section: Bitcoin & Payments ── */}
|
||||||
|
|
@ -614,6 +608,107 @@ const FieldRow: React.FC<{
|
||||||
</div>
|
</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
|
// Reusable component for image upload fields
|
||||||
interface ImageUploadFieldProps {
|
interface ImageUploadFieldProps {
|
||||||
field: {
|
field: {
|
||||||
|
|
|
||||||
126
src/components/ShareProfileDialog.tsx
Normal file
126
src/components/ShareProfileDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
Check,
|
Check,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Instagram,
|
Instagram,
|
||||||
|
Share2,
|
||||||
Users,
|
Users,
|
||||||
Crown,
|
Crown,
|
||||||
Bot,
|
Bot,
|
||||||
|
|
@ -48,8 +49,47 @@ import {
|
||||||
Home,
|
Home,
|
||||||
Clock,
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
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 }) {
|
function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const author = useAuthor(pubkey);
|
const author = useAuthor(pubkey);
|
||||||
|
|
@ -84,10 +124,15 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
: [];
|
: [];
|
||||||
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
|
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
|
||||||
const [spCopied, setSpCopied] = useState(false);
|
const [spCopied, setSpCopied] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
const isOwnProfile = user?.pubkey === 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
|
// 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
|
// profile leaves them empty — kind-0 stays the single source of truth for
|
||||||
// shared fields (bio/image/website), 35000 only carries skate-specific data.
|
// shared fields (bio/image/website), 35000 only carries skate-specific data.
|
||||||
|
|
@ -216,6 +261,16 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{isOwnProfile && (
|
||||||
<Link to="/settings?section=profile">
|
<Link to="/settings?section=profile">
|
||||||
<Button variant="outline" className="gap-2 border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10">
|
<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>
|
||||||
</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 */}
|
{/* Profile Info */}
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|
@ -382,7 +457,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spots by this user */}
|
{/* Spots by this user — hidden entirely for others when empty */}
|
||||||
|
{(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
||||||
|
|
@ -409,16 +485,23 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-dashed border-2 border-gray-800">
|
// Only shown on your own profile — an actionable nudge, not a dead end.
|
||||||
<CardContent className="py-8 text-center">
|
<Link
|
||||||
<MapPin className="h-10 w-10 mx-auto mb-3 text-gray-600" />
|
to="/create-spot"
|
||||||
<p className="text-gray-500">No spots added yet</p>
|
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"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Shops by this user */}
|
{/* Shops by this user — only render when there's something, or it's you */}
|
||||||
|
{(shopsLoading || (shops && shops.length > 0)) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
<h2 className="text-xl font-black uppercase flex items-center gap-2">
|
||||||
|
|
@ -438,9 +521,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
<Skeleton key={i} className="h-20" />
|
<Skeleton key={i} className="h-20" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : shops && shops.length > 0 ? (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{shops.slice(0, 4).map((shop) => (
|
{shops!.slice(0, 4).map((shop) => (
|
||||||
<Link key={shop.id} to={`/shop/${shop.pubkey}/${shop.identifier}`}>
|
<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">
|
<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">
|
<CardContent className="p-4 flex items-center gap-4">
|
||||||
|
|
@ -458,15 +541,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Public Stash — gear this skater chose to show publicly */}
|
{/* Public Stash — gear this skater chose to show publicly */}
|
||||||
{(publicStashLoading || (publicStash && publicStash.length > 0)) && (
|
{(publicStashLoading || (publicStash && publicStash.length > 0)) && (
|
||||||
|
|
@ -734,31 +811,6 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</Card>
|
</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 */}
|
{/* Tip Card */}
|
||||||
{!isOwnProfile && (profileEvent || nofferCode) && (
|
{!isOwnProfile && (profileEvent || nofferCode) && (
|
||||||
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ShareProfileDialog
|
||||||
|
open={shareOpen}
|
||||||
|
onOpenChange={setShareOpen}
|
||||||
|
npub={npub}
|
||||||
|
displayName={displayName}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue