@mantle-rwa/react 0.1.0

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.
Files changed (66) hide show
  1. package/dist/cjs/components/InvestorDashboard/index.js +156 -0
  2. package/dist/cjs/components/InvestorDashboard/index.js.map +1 -0
  3. package/dist/cjs/components/KYCFlow/index.js +231 -0
  4. package/dist/cjs/components/KYCFlow/index.js.map +1 -0
  5. package/dist/cjs/components/TokenMintForm/index.js +286 -0
  6. package/dist/cjs/components/TokenMintForm/index.js.map +1 -0
  7. package/dist/cjs/components/YieldCalculator/index.js +245 -0
  8. package/dist/cjs/components/YieldCalculator/index.js.map +1 -0
  9. package/dist/cjs/components/index.js +15 -0
  10. package/dist/cjs/components/index.js.map +1 -0
  11. package/dist/cjs/hooks/index.js +9 -0
  12. package/dist/cjs/hooks/index.js.map +1 -0
  13. package/dist/cjs/hooks/useRWA.js +54 -0
  14. package/dist/cjs/hooks/useRWA.js.map +1 -0
  15. package/dist/cjs/index.js +20 -0
  16. package/dist/cjs/index.js.map +1 -0
  17. package/dist/cjs/types/index.js +10 -0
  18. package/dist/cjs/types/index.js.map +1 -0
  19. package/dist/esm/components/InvestorDashboard/index.js +153 -0
  20. package/dist/esm/components/InvestorDashboard/index.js.map +1 -0
  21. package/dist/esm/components/KYCFlow/index.js +228 -0
  22. package/dist/esm/components/KYCFlow/index.js.map +1 -0
  23. package/dist/esm/components/TokenMintForm/index.js +279 -0
  24. package/dist/esm/components/TokenMintForm/index.js.map +1 -0
  25. package/dist/esm/components/YieldCalculator/index.js +236 -0
  26. package/dist/esm/components/YieldCalculator/index.js.map +1 -0
  27. package/dist/esm/components/index.js +8 -0
  28. package/dist/esm/components/index.js.map +1 -0
  29. package/dist/esm/hooks/index.js +5 -0
  30. package/dist/esm/hooks/index.js.map +1 -0
  31. package/dist/esm/hooks/useRWA.js +51 -0
  32. package/dist/esm/hooks/useRWA.js.map +1 -0
  33. package/dist/esm/index.js +12 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/types/index.js +6 -0
  36. package/dist/esm/types/index.js.map +1 -0
  37. package/dist/styles.css +1 -0
  38. package/dist/types/components/InvestorDashboard/index.d.ts +25 -0
  39. package/dist/types/components/InvestorDashboard/index.d.ts.map +1 -0
  40. package/dist/types/components/KYCFlow/index.d.ts +25 -0
  41. package/dist/types/components/KYCFlow/index.d.ts.map +1 -0
  42. package/dist/types/components/TokenMintForm/index.d.ts +60 -0
  43. package/dist/types/components/TokenMintForm/index.d.ts.map +1 -0
  44. package/dist/types/components/YieldCalculator/index.d.ts +59 -0
  45. package/dist/types/components/YieldCalculator/index.d.ts.map +1 -0
  46. package/dist/types/components/index.d.ts +8 -0
  47. package/dist/types/components/index.d.ts.map +1 -0
  48. package/dist/types/hooks/index.d.ts +5 -0
  49. package/dist/types/hooks/index.d.ts.map +1 -0
  50. package/dist/types/hooks/useRWA.d.ts +22 -0
  51. package/dist/types/hooks/useRWA.d.ts.map +1 -0
  52. package/dist/types/index.d.ts +11 -0
  53. package/dist/types/index.d.ts.map +1 -0
  54. package/dist/types/types/index.d.ts +177 -0
  55. package/dist/types/types/index.d.ts.map +1 -0
  56. package/package.json +66 -0
  57. package/src/components/InvestorDashboard/index.tsx +359 -0
  58. package/src/components/KYCFlow/index.tsx +434 -0
  59. package/src/components/TokenMintForm/index.tsx +590 -0
  60. package/src/components/YieldCalculator/index.tsx +541 -0
  61. package/src/components/index.ts +8 -0
  62. package/src/hooks/index.ts +5 -0
  63. package/src/hooks/useRWA.ts +70 -0
  64. package/src/index.ts +32 -0
  65. package/src/styles/index.css +197 -0
  66. package/src/types/index.ts +193 -0
