SkateSpots/src/components/WalletModal.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

408 lines
No EOL
15 KiB
TypeScript

import { useState, forwardRef } from 'react';
import { Wallet, Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X, ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerClose } from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useTranslation } from '@/contexts/language';
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
import type { WebLNProvider } from "@webbtc/webln-types";
interface WalletModalProps {
children?: React.ReactNode;
className?: string;
}
// Extracted AddWalletContent to prevent re-renders
const AddWalletContent = forwardRef<HTMLDivElement, {
alias: string;
setAlias: (value: string) => void;
connectionUri: string;
setConnectionUri: (value: string) => void;
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => {
const { t } = useTranslation();
return (
<div className="space-y-4 px-4" ref={ref}>
<div>
<Label htmlFor="alias">{t('walletUi.modal.walletName')}</Label>
<Input
id="alias"
placeholder={t('walletUi.modal.walletNamePlaceholder')}
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
</div>
<div>
<Label htmlFor="connection-uri">{t('walletUi.modal.connectionUri')}</Label>
<Textarea
id="connection-uri"
placeholder="nostr+walletconnect://..."
value={connectionUri}
onChange={(e) => setConnectionUri(e.target.value)}
rows={3}
/>
</div>
{/* Security guidance: an NWC string is a spend credential. Encourage users
to cap it in their wallet so a compromised connection can't be drained. */}
<div className="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<ShieldAlert className="h-4 w-4 flex-shrink-0 text-amber-500 mt-0.5" />
<div className="space-y-1 text-xs text-amber-200/90">
<p className="font-semibold text-amber-300">{t('walletUi.modal.spendLimitTitle')}</p>
<p>
{t('walletUi.modal.spendLimitBody1')} <strong>{t('walletUi.modal.dailyBudget')}</strong>{' '}
{t('walletUi.modal.spendLimitBody2')} <strong>pay_invoice</strong>.{' '}
{t('walletUi.modal.spendLimitBody3')}
</p>
</div>
</div>
</div>
);
});
AddWalletContent.displayName = 'AddWalletContent';
// Recommended wallets for NWC
const RECOMMENDED_WALLETS = [
{ name: 'Alby', url: 'https://getalby.com', type: 'extension' },
{ name: 'Minibits', url: 'https://www.minibits.cash', type: 'mobile' },
{ name: 'Zeus', url: 'https://zeusln.com', type: 'mobile' },
{ name: 'Coinos', url: 'https://coinos.io', type: 'web' },
];
// Extracted WalletContent to prevent re-renders
const WalletContent = forwardRef<HTMLDivElement, {
webln: WebLNProvider | null;
hasNWC: boolean;
connections: NWCConnection[];
connectionInfo: Record<string, NWCInfo>;
activeConnection: string | null;
handleSetActive: (cs: string) => void;
handleRemoveConnection: (cs: string) => void;
setAddDialogOpen: (open: boolean) => void;
}>(({
webln,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen
}, ref) => {
const { t } = useTranslation();
return (
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
{/* NWC First - Primary Option */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm sm:text-base">Nostr Wallet Connect</h3>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('walletUi.modal.addWallet')}
</Button>
</div>
{/* Connected Wallets List */}
{connections.length > 0 ? (
<div className="space-y-2">
{connections.map((connection) => {
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
return (
<div key={connection.connectionString} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary bg-primary/5' : ''}`}>
<div className="flex items-center gap-3">
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{connection.alias || info?.alias || t('walletUi.modal.lightningWallet')}
</p>
<p className="text-xs text-muted-foreground">
{t('walletUi.modal.nwcConnection')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isActive && <CheckCircle className="h-4 w-4 text-green-600" />}
{!isActive && (
<Button
size="sm"
variant="ghost"
onClick={() => handleSetActive(connection.connectionString)}
>
<Zap className="h-3 w-3" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveConnection(connection.connectionString)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-4 px-3 border border-dashed rounded-lg">
<WalletMinimal className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-3">
{t('walletUi.modal.pasteNwc')}
</p>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('walletUi.modal.connectWallet')}
</Button>
</div>
)}
</div>
<Separator />
{/* WebLN - Secondary Option */}
<div className="space-y-2">
<div className="flex items-center justify-between p-2 sm:p-3 border rounded-lg">
<div className="flex items-center gap-2 sm:gap-3">
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs sm:text-sm font-medium">{t('walletUi.modal.browserExtension')}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground">{t('walletUi.modal.autoDetected')}</p>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{webln && <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-600" />}
<Badge variant={webln ? "default" : "secondary"} className="text-[10px] sm:text-xs">
{webln ? t('walletUi.modal.ready') : t('walletUi.modal.notFound')}
</Badge>
</div>
</div>
</div>
{/* Recommended Wallets - Show when no wallet connected */}
{!webln && connections.length === 0 && (
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">{t('walletUi.modal.getNwcFrom')}</h4>
<div className="grid gap-2">
{RECOMMENDED_WALLETS.map((wallet) => (
<a
key={wallet.name}
href={wallet.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-2 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
<span className="text-sm font-medium">{wallet.name}</span>
</div>
<Badge variant="outline" className="text-[10px]">
{wallet.type}
</Badge>
</a>
))}
</div>
</div>
</>
)}
</div>
);
});
WalletContent.displayName = 'WalletContent';
export function WalletModal({ children, className }: WalletModalProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isMobile = useIsMobile();
const {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection
} = useNWC();
const { webln } = useWallet();
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast();
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: t('walletUi.modal.connectionRequired'),
description: t('walletUi.modal.connectionRequiredDesc'),
variant: 'destructive',
});
return;
}
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
setConnectionUri('');
setAlias('');
setAddDialogOpen(false);
}
} finally {
setIsConnecting(false);
}
};
const handleRemoveConnection = (connectionString: string) => {
removeConnection(connectionString);
};
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: t('walletUi.modal.activeChanged'),
description: t('walletUi.modal.activeChangedDesc'),
});
};
const walletContentProps = {
webln,
hasNWC,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen,
};
const addWalletDialog = (
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{t('walletUi.modal.connectNwc')}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{t('walletUi.modal.connectNwcDesc')}
</DialogDescription>
</DialogHeader>
<AddWalletContent
alias={alias}
setAlias={setAlias}
connectionUri={connectionUri}
setConnectionUri={setConnectionUri}
/>
<DialogFooter className="px-0 sm:px-4">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? t('walletUi.modal.connecting') : t('walletUi.modal.connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (isMobile) {
return (
<>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
{t('walletUi.modal.walletSettings')}
</Button>
)}
</DrawerTrigger>
<DrawerContent className="h-full">
<DrawerHeader className="text-center relative">
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
<X className="h-4 w-4" />
<span className="sr-only">{t('common.close')}</span>
</Button>
</DrawerClose>
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
<Wallet className="h-5 w-5" />
{t('walletUi.modal.lightningWallet')}
</DrawerTitle>
<DrawerDescription>
{t('walletUi.modal.connectDesc')}
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto">
<WalletContent {...walletContentProps} />
</div>
</DrawerContent>
</Drawer>
{/* Render Add Wallet as a separate Drawer for mobile */}
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('walletUi.modal.connectNwc')}</DrawerTitle>
<DrawerDescription>
{t('walletUi.modal.connectNwcDesc')}
</DrawerDescription>
</DrawerHeader>
<AddWalletContent
alias={alias}
setAlias={setAlias}
connectionUri={connectionUri}
setConnectionUri={setConnectionUri}
/>
<div className="p-4">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? t('walletUi.modal.connecting') : t('walletUi.modal.connect')}
</Button>
</div>
</DrawerContent>
</Drawer>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
{t('walletUi.modal.walletSettings')}
</Button>
)}
</DialogTrigger>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[500px] max-h-[85vh] max-h-[85dvh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Wallet className="h-5 w-5" />
{t('walletUi.modal.lightningWallet')}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{t('walletUi.modal.connectDesc')}
</DialogDescription>
</DialogHeader>
<WalletContent {...walletContentProps} />
</DialogContent>
</Dialog>
{addWalletDialog}
</>
);
}