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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user