SkateSpots/src/components/wallet/MintPrivacyManager.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

486 lines
18 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 { Progress } from '@/components/ui/progress';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Shield,
Plus,
Trash2,
AlertTriangle,
Info,
ExternalLink,
ChevronDown,
ChevronUp,
Star,
Globe,
Lock,
Eye,
EyeOff,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
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)
const RECOMMENDED_MINTS = [
{
url: 'https://mint.minibits.cash/Bitcoin',
name: 'Minibits',
description: 'Popular mobile-friendly mint',
},
{
url: 'https://mint.coinos.io',
name: 'Coinos',
description: 'Web-based mint from Canada',
},
{
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
name: 'LNbits Legend',
description: 'Community mint on LNbits',
},
];
export function MintPrivacyManager({
mints,
balances,
activeMintUrl,
onSetActiveMint,
onAddMint,
onRemoveMint,
totalBalance,
className,
}: MintPrivacyManagerProps) {
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 [autoDistribute, setAutoDistribute] = useState(false);
const { toast } = useToast();
// 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: 'No Mints', color: 'text-gray-500' };
if (mints.length === 1) return { score: 30, label: 'Single Mint', 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: 'Concentrated', color: 'text-amber-400' };
if (maxPercentage > 60) return { score: 70, label: 'Moderate', color: 'text-yellow-400' };
return { score: 90, label: 'Well Distributed', color: 'text-green-400' };
};
const privacyScore = getPrivacyScore();
const handleAddMint = async () => {
if (!mintUrl.trim()) return;
// Basic validation
if (!mintUrl.startsWith('https://')) {
toast({ variant: 'destructive', title: 'Invalid URL', description: 'Mint URL must start with https://' });
return;
}
setIsAddingMint(true);
try {
await onAddMint(mintUrl.trim());
setMintUrl('');
setShowAddMint(false);
toast({ title: 'Mint Added', description: 'New mint added successfully' });
} catch (err) {
toast({
variant: 'destructive',
title: 'Failed to add mint',
description: err instanceof Error ? err.message : 'Unknown error'
});
} 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" />
Mint Privacy
</CardTitle>
<Badge
variant="outline"
className={cn("text-xs", privacyScore.color)}
>
{privacyScore.label}
</Badge>
</div>
<CardDescription className="text-xs">
Using multiple mints improves privacy by preventing any single mint from seeing all your transactions
</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">Fund 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">
Using a single mint means that mint can see all your transaction timing and amounts.
Consider adding more mints for better privacy.
</AlertDescription>
</Alert>
)}
{/* Mint List */}
<div className="space-y-2">
<Label className="text-xs font-medium">Your Mints ({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" />
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" />
Add Mint
</Button>
{/* Auto Distribution Toggle */}
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Auto-distribute funds</Label>
<p className="text-xs text-muted-foreground">
Spread received funds across multiple mints
</p>
</div>
<Switch
checked={autoDistribute}
onCheckedChange={setAutoDistribute}
/>
</div>
{autoDistribute && (
<p className="text-xs text-amber-500 italic">
Coming soon - this feature is planned for a future update
</p>
)}
{/* 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" />
Privacy Tips
</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">Use 2-3 different mints minimum for better privacy</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Don't keep all funds in one mint - spread them out</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Consider mint jurisdiction (different countries = different legal access)</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Mints see timing of transactions but NOT who you pay (blind signatures)</span>
</div>
<div className="mt-2 pt-2 border-t border-blue-500/20">
<p className="text-gray-500 italic">
Future features: Mint rotation and privacy scoring - stay tuned!
</p>
</div>
</div>
)}
{/* External Link */}
<a
href="https://cashumints.space/"
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" />
Find more mints at cashumints.space
</a>
</CardContent>
</Card>
{/* Add Mint Dialog */}
<Dialog open={showAddMint} onOpenChange={setShowAddMint}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Mint</DialogTitle>
<DialogDescription>
Add a new Cashu mint to your wallet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Manual Entry */}
<div className="space-y-2">
<Label className="text-sm">Mint URL</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 ? 'Adding...' : '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">Or choose from list</span>
</div>
</div>
{/* Recommended Mints */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Recommended Mints</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">{mint.description}</p>
</div>
{isAdded ? (
<Badge variant="secondary" className="text-xs">Added</Badge>
) : (
<Button
size="sm"
variant="outline"
onClick={async () => {
setIsAddingMint(true);
try {
await onAddMint(mint.url);
toast({ title: 'Mint Added', description: `${mint.name} added successfully` });
} catch (err) {
toast({
variant: 'destructive',
title: 'Failed',
description: err instanceof Error ? err.message : 'Unknown error'
});
} finally {
setIsAddingMint(false);
}
}}
disabled={isAddingMint}
>
Add
</Button>
)}
</div>
);
})}
</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">Trust Warning:</span> Mints are custodial - they hold your funds.
Only use mints you trust. If a mint disappears, your funds may be lost.
Spreading funds across multiple mints reduces risk.
</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>Remove Mint?</DialogTitle>
<DialogDescription>
Are you sure you want to remove {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">Warning:</span> This mint has {(balances[mintToRemove.url] || 0).toLocaleString()} sats.
Withdraw your funds before removing!
</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setMintToRemove(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmRemove}>
Remove Mint
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}