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() { - - - - - - + + + + + } /> - - - - - - + + + + + } /> { const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); @@ -44,7 +47,14 @@ function TelegramBackButton() { // Stable handler — ref prevents re-subscription on every render const handler = useCallback(() => { - navigateRef.current(-1); + // When opened via a bot deep-link directly on a nested route, there is no + // in-app history and navigate(-1) is a no-op — the back button looks dead. + // Fall back to the parent route so it always navigates somewhere sensible. + if (hasInAppHistory()) { + navigateRef.current(-1); + } else { + navigateRef.current(getFallbackParentPath(pathnameRef.current), { replace: true }); + } }, []); useEffect(() => { diff --git a/src/api/menuLayout.ts b/src/api/menuLayout.ts index 148367f..ce8c95b 100644 --- a/src/api/menuLayout.ts +++ b/src/api/menuLayout.ts @@ -41,9 +41,9 @@ export type BuiltinSection = (typeof BUILTIN_SECTIONS)[number]; export const STYLE_OPTIONS = [ { value: 'default' as const, colorClass: 'bg-dark-500' }, - { value: 'primary' as const, colorClass: 'bg-blue-500' }, + { value: 'primary' as const, colorClass: 'bg-accent-500' }, { value: 'success' as const, colorClass: 'bg-success-500' }, - { value: 'danger' as const, colorClass: 'bg-red-500' }, + { value: 'danger' as const, colorClass: 'bg-error-500' }, ]; const DEFAULT_CONFIG: MenuConfig = { rows: [] }; diff --git a/src/api/subscription.ts b/src/api/subscription.ts index c80bad4..86f744a 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -1,4 +1,5 @@ import apiClient from './client'; +import { getYandexCid } from '../utils/yandexCid'; import type { Subscription, SubscriptionStatusResponse, @@ -83,7 +84,10 @@ export const subscriptionApi = { }> => { const response = await apiClient.post( '/cabinet/subscription/renew', - ...bodyWithSubId({ period_days: periodDays }, subscriptionId), + ...bodyWithSubId( + { period_days: periodDays, yandex_cid: getYandexCid() || undefined }, + subscriptionId, + ), ); return response.data; }, @@ -108,7 +112,7 @@ export const subscriptionApi = { }> => { const response = await apiClient.post( '/cabinet/subscription/traffic', - ...bodyWithSubId({ gb }, subscriptionId), + ...bodyWithSubId({ gb, yandex_cid: getYandexCid() || undefined }, subscriptionId), ); return response.data; }, @@ -127,7 +131,7 @@ export const subscriptionApi = { }> => { const response = await apiClient.put( '/cabinet/subscription/traffic', - ...bodyWithSubId({ gb }, subscriptionId), + ...bodyWithSubId({ gb, yandex_cid: getYandexCid() || undefined }, subscriptionId), ); return response.data; }, @@ -181,7 +185,7 @@ export const subscriptionApi = { }> => { const response = await apiClient.post( '/cabinet/subscription/devices/purchase', - ...bodyWithSubId({ devices }, subscriptionId), + ...bodyWithSubId({ devices, yandex_cid: getYandexCid() || undefined }, subscriptionId), ); return response.data; }, @@ -353,7 +357,9 @@ export const subscriptionApi = { }, activateTrial: async (): Promise => { - const response = await apiClient.post('/cabinet/subscription/trial'); + const response = await apiClient.post('/cabinet/subscription/trial', { + yandex_cid: getYandexCid() || undefined, + }); return response.data; }, @@ -389,7 +395,7 @@ export const subscriptionApi = { }> => { const response = await apiClient.post( '/cabinet/subscription/purchase', - ...bodyWithSubId({ selection }, subscriptionId), + ...bodyWithSubId({ selection, yandex_cid: getYandexCid() || undefined }, subscriptionId), ); return response.data; }, @@ -398,6 +404,14 @@ export const subscriptionApi = { tariffId: number, periodDays: number, trafficGb?: number, + /** + * Subscription ID being renewed. Pass this when the user clicked + * "Renew" on an existing subscription so the backend can resolve + * the target row by ID instead of doing a (user_id, tariff_id) + * re-lookup that races with concurrent panel webhooks. Omit when + * this is a fresh purchase from the catalog. + */ + subscriptionId?: number, ): Promise<{ success: boolean; message: string; @@ -411,6 +425,8 @@ export const subscriptionApi = { tariff_id: tariffId, period_days: periodDays, traffic_gb: trafficGb, + subscription_id: subscriptionId, + yandex_cid: getYandexCid() || undefined, }); return response.data; }, @@ -457,7 +473,7 @@ export const subscriptionApi = { }> => { const response = await apiClient.post( '/cabinet/subscription/countries', - ...bodyWithSubId({ countries }, subscriptionId), + ...bodyWithSubId({ countries, yandex_cid: getYandexCid() || undefined }, subscriptionId), ); return response.data; }, @@ -557,7 +573,10 @@ export const subscriptionApi = { }> => { const response = await apiClient.post( '/cabinet/subscription/tariff/switch', - ...bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId), + ...bodyWithSubId( + { tariff_id: tariffId, period_days: 30, yandex_cid: getYandexCid() || undefined }, + subscriptionId, + ), ); return response.data; }, diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 8a8fd17..0ecc9ac 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -281,6 +281,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="360" value={hsl.h} onChange={handleHueChange} + aria-label="Hue" className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: @@ -301,6 +302,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="100" value={hsl.s} onChange={handleSaturationChange} + aria-label="Saturation" className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`, @@ -320,6 +322,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="100" value={hsl.l} onChange={handleLightnessChange} + aria-label="Lightness" className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`, diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 0285597..ab8206d 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -65,7 +65,7 @@ export class ErrorBoundary extends Component +
⚠️

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. */} +