From 782568e091fbe636c9f3136002f5b230089cdc04 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 4 Jun 2026 13:38:35 +0300 Subject: [PATCH] feat(cabinet): premium redesign of all 5 full-screen blocking states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status screens (service-unavailable, maintenance, channel-subscription, blacklist, account-deleted) all shared a generic flat look — an icon in a grey circle, a title, a subtitle, three raw pulsing dots — that didn't match the app's premium dark-glass aesthetic. Introduce one shared BlockingShell that all five compose: an opaque themed canvas with a self-contained accent glow, a centered glass card (rounded-[--bento-radius] border bg-dark-900/80 backdrop-blur + inset highlight), a gradient-ringed icon medallion (warning/error/info accent) instead of a flat grey circle, font-display typography, the canonical Button for every action, and a subtle framer-motion scale/slide entrance. Accent per screen: warning (maintenance, account-deleted, service-unavailable), error (blacklist), info (channel). Behavior is preserved 1:1 — every i18n key, the channel list + open buttons, the telegram deep-link, retry/check/close, the recovery poll, focus trap and aria all carried over verbatim; only the visuals change. --- .../blocking/AccountDeletedScreen.tsx | 58 ++---- src/components/blocking/BlacklistedScreen.tsx | 50 ++--- src/components/blocking/BlockingShell.tsx | 196 ++++++++++++++++++ .../blocking/ChannelSubscriptionScreen.tsx | 191 ++++++++--------- src/components/blocking/MaintenanceScreen.tsx | 69 ++---- .../blocking/ServiceUnavailableScreen.tsx | 134 ++++-------- 6 files changed, 373 insertions(+), 325 deletions(-) create mode 100644 src/components/blocking/BlockingShell.tsx diff --git a/src/components/blocking/AccountDeletedScreen.tsx b/src/components/blocking/AccountDeletedScreen.tsx index fb2e319..1d23e11 100644 --- a/src/components/blocking/AccountDeletedScreen.tsx +++ b/src/components/blocking/AccountDeletedScreen.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next'; import { usePlatform } from '@/platform'; import { InfoIcon } from '@/components/icons'; +import { Button } from '@/components/primitives'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; +import BlockingShell from './BlockingShell'; /** * Full-screen block shown when the backend returns @@ -45,48 +47,26 @@ export default function AccountDeletedScreen() { }; return ( -
-
-
-
- -
-
- -

- {t('blocking.accountDeleted.title')} -

- -

{t('blocking.accountDeleted.description')}

- -
+ } + title={t('blocking.accountDeleted.title')} + description={t('blocking.accountDeleted.description')} + footer={t('blocking.accountDeleted.hint')} + actions={ + <> {deepLink && ( - + )} - -
- -

{t('blocking.accountDeleted.hint')}

-
-
+ + + } + /> ); } diff --git a/src/components/blocking/BlacklistedScreen.tsx b/src/components/blocking/BlacklistedScreen.tsx index de533f5..20481dc 100644 --- a/src/components/blocking/BlacklistedScreen.tsx +++ b/src/components/blocking/BlacklistedScreen.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { BanIcon } from '@/components/icons'; +import BlockingShell from './BlockingShell'; export default function BlacklistedScreen() { const { t } = useTranslation(); @@ -9,40 +10,23 @@ export default function BlacklistedScreen() { const screenRef = useFocusTrap(true, { lockScroll: false }); return ( -
} + title={t('blocking.blacklisted.title')} + description={t('blocking.blacklisted.defaultMessage')} + footer={t('blocking.blacklisted.contactSupport')} > -
- {/* Icon */} -
-
- -
+ {blacklistedInfo?.message && ( +
+

+ {t('blocking.blacklisted.reason')}: +

+

{blacklistedInfo.message}

- - {/* Title */} -

- {t('blocking.blacklisted.title')} -

- - {/* Message */} -

{t('blocking.blacklisted.defaultMessage')}

- - {/* Reason */} - {blacklistedInfo?.message && ( -
-

{t('blocking.blacklisted.reason')}:

-

{blacklistedInfo.message}

-
- )} - -

{t('blocking.blacklisted.contactSupport')}

-
-
+ )} + ); } diff --git a/src/components/blocking/BlockingShell.tsx b/src/components/blocking/BlockingShell.tsx new file mode 100644 index 0000000..1564cbd --- /dev/null +++ b/src/components/blocking/BlockingShell.tsx @@ -0,0 +1,196 @@ +import { type ReactNode, type Ref } from 'react'; +import { motion } from 'framer-motion'; +import { scale, scaleTransition, slideUp, slideUpTransition } from '../motion/transitions'; +import { cn } from '@/lib/utils'; + +export type BlockingAccent = 'warning' | 'error' | 'info'; + +/** + * Per-accent class recipe. `info` maps to the theme accent-* scale (default + * blue). Colors are theme-driven CSS vars (RGB triples), never hardcoded hex. + */ +const accentMap: Record< + BlockingAccent, + { + glow: string; + medallion: string; + sheen: string; + iconColor: string; + dot: string; + hairline: string; + } +> = { + warning: { + glow: 'bg-warning-500/10', + medallion: + 'bg-warning-500/10 ring-warning-500/30 shadow-[0_0_44px_-8px_rgba(var(--color-warning-500),0.5)]', + sheen: 'from-warning-500/25', + iconColor: 'text-warning-400', + dot: 'bg-warning-500', + hairline: 'via-warning-500/40', + }, + error: { + glow: 'bg-error-500/10', + medallion: + 'bg-error-500/10 ring-error-500/30 shadow-[0_0_44px_-8px_rgba(var(--color-error-500),0.5)]', + sheen: 'from-error-500/25', + iconColor: 'text-error-400', + dot: 'bg-error-500', + hairline: 'via-error-500/40', + }, + info: { + glow: 'bg-accent-500/10', + medallion: 'bg-accent-500/10 ring-accent-500/30 shadow-glow-lg', + sheen: 'from-accent-500/25', + iconColor: 'text-accent-400', + dot: 'bg-accent-500', + hairline: 'via-accent-500/40', + }, +}; + +interface BlockingShellProps { + /** Unique id wired to aria-labelledby (e.g. 'maintenance-title'). */ + titleId: string; + accent: BlockingAccent; + /** Rendered icon element (sized by the caller, e.g. ). */ + icon: ReactNode; + /** Already-translated title. */ + title: string; + description?: ReactNode; + /** Per-screen body: reason cards, channel list, error block. */ + children?: ReactNode; + /** CTA area — pass canonical - )} -
- ))} - - )} - - {/* Fallback: single channel (legacy) */} - {channels.length === 0 && channelInfo?.channel_link && ( - - )} - - {/* Error message */} - {error && ( -
-

{error}

-
- )} - - {/* Check subscription button */} - + {checkLabel} + + } + > + {/* Channel list (only unsubscribed channels) */} + {channels.length > 0 && ( +
+ {channels.map((ch) => ( +
+ + {ch.title || ch.channel_id} + + {ch.channel_link && ( + + )} +
+ ))} +
+ )} - {/* Hint */} -

