SkateSpots/src/components/WalletModal.tsx
2026-11-01 13:25:22 +07:00

418 lines
No EOL
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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