Skip to content

Commit eff98ec

Browse files
authored
Merge pull request #135 from Majormaxx/feat/form-validation-feedback
feat: per-field validation feedback on Create Payment form
2 parents 698eb18 + 5a08681 commit eff98ec

1 file changed

Lines changed: 63 additions & 21 deletions

File tree

frontend/src/components/CreatePaymentForm.tsx

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
} from "@/lib/merchant-store";
1616
import { useLocalStorage } from "@/hooks/useLocalStorage";
1717

18-
1918
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
2019

2120
const USDC_ISSUER =
@@ -179,8 +178,7 @@ function SuccessCard({ created, onReset, t }: SuccessCardProps) {
179178
useEffect(() => {
180179
fireConfetti();
181180
setCanShare(
182-
typeof navigator !== "undefined" &&
183-
typeof navigator.share === "function",
181+
typeof navigator !== "undefined" && typeof navigator.share === "function",
184182
);
185183
}, []);
186184

@@ -284,7 +282,10 @@ function SuccessCard({ created, onReset, t }: SuccessCardProps) {
284282
</div>
285283
</motion.div>
286284

287-
<motion.div variants={childVariants} className="mt-4 flex flex-wrap gap-2">
285+
<motion.div
286+
variants={childVariants}
287+
className="mt-4 flex flex-wrap gap-2"
288+
>
288289
{canShare && (
289290
<button
290291
type="button"
@@ -349,6 +350,8 @@ export default function CreatePaymentForm() {
349350
);
350351
const [loading, setLoading] = useState(false);
351352
const [error, setError] = useState<string | null>(null);
353+
const [amountError, setAmountError] = useState<string | null>(null);
354+
const [recipientError, setRecipientError] = useState<string | null>(null);
352355
const [created, setCreated] = useState<CreatedPayment | null>(null);
353356
const apiKey = useMerchantApiKey();
354357
const hydrated = useMerchantHydrated();
@@ -413,15 +416,20 @@ export default function CreatePaymentForm() {
413416
e.preventDefault();
414417
setError(null);
415418

419+
// Client-side validation
420+
let hasError = false;
416421
const numAmount = parseFloat(amount);
417422
if (isNaN(numAmount) || numAmount <= 0) {
418-
setError(t("invalidAmount"));
419-
return;
423+
setAmountError("Amount must be greater than 0.");
424+
hasError = true;
420425
}
421426
if (!STELLAR_ADDRESS_RE.test(recipient.trim())) {
422-
setError(t("invalidRecipient"));
423-
return;
427+
setRecipientError(
428+
"Must be a valid Stellar public key (56 characters, starts with G).",
429+
);
430+
hasError = true;
424431
}
432+
if (hasError) return;
425433

426434
setLoading(true);
427435
try {
@@ -483,6 +491,8 @@ export default function CreatePaymentForm() {
483491
localStorage.removeItem("payment_branding");
484492
localStorage.removeItem("payment_trusted_address");
485493
setError(null);
494+
setAmountError(null);
495+
setRecipientError(null);
486496
setRetryAfter(0);
487497
};
488498

@@ -496,7 +506,7 @@ export default function CreatePaymentForm() {
496506

497507
const updateBrandingField = (
498508
key: keyof typeof DEFAULT_BRANDING,
499-
value: string
509+
value: string,
500510
) => {
501511
setBranding((current) => ({ ...current, [key]: normalizeHexInput(value) }));
502512
};
@@ -571,10 +581,24 @@ export default function CreatePaymentForm() {
571581
step="any"
572582
required
573583
value={amount}
574-
onChange={(e) => setAmount(e.target.value)}
575-
className="rounded-xl border border-white/10 bg-white/5 p-3 text-white placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
584+
onChange={(e) => {
585+
setAmount(e.target.value);
586+
setAmountError(null);
587+
}}
588+
aria-invalid={!!amountError}
589+
aria-describedby={amountError ? "amount-error" : undefined}
590+
className={`rounded-xl border bg-white/5 p-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${amountError ? "border-red-500/50 focus:border-red-500/50 focus:ring-red-500/50" : "border-white/10 focus:border-mint/50 focus:ring-mint/50"}`}
576591
placeholder={amountPlaceholder}
577592
/>
593+
{amountError && (
594+
<p
595+
id="amount-error"
596+
role="alert"
597+
className="text-xs text-red-400"
598+
>
599+
{amountError}
600+
</p>
601+
)}
578602
</div>
579603

580604
{/* Asset */}
@@ -593,10 +617,11 @@ export default function CreatePaymentForm() {
593617
type="button"
594618
onClick={() => setAsset(a)}
595619
aria-pressed={asset === a}
596-
className={`flex-1 rounded-xl border py-2.5 text-sm font-medium transition-all ${asset === a
597-
? "border-mint/50 bg-mint/10 text-mint"
598-
: "border-white/10 bg-white/5 text-slate-400 hover:border-white/20 hover:text-white"
599-
}`}
620+
className={`flex-1 rounded-xl border py-2.5 text-sm font-medium transition-all ${
621+
asset === a
622+
? "border-mint/50 bg-mint/10 text-mint"
623+
: "border-white/10 bg-white/5 text-slate-400 hover:border-white/20 hover:text-white"
624+
}`}
600625
>
601626
{a}
602627
</button>
@@ -651,12 +676,28 @@ export default function CreatePaymentForm() {
651676
type="text"
652677
required
653678
value={recipient}
654-
onChange={(e) => setRecipient(e.target.value)}
655-
className="rounded-xl border border-white/10 bg-white/5 p-3 font-mono text-sm text-white placeholder:font-sans placeholder:text-slate-600 focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50"
679+
onChange={(e) => {
680+
setRecipient(e.target.value);
681+
setRecipientError(null);
682+
}}
683+
aria-invalid={!!recipientError}
684+
aria-describedby={
685+
recipientError ? "recipient-error" : undefined
686+
}
687+
className={`rounded-xl border bg-white/5 p-3 font-mono text-sm text-white placeholder:font-sans placeholder:text-slate-600 focus:outline-none focus:ring-1 ${recipientError ? "border-red-500/50 focus:border-red-500/50 focus:ring-red-500/50" : "border-white/10 focus:border-mint/50 focus:ring-mint/50"}`}
656688
placeholder={recipientPlaceholder}
657689
autoComplete="off"
658690
spellCheck={false}
659691
/>
692+
{recipientError && (
693+
<p
694+
id="recipient-error"
695+
role="alert"
696+
className="text-xs text-red-400"
697+
>
698+
{recipientError}
699+
</p>
700+
)}
660701
</div>
661702

662703
{/* Description */}
@@ -694,10 +735,11 @@ export default function CreatePaymentForm() {
694735
<button
695736
type="button"
696737
onClick={() => setUseSessionBranding((v) => !v)}
697-
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${useSessionBranding
698-
? "bg-mint text-black"
699-
: "border border-white/20 text-slate-300"
700-
}`}
738+
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${
739+
useSessionBranding
740+
? "bg-mint text-black"
741+
: "border border-white/20 text-slate-300"
742+
}`}
701743
>
702744
{useSessionBranding ? t("enabled") : t("disabled")}
703745
</button>

0 commit comments

Comments
 (0)