Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/web/src/components/forms/SavingsDepositForm.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
.form {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 480px;
margin: 0 auto;
padding: 24px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

.title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px 0;
text-align: center;
}

.success {
padding: 12px 16px;
background: #d1fae5;
border: 1px solid #10b981;
border-radius: 8px;
color: #065f46;
font-size: 0.875rem;
font-weight: 500;
text-align: center;
}

.submitError {
padding: 12px 16px;
background: #fee2e2;
border: 1px solid #ef4444;
border-radius: 8px;
color: #991b1b;
font-size: 0.875rem;
font-weight: 500;
text-align: center;
}

.submitButton {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 24px;
font-size: 1rem;
font-weight: 600;
color: #ffffff;
background: #6366f1;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 150ms ease, transform 150ms ease;
min-height: 48px;
}

.submitButton:hover:not(:disabled) {
background: #4f46e5;
}

.submitButton:focus-visible {
outline: 3px solid #6366f1;
outline-offset: 2px;
}

.submitButton:active:not(:disabled) {
transform: scale(0.98);
}

.submitButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}

.buttonSpinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
.form {
background: #1f2937;
}

.title {
color: #f3f4f6;
}
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.submitButton,
.buttonSpinner {
transition: none;
animation: none;
}
}
223 changes: 223 additions & 0 deletions apps/web/src/components/forms/SavingsDepositForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import React, { useState, useCallback } from 'react';
import { useValidation } from '@/lib/validation/useValidation';
import {
isValidStellarAddress,
isValidAmount,
isValidGoalName,
isValidDuration,
sanitizeInput,
validateForm,
isFormValid,
} from '@/lib/validation/validators';
import { ValidatedInput } from '@/components/ui/ValidatedInput';
import styles from './SavingsDepositForm.module.css';

interface SavingsDepositFormData {
walletAddress: string;
amount: string;
goalName: string;
duration: string;
}

export const SavingsDepositForm: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState(false);

// Real-time validation hooks
const walletValidation = useValidation({
validator: isValidStellarAddress,
debounceMs: 200,
});

const amountValidation = useValidation({
validator: (value) =>
isValidAmount(value, { min: 1, max: 100000, decimals: 7, currency: 'USDC' }),
debounceMs: 150,
});

const goalValidation = useValidation({
validator: isValidGoalName,
debounceMs: 300,
});

const durationValidation = useValidation({
validator: isValidDuration,
debounceMs: 200,
});

// Handle form submission
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setSubmitError(null);
setSubmitSuccess(false);

// Mark all fields as touched
walletValidation.bind.onBlur();
amountValidation.bind.onBlur();
goalValidation.bind.onBlur();
durationValidation.bind.onBlur();

// Force validate all
const results = validateForm({
walletAddress: {
value: walletValidation.value,
validator: isValidStellarAddress,
required: true,
},
amount: {
value: amountValidation.value,
validator: (v) => isValidAmount(v, { min: 1, max: 100000, decimals: 7 }),
required: true,
},
goalName: {
value: goalValidation.value,
validator: isValidGoalName,
required: true,
},
duration: {
value: durationValidation.value,
validator: isValidDuration,
required: true,
},
});

if (!isFormValid(results)) {
setSubmitError('Please fix the errors above before submitting.');
return;
}

setIsSubmitting(true);

try {
// Sanitize all inputs before sending
const sanitizedData: SavingsDepositFormData = {
walletAddress: sanitizeInput(walletValidation.value).trim(),
amount: sanitizeInput(amountValidation.value).trim(),
goalName: sanitizeInput(goalValidation.value).trim(),
duration: sanitizeInput(durationValidation.value).trim(),
};

// Submit to API
const response = await fetch('/api/savings/deposit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sanitizedData),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create savings deposit');
}

setSubmitSuccess(true);

// Reset form
walletValidation.reset();
amountValidation.reset();
goalValidation.reset();
durationValidation.reset();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'An unexpected error occurred');
} finally {
setIsSubmitting(false);
}
},
[
walletValidation,
amountValidation,
goalValidation,
durationValidation,
]
);

return (
<form onSubmit={handleSubmit} className={styles.form} noValidate>
<h2 className={styles.title}>Create Savings Deposit</h2>

{/* Success message */}
{submitSuccess && (
<div className={styles.success} role="status" aria-live="polite">
✅ Savings deposit created successfully!
</div>
)}

{/* Submit error */}
{submitError && (
<div className={styles.submitError} role="alert" aria-live="assertive">
{submitError}
</div>
)}

{/* Wallet Address */}
<ValidatedInput
label="Stellar Wallet Address"
type="text"
placeholder="G..."
required
helperText="Your Stellar public key starting with G"
leftIcon={<span>👛</span>}
validation={walletValidation.validation}
showError={walletValidation.showError}
{...walletValidation.bind}
/>

{/* Amount */}
<ValidatedInput
label="Deposit Amount"
type="text"
placeholder="100.00"
required
helperText="Minimum 1 USDC, maximum 100,000 USDC"
leftIcon={<span>💰</span>}
validation={amountValidation.validation}
showError={amountValidation.showError}
{...amountValidation.bind}
/>

{/* Goal Name */}
<ValidatedInput
label="Savings Goal Name"
type="text"
placeholder="Emergency Fund"
required
helperText="Give your savings goal a memorable name"
leftIcon={<span>🎯</span>}
validation={goalValidation.validation}
showError={goalValidation.showError}
{...goalValidation.bind}
/>

{/* Duration */}
<ValidatedInput
label="Lock Duration (days)"
type="text"
placeholder="30"
required
helperText="How long to lock your savings (1-3650 days)"
leftIcon={<span>📅</span>}
validation={durationValidation.validation}
showError={durationValidation.showError}
{...durationValidation.bind}
/>

{/* Submit button */}
<button
type="submit"
className={styles.submitButton}
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? (
<>
<span className={styles.buttonSpinner} aria-hidden="true" />
Creating Deposit...
</>
) : (
'Create Savings Deposit'
)}
</button>
</form>
);
};
Loading