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 { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
SKATE_CREW_KIND,
|
SKATE_CREW_KIND,
|
||||||
parseSkateCrew,
|
parseSkateCrew,
|
||||||
|
|
@ -116,3 +117,36 @@ export function useCrew(identifier: string, pubkey?: string) {
|
||||||
refetchOnMount: true,
|
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 { Layout } from '@/components/Layout';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { NewMessageDialog } from '@/components/NewMessageDialog';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||||
import { useConversations, formatPubkeyShort } from '@/hooks/useDirectMessages';
|
import { useConversations } from '@/hooks/useDirectMessages';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
|
@ -27,7 +19,6 @@ import { formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
|
||||||
Lock,
|
Lock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
|
@ -140,39 +131,12 @@ export default function DirectMessagesPage() {
|
||||||
const { logins } = useNostrLogin();
|
const { logins } = useNostrLogin();
|
||||||
const { data: conversations, isLoading, error } = useConversations();
|
const { data: conversations, isLoading, error } = useConversations();
|
||||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||||
const [newRecipient, setNewRecipient] = useState('');
|
|
||||||
const [recipientError, setRecipientError] = useState('');
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: 'Messages - SkateSpots',
|
title: 'Messages - SkateSpots',
|
||||||
description: 'Private encrypted messages with the skateboarding community',
|
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
|
// Not logged in
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -317,52 +281,8 @@ export default function DirectMessagesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Message Dialog */}
|
{/* New Message — searchable people picker */}
|
||||||
<Dialog open={showNewMessage} onOpenChange={setShowNewMessage}>
|
<NewMessageDialog 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>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ import {
|
||||||
Layers,
|
Layers,
|
||||||
Calendar,
|
Calendar,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SpotChat } from '@/components/SpotChat';
|
import { SpotChat } from '@/components/SpotChat';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -1478,30 +1479,38 @@ export default function MapPage() {
|
||||||
{/* Add buttons - icons only on mobile */}
|
{/* Add buttons - icons only on mobile */}
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Primary action: add a spot (this is a map, after all) */}
|
||||||
<Link to="/create-spot" className="flex-shrink-0">
|
<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">
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden md:inline">{t('home.addSpot')}</span>
|
<span className="hidden md:inline">{t('home.addSpot')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/create-shop" className="flex-shrink-0 hidden sm:block">
|
{/* Secondary add actions consolidated into one menu to de-clutter */}
|
||||||
<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">
|
<Popover>
|
||||||
<Plus className="h-4 w-4" />
|
<PopoverTrigger asChild>
|
||||||
<span className="hidden md:inline">{t('shops.addShop')}</span>
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
</Link>
|
variant="outline"
|
||||||
<Link to="/marketplace" className="flex-shrink-0 hidden sm:block">
|
className="flex-shrink-0 gap-1 border-2 border-gray-700 hover:border-[#ccff00]/50 font-bold h-8 px-2 sm:px-3"
|
||||||
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden md:inline">Buy/Sell Gear</span>
|
<span className="hidden md:inline">More</span>
|
||||||
</Button>
|
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||||
</Link>
|
</Button>
|
||||||
<Link to="/create-tour" className="flex-shrink-0 hidden sm:block">
|
</PopoverTrigger>
|
||||||
<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">
|
<PopoverContent align="end" className="w-52 p-1.5">
|
||||||
<Route className="h-4 w-4" />
|
<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">
|
||||||
<span className="hidden md:inline">Offer Tour</span>
|
<Store className="h-4 w-4" /> {t('shops.addShop')}
|
||||||
</Button>
|
</Link>
|
||||||
</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">
|
<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 { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useUserShops } from '@/hooks/useShops';
|
import { useUserShops } from '@/hooks/useShops';
|
||||||
|
import { useUserCrews } from '@/hooks/useCrews';
|
||||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
|
|
@ -26,6 +27,9 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
Users,
|
||||||
|
Crown,
|
||||||
|
Bot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||||
|
|
@ -35,6 +39,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
||||||
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
||||||
|
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
|
|
@ -45,6 +50,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const website = metadata?.website;
|
const website = metadata?.website;
|
||||||
const nip05 = metadata?.nip05;
|
const nip05 = metadata?.nip05;
|
||||||
const lud16 = metadata?.lud16;
|
const lud16 = metadata?.lud16;
|
||||||
|
const isBot = metadata?.bot === true;
|
||||||
// Custom CLINK offer code stored in the kind 0 content JSON.
|
// Custom CLINK offer code stored in the kind 0 content JSON.
|
||||||
const noffer = (metadata as Record<string, unknown> | undefined)?.noffer;
|
const noffer = (metadata as Record<string, unknown> | undefined)?.noffer;
|
||||||
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
||||||
|
|
@ -121,10 +127,15 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 text-center sm:text-left pb-2">
|
<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">
|
<h1 className="text-2xl sm:text-3xl font-black uppercase text-white">
|
||||||
{displayName}
|
{displayName}
|
||||||
</h1>
|
</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" />
|
<UserStatusIndicator pubkey={pubkey} size="md" showLabel className="hidden sm:flex" />
|
||||||
</div>
|
</div>
|
||||||
{nip05 && (
|
{nip05 && (
|
||||||
|
|
@ -133,6 +144,31 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
{nip05}
|
{nip05}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|
@ -301,11 +337,61 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Stats Card */}
|
||||||
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
|
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<h3 className="text-sm font-bold uppercase text-gray-400 mb-3">Contributions</h3>
|
<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">
|
<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-2xl font-black text-[#ff0040]">{spots?.length || 0}</p>
|
||||||
<p className="text-xs text-gray-500 uppercase">Spots</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-2xl font-black text-[#ccff00]">{shops?.length || 0}</p>
|
||||||
<p className="text-xs text-gray-500 uppercase">Shops</p>
|
<p className="text-xs text-gray-500 uppercase">Shops</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue