Skip to content

Commit dbf1a92

Browse files
authored
Merge pull request #519 from charlesbarleyman/feature/issues-511-517-513-515
Improve dashboard accessibility, validation UX, and skeleton alignment
2 parents dd285d7 + 6542f0d commit dbf1a92

File tree

8 files changed

+307
-72
lines changed

8 files changed

+307
-72
lines changed

frontend/src/components/CreatePaymentForm.tsx

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import CopyButton from "./CopyButton";
88
import { toast } from "sonner";
99
import IntegrationCodeSnippets from "./IntegrationCodeSnippets";
1010
import Link from "next/link";
11+
import { InfoTooltip } from "./InfoTooltip";
1112
import {
1213
useHydrateMerchantStore,
1314
useMerchantApiKey,
@@ -354,6 +355,7 @@ export default function CreatePaymentForm() {
354355
const [error, setError] = useState<string | null>(null);
355356
const [amountError, setAmountError] = useState<string | null>(null);
356357
const [recipientError, setRecipientError] = useState<string | null>(null);
358+
const [webhookUrlError, setWebhookUrlError] = useState<string | null>(null);
357359
const [created, setCreated] = useState<CreatedPayment | null>(null);
358360
const apiKey = useMerchantApiKey();
359361
const hydrated = useMerchantHydrated();
@@ -384,7 +386,41 @@ export default function CreatePaymentForm() {
384386
label: selectedTrustedAddressLabel,
385387
})
386388
: t("recipientPlaceholder", { asset });
387-
const descriptionPlaceholder = t("descriptionPlaceholder", { asset });
389+
const validateAmount = (value: string) => {
390+
const numAmount = parseFloat(value);
391+
if (isNaN(numAmount) || numAmount <= 0) {
392+
return "Amount must be greater than 0.";
393+
}
394+
return null;
395+
};
396+
397+
const validateRecipient = (value: string) => {
398+
if (!STELLAR_ADDRESS_RE.test(value.trim())) {
399+
return "Must be a valid Stellar public key (56 characters, starts with G).";
400+
}
401+
return null;
402+
};
403+
404+
const validateWebhookUrl = (value: string) => {
405+
const trimmed = value.trim();
406+
if (!trimmed) return null;
407+
try {
408+
const parsed = new URL(trimmed);
409+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
410+
return "Webhook URL must start with http:// or https://";
411+
}
412+
} catch {
413+
return "Enter a valid webhook URL.";
414+
}
415+
return null;
416+
};
417+
418+
const isFormValid =
419+
!validateAmount(amount) &&
420+
!validateRecipient(recipient) &&
421+
!validateWebhookUrl(description) &&
422+
amount.trim().length > 0 &&
423+
recipient.trim().length > 0;
388424

