diff --git a/.gitignore b/.gitignore index c9836a3..7e86672 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,8 @@ miniapp/ .ai/ CLAUDE.md **/CLAUDE.md + +# Impeccable design context (local only, not for shared repo) +PRODUCT.md +DESIGN.md +DESIGN.json diff --git a/eslint.config.js b/eslint.config.js index 3d94809..2cacac2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -39,6 +39,49 @@ export default tseslint.config( 'no-implied-eval': 'error', 'no-new-func': 'error', 'no-script-url': 'error', + // Cross-platform guard: these browser APIs misbehave inside the Telegram WebView. + // Route them through the platform abstraction instead. The platform adapters and + // the clipboard util (the canonical implementations) are exempted below. + 'no-restricted-properties': [ + 'error', + { + object: 'window', + property: 'confirm', + message: + 'Use useNativeDialog().confirm — window.confirm is silently ignored in the Telegram WebView.', + }, + { + object: 'window', + property: 'alert', + message: + 'Use useNativeDialog().alert — window.alert is silently ignored in the Telegram WebView.', + }, + { + object: 'window', + property: 'open', + message: + 'Use usePlatform().openLink / openTelegramLink — window.open is intercepted by the Telegram WebView.', + }, + { + object: 'window', + property: 'prompt', + message: + 'Use usePrompt() (PromptDialogHost) — window.prompt is not supported in the Telegram WebView.', + }, + { + object: 'navigator', + property: 'clipboard', + message: + 'Use copyToClipboard from @/utils/clipboard — it falls back to execCommand when the Clipboard API is unavailable (Telegram WebView).', + }, + ], + }, + }, + { + // Canonical implementations that intentionally call the restricted browser APIs. + files: ['src/platform/**/*.{ts,tsx}', 'src/utils/clipboard.ts'], + rules: { + 'no-restricted-properties': 'off', }, }, eslintConfigPrettier, diff --git a/src/App.tsx b/src/App.tsx index 6fbd3ba..589781d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -200,9 +200,16 @@ function AdminRoute({ children }: { children: React.ReactNode }) { return {children}; } -// Suspense wrapper for lazy components +// Suspense + error boundary wrapper for lazy routes. The boundary lives +// OUTSIDE Suspense so chunk-load failures (caught by lazyWithRetry's reload +// path) and render-time exceptions both surface in the page-level fallback +// instead of crashing the entire shell via the top-level boundary. function LazyPage({ children }: { children: React.ReactNode }) { - return }>{children}; + return ( + + }>{children} + + ); } function BlockingOverlay() { @@ -264,31 +271,25 @@ function App() { - - - - + + + } /> - - - - + + + } /> - - - - + + + } /> @@ -387,11 +388,9 @@ function App() { path="/balance/top-up/result" element={ - - - - - + + + } /> @@ -518,25 +517,21 @@ function App() { - - - - - - + + + + + } /> - - - - - - + + + + + } /> +
⚠️

Something went wrong

diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 981a8cd..218b9f6 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -34,15 +34,12 @@ export default function LanguageSwitcher() { }, []); const changeLanguage = (code: string) => { + // i18n.ts subscribes to languageChanged and syncs + dir + // centrally — no need to set documentElement.dir here. i18n.changeLanguage(code); - document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'; setIsOpen(false); }; - useEffect(() => { - document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'; - }, [i18n.language]); - if (availableLanguages.length <= 1) { return null; } diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 63ae244..08a87c1 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; +import { useFocusTrap } from '../hooks/useFocusTrap'; interface OnboardingStep { target: string; // data-onboarding attribute value @@ -126,6 +127,17 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp onSkip(); }; + // Trap focus inside the tooltip while the tour runs; Esc skips it. + // lockScroll stays off so scrollIntoView can bring each target into view. + const trapRef = useFocusTrap(true, { onEscape: handleSkip, lockScroll: false }); + const setTooltipNode = useCallback( + (node: HTMLDivElement | null) => { + tooltipRef.current = node; + trapRef.current = node; + }, + [trapRef], + ); + // Calculate tooltip position const getTooltipStyle = (): React.CSSProperties => { if (!targetRect) return { opacity: 0 }; @@ -205,7 +217,11 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp {/* Tooltip */}
{/* Content */} -

{step.title}

-

{step.description}

+

+ {step.title} +

+

+ {step.description} +

{/* Actions */}
@@ -259,6 +279,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp {/* Click handler to advance on target click — only when overlay is fully visible */} {targetRect && isVisible && ( + +
, + document.body, + ); +} diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index 1f43437..72e78f2 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router'; import { useSuccessNotification } from '../store/successNotification'; import { useCurrency } from '../hooks/useCurrency'; import { useTelegramSDK } from '../hooks/useTelegramSDK'; +import { useFocusTrap } from '../hooks/useFocusTrap'; import { useHaptic } from '@/platform'; // Icons @@ -93,6 +94,9 @@ export default function SuccessNotificationModal() { hide(); }, [hide]); + // Esc + scroll-lock are handled by the effects below; the trap only manages focus. + const modalRef = useFocusTrap(isOpen, { lockScroll: false }); + // Escape key to close useEffect(() => { if (!isOpen) return; @@ -201,10 +205,15 @@ export default function SuccessNotificationModal() { const modalContent = (
{/* Backdrop */} -
+
{/* Modal */}
@@ -223,8 +233,12 @@ export default function SuccessNotificationModal() {
-
{icon}
-

{title}

+ {/* Use animate-pulse for celebration; bounce easing reads dated and + the lift is the moment, not the bounce. */} +
{icon}
+

+ {title} +

{message &&

{message}

}
@@ -318,7 +332,7 @@ export default function SuccessNotificationModal() { {isSubscription && ( - {oidcError &&

{oidcError}

} + {oidcError &&

{oidcError}

}
) : (
)}
-

{t('auth.orOpenInApp')}

+

{t('auth.orOpenInApp')}

{children} - {/* Toast Container — safe area aware, adaptive width */} -
+ {/* Toast region — safe area aware, adaptive width. role+aria-live lets + screen readers announce arriving toasts without stealing focus. */} +
{toasts.map((toast) => ( removeToast(toast.id)} /> ))} @@ -111,35 +117,51 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { onClose(); }; + // Semantic carries through the icon box + a full tinted border. No side + // stripe (was a 4px border-l accent — impeccable absolute ban) and no + // background tint flood — the contained icon is enough at this size. const typeStyles = { success: { - border: 'border-l-success-500', + border: 'border-success-500/40', icon: 'text-success-400', - iconBg: 'bg-success-500/20', - progress: 'bg-success-400', + iconBg: 'bg-success-500/15', + progress: 'bg-success-500', }, error: { - border: 'border-l-error-500', + border: 'border-error-500/40', icon: 'text-error-400', - iconBg: 'bg-error-500/20', - progress: 'bg-error-400', + iconBg: 'bg-error-500/15', + progress: 'bg-error-500', }, warning: { - border: 'border-l-warning-500', + border: 'border-warning-500/40', icon: 'text-warning-400', - iconBg: 'bg-warning-500/20', - progress: 'bg-warning-400', + iconBg: 'bg-warning-500/15', + progress: 'bg-warning-500', }, info: { - border: 'border-l-accent-500', + border: 'border-accent-500/40', icon: 'text-accent-400', - iconBg: 'bg-accent-500/20', - progress: 'bg-accent-400', + iconBg: 'bg-accent-500/15', + progress: 'bg-accent-500', }, }; const style = typeStyles[toast.type || 'info']; + // Errors interrupt the screen reader; everything else announces politely. + const role = toast.type === 'error' ? 'alert' : 'status'; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + const defaultIcons = { success: ( void }) { return (
- {/* Icon */} + {/* Icon — carries the semantic by itself; the border is a soft echo */}
- {/* Progress bar */} -
+ {/* Progress bar — visual countdown until auto-dismiss. scaleX animates + on the compositor, no layout reflow. aria-hidden because the visual + timer doesn't carry meaning beyond the toast lifetime. */} +