Enrich profiles (crews, bot, CLINK), best-in-class New Message picker, map toolbar declutter
Profile page (NIP19Page): - Add "member of crew X" summary under the header (with founder crown) - Add a full Crews sidebar card listing every crew the user founds/belongs to - Add crews count to the Contributions stats (now Spots/Shops/Crews) - Show a Bot badge for kind 0 bot accounts - CLINK "Tip in Bitcoin" already surfaces on profiles (from prior work) - New useUserCrews(pubkey) hook powering the above Messages: - Replace paste-only "New Message" dialog with NewMessageDialog: a searchable people picker (crew-mates + following, grouped, name-searchable) that also accepts a pasted npub/hex as a fallback. Starting a DM now feels like a real contacts screen. Map toolbar consistency: - Consolidate secondary add actions (Shop / Gear / Tour) into a single "+ More" popover; keep primary "Add Spot" prominent. 4 buttons -> 2 controls. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
ee9e04d3e0
commit
433f59415b
5 changed files with 415 additions and 104 deletions
258
src/components/NewMessageDialog.tsx
Normal file
258
src/components/NewMessageDialog.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useCrewmates } from '@/hooks/useCrewmates';
|
||||
import { useFollowing } from '@/hooks/useFollowing';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageCircle, Search, Users, UserCheck, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface NewMessageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
pubkey: string;
|
||||
/** Short context line, e.g. shared crews or "Following". */
|
||||
hint?: string;
|
||||
/** Grouping bucket for section headers. */
|
||||
group: 'crew' | 'following';
|
||||
}
|
||||
|
||||
/** A single selectable person row. Resolves its own profile for name/avatar. */
|
||||
function PersonRow({
|
||||
candidate,
|
||||
query,
|
||||
onPick,
|
||||
}: {
|
||||
candidate: Candidate;
|
||||
query: string;
|
||||
onPick: (pubkey: string) => void;
|
||||
}) {
|
||||
const author = useAuthor(candidate.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(candidate.pubkey);
|
||||
const nip05 = metadata?.nip05;
|
||||
|
||||
// Name-based filtering happens here (names are resolved async per row).
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
const haystack = `${name} ${nip05 ?? ''} ${candidate.hint ?? ''}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(candidate.pubkey)}
|
||||
className="flex w-full items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-gray-800 active:scale-[0.99]"
|
||||
>
|
||||
<Avatar className="h-10 w-10 border-2 border-gray-700 flex-shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-xs">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-white text-sm truncate">{name}</p>
|
||||
{candidate.hint && (
|
||||
<p className="text-xs text-gray-500 truncate">{candidate.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-gray-600 flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A best-in-class "New Message" picker: search your crew-mates and the people
|
||||
* you follow by name, or paste a raw npub / hex key. Replaces the old
|
||||
* paste-only flow so starting a DM feels like a real contacts screen.
|
||||
*/
|
||||
export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { crewmates, isLoading: crewmatesLoading } = useCrewmates();
|
||||
const { data: following, isLoading: followingLoading } = useFollowing(user?.pubkey);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [rawError, setRawError] = useState('');
|
||||
|
||||
// Merge crew-mates (with shared-crew hints) and followed accounts, de-duped.
|
||||
const candidates = useMemo<Candidate[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const list: Candidate[] = [];
|
||||
|
||||
for (const cm of crewmates) {
|
||||
if (seen.has(cm.pubkey)) continue;
|
||||
seen.add(cm.pubkey);
|
||||
list.push({
|
||||
pubkey: cm.pubkey,
|
||||
group: 'crew',
|
||||
hint:
|
||||
cm.sharedCrews.length > 0
|
||||
? `In your crew${cm.sharedCrews.length > 1 ? 's' : ''}: ${cm.sharedCrews.join(', ')}`
|
||||
: 'Crew-mate',
|
||||
});
|
||||
}
|
||||
|
||||
for (const pk of following ?? []) {
|
||||
if (pk === user?.pubkey || seen.has(pk)) continue;
|
||||
seen.add(pk);
|
||||
list.push({ pubkey: pk, group: 'following', hint: 'Following' });
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [crewmates, following, user?.pubkey]);
|
||||
|
||||
const crewCandidates = candidates.filter((c) => c.group === 'crew');
|
||||
const followingCandidates = candidates.filter((c) => c.group === 'following');
|
||||
|
||||
const goToConversation = (pubkey: string) => {
|
||||
onOpenChange(false);
|
||||
setQuery('');
|
||||
setRawError('');
|
||||
navigate(`/messages/${nip19.npubEncode(pubkey)}`);
|
||||
};
|
||||
|
||||
// If the query looks like a raw key, offer to open it directly.
|
||||
const trimmed = query.trim();
|
||||
const looksLikeNpub = trimmed.startsWith('npub1');
|
||||
const looksLikeHex = /^[a-f0-9]{64}$/i.test(trimmed);
|
||||
|
||||
const handleOpenRaw = () => {
|
||||
setRawError('');
|
||||
try {
|
||||
let pubkey = trimmed;
|
||||
if (looksLikeNpub) {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type !== 'npub') throw new Error('Invalid npub');
|
||||
pubkey = decoded.data;
|
||||
} else if (!looksLikeHex) {
|
||||
throw new Error('Invalid key');
|
||||
}
|
||||
goToConversation(pubkey);
|
||||
} catch {
|
||||
setRawError('That doesn\u2019t look like a valid npub or public key.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = crewmatesLoading || followingLoading;
|
||||
const hasAnyCandidates = candidates.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-black border-2 border-[#ff0040]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<MessageCircle className="h-5 w-5 text-[#ff0040]" />
|
||||
New Message
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400 text-sm">
|
||||
Search your people by name, or paste an npub to message anyone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setRawError('');
|
||||
}}
|
||||
placeholder="Search name or paste npub1..."
|
||||
className="bg-gray-900 border-gray-700 text-white pl-9 h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Direct-open affordance when a raw key is pasted */}
|
||||
{(looksLikeNpub || looksLikeHex) && (
|
||||
<Button
|
||||
onClick={handleOpenRaw}
|
||||
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-11"
|
||||
>
|
||||
Message this key
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
{rawError && <p className="text-sm text-red-500">{rawError}</p>}
|
||||
|
||||
{/* People list */}
|
||||
{!looksLikeNpub && !looksLikeHex && (
|
||||
<ScrollArea className="h-[min(50vh,360px)] pr-2">
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-gray-500">
|
||||
Loading your people...
|
||||
</div>
|
||||
) : !hasAnyCandidates ? (
|
||||
<div className="py-10 text-center px-4">
|
||||
<Users className="h-10 w-10 mx-auto mb-3 text-gray-600" />
|
||||
<p className="text-sm text-gray-400">
|
||||
No crew-mates or follows yet.
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Paste someone's npub above to start a conversation.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{crewCandidates.length > 0 && (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-teal-400">
|
||||
<Users className="h-3.5 w-3.5" /> Your crews
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{crewCandidates.map((c) => (
|
||||
<PersonRow
|
||||
key={c.pubkey}
|
||||
candidate={c}
|
||||
query={query}
|
||||
onPick={goToConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{followingCandidates.length > 0 && (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400">
|
||||
<UserCheck className="h-3.5 w-3.5" /> Following
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{followingCandidates.map((c) => (
|
||||
<PersonRow
|
||||
key={c.pubkey}
|
||||
candidate={c}
|
||||
query={query}
|
||||
onPick={goToConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
SKATE_CREW_KIND,
|
||||
parseSkateCrew,
|
||||
|
|
@ -116,3 +117,36 @@ export function useCrew(identifier: string, pubkey?: string) {
|
|||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export interface UserCrewMembership {
|
||||
crew: SkateCrew;
|
||||
/** How the user is attached to this crew. */
|
||||
role: 'founder' | 'member';
|
||||
}
|
||||
|
||||
/**
|
||||
* The crews a given pubkey belongs to — as founder or listed member.
|
||||
*
|
||||
* Powers the "Crews" section on a user's public profile. Founder crews sort
|
||||
* first, then member crews, newest first within each group.
|
||||
*/
|
||||
export function useUserCrews(pubkey?: string) {
|
||||
const { data: crews, isLoading, error } = useCrews(200);
|
||||
|
||||
const memberships = useMemo<UserCrewMembership[]>(() => {
|
||||
if (!pubkey || !crews) return [];
|
||||
|
||||
return crews
|
||||
.filter((c) => c.pubkey === pubkey || c.members.includes(pubkey))
|
||||
.map((crew) => ({
|
||||
crew,
|
||||
role: crew.pubkey === pubkey ? ('founder' as const) : ('member' as const),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.role !== b.role) return a.role === 'founder' ? -1 : 1;
|
||||
return b.crew.createdAt - a.crew.createdAt;
|
||||
});
|
||||
}, [pubkey, crews]);
|
||||
|
||||
return { memberships, isLoading, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,13 @@ import { nip19 } from 'nostr-tools';
|
|||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { NewMessageDialog } from '@/components/NewMessageDialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { useConversations, formatPubkeyShort } from '@/hooks/useDirectMessages';
|
||||
import { useConversations } from '@/hooks/useDirectMessages';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
|
@ -27,7 +19,6 @@ import { formatDistanceToNow } from 'date-fns';
|
|||
import {
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
|
|
@ -140,39 +131,12 @@ export default function DirectMessagesPage() {
|
|||
const { logins } = useNostrLogin();
|
||||
const { data: conversations, isLoading, error } = useConversations();
|
||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||
const [newRecipient, setNewRecipient] = useState('');
|
||||
const [recipientError, setRecipientError] = useState('');
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Messages - SkateSpots',
|
||||
description: 'Private encrypted messages with the skateboarding community',
|
||||
});
|
||||
|
||||
const handleStartConversation = () => {
|
||||
setRecipientError('');
|
||||
|
||||
// Try to parse the input as npub or hex pubkey
|
||||
let pubkey = newRecipient.trim();
|
||||
|
||||
try {
|
||||
if (pubkey.startsWith('npub1')) {
|
||||
const decoded = nip19.decode(pubkey);
|
||||
if (decoded.type === 'npub') {
|
||||
pubkey = decoded.data;
|
||||
} else {
|
||||
throw new Error('Invalid npub');
|
||||
}
|
||||
} else if (!/^[a-f0-9]{64}$/i.test(pubkey)) {
|
||||
throw new Error('Invalid pubkey format');
|
||||
}
|
||||
|
||||
// Navigate to conversation
|
||||
window.location.href = `/messages/${nip19.npubEncode(pubkey)}`;
|
||||
} catch {
|
||||
setRecipientError('Please enter a valid npub or pubkey');
|
||||
}
|
||||
};
|
||||
|
||||
// Not logged in
|
||||
if (!user) {
|
||||
return (
|
||||
|
|
@ -317,52 +281,8 @@ export default function DirectMessagesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Message Dialog */}
|
||||
<Dialog open={showNewMessage} onOpenChange={setShowNewMessage}>
|
||||
<DialogContent className="bg-black border-2 border-[#ff0040]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<MessageCircle className="h-5 w-5 text-[#ff0040]" />
|
||||
New Message
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400 text-sm">
|
||||
Enter the npub or pubkey of the person you want to message
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={newRecipient}
|
||||
onChange={(e) => {
|
||||
setNewRecipient(e.target.value);
|
||||
setRecipientError('');
|
||||
}}
|
||||
placeholder="npub1... or hex pubkey"
|
||||
className="bg-black border-gray-700 text-white font-mono text-sm h-11"
|
||||
/>
|
||||
{recipientError && (
|
||||
<p className="text-sm text-red-500">{recipientError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNewMessage(false)}
|
||||
className="flex-1 border-gray-700 h-11"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStartConversation}
|
||||
disabled={!newRecipient.trim()}
|
||||
className="flex-1 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-11"
|
||||
>
|
||||
Start Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* New Message — searchable people picker */}
|
||||
<NewMessageDialog open={showNewMessage} onOpenChange={setShowNewMessage} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import {
|
|||
Layers,
|
||||
Calendar,
|
||||
MessageCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { SpotChat } from '@/components/SpotChat';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -1478,30 +1479,38 @@ export default function MapPage() {
|
|||
{/* Add buttons - icons only on mobile */}
|
||||
{user ? (
|
||||
<>
|
||||
{/* Primary action: add a spot (this is a map, after all) */}
|
||||
<Link to="/create-spot" className="flex-shrink-0">
|
||||
<Button size="sm" className="gap-1.5 bg-[#ff0040] hover:bg-[#ff0040]/90 border-2 border-[#ff0040] font-bold h-8 px-2 sm:px-3">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden md:inline">{t('home.addSpot')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/create-shop" className="flex-shrink-0 hidden sm:block">
|
||||
<Button size="sm" variant="outline" className="gap-1.5 border-2 border-[#ccff00]/50 hover:bg-[#ccff00]/10 text-[#ccff00] font-bold h-8 px-2 sm:px-3">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden md:inline">{t('shops.addShop')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/marketplace" className="flex-shrink-0 hidden sm:block">
|
||||
<Button size="sm" variant="outline" className="gap-1.5 border-2 border-orange-500/50 hover:bg-orange-500/10 text-orange-400 font-bold h-8 px-2 sm:px-3">
|
||||
<ShoppingBag className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Buy/Sell Gear</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/create-tour" className="flex-shrink-0 hidden sm:block">
|
||||
<Button size="sm" variant="outline" className="gap-1.5 border-2 border-cyan-500/50 hover:bg-cyan-500/10 text-cyan-400 font-bold h-8 px-2 sm:px-3">
|
||||
<Route className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Offer Tour</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{/* Secondary add actions consolidated into one menu to de-clutter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-shrink-0 gap-1 border-2 border-gray-700 hover:border-[#ccff00]/50 font-bold h-8 px-2 sm:px-3"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden md:inline">More</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-52 p-1.5">
|
||||
<Link to="/create-shop" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-[#ccff00]/10 text-[#ccff00] transition-colors">
|
||||
<Store className="h-4 w-4" /> {t('shops.addShop')}
|
||||
</Link>
|
||||
<Link to="/marketplace" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-orange-500/10 text-orange-400 transition-colors">
|
||||
<ShoppingBag className="h-4 w-4" /> Buy/Sell Gear
|
||||
</Link>
|
||||
<Link to="/create-tour" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-cyan-500/10 text-cyan-400 transition-colors">
|
||||
<Route className="h-4 w-4" /> Offer Tour
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-yellow-500/50 text-yellow-400 text-[10px] px-2 flex-shrink-0">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useUserShops } from '@/hooks/useShops';
|
||||
import { useUserCrews } from '@/hooks/useCrews';
|
||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import NotFound from './NotFound';
|
||||
|
|
@ -26,6 +27,9 @@ import {
|
|||
ArrowLeft,
|
||||
Copy,
|
||||
Check,
|
||||
Users,
|
||||
Crown,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||
|
|
@ -35,6 +39,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
const { user } = useCurrentUser();
|
||||
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
||||
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
||||
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -45,6 +50,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
const website = metadata?.website;
|
||||
const nip05 = metadata?.nip05;
|
||||
const lud16 = metadata?.lud16;
|
||||
const isBot = metadata?.bot === true;
|
||||
// Custom CLINK offer code stored in the kind 0 content JSON.
|
||||
const noffer = (metadata as Record<string, unknown> | undefined)?.noffer;
|
||||
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
||||
|
|
@ -121,10 +127,15 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
</div>
|
||||
|
||||
<div className="flex-1 text-center sm:text-left pb-2">
|
||||
<div className="flex items-center justify-center sm:justify-start gap-2 mb-1">
|
||||
<div className="flex items-center justify-center sm:justify-start gap-2 mb-1 flex-wrap">
|
||||
<h1 className="text-2xl sm:text-3xl font-black uppercase text-white">
|
||||
{displayName}
|
||||
</h1>
|
||||
{isBot && (
|
||||
<Badge className="bg-gray-700/60 text-gray-300 border-gray-600 gap-1">
|
||||
<Bot className="h-3 w-3" /> Bot
|
||||
</Badge>
|
||||
)}
|
||||
<UserStatusIndicator pubkey={pubkey} size="md" showLabel className="hidden sm:flex" />
|
||||
</div>
|
||||
{nip05 && (
|
||||
|
|
@ -133,6 +144,31 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
{nip05}
|
||||
</p>
|
||||
)}
|
||||
{/* Crew membership summary — links straight to the crews */}
|
||||
{crews.length > 0 && (
|
||||
<p className="mt-1.5 text-sm text-gray-400 flex items-center justify-center sm:justify-start gap-1.5 flex-wrap">
|
||||
<Users className="h-3.5 w-3.5 text-teal-400 flex-shrink-0" />
|
||||
<span>
|
||||
{crews.slice(0, 2).map((m, i) => (
|
||||
<span key={`${m.crew.pubkey}:${m.crew.identifier}`}>
|
||||
{i > 0 && ', '}
|
||||
<Link
|
||||
to={`/crew/${m.crew.pubkey}/${m.crew.identifier}`}
|
||||
className="font-bold text-teal-300 hover:text-teal-200"
|
||||
>
|
||||
{m.crew.name}
|
||||
</Link>
|
||||
{m.role === 'founder' && (
|
||||
<Crown className="inline h-3 w-3 ml-0.5 -mt-0.5 text-yellow-500" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{crews.length > 2 && (
|
||||
<span className="text-gray-500"> +{crews.length - 2} more</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
|
@ -301,11 +337,61 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Crews Card */}
|
||||
{(crewsLoading || crews.length > 0) && (
|
||||
<Card className="bg-black/90 border-2 border-teal-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-teal-400" />
|
||||
Crews
|
||||
{crews.length > 0 && (
|
||||
<Badge className="bg-teal-500/20 text-teal-300 border-teal-500/40">
|
||||
{crews.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{crewsLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</>
|
||||
) : (
|
||||
crews.map((m) => (
|
||||
<Link
|
||||
key={`${m.crew.pubkey}:${m.crew.identifier}`}
|
||||
to={`/crew/${m.crew.pubkey}/${m.crew.identifier}`}
|
||||
className="flex items-center gap-3 p-2.5 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Avatar className="h-9 w-9 rounded-lg border border-teal-500/30">
|
||||
<AvatarImage src={m.crew.image} alt={m.crew.name} className="object-cover" />
|
||||
<AvatarFallback className="rounded-lg bg-teal-900/40 text-teal-300 text-xs font-black">
|
||||
{m.crew.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-white text-sm truncate">{m.crew.name}</p>
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{m.role === 'founder' ? (
|
||||
<><Crown className="h-3 w-3 text-yellow-500" /> Founder</>
|
||||
) : (
|
||||
<>Member</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</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-2 gap-3">
|
||||
<div className="grid grid-cols-3 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-xs text-gray-500 uppercase">Spots</p>
|
||||
|
|
@ -314,6 +400,10 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
<p className="text-2xl font-black text-[#ccff00]">{shops?.length || 0}</p>
|
||||
<p className="text-xs 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-xs text-gray-500 uppercase">Crews</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Reference in a new issue