{t('blocking.channel.hint')}

- - + {/* Fallback: single channel (legacy) */} + {channels.length === 0 && channelInfo?.channel_link && ( + + )} + + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + ); } diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx index 00b392b..89cb110 100644 --- a/src/components/blocking/MaintenanceScreen.tsx +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { WrenchIcon } from '@/components/icons'; +import BlockingShell from './BlockingShell'; export default function MaintenanceScreen() { const { t } = useTranslation(); @@ -9,58 +10,24 @@ export default function MaintenanceScreen() { const screenRef = useFocusTrap(true, { lockScroll: false }); return ( -
} + title={t('blocking.maintenance.title')} + description={maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')} + pulse + footer={t('blocking.maintenance.waitMessage')} > -
- {/* Icon */} -
-
- -
+ {maintenanceInfo?.reason && ( +
+

+ {t('blocking.maintenance.reason')}: +

+

{maintenanceInfo.reason}

- - {/* Title */} -

- {t('blocking.maintenance.title')} -

- - {/* Message */} -

- {maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')} -

- - {/* Reason */} - {maintenanceInfo?.reason && ( -
-

{t('blocking.maintenance.reason')}:

-

{maintenanceInfo.reason}

-
- )} - - {/* Decorative dots */} -
-
-
-
-
- -

{t('blocking.maintenance.waitMessage')}

-
-
+ )} + ); } diff --git a/src/components/blocking/ServiceUnavailableScreen.tsx b/src/components/blocking/ServiceUnavailableScreen.tsx index c60eadf..cfcd64b 100644 --- a/src/components/blocking/ServiceUnavailableScreen.tsx +++ b/src/components/blocking/ServiceUnavailableScreen.tsx @@ -6,6 +6,9 @@ import { useFocusTrap } from '../../hooks/useFocusTrap'; import { pingBackend, hasEverReachedBackend } from '../../api/health'; import { isInTelegramWebApp, closeTelegramApp } from '../../hooks/useTelegramSDK'; import { CloudWarningIcon, RestartIcon, CloseIcon } from '@/components/icons'; +import { Button } from '@/components/primitives'; +import { cn } from '@/lib/utils'; +import BlockingShell from './BlockingShell'; const POLL_INTERVAL_MS = 5000; @@ -73,98 +76,45 @@ export default function ServiceUnavailableScreen() { const screenRef = useFocusTrap(true, { lockScroll: false }); return ( -
-
- {/* Icon */} -
-
- -
-
- - {/* Title */} -

- {t('blocking.serviceUnavailable.title')} -

- - {/* Message */} -

{t('blocking.serviceUnavailable.description')}

- - {/* Retry button */} - - - {/* Close button — Telegram Mini App only (a browser tab can't be closed - by script). Reliably exits the Mini App instead of routing back. */} - {inTelegram && ( - - )} - - {/* Decorative dots */} -
-
-
-
-
- -

{t('blocking.serviceUnavailable.hint')}

-
-
+ {isChecking + ? t('blocking.serviceUnavailable.checking') + : t('blocking.serviceUnavailable.retry')} + + {/* Telegram Mini App only — a browser tab can't be closed by script. + Reliably exits the Mini App instead of routing back. */} + {inTelegram && ( + + )} + + } + /> ); }