feat(cabinet): premium redesign of all 5 full-screen blocking states

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.
This commit is contained in:
c0mrade
2026-06-04 13:38:35 +03:00
parent 7413837faf
commit 782568e091
6 changed files with 373 additions and 325 deletions

View File

@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { InfoIcon } from '@/components/icons'; import { InfoIcon } from '@/components/icons';
import { Button } from '@/components/primitives';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import BlockingShell from './BlockingShell';
/** /**
* Full-screen block shown when the backend returns * Full-screen block shown when the backend returns
@@ -45,48 +47,26 @@ export default function AccountDeletedScreen() {
}; };
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="account-deleted-title"
aria-modal="true" accent="warning"
aria-labelledby="account-deleted-title" icon={<InfoIcon className="h-9 w-9" />}
tabIndex={-1} title={t('blocking.accountDeleted.title')}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" description={t('blocking.accountDeleted.description')}
> footer={t('blocking.accountDeleted.hint')}
<div className="w-full max-w-md text-center"> actions={
<div className="mb-8"> <>
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<InfoIcon className="h-12 w-12 text-warning-400" />
</div>
</div>
<h1 id="account-deleted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.accountDeleted.title')}
</h1>
<p className="mb-6 text-lg text-dark-400">{t('blocking.accountDeleted.description')}</p>
<div className="space-y-3">
{deepLink && ( {deepLink && (
<button <Button variant="primary" size="lg" fullWidth onClick={handleOpenBot}>
type="button"
onClick={handleOpenBot}
className="block w-full rounded-xl bg-blue-600 px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.openBot')} {t('blocking.accountDeleted.openBot')}
</button> </Button>
)} )}
<button <Button variant="secondary" size="lg" fullWidth onClick={handleRetry}>
type="button"
onClick={handleRetry}
className="block w-full rounded-xl bg-dark-800 px-6 py-3 text-base font-medium text-dark-200 transition-colors hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-dark-400 focus:ring-offset-2 focus:ring-offset-dark-950"
>
{t('blocking.accountDeleted.retry')} {t('blocking.accountDeleted.retry')}
</button> </Button>
</div> </>
}
<p className="mt-8 text-sm text-dark-500">{t('blocking.accountDeleted.hint')}</p> />
</div>
</div>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { BanIcon } from '@/components/icons'; import { BanIcon } from '@/components/icons';
import BlockingShell from './BlockingShell';
export default function BlacklistedScreen() { export default function BlacklistedScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -9,40 +10,23 @@ export default function BlacklistedScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="blacklisted-title"
aria-modal="true" accent="error"
aria-labelledby="blacklisted-title" icon={<BanIcon className="h-9 w-9" />}
tabIndex={-1} title={t('blocking.blacklisted.title')}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" description={t('blocking.blacklisted.defaultMessage')}
footer={t('blocking.blacklisted.contactSupport')}
> >
<div className="w-full max-w-md text-center"> {blacklistedInfo?.message && (
{/* Icon */} <div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<div className="mb-8"> <p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> {t('blocking.blacklisted.reason')}:
<BanIcon className="h-12 w-12 text-error-500" /> </p>
</div> <p className="text-sm text-dark-300">{blacklistedInfo.message}</p>
</div> </div>
)}
{/* Title */} </BlockingShell>
<h1 id="blacklisted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.blacklisted.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">{t('blocking.blacklisted.defaultMessage')}</p>
{/* Reason */}
{blacklistedInfo?.message && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-dark-500">{t('blocking.blacklisted.reason')}:</p>
<p className="text-dark-300">{blacklistedInfo.message}</p>
</div>
)}
<p className="mt-8 text-sm text-dark-500">{t('blocking.blacklisted.contactSupport')}</p>
</div>
</div>
); );
} }

View File

@@ -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. <WrenchIcon className="h-9 w-9" />). */
icon: ReactNode;
/** Already-translated title. */
title: string;
description?: ReactNode;
/** Per-screen body: reason cards, channel list, error block. */
children?: ReactNode;
/** CTA area — pass canonical <Button> elements. */
actions?: ReactNode;
/** Hint / contact-support line under the card. */
footer?: ReactNode;
/** Accent-tinted "working" dots, for screens that actively wait/poll. */
pulse?: boolean;
/** 'polite' for screens whose state changes (retry/error) should announce. */
ariaLive?: 'polite' | 'off';
/** Focus-trap ref — owned by the caller so each screen keeps its own trap. */
screenRef: Ref<HTMLDivElement>;
}
/**
* Shared premium shell for every full-screen blocking/status state: an opaque
* dark canvas with a self-contained accent glow, a centered glass card, and a
* gradient-ringed icon medallion. Replaces the old flat grey-circle + three
* raw dots look. Behavior (focus trap, aria, actions) is supplied by each
* screen; this component owns only the visual chrome.
*/
export default function BlockingShell({
titleId,
accent,
icon,
title,
description,
children,
actions,
footer,
pulse = false,
ariaLive = 'off',
screenRef,
}: BlockingShellProps) {
const a = accentMap[accent];
return (
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="fixed inset-0 z-[100] overflow-y-auto bg-dark-950"
>
{/* Self-contained backdrop — accent glow behind the card. No app reveal:
the canvas stays opaque so this remains a hard block. */}
<div aria-hidden className="pointer-events-none fixed inset-0 overflow-hidden">
<div
className={cn(
'absolute left-1/2 top-1/2 h-[30rem] w-[30rem] -translate-x-1/2 -translate-y-[58%] rounded-full blur-[130px]',
a.glow,
)}
/>
<div className="absolute -bottom-24 left-1/2 h-72 w-[22rem] -translate-x-1/2 rounded-full bg-dark-800/30 blur-[120px]" />
</div>
{/* Scroll-safe centering: min-h-full + items-center centers when it fits
and scrolls without clipping the top when content is tall. */}
<div className="relative flex min-h-full items-center justify-center p-6">
<motion.div
variants={scale}
initial="initial"
animate="animate"
transition={scaleTransition}
aria-live={ariaLive === 'polite' ? 'polite' : undefined}
aria-atomic={ariaLive === 'polite' ? true : undefined}
className="relative w-full max-w-md overflow-hidden rounded-[var(--bento-radius)] border border-dark-700/40 bg-dark-900/80 p-8 text-center shadow-[0_4px_24px_-4px_rgba(0,0,0,0.4),inset_0_1px_0_0_rgba(255,255,255,0.05)] backdrop-blur-xl sm:p-10"
>
{/* Top accent hairline */}
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent to-transparent',
a.hairline,
)}
/>
{/* Icon medallion — gradient ring + glow, not a flat grey circle */}
<motion.div
variants={slideUp}
initial="initial"
animate="animate"
transition={slideUpTransition}
className="mb-6 flex justify-center"
>
<span
className={cn(
'relative flex h-20 w-20 items-center justify-center rounded-full ring-1',
a.medallion,
)}
>
<span
aria-hidden
className={cn(
'absolute inset-0 rounded-full bg-gradient-to-br to-transparent',
a.sheen,
)}
/>
<span className={cn('relative', a.iconColor)}>{icon}</span>
</span>
</motion.div>
<h1 id={titleId} className="font-display text-2xl font-bold tracking-tight text-dark-50">
{title}
</h1>
{description && (
<p className="mt-3 text-base leading-relaxed text-dark-400">{description}</p>
)}
{children && <div className="mt-6 space-y-3 text-left">{children}</div>}
{actions && <div className="mt-7 flex flex-col gap-3">{actions}</div>}
{pulse && (
<div aria-hidden className="mt-7 flex items-center justify-center gap-1.5">
<span
className={cn('h-1.5 w-1.5 animate-pulse rounded-full', a.dot)}
style={{ animationDelay: '0ms' }}
/>
<span
className={cn('h-1.5 w-1.5 animate-pulse rounded-full', a.dot)}
style={{ animationDelay: '300ms' }}
/>
<span
className={cn('h-1.5 w-1.5 animate-pulse rounded-full', a.dot)}
style={{ animationDelay: '600ms' }}
/>
</div>
)}
{footer && <p className="mt-6 text-sm text-dark-500">{footer}</p>}
</motion.div>
</div>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { TelegramIcon, ClockIcon, CheckIcon } from '@/components/icons'; import { TelegramIcon, ClockIcon, CheckIcon, RestartIcon } from '@/components/icons';
import { Button } from '@/components/primitives';
import BlockingShell from './BlockingShell';
const CHECK_COOLDOWN_SECONDS = 5; const CHECK_COOLDOWN_SECONDS = 5;
@@ -84,118 +86,87 @@ export default function ChannelSubscriptionScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
// Check-subscription button — 3 states (checking / cooldown / idle).
let checkIcon: ReactNode;
let checkLabel: string;
if (isChecking) {
checkIcon = <RestartIcon className="h-5 w-5 animate-spin" />;
checkLabel = t('blocking.channel.checking');
} else if (cooldown > 0) {
checkIcon = <ClockIcon className="h-5 w-5" />;
checkLabel = t('blocking.channel.waitSeconds', { seconds: cooldown });
} else {
checkIcon = <CheckIcon className="h-5 w-5" />;
checkLabel = t('blocking.channel.checkSubscription');
}
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="channel-sub-title"
aria-modal="true" accent="info"
aria-labelledby="channel-sub-title" ariaLive="polite"
tabIndex={-1} icon={<TelegramIcon className="h-9 w-9" />}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" title={t('blocking.channel.title')}
> description={channelInfo?.message || t('blocking.channel.defaultMessage')}
<div className="w-full max-w-md text-center"> footer={t('blocking.channel.hint')}
{/* Icon */} actions={
<div className="mb-8"> <Button
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20"> variant="secondary"
<TelegramIcon className="h-12 w-12 text-blue-400" /> size="lg"
</div> fullWidth
</div>
{/* Title */}
<h1 id="channel-sub-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.channel.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">
{channelInfo?.message || t('blocking.channel.defaultMessage')}
</p>
{/* Channel list (only unsubscribed channels) */}
{channels.length > 0 && (
<div className="mb-6 space-y-3">
{channels.map((ch) => (
<div
key={ch.channel_id}
className="flex items-center justify-between rounded-xl border border-error-500/30 bg-error-500/10 p-3"
>
<span className="text-sm font-medium text-white">{ch.title || ch.channel_id}</span>
{ch.channel_link && (
<button
onClick={() => openChannel(ch.channel_link)}
className="rounded-lg bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-400 hover:bg-blue-500/30"
>
{t('blocking.channel.openChannel')}
</button>
)}
</div>
))}
</div>
)}
{/* Fallback: single channel (legacy) */}
{channels.length === 0 && channelInfo?.channel_link && (
<button
onClick={() => openChannel(channelInfo.channel_link)}
className="mb-6 flex w-full items-center justify-center gap-3 rounded-xl bg-accent-500 px-6 py-4 font-semibold text-white transition-colors duration-200 hover:bg-accent-400"
>
{t('blocking.channel.openChannel')}
</button>
)}
{/* Error message */}
{error && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3">
<p className="text-sm text-error-400">{error}</p>
</div>
)}
{/* Check subscription button */}
<button
onClick={checkSubscription} onClick={checkSubscription}
disabled={isChecking || cooldown > 0} disabled={isChecking || cooldown > 0}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60" leftIcon={checkIcon}
> >
{isChecking ? ( {checkLabel}
<> </Button>
<svg }
className="h-5 w-5 animate-spin" >
xmlns="http://www.w3.org/2000/svg" {/* Channel list (only unsubscribed channels) */}
fill="none" {channels.length > 0 && (
viewBox="0 0 24 24" <div className="space-y-2">
> {channels.map((ch) => (
<circle <div
className="opacity-25" key={ch.channel_id}
cx="12" className="flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3"
cy="12" >
r="10" <span className="truncate text-sm font-medium text-dark-50">
stroke="currentColor" {ch.title || ch.channel_id}
strokeWidth="4" </span>
/> {ch.channel_link && (
<path <Button
className="opacity-75" variant="secondary"
fill="currentColor" size="sm"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" className="shrink-0"
/> onClick={() => openChannel(ch.channel_link)}
</svg> >
{t('blocking.channel.checking')} {t('blocking.channel.openChannel')}
</> </Button>
) : cooldown > 0 ? ( )}
<> </div>
<ClockIcon className="h-5 w-5 text-dark-500" /> ))}
{t('blocking.channel.waitSeconds', { seconds: cooldown })} </div>
</> )}
) : (
<>
<CheckIcon className="h-5 w-5" />
{t('blocking.channel.checkSubscription')}
</>
)}
</button>
{/* Hint */} {/* Fallback: single channel (legacy) */}
<p className="mt-4 text-sm text-dark-500">{t('blocking.channel.hint')}</p> {channels.length === 0 && channelInfo?.channel_link && (
</div> <Button
</div> variant="primary"
size="lg"
fullWidth
onClick={() => openChannel(channelInfo.channel_link)}
>
{t('blocking.channel.openChannel')}
</Button>
)}
{/* Error message */}
{error && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
<p className="text-sm text-error-400">{error}</p>
</div>
)}
</BlockingShell>
); );
} }

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { WrenchIcon } from '@/components/icons'; import { WrenchIcon } from '@/components/icons';
import BlockingShell from './BlockingShell';
export default function MaintenanceScreen() { export default function MaintenanceScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -9,58 +10,24 @@ export default function MaintenanceScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="maintenance-title"
aria-modal="true" accent="warning"
aria-labelledby="maintenance-title" icon={<WrenchIcon className="h-9 w-9" />}
tabIndex={-1} title={t('blocking.maintenance.title')}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" description={maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
pulse
footer={t('blocking.maintenance.waitMessage')}
> >
<div className="w-full max-w-md text-center"> {maintenanceInfo?.reason && (
{/* Icon */} <div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<div className="mb-8"> <p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> {t('blocking.maintenance.reason')}:
<WrenchIcon className="h-12 w-12 text-warning-500" /> </p>
</div> <p className="text-sm text-dark-300">{maintenanceInfo.reason}</p>
</div> </div>
)}
{/* Title */} </BlockingShell>
<h1 id="maintenance-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.maintenance.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
</p>
{/* Reason */}
{maintenanceInfo?.reason && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-dark-500">{t('blocking.maintenance.reason')}:</p>
<p className="text-dark-300">{maintenanceInfo.reason}</p>
</div>
)}
{/* Decorative dots */}
<div className="mt-8 flex items-center justify-center gap-2">
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '0ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '300ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '600ms' }}
/>
</div>
<p className="mt-4 text-sm text-dark-500">{t('blocking.maintenance.waitMessage')}</p>
</div>
</div>
); );
} }

