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:
shakespeare.diy 2026-07-30 21:36:42 +07:00
parent ee9e04d3e0
commit 433f59415b
5 changed files with 415 additions and 104 deletions

View 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&apos;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>
);
}

View file

@ -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 };
}

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>