improive topup

This commit is contained in:
9qeklajc
2026-02-14 01:43:33 +01:00
parent 814c39898d
commit 68e537f6fb
2 changed files with 313 additions and 233 deletions
+296 -230
View File
@@ -32,6 +32,8 @@ import {
Database, Database,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Copy,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { import {
@@ -53,31 +55,29 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard'; import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard';
import { useState, useEffect } from 'react'; import { SimpleLightningTopup } from '@/components/providers/SimpleLightningTopup';
import { SimpleCashuTopup } from '@/components/providers/SimpleCashuTopup';
import { CashuPaymentWorkflow } from '@/components/landing/cashu-payment-workflow';
import { LightningPaymentWorkflow } from '@/components/landing/lightning-payment-workflow';
import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
function ProviderBalance({ function ProviderBalance({
providerId, providerId,
platformUrl, platformUrl,
isRoutstr = false, isRoutstr = false,
nodeUrl,
}: { }: {
providerId: number; providerId: number;
platformUrl?: string | null; platformUrl?: string | null;
isRoutstr?: boolean; isRoutstr?: boolean;
nodeUrl?: string;
}) { }) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false); const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState('');
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [invoiceData, setInvoiceData] = useState<{
payment_request: string;
invoice_id: string;
} | null>(null);
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
null
);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
@@ -92,102 +92,7 @@ function ProviderBalance({
retry: 1, retry: 1,
}); });
const { data: statusData } = useQuery({
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
queryFn: () =>
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
enabled: !!invoiceData && paymentStatus === 'pending',
refetchInterval: 2000,
});
useEffect(() => {
if (statusData?.paid === true) {
setPaymentStatus('paid');
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
toast.success('Payment received!', {
description: 'Your balance has been updated.',
});
}
}, [statusData, queryClient, providerId]);
const topupMutation = useMutation({
mutationFn: async (amount: number) => {
console.log('Calling top-up API with:', { providerId, amount });
try {
const result = await AdminService.initiateProviderTopup(
providerId,
amount
);
console.log('API returned:', result);
return result;
} catch (err) {
console.error('API call failed:', err);
throw err;
}
},
onSuccess: (data) => {
console.log('Top-up response:', data);
console.log('Type of data:', typeof data);
console.log('Keys in data:', Object.keys(data || {}));
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
setInvoiceData({
payment_request: data.topup_data.payment_request as string,
invoice_id: data.topup_data.invoice_id as string,
});
setPaymentStatus('pending');
} else {
console.error('Missing invoice data:', data);
console.error('topup_data:', data?.topup_data);
toast.error('No invoice returned from provider');
setIsTopupDialogOpen(false);
}
},
onError: (error: Error) => {
console.error('Top-up mutation error:', error);
toast.error(`Failed to initiate top-up: ${error.message}`);
},
});
const handleTopup = () => {
// If no dialog open logic (which depends on API implementation),
// we check if we should redirect or open dialog based on available info
// But since this function is called inside the dialog, we might want to change
// how the "Top Up" button behaves instead.
const amount = parseFloat(topupAmount);
if (isNaN(amount)) {
setTopupError('Please enter a valid amount');
return;
}
if (amount < 1 || amount > 500) {
setTopupError('Amount must be between $1 and $500');
return;
}
topupMutation.mutate(amount);
};
const handleTopUpClick = () => { const handleTopUpClick = () => {
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
// we should redirect.
// However, the prompt asks to redirect if topup is not implemented.
// The backend throws 500/400 if not implemented.
// A better approach is to check if we have a platform URL and maybe redirect there
// if we know it's not supported.
// BUT, we don't know for sure if it's supported without checking metadata or trying.
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
// The user specifically mentioned "like in openrouter".
if ( if (
platformUrl && platformUrl &&
(platformUrl.includes('openrouter.ai') || (platformUrl.includes('openrouter.ai') ||
@@ -202,10 +107,9 @@ function ProviderBalance({
const handleCloseDialog = () => { const handleCloseDialog = () => {
setIsTopupDialogOpen(false); setIsTopupDialogOpen(false);
setTopupAmount(''); queryClient.invalidateQueries({
setTopupError(''); queryKey: ['provider-balance', providerId],
setInvoiceData(null); });
setPaymentStatus(null);
}; };
if (isLoading) { if (isLoading) {
@@ -254,134 +158,58 @@ function ProviderBalance({
</Button> </Button>
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}> <Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className='sm:max-w-md'> <DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-md'>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>Top Up Balance</DialogTitle>
{paymentStatus === 'paid'
? 'Payment Confirmed!'
: 'Top Up Balance'}
</DialogTitle>
<DialogDescription> <DialogDescription>
{paymentStatus === 'paid' {isRoutstr
? 'Your account balance has been updated.' ? `Top up your balance on node ${nodeUrl}`
: invoiceData : 'Choose a payment method to top up your account balance.'}
? 'Scan the QR code or copy the Lightning invoice to pay.'
: 'Enter the amount you want to add to your account balance.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{paymentStatus === 'paid' ? ( <div className='space-y-6 py-4'>
<div className='flex flex-col items-center gap-4 py-6'> <section className='space-y-2'>
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'> <Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
<svg Lightning Top-up
className='h-12 w-12 text-green-600 dark:text-green-400' </Label>
fill='none' <SimpleLightningTopup
strokeLinecap='round' providerId={providerId}
strokeLinejoin='round' baseUrl={nodeUrl || ''}
strokeWidth='2' onSuccess={() => {
viewBox='0 0 24 24' queryClient.invalidateQueries({
stroke='currentColor' queryKey: ['provider-balance', providerId],
> });
<path d='M5 13l4 4L19 7'></path> }}
</svg> />
</div> </section>
<p className='text-center font-semibold'>Top-up successful!</p>
</div> <Separator />
) : invoiceData ? (
<div className='flex flex-col items-center gap-4 py-4'> <section className='space-y-2'>
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'> <Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
{/* eslint-disable-next-line @next/next/no-img-element */} Cashu Token Top-up
<img </Label>
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent( <SimpleCashuTopup
invoiceData.payment_request providerId={providerId}
)}`} baseUrl={nodeUrl || ''}
alt='Lightning Invoice QR Code' onSuccess={() => {
className='h-64 w-64' queryClient.invalidateQueries({
/> queryKey: ['provider-balance', providerId],
</div> });
<div className='w-full space-y-2'> }}
<Label htmlFor='invoice'>Lightning Invoice</Label> />
<div className='flex gap-2'> </section>
<Input </div>
id='invoice'
value={invoiceData.payment_request}
readOnly
className='font-mono text-xs'
/>
<Button
size='sm'
variant='outline'
onClick={() => {
navigator.clipboard.writeText(
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!');
}}
>
Copy
</Button>
</div>
</div>
{paymentStatus === 'pending' && (
<p className='text-muted-foreground text-center text-sm'>
Waiting for payment...
</p>
)}
</div>
) : (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='topup_amount'>Amount (USD)</Label>
<Input
id='topup_amount'
type='number'
placeholder='Enter amount (1-500)'
value={topupAmount}
onChange={(e) => {
setTopupAmount(e.target.value);
setTopupError('');
}}
min='1'
max='500'
step='0.01'
/>
{topupError && (
<p className='text-sm text-red-600 dark:text-red-400'>
{topupError}
</p>
)}
</div>
</div>
)}
<DialogFooter> <DialogFooter>
{paymentStatus === 'paid' ? ( <Button
<Button onClick={handleCloseDialog} className='w-full'> variant='outline'
Done onClick={handleCloseDialog}
</Button> className='w-full'
) : invoiceData ? ( >
<Button Close
variant='outline' </Button>
onClick={handleCloseDialog}
className='w-full'
>
Cancel
</Button>
) : (
<>
<Button variant='outline' onClick={handleCloseDialog}>
Cancel
</Button>
<Button
onClick={handleTopup}
disabled={topupMutation.isPending || !topupAmount}
>
{topupMutation.isPending
? 'Processing...'
: 'Generate Invoice'}
</Button>
</>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -435,6 +263,12 @@ export default function ProvidersPage() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const { data: globalSettings } = useQuery({
queryKey: ['settings'],
queryFn: () => AdminService.getSettings(),
refetchOnWindowFocus: false,
});
const { const {
data: providers = [], data: providers = [],
isLoading, isLoading,
@@ -741,6 +575,65 @@ export default function ProvidersPage() {
</div> </div>
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='grid gap-2'>
<Label
htmlFor='edit_topup_mint_url'
className='text-xs'
>
Top-up Mint
</Label>
<Select
value={
formData.provider_settings?.topup_mint_url || ''
}
onValueChange={(value) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_mint_url: value,
},
})
}
>
<SelectTrigger
id='edit_topup_mint_url'
className='h-8 text-xs'
>
<SelectValue placeholder='Select a mint from your node configuration' />
</SelectTrigger>
<SelectContent>
{(
globalSettings?.cashu_mints as
| string[]
| undefined
)?.map((mint: string) => (
<SelectItem
key={mint}
value={mint}
className='text-xs'
>
{mint}
</SelectItem>
))}
{(!(globalSettings?.cashu_mints as string[]) ||
(globalSettings?.cashu_mints as string[])
.length === 0) && (
<SelectItem
value='none'
disabled
className='text-xs'
>
No mints configured in global settings
</SelectItem>
)}
</SelectContent>
</Select>
<p className='text-muted-foreground text-[10px]'>
The token for top-up will be created from this
mint.
</p>
</div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<Label htmlFor='auto_topup' className='text-sm'> <Label htmlFor='auto_topup' className='text-sm'>
Enable Auto Top-up Enable Auto Top-up
@@ -981,6 +874,88 @@ export default function ProvidersPage() {
1.01 means +1% e.g. currency exchange, card fees, etc. 1.01 means +1% e.g. currency exchange, card fees, etc.
</p> </p>
</div> </div>
{formData.provider_type === 'routstr' &&
editingProvider && (
<div className='bg-muted/30 mt-4 space-y-6 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Upstream Node Wallet Management
</Label>
<Badge variant='outline' className='text-[10px]'>
External Node
</Badge>
</div>
<div className='bg-muted/30 mt-4 space-y-6 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Upstream Node Wallet Management
</Label>
<Badge variant='outline' className='text-[10px]'>
External Node
</Badge>
</div>
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Lightning
</Label>
<LightningPaymentWorkflow
baseUrl={formData.base_url || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
toast.success(
'New API key created and saved'
);
}}
/>
</section>
<Separator />
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Cashu
</Label>
<CashuPaymentWorkflow
baseUrl={formData.base_url || ''}
apiKey={formData.api_key || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
toast.success(
'New API key created and saved'
);
}}
/>
</section>
</div>
<Separator />
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Cashu
</Label>
<CashuPaymentWorkflow
baseUrl={formData.base_url || ''}
apiKey={formData.api_key || ''}
onApiKeyCreated={(newApiKey) => {
setFormData({
...formData,
api_key: newApiKey,
});
toast.success('New API key created and saved');
}}
/>
</section>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -1240,16 +1215,56 @@ export default function ProvidersPage() {
} }
onEdit={() => handleEdit(provider)} onEdit={() => handleEdit(provider)}
onDelete={() => handleDelete(provider.id)} onDelete={() => handleDelete(provider.id)}
onUpdateKey={() => handleEdit(provider)}
balanceComponent={ balanceComponent={
<ProviderBalance <ProviderBalance
providerId={provider.id} providerId={provider.id}
platformUrl={getPlatformUrl(provider.provider_type)} platformUrl={getPlatformUrl(provider.provider_type)}
isRoutstr={true} isRoutstr={true}
nodeUrl={provider.base_url}
/> />
} }
> >
<CardContent> <CardContent>
<div className='space-y-4'> <div className='space-y-4'>
{provider.api_key ? (
<div className='bg-muted/50 rounded border p-3'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<Label className='text-muted-foreground text-[10px] uppercase'>
Active API Key
</Label>
<div className='max-w-[200px] truncate font-mono text-xs'>
{provider.api_key}
</div>
</div>
<Button
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => {
navigator.clipboard.writeText(
provider.api_key!
);
toast.success('API key copied');
}}
>
<Copy className='h-3 w-3' />
</Button>
</div>
</div>
) : (
<div className='rounded border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-400'>
<div className='mb-1 flex items-center gap-2'>
<AlertTriangle className='h-3 w-3' />
<span className='font-semibold'>
No API Key Configured
</span>
</div>
Click the &quot;Key&quot; button above to create
a new key on the upstream node.
</div>
)}
<div className='space-y-2'> <div className='space-y-2'>
{provider.api_version && ( {provider.api_version && (
<div className='flex items-center justify-between text-sm'> <div className='flex items-center justify-between text-sm'>
@@ -1409,6 +1424,57 @@ export default function ProvidersPage() {
</div> </div>
<div className='grid gap-3'> <div className='grid gap-3'>
<div className='grid gap-2'>
<Label htmlFor='edit_topup_mint_url' className='text-xs'>
Top-up Mint
</Label>
<Select
value={formData.provider_settings?.topup_mint_url || ''}
onValueChange={(value) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_mint_url: value,
},
})
}
>
<SelectTrigger
id='edit_topup_mint_url'
className='h-8 text-xs'
>
<SelectValue placeholder='Select a mint from your node configuration' />
</SelectTrigger>
<SelectContent>
{(
globalSettings?.cashu_mints as string[] | undefined
)?.map((mint: string) => (
<SelectItem
key={mint}
value={mint}
className='text-xs'
>
{mint}
</SelectItem>
))}
{(!(globalSettings?.cashu_mints as string[]) ||
(globalSettings?.cashu_mints as string[]).length ===
0) && (
<SelectItem
value='none'
disabled
className='text-xs'
>
No mints configured in global settings
</SelectItem>
)}
</SelectContent>
</Select>
<p className='text-muted-foreground text-[10px]'>
The token for top-up will be created from this mint.
</p>
</div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<Label htmlFor='edit_auto_topup' className='text-sm'> <Label htmlFor='edit_auto_topup' className='text-sm'>
Enable Auto Top-up Enable Auto Top-up
@@ -52,7 +52,7 @@ export function SimpleLightningTopup({
const pollStatus = useCallback( const pollStatus = useCallback(
async (invoiceId: string) => { async (invoiceId: string) => {
const maxAttempts = 60; const maxAttempts = 60; // 5 minutes with 5 second intervals
let attempts = 0; let attempts = 0;
const poll = async () => { const poll = async () => {
@@ -61,6 +61,7 @@ export function SimpleLightningTopup({
providerId, providerId,
invoiceId invoiceId
); );
if (response.paid) { if (response.paid) {
toast.success('Payment received!'); toast.success('Payment received!');
setInvoice(null); setInvoice(null);
@@ -69,10 +70,23 @@ export function SimpleLightningTopup({
onSuccess?.(); onSuccess?.();
return; return;
} }
attempts++; attempts++;
if (attempts < maxAttempts) setTimeout(poll, 5000); if (attempts < maxAttempts) {
setTimeout(poll, 5000);
} else {
toast.error('Payment timeout - please check manually');
setIsWaiting(false);
}
} catch (e) { } catch (e) {
console.error(e); console.error('Failed to poll topup status:', e);
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
} else {
toast.error('Failed to check payment status');
setIsWaiting(false);
}
} }
}; };
poll(); poll();