From 949589f31d2492907c283f31772c28d963e08aff Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Tue, 29 Sep 2026 10:44:32 +0300 Subject: [PATCH] feat: account page with Yandex linking, and 404/500 pages - /account lists sign-in methods; connect Yandex ID via /api/auth/yandex?intent=link (intent bound to the OAuth state, user identified by the refresh cookie), disconnect only if a password remains - /api/auth/me returns sign-in methods - ErrorPage (widgets/error-page) for unknown routes, missing public widgets, failed editor loads, plus an app-level error boundary - Unconfigured Yandex redirects back to the app instead of JSON 503 - Vitest inlines Gravity UI so components importing its CSS are testable --- AGENTS.md | 2 +- CHANGELOG.md | 3 + client/src/app/App.tsx | 182 +++++++++------- client/src/app/AppErrorBoundary.tsx | 31 +++ client/src/pages/account/index.ts | 1 + .../pages/account/ui/AccountPage.module.css | 193 +++++++++++++++++ .../src/pages/account/ui/AccountPage.test.tsx | 66 ++++++ client/src/pages/account/ui/AccountPage.tsx | 196 ++++++++++++++++++ .../public-widget/ui/PublicWidgetPage.tsx | 35 +++- .../widget-editor/model/useWidgetEditor.ts | 7 +- .../widget-editor/ui/WidgetEditorPage.tsx | 17 +- .../widgets-gallery/ui/WidgetsGalleryPage.tsx | 4 + client/src/shared/locale/content.ts | 32 +++ client/src/widgets/error-page/index.ts | 1 + .../error-page/ui/ErrorPage.module.css | 77 +++++++ .../widgets/error-page/ui/ErrorPage.test.tsx | 22 ++ .../src/widgets/error-page/ui/ErrorPage.tsx | 73 +++++++ client/src/widgets/sidebar/ui/Sidebar.tsx | 25 +++ client/vite.config.ts | 2 + server/src/auth.test.ts | 133 +++++++++++- server/src/controllers/authController.ts | 54 ++++- server/src/models/authModel.ts | 8 + server/src/routes/auth.ts | 1 + server/src/services/authService.ts | 47 ++++- 24 files changed, 1113 insertions(+), 99 deletions(-) create mode 100644 client/src/app/AppErrorBoundary.tsx create mode 100644 client/src/pages/account/index.ts create mode 100644 client/src/pages/account/ui/AccountPage.module.css create mode 100644 client/src/pages/account/ui/AccountPage.test.tsx create mode 100644 client/src/pages/account/ui/AccountPage.tsx create mode 100644 client/src/widgets/error-page/index.ts create mode 100644 client/src/widgets/error-page/ui/ErrorPage.module.css create mode 100644 client/src/widgets/error-page/ui/ErrorPage.test.tsx create mode 100644 client/src/widgets/error-page/ui/ErrorPage.tsx diff --git a/AGENTS.md b/AGENTS.md index f7f7292..4469485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ Server imports must use `.js` extensions (NodeNext). - Widget width is currently fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored. - `statsService` and the avatar cache are per-process in-memory caches; on Vercel each cold instance starts empty. Successful lookups are cached 15 min, failed ones 60 s; the public SVG `Cache-Control` follows the same TTL. Rate limits (`express-rate-limit`, memory store) are also per instance; `trust proxy` is enabled on Vercel so limits are per client IP. - `api/*.ts` import from `server/dist/src/app.js`. `server/tsconfig.build.json` pins `rootDir: "."` to keep that path stable, and CI runs `typecheck:api` to catch breakage. -- Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`. +- Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`. Accounts are keyed by provider id (`yandexId`), never linked by email; linking happens from `/account` (`/api/auth/yandex?intent=link`, the intent is stored with the OAuth state and the callback identifies the user by the refresh cookie). - Errors: throw `AppError(status, message)` from services; controllers pass errors to `next()`; `errorMiddleware` maps them. Non-AppError → 500 with a generic message. - All user-visible strings are bilingual (ru/en). Add both. - `.env` is gitignored; `.env.example` must list every variable the code reads. diff --git a/CHANGELOG.md b/CHANGELOG.md index 130bbc2..849f4df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project are documented here. ### Added +- Added an account page with sign-in methods: connect Yandex ID to an existing account, or disconnect it when a password remains. +- Added 404 and 500 pages for unknown addresses, missing or unpublished widgets, failed widget loads, and unexpected rendering errors. - Added live SVG image export URLs and README-ready HTML snippets for public widgets. - Added proportional `width` and `height` query scaling for SVG image URLs. - Extracted shared widget SVG-rendering primitives for server-side export. @@ -23,6 +25,7 @@ All notable changes to this project are documented here. ### Fixed +- Starting Yandex sign-in when it isn't configured now returns to the app with an error message instead of a raw JSON response. - Fixed smaller block padding and larger fonts in SVG exports compared to the editor and iframe. - Fixed widget size differences between the editor, public page, iframe and SVG export caused by viewport-relative padding. - Removed the drop shadow that tinted the transparent corners of exported SVG and iframe widgets. diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 80ea97c..354e422 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -9,6 +9,9 @@ import { LandingHeader, LandingPage } from '@/pages/landing'; import { WidgetsGalleryPage } from '@/pages/widgets-gallery'; import { PublicWidgetPage } from '@/pages/public-widget'; import { WidgetEditorPage } from '@/pages/widget-editor'; +import { ErrorPage } from '@/widgets/error-page'; +import { AccountPage } from '@/pages/account'; +import { AppErrorBoundary } from '@/app/AppErrorBoundary'; import { createWidget, deleteWidget, @@ -31,7 +34,11 @@ import { ThemeReveal, type ThemeRevealState } from '@/shared/ui/theme-reveal'; import styles from '@/app/App.module.css'; import '@/app/Theme.css'; -type AppRoute = 'landing' | 'auth' | 'dashboard' | 'editor' | 'public' | 'callback'; +type AppRoute = + 'landing' | 'auth' | 'dashboard' | 'editor' | 'account' | 'public' | 'callback' | 'not-found'; + +const isPrivateRoute = (route: AppRoute) => + route === 'dashboard' || route === 'editor' || route === 'account'; const getPathname = () => window.location.pathname.replace(/\/+$/, '') || '/'; @@ -39,10 +46,12 @@ const getRoute = (): AppRoute => { const pathname = getPathname(); if (pathname === '/dashboard') return 'dashboard'; if (/^\/widgets\/[^/]+$/.test(pathname)) return 'editor'; + if (pathname === '/account') return 'account'; if (/^\/w\/[^/]+$/.test(pathname)) return 'public'; if (pathname === '/auth/callback') return 'callback'; if (['/auth', '/login', '/register'].includes(pathname)) return 'auth'; - return 'landing'; + if (pathname === '/') return 'landing'; + return 'not-found'; }; const getRouteParam = (prefix: string) => getPathname().slice(prefix.length) || null; @@ -84,8 +93,10 @@ const getDocumentTitle = (route: AppRoute, authTab: AuthTab) => { return authTab === 'signup' ? 'WidgeCode | Register' : 'WidgeCode | Sign in'; if (route === 'dashboard') return 'WidgeCode | Dashboard'; if (route === 'editor') return 'WidgeCode | Widget editor'; + if (route === 'account') return 'WidgeCode | Account'; if (route === 'public') return 'WidgeCode | Public widget'; if (route === 'callback') return 'WidgeCode | Sign in'; + if (route === 'not-found') return 'WidgeCode | Page not found'; return 'WidgeCode'; }; @@ -188,7 +199,7 @@ export const App = () => { if ( isBootstrapped && !isLoggingOut && - (route === 'dashboard' || route === 'editor') && + isPrivateRoute(route) && authStatus === 'unauthenticated' ) { window.location.replace('/login'); @@ -311,15 +322,17 @@ export const App = () => { if (isEmbedRoute) return ( - + navigate('/')} + /> ); const isRedirectingFromPrivateRoute = - isBootstrapped && - (route === 'dashboard' || route === 'editor') && - authStatus === 'unauthenticated'; - const isPrivateRouteTransitioning = - (route === 'dashboard' || route === 'editor') && !isAuthorized; + isBootstrapped && isPrivateRoute(route) && authStatus === 'unauthenticated'; + const isPrivateRouteTransitioning = isPrivateRoute(route) && !isAuthorized; const isRedirectingFromAuthRoute = isBootstrapped && route === 'auth' && authStatus === 'authenticated'; const isAuthTransitioning = @@ -368,6 +381,7 @@ export const App = () => { } onCopyWidget={handleCopyWidget} onLogout={handleLogout} + onOpenAccount={() => navigate('/account')} onDeleteWidget={(id) => void handleDeleteWidget(id)} /> ) : ( @@ -376,6 +390,7 @@ export const App = () => { locale={locale} onBack={() => navigate('/dashboard')} onOpenPublic={(slug) => navigate(`/w/${slug}`)} + onHome={() => navigate('/dashboard')} onSave={(saved) => setVisibleWidgets((current) => current.map((widget) => (widget.id === saved.id ? toCardData(saved) : widget)), @@ -386,79 +401,92 @@ export const App = () => { return ( -
-
-
- {isAuthTransitioning ? ( - - ) : ( - - - {route === 'landing' && ( - navigate('/register')} - onDashboardNavigate={() => navigate('/dashboard')} - onLogout={handleLogout} - onLocaleToggle={handleLocaleToggle} - onThemeToggle={handleThemeToggle} - /> - )} -
+
+
+
+ {isAuthTransitioning ? ( + + ) : ( + + - {isDashboardRoute ? ( - authorizedContent - ) : route === 'editor' && isAuthorized ? ( -
{authorizedContent}
- ) : route === 'auth' ? ( - window.location.assign(`${API_BASE_URL}/auth/yandex`)} - /> - ) : route === 'public' ? ( - - ) : ( - - isAuthorized ? navigate('/dashboard') : navigate('/register') - } + onAuthNavigate={() => navigate('/register')} + onDashboardNavigate={() => navigate('/dashboard')} + onLogout={handleLogout} + onLocaleToggle={handleLocaleToggle} + onThemeToggle={handleThemeToggle} /> )} -
-
-
- )} - setThemeReveal(null)} /> -
+
+ {isDashboardRoute ? ( + authorizedContent + ) : route === 'account' && isAuthorized ? ( +
+ navigate('/dashboard')} /> +
+ ) : route === 'editor' && isAuthorized ? ( +
{authorizedContent}
+ ) : route === 'auth' ? ( + window.location.assign(`${API_BASE_URL}/auth/yandex`)} + /> + ) : route === 'public' ? ( + navigate('/')} + /> + ) : route === 'not-found' ? ( + navigate(isAuthorized ? '/dashboard' : '/')} + /> + ) : ( + + isAuthorized ? navigate('/dashboard') : navigate('/register') + } + /> + )} +
+ + + )} + setThemeReveal(null)} /> +
+ ); }; diff --git a/client/src/app/AppErrorBoundary.tsx b/client/src/app/AppErrorBoundary.tsx new file mode 100644 index 0000000..eaea7e0 --- /dev/null +++ b/client/src/app/AppErrorBoundary.tsx @@ -0,0 +1,31 @@ +import { Component, type ReactNode } from 'react'; + +import { ErrorPage } from '@/widgets/error-page'; +import type { Locale } from '@/shared/locale/content'; + +type AppErrorBoundaryProps = { locale: Locale; children: ReactNode }; + +/** Shows the 500 page instead of a blank screen when rendering throws. */ +export class AppErrorBoundary extends Component { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: unknown) { + console.error(error); + } + + render() { + if (!this.state.failed) return this.props.children; + return ( + window.location.reload()} + onHome={() => window.location.assign('/')} + /> + ); + } +} diff --git a/client/src/pages/account/index.ts b/client/src/pages/account/index.ts new file mode 100644 index 0000000..e9449f1 --- /dev/null +++ b/client/src/pages/account/index.ts @@ -0,0 +1 @@ +export { AccountPage } from '@/pages/account/ui/AccountPage'; diff --git a/client/src/pages/account/ui/AccountPage.module.css b/client/src/pages/account/ui/AccountPage.module.css new file mode 100644 index 0000000..cff6d9b --- /dev/null +++ b/client/src/pages/account/ui/AccountPage.module.css @@ -0,0 +1,193 @@ +.page { + display: grid; + width: min(100%, 640px); + gap: 18px; + margin: 0 auto; + padding: 24px 16px; +} + +.header { + display: flex; + align-items: center; + gap: 12px; +} + +.header p, +.header h1 { + margin: 0; +} + +.header p { + color: var(--action); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.header h1 { + font-size: clamp(24px, 6vw, 32px); + letter-spacing: -0.06em; +} + +.card { + display: grid; + gap: 20px; + padding: 24px; + border: 1px solid color-mix(in srgb, var(--line) 78%, transparent); + border-radius: 26px; + background: linear-gradient(145deg, var(--glass-strong), var(--glass)); + box-shadow: var(--shadow); + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); +} + +.profile { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.profile > span:last-child { + display: grid; + min-width: 0; + gap: 2px; +} + +.profile strong, +.profile span span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile strong { + font-size: 18px; +} + +.profile span span { + color: var(--muted); + font-size: 13px; +} + +.avatar { + display: grid; + width: 48px; + height: 48px; + flex: 0 0 auto; + place-items: center; + color: var(--action); + border-radius: 16px; + background: color-mix(in srgb, var(--action) 14%, transparent); +} + +.sectionHeading h2, +.sectionHeading p { + margin: 0; +} + +.sectionHeading h2 { + font-size: 16px; +} + +.sectionHeading p { + margin-top: 4px; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.methods { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.method { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--line) 78%, transparent); + border-radius: 18px; + background: color-mix(in srgb, var(--glass-strong) 60%, transparent); +} + +.methodIcon { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + color: var(--action); + border-radius: 12px; + background: color-mix(in srgb, var(--action) 12%, transparent); +} + +.yandexMark { + color: #fc3f1d; + font-size: 18px; + font-weight: 900; +} + +.methodCopy { + display: grid; + flex: 1 1 auto; + min-width: 0; + gap: 2px; +} + +.methodCopy strong { + font-size: 14px; +} + +.methodCopy span { + display: flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.connected { + color: #22a477 !important; +} + +.noticeSuccess, +.noticeError { + margin: 0; + padding: 10px 14px; + border-radius: 14px; + font-size: 13px; + line-height: 1.45; +} + +.noticeSuccess { + color: #1d8a64; + background: color-mix(in srgb, #22a477 12%, transparent); +} + +.noticeError { + color: #b2475a; + background: color-mix(in srgb, #d45c71 12%, transparent); +} + +.methodsSkeleton { + display: grid; + gap: 10px; +} + +.methodsSkeleton span { + height: 62px; + border-radius: 18px; + background: color-mix(in srgb, var(--action) 8%, transparent); +} + +@media (max-width: 520px) { + .method { + flex-wrap: wrap; + } +} diff --git a/client/src/pages/account/ui/AccountPage.test.tsx b/client/src/pages/account/ui/AccountPage.test.tsx new file mode 100644 index 0000000..6f66c8c --- /dev/null +++ b/client/src/pages/account/ui/AccountPage.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { AccountPage } from '@/pages/account/ui/AccountPage'; + +const api = vi.hoisted(() => ({ apiClient: vi.fn() })); + +vi.mock('@/shared/api', () => ({ + API_BASE_URL: '/api', + apiClient: api.apiClient, + getApiErrorMessage: (error: unknown) => (error instanceof Error ? error.message : 'Error'), +})); + +const user = { id: 'user-1', email: 'person@example.com', name: 'Person' }; + +const mockMe = (methods: { password: boolean; yandex: boolean }) => + api.apiClient.mockImplementation(async (path: string, init?: RequestInit) => { + if (path === '/auth/me') return { user, methods }; + if (path === '/auth/yandex' && init?.method === 'DELETE') { + return { methods: { ...methods, yandex: false } }; + } + throw new Error(`Unexpected ${path}`); + }); + +beforeEach(() => { + api.apiClient.mockReset(); + window.history.replaceState({}, '', '/account'); +}); + +it('offers to connect Yandex when only a password is set', async () => { + mockMe({ password: true, yandex: false }); + render( {}} />); + + expect(await screen.findByText('person@example.com')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); +}); + +it('disconnects Yandex when a password remains', async () => { + mockMe({ password: true, yandex: true }); + render( {}} />); + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument()); + expect(api.apiClient).toHaveBeenCalledWith('/auth/yandex', { method: 'DELETE' }); +}); + +it('does not allow removing the only sign-in method', async () => { + mockMe({ password: false, yandex: true }); + render( {}} />); + + expect( + await screen.findByText("This is your only sign-in method, so it can't be disconnected"), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument(); +}); + +it('shows the link result from the URL once and cleans the address', async () => { + window.history.replaceState({}, '', '/account?link_error=yandex_taken'); + mockMe({ password: true, yandex: false }); + render( {}} />); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Этот Яндекс ID уже привязан к другому аккаунту.', + ); + expect(window.location.search).toBe(''); +}); diff --git a/client/src/pages/account/ui/AccountPage.tsx b/client/src/pages/account/ui/AccountPage.tsx new file mode 100644 index 0000000..bcaafdb --- /dev/null +++ b/client/src/pages/account/ui/AccountPage.tsx @@ -0,0 +1,196 @@ +import { ArrowLeft, CircleCheck, Key, Person } from '@gravity-ui/icons'; +import { Button, Icon } from '@gravity-ui/uikit'; +import { useEffect, useState, type ReactNode } from 'react'; + +import type { AuthUser } from '@/features/auth'; +import { API_BASE_URL, apiClient, getApiErrorMessage } from '@/shared/api'; +import { messages, type Locale } from '@/shared/locale/content'; +import { ErrorPage } from '@/widgets/error-page'; +import styles from '@/pages/account/ui/AccountPage.module.css'; + +type SignInMethods = { password: boolean; yandex: boolean }; +type Notice = { tone: 'success' | 'error'; text: string }; + +type AccountPageProps = { + locale: Locale; + onBack: () => void; +}; + +const noticeFromUrl = (t: (typeof messages)[Locale]): Notice | null => { + const params = new URLSearchParams(window.location.search); + if (params.get('linked') === 'yandex') return { tone: 'success', text: t.yandexLinked }; + const error = params.get('link_error'); + if (error === 'yandex_taken') return { tone: 'error', text: t.yandexTaken }; + if (error === 'session_expired') return { tone: 'error', text: t.linkSessionExpired }; + if (error) return { tone: 'error', text: t.yandexLinkFailed }; + return null; +}; + +const MethodRow = ({ + icon, + title, + status, + connected, + action, +}: { + icon: ReactNode; + title: string; + status: string; + connected: boolean; + action?: ReactNode; +}) => ( +
  • + {icon} + + {title} + + {connected && } + {status} + + + {action} +
  • +); + +export const AccountPage = ({ locale, onBack }: AccountPageProps) => { + const t = messages[locale]; + const [account, setAccount] = useState<{ user: AuthUser; methods: SignInMethods } | null>(null); + const [loadError, setLoadError] = useState(null); + const [notice, setNotice] = useState(() => noticeFromUrl(t)); + const [isUnlinking, setUnlinking] = useState(false); + + useEffect(() => { + // The link result arrives as query params; drop them so a reload doesn't repeat the notice. + if (window.location.search) window.history.replaceState({}, '', window.location.pathname); + let cancelled = false; + apiClient<{ user: AuthUser; methods: SignInMethods }>('/auth/me') + .then((response) => { + if (!cancelled) setAccount(response); + }) + .catch((error) => { + if (!cancelled) setLoadError(getApiErrorMessage(error)); + }); + return () => { + cancelled = true; + }; + }, []); + + const unlinkYandex = async () => { + setUnlinking(true); + try { + const { methods } = await apiClient<{ methods: SignInMethods }>('/auth/yandex', { + method: 'DELETE', + }); + setAccount((current) => (current ? { ...current, methods } : current)); + setNotice(null); + } catch (error) { + setNotice({ tone: 'error', text: getApiErrorMessage(error) }); + } finally { + setUnlinking(false); + } + }; + + if (loadError) + return ( + window.location.reload()} + /> + ); + + const methods = account?.methods; + const isOnlyMethod = Boolean(methods && Number(methods.password) + Number(methods.yandex) < 2); + + return ( +
    +
    + +
    +

    {t.account}

    +

    {t.accountTitle}

    +
    +
    + +
    +
    + + + + + {account ? account.user.name || account.user.email || '—' : ' '} + {account?.user.email ?? ' '} + +
    + +
    +

    {t.signInMethods}

    +

    {t.signInMethodsHint}

    +
    + + {notice && ( +

    + {notice.text} +

    + )} + + {methods ? ( +
      + } + title={t.passwordMethod} + connected={methods.password} + status={methods.password ? t.connected : t.notConnected} + /> + Я} + title={t.yandexMethod} + connected={methods.yandex} + status={ + methods.yandex && isOnlyMethod + ? t.onlySignInMethod + : methods.yandex + ? t.connected + : t.notConnected + } + action={ + methods.yandex ? ( + !isOnlyMethod && ( + + ) + ) : ( + + ) + } + /> +
    + ) : ( + + )} +
    +
    + ); +}; diff --git a/client/src/pages/public-widget/ui/PublicWidgetPage.tsx b/client/src/pages/public-widget/ui/PublicWidgetPage.tsx index a5b1da7..db90e62 100644 --- a/client/src/pages/public-widget/ui/PublicWidgetPage.tsx +++ b/client/src/pages/public-widget/ui/PublicWidgetPage.tsx @@ -8,7 +8,8 @@ import { type PaletteMode, type PublicWidgetResponse, } from '@/entities/widget'; -import { getPublicWidget, PUBLIC_WIDGET_MESSAGE_SOURCE } from '@/shared/api'; +import { ErrorPage } from '@/widgets/error-page'; +import { ApiError, getPublicWidget, PUBLIC_WIDGET_MESSAGE_SOURCE } from '@/shared/api'; import type { Locale } from '@/shared/locale/content'; import { messages } from '@/shared/locale/content'; import styles from '@/pages/public-widget/ui/PublicWidgetPage.module.css'; @@ -17,6 +18,7 @@ type PublicWidgetPageProps = { slug: string; locale: Locale; embed?: boolean; + onHome: () => void; }; const DEFAULT_WIDGET_DIMENSIONS = { width: 600, height: 315 }; @@ -26,7 +28,12 @@ const readDimension = (name: 'width' | 'height', fallback: number) => { return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), 2000) : fallback; }; -export const PublicWidgetPage = ({ slug, locale, embed = false }: PublicWidgetPageProps) => { +export const PublicWidgetPage = ({ + slug, + locale, + embed = false, + onHome, +}: PublicWidgetPageProps) => { const t = messages[locale]; const initialDimensions = { width: readDimension('width', DEFAULT_WIDGET_DIMENSIONS.width), @@ -34,6 +41,8 @@ export const PublicWidgetPage = ({ slug, locale, embed = false }: PublicWidgetPa }; const [payload, setPayload] = useState(null); const [error, setError] = useState(null); + const [errorStatus, setErrorStatus] = useState(null); + const [attempt, setAttempt] = useState(0); useEffect(() => { let cancelled = false; @@ -42,12 +51,14 @@ export const PublicWidgetPage = ({ slug, locale, embed = false }: PublicWidgetPa if (!cancelled) setPayload(nextPayload); }) .catch((loadError) => { - if (!cancelled) setError(loadError instanceof Error ? loadError.message : t.unavailable); + if (cancelled) return; + setError(loadError instanceof Error ? loadError.message : t.unavailable); + setErrorStatus(loadError instanceof ApiError ? loadError.status : null); }); return () => { cancelled = true; }; - }, [slug, t.unavailable]); + }, [attempt, slug, t.unavailable]); useEffect(() => { if (!embed || window.parent === window || (!payload && !error)) return; @@ -61,6 +72,22 @@ export const PublicWidgetPage = ({ slug, locale, embed = false }: PublicWidgetPa ); }, [embed, error, payload, slug]); + if (error && !embed) + return errorStatus === 404 ? ( + + ) : ( + { + setError(null); + setErrorStatus(null); + setAttempt((value) => value + 1); + }} + /> + ); if (error) return (
    diff --git a/client/src/pages/widget-editor/model/useWidgetEditor.ts b/client/src/pages/widget-editor/model/useWidgetEditor.ts index 83e8f45..0f11124 100644 --- a/client/src/pages/widget-editor/model/useWidgetEditor.ts +++ b/client/src/pages/widget-editor/model/useWidgetEditor.ts @@ -54,6 +54,7 @@ export const useWidgetEditor = ({ const [isSaving, setSaving] = useState(false); const [isDirty, setDirty] = useState(false); const [error, setError] = useState(null); + const [loadStatus, setLoadStatus] = useState(null); const widgetRef = useRef(null); const savePromiseRef = useRef | null>(null); const editVersionRef = useRef(0); @@ -84,7 +85,10 @@ export const useWidgetEditor = ({ setDirty(nextIsDirty); }) .catch((loadError) => { - if (!cancelled) setError(errorMessage(loadError, messages.unavailable)); + if (cancelled) return; + setError(errorMessage(loadError, messages.unavailable)); + const status = (loadError as { status?: unknown } | null)?.status; + setLoadStatus(typeof status === 'number' ? status : 500); }) .finally(() => { if (!cancelled) setLoading(false); @@ -248,6 +252,7 @@ export const useWidgetEditor = ({ setActivePanel, selectBlock, isLoading, + loadStatus, isSaving, isDirty, isDirtyRef, diff --git a/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx b/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx index b4f66cf..6cac726 100644 --- a/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx +++ b/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx @@ -12,6 +12,7 @@ import { EditorCanvas } from '@/pages/widget-editor/ui/EditorCanvas'; import { EditorHeader } from '@/pages/widget-editor/ui/EditorHeader'; import { MobileFallback } from '@/pages/widget-editor/ui/MobileFallback'; import { WidgetConfigPanel } from '@/pages/widget-editor/ui/WidgetConfigPanel'; +import { ErrorPage } from '@/widgets/error-page'; import { messages, type Locale } from '@/shared/locale/content'; import { AuthTransitionLoader } from '@/shared/ui/auth-transition-loader/AuthTransitionLoader'; import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css'; @@ -24,6 +25,7 @@ type WidgetEditorPageProps = { onBack: () => void; onOpenPublic: (slug: string) => void; onSave?: (widget: Widget) => void; + onHome: () => void; }; const useIsMobile = () => { @@ -44,6 +46,7 @@ export const WidgetEditorPage = ({ onBack, onOpenPublic, onSave, + onHome, }: WidgetEditorPageProps) => { const t = messages[locale]; const prefersReducedMotion = useReducedMotion(); @@ -75,10 +78,16 @@ export const WidgetEditorPage = ({
    ); if (!widget) - return ( -
    - {editor.error || t.unavailable} -
    + return editor.loadStatus === 404 ? ( + + ) : ( + window.location.reload()} + /> ); return ( diff --git a/client/src/pages/widgets-gallery/ui/WidgetsGalleryPage.tsx b/client/src/pages/widgets-gallery/ui/WidgetsGalleryPage.tsx index e158e58..a664e43 100644 --- a/client/src/pages/widgets-gallery/ui/WidgetsGalleryPage.tsx +++ b/client/src/pages/widgets-gallery/ui/WidgetsGalleryPage.tsx @@ -29,6 +29,7 @@ type WidgetsGalleryPageProps = { onOpenPreview: (widget: WidgetCardData) => void; onCopyWidget: (widget: WidgetCardData, format: 'iframe' | 'svg') => void | Promise; onLogout: () => void; + onOpenAccount: () => void; onDeleteWidget: (id: string) => void; }; @@ -46,12 +47,14 @@ export const WidgetsGalleryPage = ({ onOpenPreview, onCopyWidget, onLogout, + onOpenAccount, onDeleteWidget, }: WidgetsGalleryPageProps) => { const t = messages[locale]; const [isCreateModalOpen, setCreateModalOpen] = useState(false); const sidebarLabels: SidebarLabels = { logout: t.logout, + account: t.account, createWidget: t.createWidget, openWidget: t.open, language: t.language, @@ -87,6 +90,7 @@ export const WidgetsGalleryPage = ({ onCreateWidget={() => setCreateModalOpen(true)} onOpenWidget={onOpenWidget} onLogout={onLogout} + onOpenAccount={onOpenAccount} isLanguageLoading={isLanguageLoading} />
    +
    + + +