mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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 (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="account-deleted-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
<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">
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="account-deleted-title"
|
||||
accent="warning"
|
||||
icon={<InfoIcon className="h-9 w-9" />}
|
||||
title={t('blocking.accountDeleted.title')}
|
||||
description={t('blocking.accountDeleted.description')}
|
||||
footer={t('blocking.accountDeleted.hint')}
|
||||
actions={
|
||||
<>
|
||||
{deepLink && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button variant="primary" size="lg" fullWidth onClick={handleOpenBot}>
|
||||
{t('blocking.accountDeleted.openBot')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button variant="secondary" size="lg" fullWidth onClick={handleRetry}>
|
||||
{t('blocking.accountDeleted.retry')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-dark-500">{t('blocking.accountDeleted.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(true, { lockScroll: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="blacklisted-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="blacklisted-title"
|
||||
accent="error"
|
||||
icon={<BanIcon className="h-9 w-9" />}
|
||||
title={t('blocking.blacklisted.title')}
|
||||
description={t('blocking.blacklisted.defaultMessage')}
|
||||
footer={t('blocking.blacklisted.contactSupport')}
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
|
||||
<BanIcon className="h-12 w-12 text-error-500" />
|
||||
</div>
|
||||
{blacklistedInfo?.message && (
|
||||
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
|
||||
{t('blocking.blacklisted.reason')}:
|
||||
</p>
|
||||
<p className="text-sm text-dark-300">{blacklistedInfo.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<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>
|
||||
)}
|
||||
</BlockingShell>
|
||||
);
|
||||
}
|
||||
|
||||
196
src/components/blocking/BlockingShell.tsx
Normal file
196
src/components/blocking/BlockingShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { useBlockingStore } from '../../store/blocking';
|
||||
import { apiClient, isChannelSubscriptionError } from '../../api/client';
|
||||
import { usePlatform } from '../../platform';
|
||||
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;
|
||||
|
||||
@@ -84,118 +86,87 @@ export default function ChannelSubscriptionScreen() {
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="channel-sub-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<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">
|
||||
<TelegramIcon className="h-12 w-12 text-blue-400" />
|
||||
</div>
|
||||
</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
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="channel-sub-title"
|
||||
accent="info"
|
||||
ariaLive="polite"
|
||||
icon={<TelegramIcon className="h-9 w-9" />}
|
||||
title={t('blocking.channel.title')}
|
||||
description={channelInfo?.message || t('blocking.channel.defaultMessage')}
|
||||
footer={t('blocking.channel.hint')}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
onClick={checkSubscription}
|
||||
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 ? (
|
||||
<>
|
||||
<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.channel.checking')}
|
||||
</>
|
||||
) : cooldown > 0 ? (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5 text-dark-500" />
|
||||
{t('blocking.channel.waitSeconds', { seconds: cooldown })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
{t('blocking.channel.checkSubscription')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{checkLabel}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Channel list (only unsubscribed channels) */}
|
||||
{channels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{channels.map((ch) => (
|
||||
<div
|
||||
key={ch.channel_id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-dark-50">
|
||||
{ch.title || ch.channel_id}
|
||||
</span>
|
||||
{ch.channel_link && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => openChannel(ch.channel_link)}
|
||||
>
|
||||
{t('blocking.channel.openChannel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<p className="mt-4 text-sm text-dark-500">{t('blocking.channel.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Fallback: single channel (legacy) */}
|
||||
{channels.length === 0 && channelInfo?.channel_link && (
|
||||
<Button
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(true, { lockScroll: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="maintenance-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="maintenance-title"
|
||||
accent="warning"
|
||||
icon={<WrenchIcon className="h-9 w-9" />}
|
||||
title={t('blocking.maintenance.title')}
|
||||
description={maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
|
||||
pulse
|
||||
footer={t('blocking.maintenance.waitMessage')}
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
|
||||
<WrenchIcon className="h-12 w-12 text-warning-500" />
|
||||
</div>
|
||||
{maintenanceInfo?.reason && (
|
||||
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-dark-500">
|
||||
{t('blocking.maintenance.reason')}:
|
||||
</p>
|
||||
<p className="text-sm text-dark-300">{maintenanceInfo.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<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>
|
||||
)}
|
||||
</BlockingShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(true, { lockScroll: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="service-unavailable-title"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
|
||||
>
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 id="service-unavailable-title" className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.serviceUnavailable.title')}
|
||||
</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"
|
||||
<BlockingShell
|
||||
screenRef={screenRef}
|
||||
titleId="service-unavailable-title"
|
||||
accent="warning"
|
||||
ariaLive="polite"
|
||||
icon={<CloudWarningIcon className="h-9 w-9" />}
|
||||
title={t('blocking.serviceUnavailable.title')}
|
||||
description={t('blocking.serviceUnavailable.description')}
|
||||
pulse
|
||||
footer={t('blocking.serviceUnavailable.hint')}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
onClick={handleRetry}
|
||||
disabled={isChecking}
|
||||
leftIcon={<RestartIcon className={cn('h-5 w-5', isChecking && 'animate-spin')} />}
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
{t('blocking.serviceUnavailable.close')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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.serviceUnavailable.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isChecking
|
||||
? t('blocking.serviceUnavailable.checking')
|
||||
: t('blocking.serviceUnavailable.retry')}
|
||||
</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
|
||||
variant="outline"
|
||||
size="lg"
|
||||
fullWidth
|
||||
onClick={closeTelegramApp}
|
||||
leftIcon={<CloseIcon className="h-5 w-5" />}
|
||||
>
|
||||
{t('blocking.serviceUnavailable.close')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user