@@ -0,0 +1,590 @@
1
+ /**
2
+ * TokenMintForm Component
3
+ * Form for minting RWA tokens with compliance pre-checks
4
+ *
5
+ * @see Requirements 12.1, 12.2, 12.3, 12.4
6
+ */
7
+
8
+ import React, { useState, useCallback, useMemo } from 'react';
9
+ import type { TokenMintFormProps, BatchMintEntry } from '../../types';
10
+
11
+ /**
12
+ * Compliance check result interface
13
+ */
14
+ export interface ComplianceCheckResult {
15
+ eligible: boolean;
16
+ reason?: string;
17
+ checks?: Array<{
18
+ name: string;
19
+ passed: boolean;
20
+ details?: string;
21
+ }>;
22
+ }
23
+
24
+ /**
25
+ * Parse CSV content into batch mint entries
26
+ * Exported for testing purposes
27
+ */
28
+ export function parseCSV(content: string, maxBatchSize: number): { entries: BatchMintEntry[]; errors: string[] } {
29
+ const entries: BatchMintEntry[] = [];
30
+ const errors: string[] = [];
31
+ const lines = content.split('\n').filter((line) => line.trim());
32
+
33
+ if (lines.length === 0) {
34
+ return { entries, errors: ['CSV file is empty'] };
35
+ }
36
+
37
+ // Skip header if present - check if first line looks like a header
38
+ // A header should start with "address" (case insensitive) and not be a valid address
39
+ const firstLine = lines[0].toLowerCase().trim();
40
+ const isHeader = firstLine.startsWith('address') && !firstLine.startsWith('0x');
41
+ const startIndex = isHeader ? 1 : 0;
42
+
43
+ for (let i = startIndex; i < lines.length; i++) {
44
+ if (entries.length >= maxBatchSize) {
45
+ errors.push(`Maximum batch size of ${maxBatchSize} reached. Remaining entries ignored.`);
46
+ break;
47
+ }
48
+
49
+ const line = lines[i].trim();
50
+ if (!line) continue;
51
+
52
+ const parts = line.split(',').map((s) => s.trim());
53
+ if (parts.length < 2) {
54
+ errors.push(`Line ${i + 1}: Invalid format, expected "address,amount"`);
55
+ continue;
56
+ }
57
+
58
+ const [addr, amt] = parts;
59
+
60
+ if (!/^0x[a-fA-F0-9]{40}$/.test(addr)) {
61
+ errors.push(`Line ${i + 1}: Invalid address format "${addr}"`);
62
+ continue;
63
+ }
64
+
65
+ const amountNum = parseFloat(amt);
66
+ if (isNaN(amountNum) || amountNum <= 0) {
67
+ errors.push(`Line ${i + 1}: Invalid amount "${amt}"`);
68
+ continue;
69
+ }
70
+
71
+ entries.push({ recipient: addr, amount: amt });
72
+ }
73
+
74
+ return { entries, errors };
75
+ }
76
+
77
+ /**
78
+ * Validate Ethereum address format
79
+ */
80
+ export function validateAddress(address: string): boolean {
81
+ return /^0x[a-fA-F0-9]{40}$/.test(address);
82
+ }
83
+
84
+ /**
85
+ * Validate amount is a positive number
86
+ */
87
+ export function validateAmount(value: string): boolean {
88
+ const num = parseFloat(value);
89
+ return !isNaN(num) && num > 0;
90
+ }
91
+
92
+ /**
93
+ * Check compliance for a recipient address
94
+ * This is the core compliance pre-check function (Property 25)
95
+ * In production, this would call the KYC registry contract
96
+ */
97
+ export async function checkCompliance(
98
+ address: string,
99
+ _kycRegistryAddress?: string
100
+ ): Promise<ComplianceCheckResult> {
101
+ // Validate address format first
102
+ if (!validateAddress(address)) {
103
+ return {
104
+ eligible: false,
105
+ reason: 'Invalid address format',
106
+ checks: [{ name: 'Address Format', passed: false, details: 'Address must be a valid Ethereum address' }],
107
+ };
108
+ }
109
+
110
+ // Simulate async compliance check (in production, this calls the KYC registry)
111
+ await new Promise((resolve) => setTimeout(resolve, 100));
112
+
113
+ const checks: Array<{ name: string; passed: boolean; details?: string }> = [];
114
+
115
+ // Check 1: Address format (already validated above)
116
+ checks.push({ name: 'Address Format', passed: true, details: 'Valid Ethereum address' });
117
+
118
+ // Check 2: KYC verification status
119
+ // For demo/testing: addresses starting with 0x00 are not KYC verified
120
+ const isKYCVerified = !address.toLowerCase().startsWith('0x00');
121
+ checks.push({
122
+ name: 'KYC Verification',
123
+ passed: isKYCVerified,
124
+ details: isKYCVerified ? 'Recipient is KYC verified' : 'Recipient is not KYC verified',
125
+ });
126
+
127
+ // Check 3: Accreditation status
128
+ // For demo/testing: addresses starting with 0x01 are not accredited
129
+ const isAccredited = !address.toLowerCase().startsWith('0x01');
130
+ checks.push({
131
+ name: 'Accreditation Status',
132
+ passed: isAccredited,
133
+ details: isAccredited ? 'Recipient meets accreditation requirements' : 'Recipient does not meet accreditation requirements',
134
+ });
135
+
136
+ // Determine overall eligibility
137
+ const allPassed = checks.every((c) => c.passed);
138
+ const failedCheck = checks.find((c) => !c.passed);
139
+
140
+ return {
141
+ eligible: allPassed,
142
+ reason: allPassed ? undefined : failedCheck?.details,
143
+ checks,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * TokenMintForm component for issuing tokens to investors
149
+ * Supports single and batch minting with compliance pre-checks
150
+ *
151
+ * @example
152
+ * ```tsx
153
+ * <TokenMintForm
154
+ * tokenAddress="0x..."
155
+ * kycRegistryAddress="0x..."
156
+ * onMintComplete={(hash, recipient, amount) => console.log('Minted:', hash)}
157
+ * allowBatchMint={true}
158
+ * />
159
+ * ```
160
+ */
161
+ export function TokenMintForm({
162
+ tokenAddress: _tokenAddress, // Reserved for future contract interaction
163
+ kycRegistryAddress,
164
+ onMintComplete,
165
+ onError,
166
+ allowBatchMint = false,
167
+ maxBatchSize = 100,
168
+ theme = 'light',
169
+ }: TokenMintFormProps): React.ReactElement {
170
+ const [recipient, setRecipient] = useState('');
171
+ const [amount, setAmount] = useState('');
172
+ const [batchEntries, setBatchEntries] = useState<BatchMintEntry[]>([]);
173
+ const [csvErrors, setCsvErrors] = useState<string[]>([]);
174
+ const [isLoading, setIsLoading] = useState(false);
175
+ const [isCheckingCompliance, setIsCheckingCompliance] = useState(false);
176
+ const [complianceResult, setComplianceResult] = useState<ComplianceCheckResult | null>(null);
177
+ const [error, setError] = useState<string | null>(null);
178
+ const [txStatus, setTxStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
179
+ const [txHash, setTxHash] = useState<string | null>(null);
180
+ const [isBatchMode, setIsBatchMode] = useState(false);
181
+
182
+ const isDark = theme === 'dark';
183
+
184
+ // Validate form inputs
185
+ const isValidRecipient = useMemo(() => validateAddress(recipient), [recipient]);
186
+ const isValidAmount = useMemo(() => validateAmount(amount), [amount]);
187
+ const canSubmit = useMemo(() => {
188
+ if (isBatchMode) {
189
+ return batchEntries.length > 0 && !isLoading;
190
+ }
191
+ return isValidRecipient && isValidAmount && !isLoading && complianceResult?.eligible !== false;
192
+ }, [isBatchMode, batchEntries.length, isValidRecipient, isValidAmount, isLoading, complianceResult]);
193
+
194
+ // Check compliance when recipient changes
195
+ const handleRecipientChange = useCallback(async (newRecipient: string) => {
196
+ setRecipient(newRecipient);
197
+ setComplianceResult(null);
198
+ setError(null);
199
+
200
+ if (validateAddress(newRecipient)) {
201
+ setIsCheckingCompliance(true);
202
+ try {
203
+ const result = await checkCompliance(newRecipient, kycRegistryAddress);
204
+ setComplianceResult(result);
205
+ if (!result.eligible) {
206
+ setError(`Compliance check failed: ${result.reason}`);
207
+ }
208
+ } catch (err) {
209
+ setError('Failed to check compliance');
210
+ } finally {
211
+ setIsCheckingCompliance(false);
212
+ }
213
+ }
214
+ }, [kycRegistryAddress]);
215
+
216
+ const handleSingleMint = useCallback(async () => {
217
+ setError(null);
218
+ setTxStatus('pending');
219
+ setIsLoading(true);
220
+
221
+ try {
222
+ // Validate inputs
223
+ if (!validateAddress(recipient)) {
224
+ throw new Error('Invalid recipient address');
225
+ }
226
+ if (!validateAmount(amount)) {
227
+ throw new Error('Invalid amount');
228
+ }
229
+
230
+ // Compliance pre-check (Property 25: Mint Compliance Pre-Check)
231
+ // Re-check compliance before submission to ensure it's still valid
232
+ const compliance = await checkCompliance(recipient, kycRegistryAddress);
233
+ if (!compliance.eligible) {
234
+ throw new Error(`Compliance check failed: ${compliance.reason}`);
235
+ }
236
+
237
+ // Placeholder: In full implementation, this would call the token contract
238
+ await new Promise((resolve) => setTimeout(resolve, 1000));
239
+
240
+ const mockTxHash = `0x${Array(64).fill(0).map(() => Math.floor(Math.random() * 16).toString(16)).join('')}`;
241
+ setTxHash(mockTxHash);
242
+ setTxStatus('success');
243
+
244
+ onMintComplete?.(mockTxHash, recipient, amount);
245
+
246
+ // Reset form
247
+ setRecipient('');
248
+ setAmount('');
249
+ setComplianceResult(null);
250
+ } catch (err) {
251
+ const errorMessage = err instanceof Error ? err.message : 'Minting failed';
252
+ setError(errorMessage);
253
+ setTxStatus('error');
254
+ onError?.(err instanceof Error ? err : new Error(errorMessage));
255
+ } finally {
256
+ setIsLoading(false);
257
+ }
258
+ }, [recipient, amount, kycRegistryAddress, onMintComplete, onError]);
259
+
260
+ const handleCSVUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
261
+ const file = event.target.files?.[0];
262
+ if (!file) return;
263
+
264
+ setError(null);
265
+ setCsvErrors([]);
266
+
267
+ const reader = new FileReader();
268
+ reader.onload = (e) => {
269
+ try {
270
+ const text = e.target?.result as string;
271
+ const { entries, errors } = parseCSV(text, maxBatchSize);
272
+
273
+ setBatchEntries(entries);
274
+ setCsvErrors(errors);
275
+
276
+ if (entries.length === 0 && errors.length > 0) {
277
+ setError('No valid entries found in CSV file');
278
+ }
279
+ } catch (err) {
280
+ setError('Failed to parse CSV file');
281
+ }
282
+ };
283
+ reader.onerror = () => {
284
+ setError('Failed to read CSV file');
285
+ };
286
+ reader.readAsText(file);
287
+ }, [maxBatchSize]);
288
+
289
+ const handleBatchMint = useCallback(async () => {
290
+ setError(null);
291
+ setTxStatus('pending');
292
+ setIsLoading(true);
293
+
294
+ try {
295
+ // Check compliance for all entries (Property 25)
296
+ const failedEntries: string[] = [];
297
+ for (const entry of batchEntries) {
298
+ const compliance = await checkCompliance(entry.recipient, kycRegistryAddress);
299
+ if (!compliance.eligible) {
300
+ failedEntries.push(`${entry.recipient}: ${compliance.reason}`);
301
+ }
302
+ }
303
+
304
+ if (failedEntries.length > 0) {
305
+ throw new Error(`Compliance check failed for ${failedEntries.length} recipient(s):\n${failedEntries.slice(0, 3).join('\n')}${failedEntries.length > 3 ? `\n...and ${failedEntries.length - 3} more` : ''}`);
306
+ }
307
+
308
+ // Placeholder: In full implementation, this would call batch mint
309
+ await new Promise((resolve) => setTimeout(resolve, 2000));
310
+
311
+ const mockTxHash = `0x${Array(64).fill(0).map(() => Math.floor(Math.random() * 16).toString(16)).join('')}`;
312
+ setTxHash(mockTxHash);
313
+ setTxStatus('success');
314
+
315
+ // Call onMintComplete for each entry
316
+ for (const entry of batchEntries) {
317
+ onMintComplete?.(mockTxHash, entry.recipient, entry.amount);
318
+ }
319
+
320
+ // Reset
321
+ setBatchEntries([]);
322
+ setCsvErrors([]);
323
+ } catch (err) {
324
+ const errorMessage = err instanceof Error ? err.message : 'Batch minting failed';
325
+ setError(errorMessage);
326
+ setTxStatus('error');
327
+ onError?.(err instanceof Error ? err : new Error(errorMessage));
328
+ } finally {
329
+ setIsLoading(false);
330
+ }
331
+ }, [batchEntries, kycRegistryAddress, onMintComplete, onError]);
332
+
333
+ return (
334
+ <div
335
+ className={`rwa-card ${isDark ? 'dark' : ''}`}
336
+ data-testid="token-mint-form"
337
+ data-theme={theme}
338
+ >
339
+ <h2 className="text-lg font-semibold mb-4">Mint Tokens</h2>
340
+
341
+ {/* Mode Toggle */}
342
+ {allowBatchMint && (
343
+ <div className="mb-4 flex space-x-2" role="tablist" aria-label="Mint mode selection">
344
+ <button
345
+ type="button"
346
+ role="tab"
347
+ aria-selected={!isBatchMode}
348
+ className={`px-3 py-1 text-sm rounded-md ${!isBatchMode ? 'bg-mantle-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
349
+ onClick={() => setIsBatchMode(false)}
350
+ data-testid="single-mode-btn"
351
+ >
352
+ Single
353
+ </button>
354
+ <button
355
+ type="button"
356
+ role="tab"
357
+ aria-selected={isBatchMode}
358
+ className={`px-3 py-1 text-sm rounded-md ${isBatchMode ? 'bg-mantle-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
359
+ onClick={() => setIsBatchMode(true)}
360
+ data-testid="batch-mode-btn"
361
+ >
362
+ Batch
363
+ </button>
364
+ </div>
365
+ )}
366
+
367
+ {/* Error Display */}
368
+ {error && (
369
+ <div
370
+ className="mb-4 p-3 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200 rounded-md"
371
+ role="alert"
372
+ data-testid="mint-error"
373
+ >
374
+ <p className="font-medium">Error</p>
375
+ <p className="text-sm whitespace-pre-line">{error}</p>
376
+ </div>
377
+ )}
378
+
379
+ {/* CSV Parsing Warnings */}
380
+ {csvErrors.length > 0 && (
381
+ <div
382
+ className="mb-4 p-3 bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-200 rounded-md"
383
+ role="alert"
384
+ data-testid="csv-warnings"
385
+ >
386
+ <p className="font-medium">CSV Parsing Warnings</p>
387
+ <ul className="text-sm list-disc list-inside">
388
+ {csvErrors.slice(0, 5).map((err, i) => (
389
+ <li key={i}>{err}</li>
390
+ ))}
391
+ {csvErrors.length > 5 && (
392
+ <li>...and {csvErrors.length - 5} more warnings</li>
393
+ )}
394
+ </ul>
395
+ </div>
396
+ )}
397
+
398
+ {/* Transaction Status */}
399
+ {txStatus === 'success' && txHash && (
400
+ <div
401
+ className="mb-4 p-3 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200 rounded-md"
402
+ role="status"
403
+ data-testid="tx-success"
404
+ >
405
+ <p className="font-medium">Transaction Successful!</p>
406
+ <p className="text-sm">Hash: {txHash.slice(0, 10)}...{txHash.slice(-8)}</p>
407
+ </div>
408
+ )}
409
+
410
+ {txStatus === 'pending' && (
411
+ <div
412
+ className="mb-4 p-3 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200 rounded-md"
413
+ role="status"
414
+ data-testid="tx-pending"
415
+ >
416
+ <p className="font-medium">Transaction Pending...</p>
417
+ <p className="text-sm">Please wait while the transaction is being processed.</p>
418
+ </div>
419
+ )}
420
+
421
+ {!isBatchMode ? (
422
+ /* Single Mint Form */
423
+ <form
424
+ onSubmit={(e) => { e.preventDefault(); handleSingleMint(); }}
425
+ className="space-y-4"
426
+ aria-label="Single mint form"
427
+ >
428
+ <div>
429
+ <label htmlFor="recipient" className="rwa-label">
430
+ Recipient Address
431
+ </label>
432
+ <input
433
+ id="recipient"
434
+ type="text"
435
+ className={`rwa-input mt-1 ${recipient && !isValidRecipient ? 'border-red-500' : ''}`}
436
+ placeholder="0x..."
437
+ value={recipient}
438
+ onChange={(e) => handleRecipientChange(e.target.value)}
439
+ required
440
+ aria-describedby="recipient-hint recipient-error"
441
+ aria-invalid={recipient ? !isValidRecipient : undefined}
442
+ data-testid="recipient-input"
443
+ />
444
+ <p id="recipient-hint" className="mt-1 text-xs text-gray-500">
445
+ Must be a KYC-verified address
446
+ </p>
447
+ {recipient && !isValidRecipient && (
448
+ <p id="recipient-error" className="mt-1 text-xs text-red-500" data-testid="recipient-error">
449
+ Invalid Ethereum address format
450
+ </p>
451
+ )}
452
+ </div>
453
+
454
+ {/* Compliance Status Display */}
455
+ {isCheckingCompliance && (
456
+ <div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-md" data-testid="compliance-checking">
457
+ <p className="text-sm text-gray-600 dark:text-gray-300 flex items-center">
458
+ <span className="animate-spin inline-block w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full mr-2" />
459
+ Checking compliance...
460
+ </p>
461
+ </div>
462
+ )}
463
+
464
+ {complianceResult && !isCheckingCompliance && (
465
+ <div
466
+ className={`p-3 rounded-md ${complianceResult.eligible ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'}`}
467
+ data-testid="compliance-result"
468
+ >
469
+ <p className={`text-sm font-medium ${complianceResult.eligible ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'}`}>
470
+ {complianceResult.eligible ? '✓ Compliance Check Passed' : '✗ Compliance Check Failed'}
471
+ </p>
472
+ {complianceResult.checks && (
473
+ <ul className="mt-2 space-y-1">
474
+ {complianceResult.checks.map((check, i) => (
475
+ <li key={i} className="text-xs flex items-center">
476
+ <span className={check.passed ? 'text-green-600' : 'text-red-600'}>
477
+ {check.passed ? '✓' : '✗'}
478
+ </span>
479
+ <span className="ml-2 text-gray-600 dark:text-gray-400">
480
+ {check.name}: {check.details}
481
+ </span>
482
+ </li>
483
+ ))}
484
+ </ul>
485
+ )}
486
+ </div>
487
+ )}
488
+
489
+ <div>
490
+ <label htmlFor="amount" className="rwa-label">
491
+ Amount
492
+ </label>
493
+ <input
494
+ id="amount"
495
+ type="number"
496
+ className={`rwa-input mt-1 ${amount && !isValidAmount ? 'border-red-500' : ''}`}
497
+ placeholder="0.00"
498
+ value={amount}
499
+ onChange={(e) => setAmount(e.target.value)}
500
+ min="0"
501
+ step="any"
502
+ required
503
+ aria-invalid={amount ? !isValidAmount : undefined}
504
+ data-testid="amount-input"
505
+ />
506
+ {amount && !isValidAmount && (
507
+ <p className="mt-1 text-xs text-red-500" data-testid="amount-error">
508
+ Amount must be a positive number
509
+ </p>
510
+ )}
511
+ </div>
512
+
513
+ <button
514
+ type="submit"
515
+ className="rwa-button-primary w-full"
516
+ disabled={!canSubmit || isCheckingCompliance}
517
+ aria-busy={isLoading}
518
+ data-testid="mint-submit-btn"
519
+ >
520
+ {isLoading ? 'Minting...' : 'Mint Tokens'}
521
+ </button>
522
+ </form>
523
+ ) : (
524
+ /* Batch Mint Form */
525
+ <div className="space-y-4" role="tabpanel" aria-label="Batch mint form">
526
+ <div>
527
+ <label htmlFor="csv-upload" className="rwa-label">
528
+ Upload CSV File
529
+ </label>
530
+ <input
531
+ id="csv-upload"
532
+ type="file"
533
+ accept=".csv"
534
+ className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-mantle-50 file:text-mantle-700 hover:file:bg-mantle-100"
535
+ onChange={handleCSVUpload}
536
+ data-testid="csv-upload-input"
537
+ />
538
+ <p className="mt-1 text-xs text-gray-500">
539
+ CSV format: address,amount (max {maxBatchSize} entries)
540
+ </p>
541
+ </div>
542
+
543
+ {batchEntries.length > 0 && (
544
+ <div data-testid="batch-entries-preview">
545
+ <p className="text-sm font-medium mb-2">
546
+ {batchEntries.length} valid entries loaded
547
+ </p>
548
+ <div className="max-h-40 overflow-y-auto border rounded-md p-2 dark:border-gray-600">
549
+ <table className="w-full text-xs">
550
+ <thead>
551
+ <tr className="text-left text-gray-500">
552
+ <th className="pb-1">Address</th>
553
+ <th className="pb-1 text-right">Amount</th>
554
+ </tr>
555
+ </thead>
556
+ <tbody>
557
+ {batchEntries.slice(0, 5).map((entry, i) => (
558
+ <tr key={i} className="text-gray-600 dark:text-gray-400">
559
+ <td className="py-0.5">{entry.recipient.slice(0, 10)}...{entry.recipient.slice(-6)}</td>
560
+ <td className="py-0.5 text-right">{entry.amount}</td>
561
+ </tr>
562
+ ))}
563
+ </tbody>
564
+ </table>
565
+ {batchEntries.length > 5 && (
566
+ <p className="text-xs text-gray-500 mt-2 text-center">
567
+ ...and {batchEntries.length - 5} more entries
568
+ </p>
569
+ )}
570
+ </div>
571
+ </div>
572
+ )}
573
+
574
+ <button
575
+ type="button"
576
+ className="rwa-button-primary w-full"
577
+ disabled={!canSubmit}
578
+ onClick={handleBatchMint}
579
+ aria-busy={isLoading}
580
+ data-testid="batch-mint-btn"
581
+ >
582
+ {isLoading ? 'Minting...' : `Mint to ${batchEntries.length} Recipients`}
583
+ </button>
584
+ </div>
585
+ )}
586
+ </div>
587
+ );
588
+ }
589
+
590
+ export default TokenMintForm;