feat: add dedicated TopUpResult page for payment return flow

Replace toast-based payment return with animated result page showing
pending/success/failed/timeout states. Extract shared Spinner,
AnimatedCheckmark and AnimatedCrossmark components. Add sessionStorage
persistence with validation and TTL for cross-redirect payment data.
Unify ProtectedRoute with withLayout prop, add aria-live accessibility.
This commit is contained in:
Fringg
2026-03-09 03:58:40 +03:00
parent d7b1ff1052
commit b59122818c
14 changed files with 608 additions and 130 deletions

63
src/utils/topUpStorage.ts Normal file
View File

@@ -0,0 +1,63 @@
const STORAGE_KEY = 'topup_pending_payment';
const MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
export interface TopUpPendingInfo {
amount_kopeks: number;
method_id: string;
method_name: string;
payment_id: string;
created_at: number; // Date.now()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function saveTopUpPendingInfo(info: TopUpPendingInfo) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info));
} catch {
// sessionStorage unavailable (private mode, quota, etc.)
}
}
export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
!isRecord(parsed) ||
typeof parsed.amount_kopeks !== 'number' ||
typeof parsed.method_id !== 'string' ||
typeof parsed.method_name !== 'string' ||
typeof parsed.payment_id !== 'string' ||
typeof parsed.created_at !== 'number' ||
parsed.amount_kopeks <= 0
) {
return null;
}
// Discard stale entries
if (Date.now() - (parsed.created_at as number) > MAX_AGE_MS) {
clearTopUpPendingInfo();
return null;
}
return {
amount_kopeks: parsed.amount_kopeks as number,
method_id: parsed.method_id as string,
method_name: parsed.method_name as string,
payment_id: parsed.payment_id as string,
created_at: parsed.created_at as number,
};
} catch {
return null;
}
}
export function clearTopUpPendingInfo() {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}