@djangocfg/ext-payments 1.0.14 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.cjs +5 -8
- package/dist/config.js +5 -8
- package/dist/index.cjs +1906 -1043
- package/dist/index.d.cts +644 -59
- package/dist/index.d.ts +644 -59
- package/dist/index.js +1886 -1040
- package/package.json +13 -16
- package/src/WalletPage.tsx +100 -0
- package/src/api/generated/ext_payments/CLAUDE.md +10 -4
- package/src/api/generated/ext_payments/_utils/fetchers/ext_payments__payments.ts +268 -5
- package/src/api/generated/ext_payments/_utils/hooks/ext_payments__payments.ts +102 -3
- package/src/api/generated/ext_payments/_utils/schemas/Balance.schema.ts +1 -1
- package/src/api/generated/ext_payments/_utils/schemas/PaginatedWithdrawalListList.schema.ts +24 -0
- package/src/api/generated/ext_payments/_utils/schemas/PaymentCreateRequest.schema.ts +21 -0
- package/src/api/generated/ext_payments/_utils/schemas/PaymentCreateResponse.schema.ts +22 -0
- package/src/api/generated/ext_payments/_utils/schemas/PaymentDetail.schema.ts +3 -3
- package/src/api/generated/ext_payments/_utils/schemas/PaymentList.schema.ts +2 -2
- package/src/api/generated/ext_payments/_utils/schemas/Transaction.schema.ts +1 -1
- package/src/api/generated/ext_payments/_utils/schemas/WithdrawalCancelResponse.schema.ts +22 -0
- package/src/api/generated/ext_payments/_utils/schemas/WithdrawalCreateRequest.schema.ts +21 -0
- package/src/api/generated/ext_payments/_utils/schemas/WithdrawalCreateResponse.schema.ts +22 -0
- package/src/api/generated/ext_payments/_utils/schemas/WithdrawalDetail.schema.ts +42 -0
- package/src/api/generated/ext_payments/_utils/schemas/WithdrawalList.schema.ts +29 -0
- package/src/api/generated/ext_payments/_utils/schemas/index.ts +8 -0
- package/src/api/generated/ext_payments/client.ts +1 -1
- package/src/api/generated/ext_payments/enums.ts +36 -0
- package/src/api/generated/ext_payments/ext_payments__payments/client.ts +104 -6
- package/src/api/generated/ext_payments/ext_payments__payments/models.ts +168 -8
- package/src/api/generated/ext_payments/index.ts +1 -1
- package/src/api/generated/ext_payments/schema.json +752 -42
- package/src/components/ActivityItem.tsx +118 -0
- package/src/components/ActivityList.tsx +93 -0
- package/src/components/AddFundsSheet.tsx +342 -0
- package/src/components/BalanceHero.tsx +102 -0
- package/src/components/CurrencyCombobox.tsx +49 -0
- package/src/components/PaymentSheet.tsx +352 -0
- package/src/components/WithdrawSheet.tsx +355 -0
- package/src/components/WithdrawalSheet.tsx +332 -0
- package/src/components/index.ts +11 -0
- package/src/config.ts +1 -0
- package/src/contexts/WalletContext.tsx +356 -0
- package/src/contexts/index.ts +13 -42
- package/src/contexts/types.ts +43 -37
- package/src/hooks/index.ts +3 -20
- package/src/hooks/useCurrencyOptions.ts +79 -0
- package/src/hooks/useEstimate.ts +113 -0
- package/src/hooks/useWithdrawalEstimate.ts +117 -0
- package/src/index.ts +9 -18
- package/src/types/index.ts +78 -0
- package/src/utils/errors.ts +36 -0
- package/src/utils/format.ts +65 -0
- package/src/utils/index.ts +3 -0
- package/src/contexts/BalancesContext.tsx +0 -63
- package/src/contexts/CurrenciesContext.tsx +0 -64
- package/src/contexts/OverviewContext.tsx +0 -173
- package/src/contexts/PaymentsContext.tsx +0 -122
- package/src/contexts/PaymentsExtensionProvider.tsx +0 -56
- package/src/contexts/README.md +0 -201
- package/src/contexts/RootPaymentsContext.tsx +0 -66
- package/src/layouts/PaymentsLayout/PaymentsLayout.tsx +0 -90
- package/src/layouts/PaymentsLayout/components/CreatePaymentDialog.tsx +0 -274
- package/src/layouts/PaymentsLayout/components/PaymentDetailsDialog.tsx +0 -287
- package/src/layouts/PaymentsLayout/components/index.ts +0 -2
- package/src/layouts/PaymentsLayout/events.ts +0 -47
- package/src/layouts/PaymentsLayout/index.ts +0 -16
- package/src/layouts/PaymentsLayout/types.ts +0 -6
- package/src/layouts/PaymentsLayout/views/overview/components/BalanceCard.tsx +0 -121
- package/src/layouts/PaymentsLayout/views/overview/components/RecentPayments.tsx +0 -139
- package/src/layouts/PaymentsLayout/views/overview/components/index.ts +0 -2
- package/src/layouts/PaymentsLayout/views/overview/index.tsx +0 -21
- package/src/layouts/PaymentsLayout/views/payments/components/PaymentsList.tsx +0 -279
- package/src/layouts/PaymentsLayout/views/payments/components/index.ts +0 -1
- package/src/layouts/PaymentsLayout/views/payments/index.tsx +0 -18
- package/src/layouts/PaymentsLayout/views/transactions/components/TransactionsList.tsx +0 -260
- package/src/layouts/PaymentsLayout/views/transactions/components/index.ts +0 -1
- package/src/layouts/PaymentsLayout/views/transactions/index.tsx +0 -18
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Withdraw Sheet (Apple-style)
|
|
3
|
+
*
|
|
4
|
+
* Responsive: Dialog on desktop, Drawer on mobile
|
|
5
|
+
* Fetches real-time exchange rates and fees from API
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
11
|
+
import { RefreshCw, AlertCircle, Loader2 } from 'lucide-react';
|
|
12
|
+
import { useForm } from 'react-hook-form';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Alert,
|
|
18
|
+
AlertDescription,
|
|
19
|
+
Button,
|
|
20
|
+
Form,
|
|
21
|
+
FormControl,
|
|
22
|
+
FormField,
|
|
23
|
+
FormItem,
|
|
24
|
+
FormLabel,
|
|
25
|
+
FormMessage,
|
|
26
|
+
Input,
|
|
27
|
+
TokenIcon,
|
|
28
|
+
ResponsiveSheet,
|
|
29
|
+
ResponsiveSheetContent,
|
|
30
|
+
ResponsiveSheetDescription,
|
|
31
|
+
ResponsiveSheetHeader,
|
|
32
|
+
ResponsiveSheetTitle,
|
|
33
|
+
useLocalStorage,
|
|
34
|
+
} from '@djangocfg/ui-core';
|
|
35
|
+
|
|
36
|
+
import { useWallet } from '../contexts/WalletContext';
|
|
37
|
+
import { useWithdrawalEstimate, useCurrencyOptions, useDefaultCurrency, useAutoSave } from '../hooks';
|
|
38
|
+
import { formatCryptoAmount, formatUsdAmount, extractErrorMessage } from '../utils';
|
|
39
|
+
import { CurrencyCombobox } from './CurrencyCombobox';
|
|
40
|
+
import type { WithdrawalDetail } from '../contexts/types';
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Schema
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const WithdrawSchema = z.object({
|
|
47
|
+
amount: z.number().min(10, 'Minimum $10'),
|
|
48
|
+
currency: z.string().min(1, 'Select a currency'),
|
|
49
|
+
wallet_address: z.string().min(26, 'Invalid wallet address'),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
type WithdrawForm = z.infer<typeof WithdrawSchema>;
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Props
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
interface WithdrawSheetProps {
|
|
59
|
+
open: boolean;
|
|
60
|
+
onOpenChange: (open: boolean) => void;
|
|
61
|
+
onSuccess?: (withdrawal: WithdrawalDetail) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Storage
|
|
65
|
+
const STORAGE_KEY = 'payments:withdraw';
|
|
66
|
+
|
|
67
|
+
interface WithdrawSaved {
|
|
68
|
+
currency: string;
|
|
69
|
+
wallet: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Component
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export function WithdrawSheet({ open, onOpenChange, onSuccess }: WithdrawSheetProps) {
|
|
77
|
+
const { currencies, isLoadingCurrencies, withdraw, balanceAmount } = useWallet();
|
|
78
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
79
|
+
const [error, setError] = useState<string | null>(null);
|
|
80
|
+
|
|
81
|
+
// Remember last used currency and wallet address
|
|
82
|
+
const [saved, setSaved] = useLocalStorage<WithdrawSaved>(STORAGE_KEY, {
|
|
83
|
+
currency: '',
|
|
84
|
+
wallet: '',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const form = useForm<WithdrawForm>({
|
|
88
|
+
resolver: zodResolver(WithdrawSchema),
|
|
89
|
+
defaultValues: {
|
|
90
|
+
amount: 10,
|
|
91
|
+
currency: saved.currency,
|
|
92
|
+
wallet_address: saved.wallet,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const watchedAmount = form.watch('amount');
|
|
97
|
+
const watchedCurrency = form.watch('currency');
|
|
98
|
+
const watchedWallet = form.watch('wallet_address');
|
|
99
|
+
|
|
100
|
+
// Auto-save to localStorage
|
|
101
|
+
useAutoSave(watchedCurrency, (v: string) => setSaved(prev => ({ ...prev, currency: v })));
|
|
102
|
+
useAutoSave(watchedWallet, (v: string) => setSaved(prev => ({ ...prev, wallet: v })), (v) => v.length >= 26);
|
|
103
|
+
|
|
104
|
+
// Currency options
|
|
105
|
+
const currencyOptions = useCurrencyOptions(currencies);
|
|
106
|
+
|
|
107
|
+
// Set default currency
|
|
108
|
+
useDefaultCurrency({
|
|
109
|
+
currencyOptions,
|
|
110
|
+
savedCurrency: saved.currency,
|
|
111
|
+
currentValue: watchedCurrency,
|
|
112
|
+
setValue: (v) => form.setValue('currency', v),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Get selected currency for display
|
|
116
|
+
const selectedCurrency = currencyOptions.find(c => c.value === watchedCurrency);
|
|
117
|
+
const amount = watchedAmount || 0;
|
|
118
|
+
|
|
119
|
+
// Fetch withdrawal estimate with fees from API
|
|
120
|
+
const { estimate, isLoading: isLoadingEstimate } = useWithdrawalEstimate({
|
|
121
|
+
currencyCode: watchedCurrency,
|
|
122
|
+
amountUsd: amount,
|
|
123
|
+
minAmount: 10,
|
|
124
|
+
skip: amount < 10,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Prepare crypto display data
|
|
128
|
+
const cryptoDisplay = useMemo(() => {
|
|
129
|
+
if (!selectedCurrency || !estimate) return null;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
token: selectedCurrency.token,
|
|
133
|
+
cryptoAmount: formatCryptoAmount(estimate.estimatedAmount, estimate.isStablecoin),
|
|
134
|
+
};
|
|
135
|
+
}, [selectedCurrency, estimate]);
|
|
136
|
+
|
|
137
|
+
// Check if user has enough balance
|
|
138
|
+
const insufficientBalance = amount > balanceAmount;
|
|
139
|
+
|
|
140
|
+
// Handle submit
|
|
141
|
+
const handleSubmit = useCallback(async (data: WithdrawForm) => {
|
|
142
|
+
try {
|
|
143
|
+
setIsSubmitting(true);
|
|
144
|
+
setError(null);
|
|
145
|
+
|
|
146
|
+
const result = await withdraw({
|
|
147
|
+
amount_usd: String(data.amount),
|
|
148
|
+
currency_code: data.currency,
|
|
149
|
+
wallet_address: data.wallet_address,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
form.reset();
|
|
153
|
+
onOpenChange(false);
|
|
154
|
+
onSuccess?.(result);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
setError(extractErrorMessage(err, 'Failed to create withdrawal request'));
|
|
157
|
+
} finally {
|
|
158
|
+
setIsSubmitting(false);
|
|
159
|
+
}
|
|
160
|
+
}, [withdraw, form, onOpenChange, onSuccess]);
|
|
161
|
+
|
|
162
|
+
// Reset on open/close
|
|
163
|
+
const handleOpenChange = useCallback((open: boolean) => {
|
|
164
|
+
if (open) {
|
|
165
|
+
// Reset form with saved values on open
|
|
166
|
+
form.reset({
|
|
167
|
+
amount: 10,
|
|
168
|
+
currency: saved.currency,
|
|
169
|
+
wallet_address: saved.wallet,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
setError(null);
|
|
173
|
+
}
|
|
174
|
+
onOpenChange(open);
|
|
175
|
+
}, [form, onOpenChange, saved]);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<ResponsiveSheet open={open} onOpenChange={handleOpenChange}>
|
|
179
|
+
<ResponsiveSheetContent className="sm:max-w-md">
|
|
180
|
+
<ResponsiveSheetHeader>
|
|
181
|
+
<ResponsiveSheetTitle>Withdraw</ResponsiveSheetTitle>
|
|
182
|
+
<ResponsiveSheetDescription>
|
|
183
|
+
Withdraw funds to your cryptocurrency wallet
|
|
184
|
+
</ResponsiveSheetDescription>
|
|
185
|
+
</ResponsiveSheetHeader>
|
|
186
|
+
|
|
187
|
+
<Form {...form}>
|
|
188
|
+
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 p-4 sm:p-0 sm:mt-4">
|
|
189
|
+
{/* Amount Input */}
|
|
190
|
+
<FormField
|
|
191
|
+
control={form.control}
|
|
192
|
+
name="amount"
|
|
193
|
+
render={({ field }) => (
|
|
194
|
+
<FormItem>
|
|
195
|
+
<FormLabel>Amount (USD)</FormLabel>
|
|
196
|
+
<FormControl>
|
|
197
|
+
<div className="relative">
|
|
198
|
+
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
|
|
199
|
+
$
|
|
200
|
+
</span>
|
|
201
|
+
<Input
|
|
202
|
+
type="number"
|
|
203
|
+
step="0.01"
|
|
204
|
+
min="10"
|
|
205
|
+
placeholder="10"
|
|
206
|
+
className="pl-8 text-2xl h-14 font-semibold"
|
|
207
|
+
{...field}
|
|
208
|
+
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
</FormControl>
|
|
212
|
+
<FormMessage />
|
|
213
|
+
{insufficientBalance && (
|
|
214
|
+
<p className="text-sm text-destructive mt-1">
|
|
215
|
+
Insufficient balance (Available: ${formatUsdAmount(balanceAmount)})
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
</FormItem>
|
|
219
|
+
)}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
{/* Currency Selection */}
|
|
223
|
+
<FormField
|
|
224
|
+
control={form.control}
|
|
225
|
+
name="currency"
|
|
226
|
+
render={({ field }) => (
|
|
227
|
+
<FormItem>
|
|
228
|
+
<FormLabel>Withdraw as</FormLabel>
|
|
229
|
+
<FormControl>
|
|
230
|
+
<CurrencyCombobox
|
|
231
|
+
options={currencyOptions}
|
|
232
|
+
value={field.value}
|
|
233
|
+
onChange={field.onChange}
|
|
234
|
+
disabled={isLoadingCurrencies}
|
|
235
|
+
/>
|
|
236
|
+
</FormControl>
|
|
237
|
+
<FormMessage />
|
|
238
|
+
</FormItem>
|
|
239
|
+
)}
|
|
240
|
+
/>
|
|
241
|
+
|
|
242
|
+
{/* Wallet Address */}
|
|
243
|
+
<FormField
|
|
244
|
+
control={form.control}
|
|
245
|
+
name="wallet_address"
|
|
246
|
+
render={({ field }) => (
|
|
247
|
+
<FormItem>
|
|
248
|
+
<FormLabel>Wallet Address</FormLabel>
|
|
249
|
+
<FormControl>
|
|
250
|
+
<Input
|
|
251
|
+
placeholder="Enter your wallet address"
|
|
252
|
+
className="font-mono text-sm"
|
|
253
|
+
{...field}
|
|
254
|
+
/>
|
|
255
|
+
</FormControl>
|
|
256
|
+
<FormMessage />
|
|
257
|
+
</FormItem>
|
|
258
|
+
)}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
{/* Fee Breakdown */}
|
|
262
|
+
{amount >= 10 && selectedCurrency && (
|
|
263
|
+
<div className="bg-muted rounded-xl p-4 space-y-2">
|
|
264
|
+
<div className="flex items-center justify-between text-sm">
|
|
265
|
+
<span className="text-muted-foreground">Amount</span>
|
|
266
|
+
<span>${formatUsdAmount(amount)}</span>
|
|
267
|
+
</div>
|
|
268
|
+
{isLoadingEstimate ? (
|
|
269
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
|
270
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
271
|
+
<span>Calculating fees...</span>
|
|
272
|
+
</div>
|
|
273
|
+
) : estimate ? (
|
|
274
|
+
<>
|
|
275
|
+
<div className="flex items-center justify-between text-sm">
|
|
276
|
+
<span className="text-muted-foreground">
|
|
277
|
+
Service fee ({estimate.serviceFeePercent}%)
|
|
278
|
+
</span>
|
|
279
|
+
<span className="text-destructive">
|
|
280
|
+
-${formatUsdAmount(estimate.serviceFeeUsd)}
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="flex items-center justify-between text-sm">
|
|
284
|
+
<span className="text-muted-foreground">Network fee</span>
|
|
285
|
+
<span className="text-destructive">
|
|
286
|
+
-${formatUsdAmount(estimate.networkFeeUsd)}
|
|
287
|
+
</span>
|
|
288
|
+
</div>
|
|
289
|
+
<div className="border-t pt-2 mt-2">
|
|
290
|
+
<div className="flex items-center justify-between">
|
|
291
|
+
<span className="font-medium">You will receive</span>
|
|
292
|
+
<div className="text-right">
|
|
293
|
+
<div className="font-semibold">
|
|
294
|
+
${formatUsdAmount(estimate.amountToReceive)}
|
|
295
|
+
</div>
|
|
296
|
+
{cryptoDisplay && (
|
|
297
|
+
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
298
|
+
<TokenIcon symbol={cryptoDisplay.token} size={16} />
|
|
299
|
+
<span className="font-mono">
|
|
300
|
+
{cryptoDisplay.cryptoAmount} {cryptoDisplay.token}
|
|
301
|
+
</span>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</>
|
|
308
|
+
) : null}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
{/* Warning */}
|
|
313
|
+
<Alert>
|
|
314
|
+
<AlertCircle className="h-4 w-4" />
|
|
315
|
+
<AlertDescription>
|
|
316
|
+
Withdrawal requests require admin approval. Processing may take 24-48 hours.
|
|
317
|
+
</AlertDescription>
|
|
318
|
+
</Alert>
|
|
319
|
+
|
|
320
|
+
{/* Error */}
|
|
321
|
+
{error && (
|
|
322
|
+
<Alert variant="destructive">
|
|
323
|
+
<AlertDescription>{error}</AlertDescription>
|
|
324
|
+
</Alert>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
{/* Submit Button */}
|
|
328
|
+
<Button
|
|
329
|
+
type="submit"
|
|
330
|
+
size="lg"
|
|
331
|
+
className="w-full h-14 text-lg rounded-xl"
|
|
332
|
+
disabled={
|
|
333
|
+
isSubmitting ||
|
|
334
|
+
currencyOptions.length === 0 ||
|
|
335
|
+
insufficientBalance ||
|
|
336
|
+
!estimate ||
|
|
337
|
+
estimate.amountToReceive <= 0 ||
|
|
338
|
+
isLoadingEstimate
|
|
339
|
+
}
|
|
340
|
+
>
|
|
341
|
+
{isSubmitting ? (
|
|
342
|
+
<>
|
|
343
|
+
<RefreshCw className="h-5 w-5 mr-2 animate-spin" />
|
|
344
|
+
Submitting...
|
|
345
|
+
</>
|
|
346
|
+
) : (
|
|
347
|
+
'Request Withdrawal'
|
|
348
|
+
)}
|
|
349
|
+
</Button>
|
|
350
|
+
</form>
|
|
351
|
+
</Form>
|
|
352
|
+
</ResponsiveSheetContent>
|
|
353
|
+
</ResponsiveSheet>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Withdrawal Details Sheet (Apple-style)
|
|
3
|
+
*
|
|
4
|
+
* Shows withdrawal request details and status
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
AlertCircle,
|
|
12
|
+
CheckCircle2,
|
|
13
|
+
Clock,
|
|
14
|
+
ExternalLink,
|
|
15
|
+
RefreshCw,
|
|
16
|
+
XCircle,
|
|
17
|
+
Ban,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import moment from 'moment';
|
|
20
|
+
import useSWR from 'swr';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
Button,
|
|
24
|
+
CopyButton,
|
|
25
|
+
Skeleton,
|
|
26
|
+
TokenIcon,
|
|
27
|
+
ResponsiveSheet,
|
|
28
|
+
ResponsiveSheetContent,
|
|
29
|
+
ResponsiveSheetDescription,
|
|
30
|
+
ResponsiveSheetHeader,
|
|
31
|
+
ResponsiveSheetTitle,
|
|
32
|
+
} from '@djangocfg/ui-core';
|
|
33
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
34
|
+
|
|
35
|
+
import { useWallet } from '../contexts/WalletContext';
|
|
36
|
+
import type { WithdrawalDetail } from '../api/generated/ext_payments/_utils/schemas';
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Props
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface WithdrawalSheetProps {
|
|
43
|
+
withdrawalId: string | null;
|
|
44
|
+
open: boolean;
|
|
45
|
+
onOpenChange: (open: boolean) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Status Config
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const statusConfig: Record<string, { icon: any; color: string; bg: string; label: string; animate?: boolean }> = {
|
|
53
|
+
pending: {
|
|
54
|
+
icon: Clock,
|
|
55
|
+
color: 'text-yellow-500',
|
|
56
|
+
bg: 'bg-yellow-500/10',
|
|
57
|
+
label: 'Pending Approval',
|
|
58
|
+
},
|
|
59
|
+
approved: {
|
|
60
|
+
icon: CheckCircle2,
|
|
61
|
+
color: 'text-blue-500',
|
|
62
|
+
bg: 'bg-blue-500/10',
|
|
63
|
+
label: 'Approved',
|
|
64
|
+
},
|
|
65
|
+
processing: {
|
|
66
|
+
icon: RefreshCw,
|
|
67
|
+
color: 'text-blue-500',
|
|
68
|
+
bg: 'bg-blue-500/10',
|
|
69
|
+
label: 'Processing',
|
|
70
|
+
animate: true,
|
|
71
|
+
},
|
|
72
|
+
completed: {
|
|
73
|
+
icon: CheckCircle2,
|
|
74
|
+
color: 'text-green-500',
|
|
75
|
+
bg: 'bg-green-500/10',
|
|
76
|
+
label: 'Completed',
|
|
77
|
+
},
|
|
78
|
+
rejected: {
|
|
79
|
+
icon: XCircle,
|
|
80
|
+
color: 'text-red-500',
|
|
81
|
+
bg: 'bg-red-500/10',
|
|
82
|
+
label: 'Rejected',
|
|
83
|
+
},
|
|
84
|
+
cancelled: {
|
|
85
|
+
icon: Ban,
|
|
86
|
+
color: 'text-muted-foreground',
|
|
87
|
+
bg: 'bg-muted',
|
|
88
|
+
label: 'Cancelled',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// Component
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function WithdrawalSheet({ withdrawalId, open, onOpenChange }: WithdrawalSheetProps) {
|
|
97
|
+
const { getWithdrawalDetails, cancelWithdrawal, refreshWallet } = useWallet();
|
|
98
|
+
|
|
99
|
+
// Fetch withdrawal details when sheet is open
|
|
100
|
+
const { data: withdrawal, isLoading, error, mutate } = useSWR<WithdrawalDetail>(
|
|
101
|
+
open && withdrawalId ? ['withdrawal-details', withdrawalId] : null,
|
|
102
|
+
() => getWithdrawalDetails(withdrawalId!),
|
|
103
|
+
{ refreshInterval: 30000 }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Prepare all display data before render
|
|
107
|
+
const displayData = useMemo(() => {
|
|
108
|
+
const s = withdrawal?.status?.toLowerCase() || 'pending';
|
|
109
|
+
const config = statusConfig[s] || statusConfig.pending;
|
|
110
|
+
|
|
111
|
+
const isPending = s === 'pending';
|
|
112
|
+
const isCompleted = s === 'completed';
|
|
113
|
+
const isRejected = s === 'rejected';
|
|
114
|
+
const isCancelled = s === 'cancelled';
|
|
115
|
+
const isProcessing = s === 'processing' || s === 'approved';
|
|
116
|
+
const canCancel = isPending;
|
|
117
|
+
|
|
118
|
+
// Description text
|
|
119
|
+
let description = '';
|
|
120
|
+
if (isPending) description = 'Waiting for admin approval';
|
|
121
|
+
else if (isProcessing) description = 'Your withdrawal is being processed';
|
|
122
|
+
else if (isCompleted) description = 'Withdrawal completed successfully';
|
|
123
|
+
else if (isRejected) description = 'Withdrawal was rejected';
|
|
124
|
+
else if (isCancelled) description = 'Withdrawal was cancelled';
|
|
125
|
+
|
|
126
|
+
// Formatted values
|
|
127
|
+
const amountUsd = withdrawal?.amount_usd ? `$${parseFloat(withdrawal.amount_usd).toFixed(2)}` : '';
|
|
128
|
+
const finalAmountUsd = withdrawal?.final_amount_usd ? `$${parseFloat(withdrawal.final_amount_usd).toFixed(2)}` : '';
|
|
129
|
+
const totalFeeUsd = withdrawal?.total_fee_usd ? `$${parseFloat(withdrawal.total_fee_usd).toFixed(2)}` : '';
|
|
130
|
+
const createdAt = withdrawal?.created_at
|
|
131
|
+
? moment.utc(withdrawal.created_at).local().format('MMM D, YYYY HH:mm')
|
|
132
|
+
: '';
|
|
133
|
+
const completedAt = withdrawal?.completed_at
|
|
134
|
+
? moment.utc(withdrawal.completed_at).local().format('MMM D, YYYY HH:mm')
|
|
135
|
+
: null;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
status: s,
|
|
139
|
+
config,
|
|
140
|
+
isPending,
|
|
141
|
+
isCompleted,
|
|
142
|
+
isRejected,
|
|
143
|
+
isCancelled,
|
|
144
|
+
isProcessing,
|
|
145
|
+
canCancel,
|
|
146
|
+
description,
|
|
147
|
+
amountUsd,
|
|
148
|
+
finalAmountUsd,
|
|
149
|
+
totalFeeUsd,
|
|
150
|
+
createdAt,
|
|
151
|
+
completedAt,
|
|
152
|
+
};
|
|
153
|
+
}, [withdrawal]);
|
|
154
|
+
|
|
155
|
+
const { config, canCancel, description, amountUsd, finalAmountUsd, totalFeeUsd, createdAt, completedAt } = displayData;
|
|
156
|
+
const StatusIcon = config.icon;
|
|
157
|
+
|
|
158
|
+
// Handle cancel
|
|
159
|
+
const handleCancel = async () => {
|
|
160
|
+
if (!withdrawalId) return;
|
|
161
|
+
try {
|
|
162
|
+
await cancelWithdrawal(withdrawalId);
|
|
163
|
+
await mutate();
|
|
164
|
+
await refreshWallet();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error('Failed to cancel withdrawal:', err);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<ResponsiveSheet open={open} onOpenChange={onOpenChange}>
|
|
172
|
+
<ResponsiveSheetContent className="sm:max-w-lg">
|
|
173
|
+
<ResponsiveSheetHeader>
|
|
174
|
+
<ResponsiveSheetTitle>Withdrawal Details</ResponsiveSheetTitle>
|
|
175
|
+
<ResponsiveSheetDescription>{description}</ResponsiveSheetDescription>
|
|
176
|
+
</ResponsiveSheetHeader>
|
|
177
|
+
|
|
178
|
+
<div className="p-4 sm:p-0 sm:mt-4 overflow-y-auto max-h-[70vh]">
|
|
179
|
+
{isLoading && (
|
|
180
|
+
<div className="space-y-6">
|
|
181
|
+
<Skeleton className="h-16 w-full rounded-xl" />
|
|
182
|
+
<Skeleton className="h-24 w-full rounded-xl" />
|
|
183
|
+
<Skeleton className="h-20 w-full rounded-xl" />
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{error && (
|
|
188
|
+
<div className="flex flex-col items-center justify-center py-12">
|
|
189
|
+
<XCircle className="h-12 w-12 text-destructive mb-4" />
|
|
190
|
+
<p className="text-sm text-muted-foreground mb-4">Failed to load withdrawal</p>
|
|
191
|
+
<Button onClick={() => mutate()}>Try Again</Button>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{withdrawal && !isLoading && (
|
|
196
|
+
<div className="space-y-6">
|
|
197
|
+
{/* Status Badge */}
|
|
198
|
+
<div className={cn('flex items-center gap-3 p-4 rounded-xl', config.bg)}>
|
|
199
|
+
<StatusIcon className={cn('h-6 w-6', config.color, config.animate && 'animate-spin')} />
|
|
200
|
+
<div className="flex-1">
|
|
201
|
+
<div className="font-semibold">{config.label}</div>
|
|
202
|
+
{withdrawal.admin_notes && (
|
|
203
|
+
<div className="text-sm text-muted-foreground">{withdrawal.admin_notes}</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Amount Breakdown */}
|
|
209
|
+
<div className="bg-muted rounded-xl p-4 space-y-3">
|
|
210
|
+
<div className="flex items-center justify-between">
|
|
211
|
+
<span className="text-muted-foreground">Amount</span>
|
|
212
|
+
<span className="font-semibold">{amountUsd}</span>
|
|
213
|
+
</div>
|
|
214
|
+
{totalFeeUsd && (
|
|
215
|
+
<div className="flex items-center justify-between text-sm">
|
|
216
|
+
<span className="text-muted-foreground">Total fees</span>
|
|
217
|
+
<span className="text-destructive">-{totalFeeUsd}</span>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
<div className="flex items-center justify-between pt-2 border-t">
|
|
221
|
+
<span className="text-muted-foreground">You receive</span>
|
|
222
|
+
<div className="flex items-center gap-2">
|
|
223
|
+
<TokenIcon symbol={withdrawal.currency_code} size={24} />
|
|
224
|
+
<span className="font-mono font-bold text-lg">
|
|
225
|
+
{finalAmountUsd}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
{withdrawal.crypto_amount && (
|
|
230
|
+
<div className="flex items-center justify-between text-sm">
|
|
231
|
+
<span className="text-muted-foreground">Crypto amount</span>
|
|
232
|
+
<span className="font-mono">{withdrawal.crypto_amount} {withdrawal.currency_token}</span>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Wallet Address */}
|
|
238
|
+
{withdrawal.wallet_address && (
|
|
239
|
+
<div className="space-y-2">
|
|
240
|
+
<label className="text-sm font-medium">Destination Wallet</label>
|
|
241
|
+
<div className="flex items-center gap-2">
|
|
242
|
+
<div className="flex-1 p-3 bg-muted rounded-xl font-mono text-sm break-all">
|
|
243
|
+
{withdrawal.wallet_address}
|
|
244
|
+
</div>
|
|
245
|
+
<CopyButton value={withdrawal.wallet_address} variant="outline" className="shrink-0" />
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{/* Transaction Hash (if completed) */}
|
|
251
|
+
{withdrawal.transaction_hash && (
|
|
252
|
+
<div className="space-y-2">
|
|
253
|
+
<label className="text-sm font-medium">Transaction Hash</label>
|
|
254
|
+
<div className="flex items-center gap-2">
|
|
255
|
+
<div className="flex-1 p-3 bg-muted rounded-xl font-mono text-sm break-all">
|
|
256
|
+
{withdrawal.transaction_hash}
|
|
257
|
+
</div>
|
|
258
|
+
<CopyButton value={withdrawal.transaction_hash} variant="outline" className="shrink-0" />
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Explorer Link */}
|
|
264
|
+
{withdrawal.explorer_link && (
|
|
265
|
+
<Button
|
|
266
|
+
variant="outline"
|
|
267
|
+
className="w-full"
|
|
268
|
+
onClick={() => window.open(withdrawal.explorer_link!, '_blank')}
|
|
269
|
+
>
|
|
270
|
+
<ExternalLink className="h-4 w-4 mr-2" />
|
|
271
|
+
View on Explorer
|
|
272
|
+
</Button>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{/* Cancel Button */}
|
|
276
|
+
{canCancel && (
|
|
277
|
+
<Button
|
|
278
|
+
variant="destructive"
|
|
279
|
+
className="w-full"
|
|
280
|
+
onClick={handleCancel}
|
|
281
|
+
>
|
|
282
|
+
<Ban className="h-4 w-4 mr-2" />
|
|
283
|
+
Cancel Withdrawal
|
|
284
|
+
</Button>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{/* Metadata */}
|
|
288
|
+
<div className="space-y-2 text-xs text-muted-foreground pt-4 border-t">
|
|
289
|
+
<div className="flex justify-between">
|
|
290
|
+
<span>Withdrawal ID</span>
|
|
291
|
+
<span className="font-mono">{withdrawal.id}</span>
|
|
292
|
+
</div>
|
|
293
|
+
{withdrawal.internal_withdrawal_id && (
|
|
294
|
+
<div className="flex justify-between">
|
|
295
|
+
<span>Reference #</span>
|
|
296
|
+
<span className="font-mono">{withdrawal.internal_withdrawal_id}</span>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
<div className="flex justify-between">
|
|
300
|
+
<span>Created</span>
|
|
301
|
+
<span>{createdAt}</span>
|
|
302
|
+
</div>
|
|
303
|
+
{completedAt && (
|
|
304
|
+
<div className="flex justify-between">
|
|
305
|
+
<span>Completed</span>
|
|
306
|
+
<span>{completedAt}</span>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
{withdrawal.currency_network && (
|
|
310
|
+
<div className="flex justify-between">
|
|
311
|
+
<span>Network</span>
|
|
312
|
+
<span>{withdrawal.currency_network}</span>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{/* Refresh Button */}
|
|
318
|
+
<Button
|
|
319
|
+
variant="ghost"
|
|
320
|
+
className="w-full"
|
|
321
|
+
onClick={() => mutate()}
|
|
322
|
+
>
|
|
323
|
+
<RefreshCw className="h-4 w-4 mr-2" />
|
|
324
|
+
Refresh Status
|
|
325
|
+
</Button>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
</ResponsiveSheetContent>
|
|
330
|
+
</ResponsiveSheet>
|
|
331
|
+
);
|
|
332
|
+
}
|