View File

@@ -6,6 +6,9 @@ import { useFocusTrap } from '../../hooks/useFocusTrap';
import { pingBackend, hasEverReachedBackend } from '../../api/health'; import { pingBackend, hasEverReachedBackend } from '../../api/health';
import { isInTelegramWebApp, closeTelegramApp } from '../../hooks/useTelegramSDK'; import { isInTelegramWebApp, closeTelegramApp } from '../../hooks/useTelegramSDK';
import { CloudWarningIcon, RestartIcon, CloseIcon } from '@/components/icons'; 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; const POLL_INTERVAL_MS = 5000;
@@ -73,98 +76,45 @@ export default function ServiceUnavailableScreen() {
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false }); const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div <BlockingShell
ref={screenRef} screenRef={screenRef}
role="alertdialog" titleId="service-unavailable-title"
aria-modal="true" accent="warning"
aria-labelledby="service-unavailable-title" ariaLive="polite"
tabIndex={-1} icon={<CloudWarningIcon className="h-9 w-9" />}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6" title={t('blocking.serviceUnavailable.title')}
> description={t('blocking.serviceUnavailable.description')}
<div className="w-full max-w-md text-center"> pulse
{/* Icon */} footer={t('blocking.serviceUnavailable.hint')}
<div className="mb-8"> actions={
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> <>
<CloudWarningIcon className="h-12 w-12 text-warning-500" /> <Button
</div> variant="secondary"
</div> size="lg"
fullWidth
{/* Title */} onClick={handleRetry}
<h1 id="service-unavailable-title" className="mb-4 text-2xl font-bold text-white"> disabled={isChecking}
{t('blocking.serviceUnavailable.title')} leftIcon={<RestartIcon className={cn('h-5 w-5', isChecking && 'animate-spin')} />}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">{t('blocking.serviceUnavailable.description')}</p>
{/* Retry button */}
<button
onClick={handleRetry}
disabled={isChecking}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
>
{isChecking ? (
<>
<svg
className="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('blocking.serviceUnavailable.checking')}
</>
) : (
<>
<RestartIcon className="h-5 w-5" />
{t('blocking.serviceUnavailable.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 && (
<button
onClick={closeTelegramApp}
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border border-dark-700 px-6 py-4 font-semibold text-dark-300 transition-colors duration-200 hover:bg-dark-800 hover:text-white"
> >
<CloseIcon className="h-5 w-5" /> {isChecking
{t('blocking.serviceUnavailable.close')} ? t('blocking.serviceUnavailable.checking')
</button> : t('blocking.serviceUnavailable.retry')}
)} </Button>
{/* Telegram Mini App only — a browser tab can't be closed by script.
{/* Decorative dots */} Reliably exits the Mini App instead of routing back. */}
<div className="mt-8 flex items-center justify-center gap-2"> {inTelegram && (
<div <Button
className="h-2 w-2 animate-pulse rounded-full bg-warning-500" variant="outline"
style={{ animationDelay: '0ms' }} size="lg"
/> fullWidth
<div onClick={closeTelegramApp}
className="h-2 w-2 animate-pulse rounded-full bg-warning-500" leftIcon={<CloseIcon className="h-5 w-5" />}
style={{ animationDelay: '300ms' }} >
/> {t('blocking.serviceUnavailable.close')}
<div </Button>
className="h-2 w-2 animate-pulse rounded-full bg-warning-500" )}
style={{ animationDelay: '600ms' }} </>
/> }
</div> />
<p className="mt-4 text-sm text-dark-500">{t('blocking.serviceUnavailable.hint')}</p>
</div>
</div>
); );
} }