418 lines
No EOL
15 KiB
TypeScript
418 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 { isNativePlatform } from '@/lib/platform';
|
||
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';
|
||
|
||
|
||
|
||
// 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;
|
||
onSelectCashu: () => void;
|
||
}>(({
|
||
webln,
|
||
connections,
|
||
connectionInfo,
|
||
activeConnection,
|
||
handleSetActive,
|
||
handleRemoveConnection,
|
||
setAddDialogOpen,
|
||
onSelectCashu,
|
||
}, ref) => {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
|
||
{/* Native built-in wallet comes before external connection. */}
|
||
{isNativePlatform() && (
|
||
<button
|
||
type="button"
|
||
onClick={onSelectCashu}
|
||
className="flex w-full items-center gap-3 rounded-xl border border-orange-500/30 bg-orange-500/[0.06] p-4 text-left transition-colors hover:bg-orange-500/[0.12]"
|
||
>
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-orange-500/15">
|
||
<Wallet className="h-5 w-5 text-orange-400" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-sm font-bold text-white">{t('walletUi.cashu.title')}</span>
|
||
<span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
|
||
{t('walletUi.cashu.desc')}
|
||
</span>
|
||
</span>
|
||
<span className="text-gray-500">›</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* NWC - connect an existing wallet */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-medium text-sm sm:text-base">{t('walletUi.modal.connectWallet')}</h3>
|
||
{connections.length > 0 && (
|
||
<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 - show only when a provider is actually detected. */}
|
||
{webln && (
|
||
<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>
|
||
)}
|
||
|
||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||
{t('walletUi.modal.nwcHelp')}{' '}
|
||
<a
|
||
href="https://planb.academy/en/tutorials/wallet"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-orange-400 underline underline-offset-2 hover:text-orange-300"
|
||
>
|
||
{t('walletUi.page.learnPlanB')}
|
||
</a>
|
||
</p>
|
||
</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,
|
||
onSelectCashu: () => {
|
||
setOpen(false);
|
||
window.setTimeout(() => {
|
||
document.getElementById('cashu-wallet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}, 120);
|
||
},
|
||
};
|
||
|
||
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}
|
||
</>
|
||
);
|
||
} |