SkateSpots/src/components/wallet/MintPrivacyManager.tsx
SkateSpots 0e405eaa00 Polish Sasquatch bubble; full mint list; link shops to events
Sasquatch (the look regressed): I over-corrected last pass by shrinking the
bubble on the map and bolting an always-visible ✕ over the avatar on mobile.
Restored the full-size bubble + glow on every route (the map only loses the
pulse), moved Hide to a desktop-hover ✕ and an EyeOff "Hide assistant" button
in the panel header (Settings → Appearance still re-enables it).

Mints: the add-mint dialog only offered a hand-picked "Recommended" shortlist.
It now also lists the full searchable MintRadar directory (useMintDirectory)
below the recommendations.

Events: CreateEventPage could only link a skate spot. Added an optional
"Link to a Shop" picker (useSkateShops, search, location autofill) that writes
['a','30017:…'] + ['venue-name', …].
2026-10-04 17:45:42 +07:00

565 lines
23 KiB
TypeScript

import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Shield,
Plus,
Trash2,
AlertTriangle,
Info,
ExternalLink,
ChevronDown,
ChevronUp,
Star,
Globe,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { isNativePlatform } from '@/lib/platform';
import { useMintDirectory } from '@/hooks/useMintDirectory';
interface Mint {
url: string;
name?: string;
info?: unknown;
}
interface MintPrivacyManagerProps {
mints: Mint[];
balances: { [mintUrl: string]: number };
activeMintUrl: string | null;
onSetActiveMint: (url: string) => void;
onAddMint: (url: string) => Promise<void>;
onRemoveMint: (mint: Mint) => void;
totalBalance: number;
className?: string;
}
// Recommended mints (no stablecoin mints).
// Ordered by browser reachability — web wallets can only use mints that send
// CORS headers. Minibits works great natively but does NOT allow browser
// requests, so on the web it's flagged and listed last. In a native shell there
// is no CORS restriction, so Minibits (the SkateSpots brand mint) leads.
const MINIBITS = {
url: 'https://mint.minibits.cash/Bitcoin',
name: 'Minibits',
};
const COINOS = {
url: 'https://mint.coinos.io',
name: 'Coinos',
};
const LNBITS = {
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
name: 'LNbits Legend',
description: 'walletUi.mintPrivacy.mintLnbitsDesc',
};
const RECOMMENDED_MINTS_WEB = [
{ ...COINOS, description: 'walletUi.mintPrivacy.mintCoinosWebDesc' },
LNBITS,
{ ...MINIBITS, description: 'walletUi.mintPrivacy.mintMinibitsWebDesc' },
];
const RECOMMENDED_MINTS_NATIVE = [
{ ...MINIBITS, description: 'walletUi.mintPrivacy.mintMinibitsNativeDesc' },
{ ...COINOS, description: 'walletUi.mintPrivacy.mintCoinosDesc' },
LNBITS,
];
export function MintPrivacyManager({
mints,
balances,
activeMintUrl,
onSetActiveMint,
onAddMint,
onRemoveMint,
totalBalance,
className,
}: MintPrivacyManagerProps) {
const { t } = useTranslation();
const RECOMMENDED_MINTS = isNativePlatform() ? RECOMMENDED_MINTS_NATIVE : RECOMMENDED_MINTS_WEB;
const [showPrivacyTips, setShowPrivacyTips] = useState(false);
const [showAddMint, setShowAddMint] = useState(false);
const [mintUrl, setMintUrl] = useState('');
const [isAddingMint, setIsAddingMint] = useState(false);
const [mintToRemove, setMintToRemove] = useState<Mint | null>(null);
const [mintQuery, setMintQuery] = useState('');
const { toast } = useToast();
// The full MintRadar community directory, so the add dialog isn't limited to a
// handful of hand-picked recommendations.
const { data: directory, isLoading: directoryLoading } = useMintDirectory();
const directoryMints = (directory ?? []).filter((m) => {
if (!mintQuery) return true;
const q = mintQuery.toLowerCase();
return (m.name?.toLowerCase().includes(q) ?? false) || m.url.toLowerCase().includes(q);
});
// Calculate distribution across mints
const mintDistribution = mints.map(mint => ({
mint,
balance: balances[mint.url] || 0,
percentage: totalBalance > 0 ? ((balances[mint.url] || 0) / totalBalance) * 100 : 0,
}));
// Privacy score based on distribution
const getPrivacyScore = () => {
if (mints.length === 0) return { score: 0, label: t('walletUi.mintPrivacy.noMints'), color: 'text-gray-500' };
if (mints.length === 1) return { score: 30, label: t('walletUi.mintPrivacy.singleMint'), color: 'text-red-400' };
// Check if funds are well distributed
const maxPercentage = Math.max(...mintDistribution.map(m => m.percentage));
if (maxPercentage > 80) return { score: 50, label: t('walletUi.mintPrivacy.concentrated'), color: 'text-amber-400' };
if (maxPercentage > 60) return { score: 70, label: t('walletUi.mintPrivacy.moderate'), color: 'text-yellow-400' };
return { score: 90, label: t('walletUi.mintPrivacy.wellDistributed'), color: 'text-green-400' };
};
const privacyScore = getPrivacyScore();
const handleAddMint = async () => {
if (!mintUrl.trim()) return;
// Basic validation
if (!mintUrl.startsWith('https://')) {
toast({ variant: 'destructive', title: t('walletUi.mintPrivacy.invalidUrl'), description: t('walletUi.mintPrivacy.invalidUrlDesc') });
return;
}
setIsAddingMint(true);
try {
await onAddMint(mintUrl.trim());
setMintUrl('');
setShowAddMint(false);
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedDesc') });
} catch (err) {
toast({
variant: 'destructive',
title: t('walletUi.mintPrivacy.failedAdd'),
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
});
} finally {
setIsAddingMint(false);
}
};
const handleConfirmRemove = () => {
if (mintToRemove) {
onRemoveMint(mintToRemove);
setMintToRemove(null);
}
};
const getCleanMintLabel = (mint: Mint) => {
if (mint.name) return mint.name;
try {
const url = new URL(mint.url);
return url.hostname;
} catch {
return mint.url;
}
};
return (
<>
<Card className={cn("border-purple-500/30", className)}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-5 w-5 text-purple-500" />
{t('walletUi.mintPrivacy.title')}
</CardTitle>
<Badge
variant="outline"
className={cn("text-xs", privacyScore.color)}
>
{privacyScore.label}
</Badge>
</div>
<CardDescription className="text-xs">
{t('walletUi.mintPrivacy.desc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Distribution Visualization */}
{mints.length > 0 && totalBalance > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.distribution')}</Label>
<div className="h-3 rounded-full bg-gray-800 overflow-hidden flex">
{mintDistribution.map((item, index) => (
<div
key={item.mint.url}
className={cn(
"h-full transition-all",
index === 0 && "bg-purple-500",
index === 1 && "bg-blue-500",
index === 2 && "bg-green-500",
index === 3 && "bg-amber-500",
index >= 4 && "bg-gray-500"
)}
style={{ width: `${item.percentage}%` }}
title={`${getCleanMintLabel(item.mint)}: ${item.percentage.toFixed(1)}%`}
/>
))}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{mintDistribution.map((item, index) => (
<div key={item.mint.url} className="flex items-center gap-1">
<div className={cn(
"w-2 h-2 rounded-full",
index === 0 && "bg-purple-500",
index === 1 && "bg-blue-500",
index === 2 && "bg-green-500",
index === 3 && "bg-amber-500",
index >= 4 && "bg-gray-500"
)} />
<span className="text-gray-400">{getCleanMintLabel(item.mint)}</span>
<span className="text-gray-500">({item.percentage.toFixed(0)}%)</span>
</div>
))}
</div>
</div>
)}
{/* Privacy Warning */}
{mints.length === 1 && (
<Alert className="border-amber-500/30 bg-amber-500/5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<AlertDescription className="text-xs">
{t('walletUi.mintPrivacy.singleWarning')}
</AlertDescription>
</Alert>
)}
{/* Mint List */}
<div className="space-y-2">
<Label className="text-xs font-medium">{t('walletUi.mintPrivacy.yourMints', { count: mints.length })}</Label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{mints.map((mint) => (
<div
key={mint.url}
className={cn(
"flex items-center justify-between p-3 rounded-lg border transition-colors",
activeMintUrl === mint.url
? "bg-purple-500/10 border-purple-500/30"
: "bg-muted/50 border-muted"
)}
>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => onSetActiveMint(mint.url)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{getCleanMintLabel(mint)}
</span>
{activeMintUrl === mint.url && (
<Badge variant="secondary" className="text-[10px]">
<Star className="h-2.5 w-2.5 mr-0.5" />
{t('walletUi.mintPrivacy.default')}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
{(balances[mint.url] || 0).toLocaleString()} sats
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setMintToRemove(mint)}
className="text-destructive hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{/* Add Mint */}
<Button
variant="outline"
className="w-full gap-2"
onClick={() => setShowAddMint(true)}
>
<Plus className="h-4 w-4" />
{t('walletUi.mintPrivacy.addMint')}
</Button>
{/* Auto Distribution Toggle — not yet implemented, so it's shown as a
disabled affordance with a "Soon" badge rather than a switch that
flips on but does nothing. */}
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 opacity-70">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('walletUi.mintPrivacy.autoDistribute')}</Label>
<Badge variant="outline" className="text-[10px] text-amber-400 border-amber-500/30">
{t('walletUi.mintPrivacy.soon')}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{t('walletUi.mintPrivacy.autoDesc')}
</p>
</div>
<Switch checked={false} disabled aria-label={t('walletUi.mintPrivacy.autoAria')} />
</div>
{/* Privacy Tips Toggle */}
<Button
variant="ghost"
className="w-full justify-between text-sm"
onClick={() => setShowPrivacyTips(!showPrivacyTips)}
>
<span className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-500" />
{t('walletUi.privacy.title')}
</span>
{showPrivacyTips ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
{showPrivacyTips && (
<div className="space-y-2 text-xs p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip1')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip2')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip3')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip4')}</span>
</div>
<div className="mt-2 pt-2 border-t border-blue-500/20">
<p className="text-gray-500 italic">
{t('walletUi.mintPrivacy.future')}
</p>
</div>
</div>
)}
{/* External Link */}
<a
href="https://mintradar.pedani.eu/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
{t('walletUi.mintPrivacy.findMore')}
</a>
</CardContent>
</Card>
{/* Add Mint Dialog */}
<Dialog open={showAddMint} onOpenChange={setShowAddMint}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('walletUi.mintPrivacy.addMint')}</DialogTitle>
<DialogDescription>
{t('walletUi.mintPrivacy.addMintDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Manual Entry */}
<div className="space-y-2">
<Label className="text-sm">{t('walletUi.mintPrivacy.mintUrl')}</Label>
<div className="flex gap-2">
<Input
placeholder="https://mint.example.com"
value={mintUrl}
onChange={(e) => setMintUrl(e.target.value)}
className="font-mono text-xs"
/>
<Button
onClick={handleAddMint}
disabled={!mintUrl || isAddingMint}
>
{isAddingMint ? t('walletUi.mintPrivacy.adding') : t('walletUi.mintPrivacy.add')}
</Button>
</div>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.mintPrivacy.orChoose')}</span>
</div>
</div>
{/* Recommended Mints */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.recommended')}</Label>
{RECOMMENDED_MINTS.map((mint) => {
const isAdded = mints.some(m => m.url.toLowerCase() === mint.url.toLowerCase());
return (
<div
key={mint.url}
className={cn(
"flex items-center justify-between p-3 rounded-lg border",
isAdded ? "bg-green-500/5 border-green-500/20" : "bg-muted/50"
)}
>
<div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{mint.name}</span>
</div>
<p className="text-xs text-muted-foreground ml-6">{t(mint.description)}</p>
</div>
{isAdded ? (
<Badge variant="secondary" className="text-xs">{t('walletUi.mintPrivacy.added')}</Badge>
) : (
<Button
size="sm"
variant="outline"
onClick={async () => {
setIsAddingMint(true);
try {
await onAddMint(mint.url);
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedNamed', { name: mint.name }) });
} catch (err) {
toast({
variant: 'destructive',
title: t('walletUi.mintPrivacy.failed'),
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
});
} finally {
setIsAddingMint(false);
}
}}
disabled={isAddingMint}
>
Add
</Button>
)}
</div>
);
})}
</div>
{/* Full directory (MintRadar) — the recommended list above is just a
shortlist; the whole community directory is searchable here. */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.allMints')}</Label>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={mintQuery}
onChange={(e) => setMintQuery(e.target.value)}
placeholder={t('walletUi.mintPrivacy.searchMints')}
className="pl-8 text-xs"
/>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-lg border p-1.5">
{directoryLoading ? (
<p className="py-4 text-center text-xs text-muted-foreground">{t('common.loading')}</p>
) : directoryMints.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">{t('walletUi.mintPrivacy.noDirectoryMints')}</p>
) : (
directoryMints.map((mint) => {
const isAdded = mints.some((m) => m.url.toLowerCase() === mint.url.toLowerCase());
return (
<div
key={mint.url}
className={cn('flex items-center justify-between gap-2 rounded-lg px-2 py-1.5', isAdded ? 'bg-green-500/5' : 'bg-muted/40')}
>
<div className="min-w-0">
<p className="truncate text-xs font-medium">{mint.name || getCleanMintLabel({ url: mint.url })}</p>
<p className="truncate font-mono text-[10px] text-muted-foreground">{mint.url.replace(/^https?:\/\//, '')}</p>
</div>
{isAdded ? (
<Badge variant="secondary" className="shrink-0 text-[10px]">{t('walletUi.mintPrivacy.added')}</Badge>
) : (
<Button
size="sm"
variant="outline"
className="shrink-0"
disabled={isAddingMint}
onClick={async () => {
setIsAddingMint(true);
try {
await onAddMint(mint.url);
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedNamed', { name: mint.name || mint.url }) });
} catch (err) {
toast({ variant: 'destructive', title: t('walletUi.mintPrivacy.failed'), description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError') });
} finally {
setIsAddingMint(false);
}
}}
>
{t('walletUi.mintPrivacy.add')}
</Button>
)}
</div>
);
})
)}
</div>
<p className="text-[10px] text-muted-foreground">{t('walletUi.mintPrivacy.directoryHint')}</p>
</div>
{/* Trust Warning */}
<Alert className="border-amber-500/30 bg-amber-500/5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<AlertDescription className="text-xs">
<span className="font-bold text-amber-400">{t('walletUi.mintPrivacy.trustWarning')}</span> {t('walletUi.mintPrivacy.trustWarningDesc')}
</AlertDescription>
</Alert>
</div>
</DialogContent>
</Dialog>
{/* Remove Mint Confirmation */}
<Dialog open={!!mintToRemove} onOpenChange={(open) => !open && setMintToRemove(null)}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('walletUi.mintPrivacy.removeTitle')}</DialogTitle>
<DialogDescription>
{t('walletUi.mintPrivacy.removeDesc', { name: mintToRemove ? getCleanMintLabel(mintToRemove) : '' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{mintToRemove && (balances[mintToRemove.url] || 0) > 0 && (
<Alert className="border-red-500/30 bg-red-500/5">
<AlertTriangle className="h-4 w-4 text-red-500" />
<AlertDescription className="text-xs">
<span className="font-bold text-red-400">{t('walletUi.mintPrivacy.warning')}</span> {t('walletUi.mintPrivacy.withdrawWarning', { amount: (balances[mintToRemove.url] || 0).toLocaleString() })}
</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setMintToRemove(null)}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmRemove}>
{t('walletUi.mintPrivacy.removeMint')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}