389425
// ── Rate-limit countdown ──────────────────────────────────
390426
const [retryAfter, setRetryAfter] = useState(0);
@@ -419,19 +455,15 @@ export default function CreatePaymentForm() {
419455
setError(null);
420456

421457
// Client-side validation
422-
let hasError = false;
458+
const nextAmountError = validateAmount(amount);
459+
const nextRecipientError = validateRecipient(recipient);
460+
const nextWebhookUrlError = validateWebhookUrl(description);
461+
setAmountError(nextAmountError);
462+
setRecipientError(nextRecipientError);
463+
setWebhookUrlError(nextWebhookUrlError);
464+
if (nextAmountError || nextRecipientError || nextWebhookUrlError) return;
465+
423466
const numAmount = parseFloat(amount);
424-
if (isNaN(numAmount) || numAmount <= 0) {
425-
setAmountError("Amount must be greater than 0.");
426-
hasError = true;
427-
}
428-
if (!STELLAR_ADDRESS_RE.test(recipient.trim())) {
429-
setRecipientError(
430-
"Must be a valid Stellar public key (56 characters, starts with G).",
431-
);
432-
hasError = true;
433-
}
434-
if (hasError) return;
435467

436468
setLoading(true);
437469
try {
@@ -495,6 +527,7 @@ export default function CreatePaymentForm() {
495527
setError(null);
496528
setAmountError(null);
497529
setRecipientError(null);
530+
setWebhookUrlError(null);
498531
setRetryAfter(0);
499532
};
500533

@@ -637,7 +670,7 @@ export default function CreatePaymentForm() {
637670
value={amount}
638671
onChange={(e) => {
639672
setAmount(e.target.value);
640-
setAmountError(null);
673+
setAmountError(validateAmount(e.target.value));
641674
}}
642675
aria-invalid={!!amountError}
643676
aria-describedby={amountError ? "amount-error" : undefined}
@@ -724,6 +757,21 @@ export default function CreatePaymentForm() {
724757
className="text-xs font-medium uppercase tracking-wider text-slate-400"
725758
>
726759
{t("recipientAddress")}
760+
<InfoTooltip
761+
className="ml-2"
762+
content={
763+
<span>
764+
Use a valid Stellar public key that starts with G and is 56
765+
characters long. Example:
766+
<br />
767+
<code className="text-[11px] text-mint">
768+
GDQP2KPQGKIH...MBCQ4MMR
769+
</code>
770+
</span>
771+
}
772+
>
773+
<span tabIndex={0}>What is this?</span>
774+
</InfoTooltip>
727775
</label>
728776
<input
729777
id="recipient"
@@ -732,7 +780,7 @@ export default function CreatePaymentForm() {
732780
value={recipient}
733781
onChange={(e) => {
734782
setRecipient(e.target.value);
735-
setRecipientError(null);
783+
setRecipientError(validateRecipient(e.target.value));
736784
}}
737785
aria-invalid={!!recipientError}
738786
aria-describedby={
@@ -764,15 +812,39 @@ export default function CreatePaymentForm() {
764812
<span className="normal-case text-slate-600">
765813
({t("optional")})
766814
</span>
815+
<InfoTooltip
816+
className="ml-2"
817+
content={
818+
<span>
819+
If you add a webhook URL here, use a full URL like
820+
<br />
821+
<code className="text-[11px] text-mint">
822+
https://example.com/api/webhooks/stellar
823+
</code>
824+
</span>
825+
}
826+
>
827+
<span tabIndex={0}>Webhook URL help</span>
828+
</InfoTooltip>
767829
</label>
768830
<input
769831
id="description"
770832
type="text"
771833
value={description}
772-
onChange={(e) => setDescription(e.target.value)}
773-
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"
774-
placeholder={descriptionPlaceholder}
834+
onChange={(e) => {
835+
setDescription(e.target.value);
836+
setWebhookUrlError(validateWebhookUrl(e.target.value));
837+
}}
838+
aria-invalid={Boolean(webhookUrlError)}
839+
aria-describedby={webhookUrlError ? "webhook-url-error" : undefined}
840+
className={`rounded-xl border bg-white/5 p-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-1 ${webhookUrlError ? "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"}`}
841+
placeholder="Optional memo or webhook URL (https://...)"
775842
/>
843+
{webhookUrlError && (
844+
<p id="webhook-url-error" className="text-xs text-red-400" role="alert">
845+
{webhookUrlError}
846+
</p>
847+
)}
776848
</div>
777849

778850
{/* Branding panel */}
@@ -860,7 +932,7 @@ export default function CreatePaymentForm() {
860932
{/* Submit */}
861933
<button
862934
type="submit"
863-
disabled={loading}
935+
disabled={loading || !isFormValid}
864936
className="group relative flex h-12 items-center justify-center rounded-xl bg-mint px-6 font-bold text-black transition-all hover:bg-glow disabled:cursor-not-allowed disabled:opacity-50"
865937
>
866938
{loading ? (

frontend/src/components/DashboardSkeleton.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ export default function DashboardSkeleton() {
1818
<section className="flex flex-col gap-4">
1919
<Skeleton width={180} height={28} borderRadius={6} />
2020
<div className="grid gap-4 sm:grid-cols-2">
21-
{[...Array(2)].map((_, i) => (
21+
{[...Array(3)].map((_, i) => (
2222
<div key={i} className="rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur">
23-
<Skeleton width={100} height={14} borderRadius={4} />
23+
<Skeleton width={126} height={14} borderRadius={4} />
2424
<div className="mt-2 flex items-baseline gap-2">
25-
<Skeleton width={120} height={36} borderRadius={6} />
26-
<Skeleton width={40} height={20} borderRadius={4} />
25+
<Skeleton width={132} height={36} borderRadius={6} />
26+
<Skeleton width={56} height={20} borderRadius={4} />
2727
</div>
2828
</div>
2929
))}
@@ -41,11 +41,11 @@ export default function DashboardSkeleton() {
4141
<Skeleton width={140} height={32} borderRadius={8} />
4242
</div>
4343
</div>
44-
<div className="mt-4 flex gap-2">
44+
<div className="flex flex-wrap gap-2">
4545
<Skeleton width={60} height={24} borderRadius={12} />
4646
<Skeleton width={60} height={24} borderRadius={12} />
4747
</div>
48-
<div className="mt-4 h-[300px]">
48+
<div className="h-[300px]">
4949
<Skeleton height="100%" borderRadius={8} />
5050
</div>
5151
</div>

frontend/src/components/DevSandbox.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
44
import CopyButton from "@/components/CopyButton";
55
import { useMerchantApiKey } from "@/lib/merchant-store";
66
import ApiUsageChart from "@/components/ApiUsageChart";
7+
import { InfoTooltip } from "@/components/InfoTooltip";
78

89
type SandboxExample = {
910
id: string;
@@ -226,6 +227,22 @@ export default function DevSandbox() {
226227
<div className="grid gap-4 rounded-xl border border-white/10 bg-black/20 p-4 lg:grid-cols-2">
227228
<label className="flex flex-col gap-1.5 text-xs uppercase tracking-wider text-slate-400">
228229
API Base URL
230+
<InfoTooltip
231+
className="normal-case"
232+
content={
233+
<span>
234+
Use your backend origin for local or deployed testing.
235+
<br />
236+
Example:
237+
<br />
238+
<code className="text-[11px] text-mint">
239+
https://api.yourdomain.com
240+
</code>
241+
</span>
242+
}
243+
>
244+
<span tabIndex={0}>help</span>
245+
</InfoTooltip>
229246
<input
230247
value={apiBaseUrl}
231248
onChange={(event) => setApiBaseUrl(event.target.value)}
@@ -235,6 +252,17 @@ export default function DevSandbox() {
235252

236253
<label className="flex flex-col gap-1.5 text-xs uppercase tracking-wider text-slate-400">
237254
API Key (Optional)
255+
<InfoTooltip
256+
className="normal-case"
257+
content={
258+
<span>
259+
Required for protected endpoints like create payment, metrics, and
260+
list payments.
261+
</span>
262+
}
263+
>
264+
<span tabIndex={0}>when needed</span>
265+
</InfoTooltip>
238266
<input
239267
value={apiKey}
240268
onChange={(event) => setApiKey(event.target.value)}

frontend/src/components/InfoTooltip.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useState } from "react";
44
import { motion, AnimatePresence } from "framer-motion";
55

66
interface InfoTooltipProps {
7-
content: string;
7+
content: React.ReactNode;
88
children: React.ReactNode;
99
className?: string;
1010
}
@@ -20,7 +20,7 @@ export function InfoTooltip({ content, children, className = "" }: InfoTooltipPr
2020
onFocus={() => setIsVisible(true)}
2121
onBlur={() => setIsVisible(false)}
2222
>
23-
<span className="cursor-help border-b border-dotted border-white/30 decoration-white/30 transition-colors hover:border-mint hover:text-mint">
23+
<span className="cursor-help border-b border-dotted border-white/30 decoration-white/30 transition-colors hover:border-mint hover:text-mint focus-visible:text-mint">
2424
{children}
2525
</span>
2626

@@ -31,7 +31,7 @@ export function InfoTooltip({ content, children, className = "" }: InfoTooltipPr
3131
animate={{ opacity: 1, scale: 1, y: 0 }}
3232
exit={{ opacity: 0, scale: 0.95, y: 5 }}
3333
transition={{ duration: 0.15, ease: "easeOut" }}
34-
className="absolute bottom-full left-1/2 z-[100] mb-2 w-48 -translate-x-1/2 rounded-lg border border-white/10 bg-[#16171a] p-2.5 text-xs leading-relaxed text-slate-300 shadow-2xl backdrop-blur-md"
34+
className="absolute bottom-full left-1/2 z-[100] mb-2 w-64 -translate-x-1/2 rounded-lg border border-white/10 bg-[#16171a] p-2.5 text-xs leading-relaxed text-slate-200 shadow-2xl backdrop-blur-md"
3535
>
3636
{content}
3737
{/* Arrow */}

frontend/src/components/MaskedValue.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export default function MaskedValue({
9494
<section className={`flex flex-col gap-3 ${className}`.trim()}>
9595
<div className="flex items-center justify-between">
9696
{label ? (
97-
<h2 className="text-xs font-medium uppercase tracking-wider text-slate-400">
97+
<h2 className="text-xs font-medium uppercase tracking-wider text-slate-300">
9898
{label}
9999
</h2>
100100
) : (
@@ -104,7 +104,7 @@ export default function MaskedValue({
104104
type="button"
105105
onClick={toggle}
106106
aria-label={ariaLabel}
107-
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-white/5 hover:text-white"
107+
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-slate-300 transition-colors hover:bg-white/5 hover:text-white focus-visible:bg-white/10 focus-visible:text-white"
108108
>
109109
<EyeIcon open={isRevealed} />
110110
{isRevealed ? hideLabel : showLabel}
@@ -114,7 +114,7 @@ export default function MaskedValue({
114114
<div className="flex items-center gap-2 overflow-hidden rounded-xl border border-white/10 bg-black/40 p-1 pl-4">
115115
<code
116116
className={`flex-1 truncate font-mono text-sm transition-colors ${
117-
isRevealed ? "text-mint" : "text-slate-500"
117+
isRevealed ? "text-mint" : "text-slate-300"
118118
}`}
119119
>
120120
{displayValue}
@@ -123,7 +123,7 @@ export default function MaskedValue({
123123
</div>
124124

125125
{helperText ? (
126-
<p className="text-[11px] text-slate-600">{helperText}</p>
126+
<p className="text-[11px] text-slate-400">{helperText}</p>
127127
) : null}
128128
</section>
129129
);

frontend/src/components/MetricsSkeleton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function SummaryGridSkeleton() {
3636

3737
{/* Value + unit on the same baseline */}
3838
<div className="mt-2 flex items-baseline gap-2">
39-
<Skeleton width={90} height={36} borderRadius={6} />
39+
<Skeleton width={104} height={36} borderRadius={6} />
4040
<Skeleton width={36} height={18} borderRadius={4} />
4141
</div>
4242
</div>
@@ -83,7 +83,7 @@ function ChartPanelSkeleton() {
8383
</div>
8484

8585
{/* ── Chart area ── */}
86-
<div className="mt-2 h-[300px]">
86+
<div className="h-[300px]">
8787
<Skeleton height="100%" borderRadius={8} />
8888
</div>
8989
</div>

0 commit comments

Comments
 (0)