mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor: full codebase cleanup, dependency updates, and lint fixes
Phase 0: Remove ~920 lines of dead code (ThemeBentoPicker, PromoDiscountBadge, AdminLayout, SettingsSidebar, MovingGradient, EmptyState, miniapp API, unused types/functions/transitions/skeleton helpers). Fix API barrel file (add 20 missing exports). Phase 1: Add ErrorBoundary (app/page/widget levels), centralize constants, add axios timeout, fix staleTime:0 in React Query. Phase 2: Consolidate hexToHsl, extract email validation, fix duplicate code. Phase 3: Fix auth race condition (await checkAdminStatus), memoize useCurrency, add WebSocket message validation. Phase 4: Extract useBranding, useFeatureFlags, useScrollRestoration from AppShell. Phase 5: Remove all eslint-disable react-hooks/exhaustive-deps (14 total), simplify logger, remove deprecated hooks (useBackButton, useTelegramDnd, useTelegramWebApp). Dependencies: React 19, react-router 7, zustand 5, i18next 25, react-i18next 16, eslint-plugin-react-refresh 0.5. Remove unused @lottiefiles/dotlottie-react. Convert vite manualChunks to function-based approach for react-router v7 compat. Lint: Fix logger.ts no-unused-expressions, fix react-refresh/only-export-components in 6 Radix primitive files (const re-exports → direct re-exports), fix WebSocketProvider exhaustive-deps. Result: 0 errors, 0 warnings. Add CLAUDE.md to .gitignore.
This commit is contained in:
@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authApi } from '../api/auth';
|
||||
import { isValidEmail } from '../utils/validation';
|
||||
import { UI } from '../config/constants';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import { useBackButton } from '@/platform';
|
||||
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||
|
||||
// Icons
|
||||
const CloseIcon = () => (
|
||||
@@ -50,13 +51,13 @@ function useIsMobile() {
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
const RESEND_COOLDOWN = 60; // seconds
|
||||
const RESEND_COOLDOWN = UI.RESEND_COOLDOWN_SEC;
|
||||
|
||||
export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { setUser } = useAuthStore();
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
||||
const isMobileScreen = useIsMobile();
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -88,9 +89,6 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
// Telegram back button - using platform hook
|
||||
useBackButton(handleClose);
|
||||
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
const scrollY = window.scrollY;
|
||||
@@ -181,18 +179,13 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
|
||||
},
|
||||
});
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email.trim());
|
||||
};
|
||||
|
||||
const handleSendCode = () => {
|
||||
setError(null);
|
||||
if (!newEmail.trim()) {
|
||||
setError(t('profile.emailRequired'));
|
||||
return;
|
||||
}
|
||||
if (!validateEmail(newEmail)) {
|
||||
if (!isValidEmail(newEmail.trim())) {
|
||||
setError(t('profile.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
93
src/components/ErrorBoundary.tsx
Normal file
93
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
level?: 'app' | 'page' | 'widget';
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[ErrorBoundary]', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
const { level = 'page' } = this.props;
|
||||
|
||||
if (level === 'app') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-4 text-4xl">⚠️</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
|
||||
<p className="mb-6 text-dark-400">
|
||||
An unexpected error occurred. Please try reloading the page.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (level === 'widget') {
|
||||
return (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
|
||||
<p className="text-sm text-error-400">Failed to load this section</p>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="mt-2 text-sm text-accent-400 hover:text-accent-300"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// level === 'page' (default)
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-4 text-4xl">⚠️</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
|
||||
<p className="mb-6 text-sm text-dark-400">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { promoApi } from '../api/promo';
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XCircleIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WarningIcon = () => (
|
||||
<svg
|
||||
className="h-12 w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
||||
const now = new Date();
|
||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||
let expires: Date;
|
||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||
expires = new Date(expiresAt);
|
||||
} else {
|
||||
// No timezone - treat as UTC
|
||||
expires = new Date(expiresAt + 'Z');
|
||||
}
|
||||
const diffMs = expires.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return '';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}${t('promo.time.days')}`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
|
||||
}
|
||||
return `${minutes}${t('promo.time.minutes')}`;
|
||||
};
|
||||
|
||||
// ------- Confirmation Modal -------
|
||||
|
||||
interface DeactivateConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
discountPercent: number;
|
||||
isDeactivating: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DeactivateConfirmModal({
|
||||
isOpen,
|
||||
discountPercent,
|
||||
isDeactivating,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeactivateConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isDeactivating) {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, isDeactivating, onCancel]);
|
||||
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="deactivate-modal-title"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={isDeactivating ? undefined : onCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Warning header */}
|
||||
<div className="flex flex-col items-center bg-gradient-to-br from-warning-500/20 to-red-500/20 px-6 pb-6 pt-8">
|
||||
<div className="mb-3 text-warning-400">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<h2 id="deactivate-modal-title" className="text-center text-lg font-bold text-dark-100">
|
||||
{t('promo.deactivate.confirmTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-dark-300">
|
||||
{t('promo.deactivate.confirmDescription', { percent: discountPercent })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Warning text */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="rounded-lg border border-warning-500/20 bg-warning-500/10 px-4 py-3">
|
||||
<p className="text-center text-sm text-warning-400">{t('promo.deactivate.warning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3 px-6 pb-6">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isDeactivating}
|
||||
className="flex-1 rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isDeactivating}
|
||||
className="flex-1 rounded-xl bg-gradient-to-r from-red-500 to-red-600 py-3 font-semibold text-white shadow-lg shadow-red-500/25 transition-all hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isDeactivating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
{t('promo.deactivate.deactivating')}
|
||||
</span>
|
||||
) : (
|
||||
t('promo.deactivate.confirm')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
return modalContent;
|
||||
}
|
||||
|
||||
// ------- Main Component -------
|
||||
|
||||
export default function PromoDiscountBadge() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [deactivateError, setDeactivateError] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const source = activeDiscount?.source;
|
||||
if (source && source.startsWith('promocode:')) {
|
||||
await promoApi.deactivateDiscount();
|
||||
} else {
|
||||
await promoApi.clearActiveDiscount();
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
setShowConfirmModal(false);
|
||||
setIsOpen(false);
|
||||
setDeactivateError(null);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setDeactivateError(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCloseConfirmModal = useCallback(() => {
|
||||
if (!deactivateMutation.isPending) {
|
||||
setShowConfirmModal(false);
|
||||
setDeactivateError(null);
|
||||
}
|
||||
}, [deactivateMutation.isPending]);
|
||||
|
||||
const handleConfirmDeactivate = useCallback(() => {
|
||||
setDeactivateError(null);
|
||||
deactivateMutation.mutate();
|
||||
}, [deactivateMutation]);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Don't render if no active discount
|
||||
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleGoToSubscription = () => {
|
||||
setIsOpen(false);
|
||||
navigate('/subscription');
|
||||
};
|
||||
|
||||
const handleDeactivateClick = () => {
|
||||
setDeactivateError(null);
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Badge button */}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
|
||||
title={t('promo.activeDiscount', 'Active discount')}
|
||||
>
|
||||
<span className="text-sm font-bold text-success-400">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
|
||||
{/* Header */}
|
||||
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
|
||||
|
||||
{/* Time remaining */}
|
||||
{timeLeft && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
|
||||
<ClockIcon />
|
||||
<span>
|
||||
{t('promo.expiresIn')}:{' '}
|
||||
<span className="font-medium text-warning-400">{timeLeft}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deactivation error */}
|
||||
{deactivateError && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||
{deactivateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
<button
|
||||
onClick={handleGoToSubscription}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium"
|
||||
>
|
||||
{t('promo.useNow')}
|
||||
</button>
|
||||
|
||||
{/* Deactivate Button */}
|
||||
<button
|
||||
onClick={handleDeactivateClick}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dark-600/50 bg-dark-900/50 py-2 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
|
||||
aria-label={t('promo.deactivate.button')}
|
||||
>
|
||||
<XCircleIcon />
|
||||
<span>{t('promo.deactivate.button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deactivation Confirmation Modal */}
|
||||
<DeactivateConfirmModal
|
||||
isOpen={showConfirmModal}
|
||||
discountPercent={activeDiscount.discount_percent}
|
||||
isDeactivating={deactivateMutation.isPending}
|
||||
onConfirm={handleConfirmDeactivate}
|
||||
onCancel={handleCloseConfirmModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { promoApi, PromoOffer } from '../api/promo';
|
||||
import { ClockIcon, CheckIcon } from './icons';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useSuccessNotification } from '../store/successNotification';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import { useBackButton, useHaptic } from '@/platform';
|
||||
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||
import { useHaptic } from '@/platform';
|
||||
|
||||
// Icons
|
||||
const CheckCircleIcon = () => (
|
||||
@@ -80,7 +80,7 @@ export default function SuccessNotificationModal() {
|
||||
const navigate = useNavigate();
|
||||
const { isOpen, data, hide } = useSuccessNotification();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
|
||||
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
|
||||
const haptic = useHaptic();
|
||||
|
||||
const safeBottom = isTelegramWebApp
|
||||
@@ -106,9 +106,6 @@ export default function SuccessNotificationModal() {
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
// Telegram back button - using platform hook
|
||||
useBackButton(isOpen ? handleClose : null);
|
||||
|
||||
// Haptic feedback on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
|
||||
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
|
||||
import { applyThemeColors } from '../hooks/useThemeColors';
|
||||
|
||||
interface ThemeBentoPickerProps {
|
||||
currentColors: ThemeColors;
|
||||
onColorsChange: (colors: ThemeColors) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SunIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const StatusIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PaletteIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
preset: ColorPreset;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const isRu = i18n.language === 'ru';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
|
||||
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
|
||||
style={{ backgroundColor: preset.preview.background }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
|
||||
style={{ backgroundColor: preset.preview.accent }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
|
||||
style={{ backgroundColor: preset.preview.text }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
|
||||
style={{ backgroundColor: preset.preview.text }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-xs font-semibold text-dark-100">
|
||||
{isRu ? preset.nameRu : preset.name}
|
||||
</h4>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HSLSlider({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
gradient,
|
||||
suffix = '',
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
max: number;
|
||||
gradient: string;
|
||||
suffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-dark-300">{label}</label>
|
||||
<span className="font-mono text-xs tabular-nums text-dark-500">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{ background: gradient }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactColorInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
let formatted = newValue.toUpperCase();
|
||||
if (!formatted.startsWith('#')) {
|
||||
formatted = '#' + formatted;
|
||||
}
|
||||
setLocalValue(formatted);
|
||||
if (isValidHex(formatted)) {
|
||||
onChange(formatted);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
if (!isValidHex(localValue)) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
|
||||
{label}
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||
autoFocus
|
||||
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
|
||||
maxLength={7}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
icon,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
badge,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="text-dark-400">{icon}</div>
|
||||
<span className="text-sm font-medium text-dark-200">{title}</span>
|
||||
{badge && (
|
||||
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-dark-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`grid transition-all duration-200 ease-out ${
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeBentoPicker({
|
||||
currentColors,
|
||||
onColorsChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ThemeBentoPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
|
||||
const [hexInput, setHexInput] = useState(currentColors.accent);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
|
||||
const [isAccentOpen, setIsAccentOpen] = useState(false);
|
||||
const [isDarkOpen, setIsDarkOpen] = useState(false);
|
||||
const [isLightOpen, setIsLightOpen] = useState(false);
|
||||
const [isStatusOpen, setIsStatusOpen] = useState(false);
|
||||
|
||||
const selectedPresetId = useMemo(() => {
|
||||
const match = COLOR_PRESETS.find(
|
||||
(p) =>
|
||||
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
|
||||
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
|
||||
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
|
||||
);
|
||||
return match?.id ?? null;
|
||||
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
|
||||
|
||||
useEffect(() => {
|
||||
setHsl(hexToHsl(currentColors.accent));
|
||||
setHexInput(currentColors.accent);
|
||||
}, [currentColors.accent]);
|
||||
|
||||
const updateColor = useCallback(
|
||||
(key: keyof ThemeColors, value: string) => {
|
||||
const newColors = { ...currentColors, [key]: value };
|
||||
onColorsChange(newColors);
|
||||
applyThemeColors(newColors);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[currentColors, onColorsChange],
|
||||
);
|
||||
|
||||
const updateAccentFromHsl = useCallback(
|
||||
(newHsl: HSLColor) => {
|
||||
setHsl(newHsl);
|
||||
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
|
||||
setHexInput(newHex);
|
||||
updateColor('accent', newHex);
|
||||
},
|
||||
[updateColor],
|
||||
);
|
||||
|
||||
const handleHexInputChange = (value: string) => {
|
||||
setHexInput(value);
|
||||
if (isValidHex(value)) {
|
||||
const newHsl = hexToHsl(value);
|
||||
setHsl(newHsl);
|
||||
updateColor('accent', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: ColorPreset) => {
|
||||
onColorsChange(preset.colors);
|
||||
applyThemeColors(preset.colors);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const hueGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(0, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(60, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(120, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(180, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(240, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(300, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(360, ${hsl.s}%, ${hsl.l}%)
|
||||
)`;
|
||||
}, [hsl.s, hsl.l]);
|
||||
|
||||
const saturationGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(${hsl.h}, 0%, ${hsl.l}%),
|
||||
hsl(${hsl.h}, 100%, ${hsl.l}%)
|
||||
)`;
|
||||
}, [hsl.h, hsl.l]);
|
||||
|
||||
const lightnessGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(${hsl.h}, ${hsl.s}%, 0%),
|
||||
hsl(${hsl.h}, ${hsl.s}%, 50%),
|
||||
hsl(${hsl.h}, ${hsl.s}%, 100%)
|
||||
)`;
|
||||
}, [hsl.h, hsl.s]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<CollapsibleSection
|
||||
title={t('admin.theme.quickPresets', 'Quick Presets')}
|
||||
icon={<PaletteIcon />}
|
||||
badge={`${COLOR_PRESETS.length}`}
|
||||
isOpen={isPresetsOpen}
|
||||
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
|
||||
>
|
||||
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
|
||||
{COLOR_PRESETS.map((preset, index) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="min-h-[100px]"
|
||||
style={{ '--stagger': index } as React.CSSProperties}
|
||||
>
|
||||
<PresetCard
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||
{t('admin.theme.customizeColors', 'Customize Colors')}
|
||||
</h3>
|
||||
|
||||
<CollapsibleSection
|
||||
title={t('admin.theme.accentColor', 'Accent Color')}
|
||||
icon={<PaletteIcon />}
|
||||
badge={hexInput.toUpperCase()}
|
||||
isOpen={isAccentOpen}
|
||||
onToggle={() => setIsAccentOpen(!isAccentOpen)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
|
||||
{hexInput.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HSLSlider
|
||||
label={t('admin.theme.hue', 'Hue')}
|
||||
value={hsl.h}
|
||||
onChange={(h) => updateAccentFromHsl({ ...hsl, h })}
|
||||
max={360}
|
||||
gradient={hueGradient}
|
||||
suffix="°"
|
||||
/>
|
||||
|
||||
<HSLSlider
|
||||
label={t('admin.theme.saturation', 'Saturation')}
|
||||
value={hsl.s}
|
||||
onChange={(s) => updateAccentFromHsl({ ...hsl, s })}
|
||||
max={100}
|
||||
gradient={saturationGradient}
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
<HSLSlider
|
||||
label={t('admin.theme.lightness', 'Lightness')}
|
||||
value={hsl.l}
|
||||
onChange={(l) => updateAccentFromHsl({ ...hsl, l })}
|
||||
max={100}
|
||||
gradient={lightnessGradient}
|
||||
suffix="%"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.theme.hexCode', 'HEX Code')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hexInput}
|
||||
onChange={(e) => handleHexInputChange(e.target.value)}
|
||||
placeholder="#3b82f6"
|
||||
maxLength={7}
|
||||
className="input w-full font-mono text-sm uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title={t('theme.darkTheme', 'Dark Theme')}
|
||||
icon={<MoonIcon />}
|
||||
isOpen={isDarkOpen}
|
||||
onToggle={() => setIsDarkOpen(!isDarkOpen)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<CompactColorInput
|
||||
label={t('theme.background', 'Background')}
|
||||
value={currentColors.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
||||
onChange={(c) => updateColor('darkBackground', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.surface', 'Surface')}
|
||||
value={currentColors.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
|
||||
onChange={(c) => updateColor('darkSurface', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.text', 'Text')}
|
||||
value={currentColors.darkText || DEFAULT_THEME_COLORS.darkText}
|
||||
onChange={(c) => updateColor('darkText', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.textSecondary', 'Secondary')}
|
||||
value={currentColors.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
|
||||
onChange={(c) => updateColor('darkTextSecondary', c)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title={t('theme.lightTheme', 'Light Theme')}
|
||||
icon={<SunIcon />}
|
||||
isOpen={isLightOpen}
|
||||
onToggle={() => setIsLightOpen(!isLightOpen)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<CompactColorInput
|
||||
label={t('theme.background', 'Background')}
|
||||
value={currentColors.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
||||
onChange={(c) => updateColor('lightBackground', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.surface', 'Surface')}
|
||||
value={currentColors.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
|
||||
onChange={(c) => updateColor('lightSurface', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.text', 'Text')}
|
||||
value={currentColors.lightText || DEFAULT_THEME_COLORS.lightText}
|
||||
onChange={(c) => updateColor('lightText', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.textSecondary', 'Secondary')}
|
||||
value={currentColors.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
|
||||
onChange={(c) => updateColor('lightTextSecondary', c)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title={t('theme.statusColors', 'Status Colors')}
|
||||
icon={<StatusIcon />}
|
||||
isOpen={isStatusOpen}
|
||||
onToggle={() => setIsStatusOpen(!isStatusOpen)}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<CompactColorInput
|
||||
label={t('theme.success', 'Success')}
|
||||
value={currentColors.success || DEFAULT_THEME_COLORS.success}
|
||||
onChange={(c) => updateColor('success', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.warning', 'Warning')}
|
||||
value={currentColors.warning || DEFAULT_THEME_COLORS.warning}
|
||||
onChange={(c) => updateColor('warning', c)}
|
||||
/>
|
||||
<CompactColorInput
|
||||
label={t('theme.error', 'Error')}
|
||||
value={currentColors.error || DEFAULT_THEME_COLORS.error}
|
||||
onChange={(c) => updateColor('error', c)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
|
||||
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||
{t('theme.preview', 'Preview')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="btn-primary text-sm">{t('theme.previewButton', 'Primary')}</button>
|
||||
<button className="btn-secondary text-sm">
|
||||
{t('theme.previewSecondary', 'Secondary')}
|
||||
</button>
|
||||
<span className="badge-success">{t('theme.success', 'Success')}</span>
|
||||
<span className="badge-warning">{t('theme.warning', 'Warning')}</span>
|
||||
<span className="badge-error">{t('theme.error', 'Error')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex animate-fade-in justify-end">
|
||||
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
||||
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ticketNotificationsApi } from '../api/ticketNotifications';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useToast } from './Toast';
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||
import type { TicketNotification } from '../types';
|
||||
|
||||
const BellIcon = () => (
|
||||
@@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
const { showToast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
||||
|
||||
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
|
||||
const dropdownTop = isFullscreen
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { BackIcon } from './icons';
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminLayout - wrapper for all admin pages.
|
||||
* Animations removed to prevent black flash during transitions.
|
||||
*/
|
||||
export function AdminLayout({ children, className }: AdminLayoutProps) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding';
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground';
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection: string;
|
||||
setActiveSection: (section: string) => void;
|
||||
mobileMenuOpen: boolean;
|
||||
setMobileMenuOpen: (open: boolean) => void;
|
||||
favoritesCount: number;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
favoritesCount,
|
||||
}: SettingsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700" />
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
|
||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.id}>
|
||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveSection(item.id);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{favoritesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ export function ThemeTab() {
|
||||
// Local draft state
|
||||
const [draftColors, setDraftColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS);
|
||||
const savedColorsRef = useRef<ThemeColors>(DEFAULT_THEME_COLORS);
|
||||
const draftColorsRef = useRef(draftColors);
|
||||
draftColorsRef.current = draftColors;
|
||||
|
||||
// Sync server data into draft and saved snapshot when it arrives
|
||||
useEffect(() => {
|
||||
@@ -79,14 +81,14 @@ export function ThemeTab() {
|
||||
};
|
||||
// Only sync if saved snapshot matches current draft (no unsaved changes)
|
||||
if (
|
||||
colorsEqual(savedColorsRef.current, draftColors) ||
|
||||
colorsEqual(savedColorsRef.current, draftColorsRef.current) ||
|
||||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
|
||||
) {
|
||||
setDraftColors(colors);
|
||||
}
|
||||
savedColorsRef.current = colors;
|
||||
}
|
||||
}, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [serverColors]);
|
||||
|
||||
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Components
|
||||
export * from './AdminBackButton';
|
||||
export * from './AdminLayout';
|
||||
export * from './icons';
|
||||
export * from './Toggle';
|
||||
export * from './SettingInput';
|
||||
@@ -10,7 +9,6 @@ export * from './BrandingTab';
|
||||
export * from './ThemeTab';
|
||||
export * from './FavoritesTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './SettingsSidebar';
|
||||
export * from './SettingsMobileTabs';
|
||||
export * from './SettingsSearch';
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { forwardRef, type ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, type ButtonProps } from '@/components/primitives/Button';
|
||||
import { fadeIn, fadeInTransition } from '@/components/motion/transitions';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: ButtonProps['variant'];
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
||||
({ icon, title, description, action, className }, ref) => {
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
|
||||
variants={fadeIn}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
transition={fadeInTransition}
|
||||
>
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50 text-dark-400">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && <p className="mt-2 max-w-sm text-sm text-dark-400">{description}</p>}
|
||||
|
||||
{/* Action */}
|
||||
{action && (
|
||||
<div className="mt-6">
|
||||
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
@@ -1 +0,0 @@
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './Card';
|
||||
export * from './StatCard';
|
||||
export * from './EmptyState';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import { useBackButton, useHaptic } from '@/platform';
|
||||
import { useHaptic } from '@/platform';
|
||||
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useBranding } from '@/hooks/useBranding';
|
||||
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
|
||||
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
|
||||
import { themeColorsApi } from '@/api/themeColors';
|
||||
import { referralApi } from '@/api/referral';
|
||||
import { wheelApi } from '@/api/wheel';
|
||||
import { contestsApi } from '@/api/contests';
|
||||
import { pollsApi } from '@/api/polls';
|
||||
import {
|
||||
brandingApi,
|
||||
getCachedBranding,
|
||||
setCachedBranding,
|
||||
preloadLogo,
|
||||
isLogoPreloaded,
|
||||
} from '@/api/branding';
|
||||
import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
|
||||
import { isLogoPreloaded } from '@/api/branding';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { UI } from '@/config/constants';
|
||||
|
||||
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||
@@ -176,9 +169,6 @@ const MoonIcon = ({ className }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -186,20 +176,17 @@ interface AppShellProps {
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, isAuthenticated, logout } = useAuthStore();
|
||||
const {
|
||||
isFullscreen,
|
||||
safeAreaInset,
|
||||
contentSafeAreaInset,
|
||||
requestFullscreen,
|
||||
isTelegramWebApp,
|
||||
platform,
|
||||
isMobile,
|
||||
} = useTelegramSDK();
|
||||
const { isAdmin, logout } = useAuthStore();
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
|
||||
useTelegramSDK();
|
||||
const haptic = useHaptic();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
|
||||
// Extracted hooks
|
||||
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
|
||||
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
|
||||
useScrollRestoration();
|
||||
|
||||
// Theme toggle visibility
|
||||
const { data: enabledThemes } = useQuery({
|
||||
queryKey: ['enabled-themes'],
|
||||
@@ -214,147 +201,6 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
||||
|
||||
// Scroll position restoration for admin pages
|
||||
const scrollPositions = useRef<Record<string, number>>({});
|
||||
|
||||
// Disable browser's automatic scroll restoration
|
||||
useEffect(() => {
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Continuously save scroll position for current path
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// Only track scroll for admin pages
|
||||
if (!currentPath.startsWith('/admin')) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollPositions.current[currentPath] = window.scrollY;
|
||||
};
|
||||
|
||||
// Save on scroll
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// Restore scroll position immediately (synchronous)
|
||||
const savedPosition = scrollPositions.current[currentPath];
|
||||
if (savedPosition !== undefined && savedPosition > 0) {
|
||||
// Immediate restore
|
||||
window.scrollTo({ top: savedPosition, behavior: 'instant' });
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
// Branding
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: async () => {
|
||||
const data = await brandingApi.getBranding();
|
||||
setCachedBranding(data);
|
||||
preloadLogo(data);
|
||||
return data;
|
||||
},
|
||||
initialData: getCachedBranding() ?? undefined,
|
||||
staleTime: 60000,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const appName = branding ? branding.name : FALLBACK_NAME;
|
||||
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
|
||||
const hasCustomLogo = branding?.has_custom_logo || false;
|
||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||
|
||||
// Set document title
|
||||
useEffect(() => {
|
||||
document.title = appName || 'VPN';
|
||||
}, [appName]);
|
||||
|
||||
// Update favicon
|
||||
useEffect(() => {
|
||||
if (!logoUrl) return;
|
||||
|
||||
const link =
|
||||
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
|
||||
document.createElement('link');
|
||||
link.type = 'image/x-icon';
|
||||
link.rel = 'shortcut icon';
|
||||
link.href = logoUrl;
|
||||
document.head.appendChild(link);
|
||||
}, [logoUrl]);
|
||||
|
||||
// Fullscreen setting from server
|
||||
const { data: fullscreenSetting } = useQuery({
|
||||
queryKey: ['fullscreen-enabled'],
|
||||
queryFn: brandingApi.getFullscreenEnabled,
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// Apply fullscreen setting when loaded from server
|
||||
// Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen
|
||||
useEffect(() => {
|
||||
if (!fullscreenSetting || !isTelegramWebApp) return;
|
||||
|
||||
// Update cache for future app starts
|
||||
setCachedFullscreenEnabled(fullscreenSetting.enabled);
|
||||
|
||||
// Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
|
||||
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
|
||||
requestFullscreen();
|
||||
}
|
||||
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
|
||||
|
||||
// Feature flags
|
||||
const { data: referralTerms } = useQuery({
|
||||
queryKey: ['referral-terms'],
|
||||
queryFn: referralApi.getReferralTerms,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: wheelConfig } = useQuery({
|
||||
queryKey: ['wheel-config'],
|
||||
queryFn: wheelApi.getConfig,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: contestsCount } = useQuery({
|
||||
queryKey: ['contests-count'],
|
||||
queryFn: contestsApi.getCount,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: pollsCount } = useQuery({
|
||||
queryKey: ['polls-count'],
|
||||
queryFn: pollsApi.getCount,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// BackButton for Telegram Mini App
|
||||
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
|
||||
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
|
||||
const isMainTabPage = mainTabPaths.includes(location.pathname);
|
||||
const handleBack = useCallback(() => {
|
||||
if (mobileMenuOpen) {
|
||||
setMobileMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
navigate(-1);
|
||||
}, [mobileMenuOpen, navigate]);
|
||||
|
||||
useBackButton(isMainTabPage ? null : handleBack);
|
||||
|
||||
// Keyboard detection for hiding bottom nav
|
||||
useEffect(() => {
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
@@ -405,7 +251,8 @@ export function AppShell({ children }: AppShellProps) {
|
||||
// Calculate header height based on fullscreen mode (only on mobile Telegram)
|
||||
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
|
||||
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
|
||||
const telegramHeaderHeight = platform === 'android' ? 48 : 45;
|
||||
const telegramHeaderHeight =
|
||||
platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
|
||||
const headerHeight = isMobileFullscreen
|
||||
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
|
||||
: 64;
|
||||
@@ -465,7 +312,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{referralTerms?.is_enabled && (
|
||||
{referralEnabled && (
|
||||
<Link
|
||||
to="/referral"
|
||||
onClick={handleNavClick}
|
||||
@@ -541,10 +388,10 @@ export function AppShell({ children }: AppShellProps) {
|
||||
safeAreaInset={safeAreaInset}
|
||||
contentSafeAreaInset={contentSafeAreaInset}
|
||||
telegramPlatform={platform}
|
||||
wheelEnabled={wheelConfig?.is_enabled}
|
||||
referralEnabled={referralTerms?.is_enabled}
|
||||
hasContests={(contestsCount?.count ?? 0) > 0}
|
||||
hasPolls={(pollsCount?.count ?? 0) > 0}
|
||||
wheelEnabled={wheelEnabled}
|
||||
referralEnabled={referralEnabled}
|
||||
hasContests={hasContests}
|
||||
hasPolls={hasPolls}
|
||||
/>
|
||||
|
||||
{/* Desktop spacer */}
|
||||
@@ -559,8 +406,8 @@ export function AppShell({ children }: AppShellProps) {
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<MobileBottomNav
|
||||
isKeyboardOpen={isKeyboardOpen}
|
||||
referralEnabled={referralTerms?.is_enabled}
|
||||
wheelEnabled={wheelConfig?.is_enabled}
|
||||
referralEnabled={referralEnabled}
|
||||
wheelEnabled={wheelEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -154,6 +154,14 @@ export function Aurora() {
|
||||
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
|
||||
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
|
||||
|
||||
// Refs for initial color values (WebGL context shouldn't recreate on color change)
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const surfaceRef = useRef(surface);
|
||||
surfaceRef.current = surface;
|
||||
const accentRef = useRef(themeColors.accent);
|
||||
accentRef.current = themeColors.accent;
|
||||
|
||||
// Initialize WebGL context once (only depends on isEnabled)
|
||||
useEffect(() => {
|
||||
if (!isEnabled || !containerRef.current) return;
|
||||
@@ -175,7 +183,11 @@ export function Aurora() {
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
|
||||
const colorStops = generateColorStops(background, surface, themeColors.accent);
|
||||
const colorStops = generateColorStops(
|
||||
backgroundRef.current,
|
||||
surfaceRef.current,
|
||||
accentRef.current,
|
||||
);
|
||||
const colorStopsArray = colorStops
|
||||
.map((hex) => {
|
||||
const c = new Color(hex);
|
||||
@@ -238,7 +250,7 @@ export function Aurora() {
|
||||
rendererRef.current = null;
|
||||
programRef.current = null;
|
||||
};
|
||||
}, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [isEnabled]);
|
||||
|
||||
// Update color uniforms reactively without recreating WebGL context
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Animated moving gradient background.
|
||||
* Uses CSS variables for colors to support theme switching.
|
||||
* Lightweight - pure CSS gradients with Framer Motion animation.
|
||||
*/
|
||||
export function MovingGradient() {
|
||||
return (
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
{/* Base background */}
|
||||
<div className="absolute inset-0 bg-dark-950" />
|
||||
|
||||
{/* Animated gradient layer */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 80% 50% at 20% 40%, rgba(var(--color-accent-500), 0.12) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 80% 60%, rgba(var(--color-accent-500), 0.08) 0%, transparent 50%)',
|
||||
}}
|
||||
animate={{
|
||||
backgroundPosition: ['0% 0%', '100% 100%', '0% 0%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Second animated layer - moves in opposite direction */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-40"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 70% 60% at 70% 30%, rgba(var(--color-accent-500), 0.1) 0%, transparent 50%), radial-gradient(ellipse 50% 50% at 30% 70%, rgba(var(--color-accent-500), 0.06) 0%, transparent 50%)',
|
||||
}}
|
||||
animate={{
|
||||
backgroundPosition: ['100% 100%', '0% 0%', '100% 100%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 25,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle noise texture overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,6 @@ export const springTransition: Transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
// Smooth spring for larger movements
|
||||
export const smoothSpring: Transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
};
|
||||
|
||||
// Expo easing curve (Linear-style)
|
||||
export const easeOutExpo = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
@@ -42,27 +35,6 @@ export const slideUpTransition: Transition = {
|
||||
ease: easeOutExpo,
|
||||
};
|
||||
|
||||
// Slide down animation (for dropdowns, popovers)
|
||||
export const slideDown: Variants = {
|
||||
initial: { opacity: 0, y: -8 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
// Slide from right (for sheets, sidebars)
|
||||
export const slideRight: Variants = {
|
||||
initial: { opacity: 0, x: 20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: 20 },
|
||||
};
|
||||
|
||||
// Slide from left
|
||||
export const slideLeft: Variants = {
|
||||
initial: { opacity: 0, x: -20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -20 },
|
||||
};
|
||||
|
||||
// Scale animation (for modals, dialogs)
|
||||
export const scale: Variants = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
@@ -92,16 +64,6 @@ export const staggerContainer: Variants = {
|
||||
},
|
||||
};
|
||||
|
||||
// Fast stagger for dense lists
|
||||
export const fastStaggerContainer: Variants = {
|
||||
initial: {},
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.03,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stagger item (use with staggerContainer)
|
||||
// Exit is instant to avoid visual glitches in Telegram Mini App
|
||||
export const staggerItem: Variants = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
|
||||
|
||||
export {
|
||||
Trigger as DialogTrigger,
|
||||
Portal as DialogPortal,
|
||||
Close as DialogClose,
|
||||
} from '@radix-ui/react-dialog';
|
||||
|
||||
// Close icon
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
@@ -41,15 +47,6 @@ export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps)
|
||||
);
|
||||
};
|
||||
|
||||
// Trigger
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
// Portal
|
||||
export const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
// Close
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
// Overlay
|
||||
export type DialogOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
|
||||
|
||||
@@ -86,7 +83,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
const { open } = useContext(DialogContext);
|
||||
|
||||
return (
|
||||
<DialogPortal forceMount>
|
||||
<DialogPrimitive.Portal forceMount>
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<>
|
||||
@@ -131,7 +128,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogPortal>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,6 +5,15 @@ import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { dropdown, dropdownTransition } from '../../motion/transitions';
|
||||
|
||||
export {
|
||||
Root as DropdownMenu,
|
||||
Trigger as DropdownMenuTrigger,
|
||||
Group as DropdownMenuGroup,
|
||||
Portal as DropdownMenuPortal,
|
||||
Sub as DropdownMenuSub,
|
||||
RadioGroup as DropdownMenuRadioGroup,
|
||||
} from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
// Icons
|
||||
const CheckIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
@@ -36,24 +45,6 @@ const DotIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
// Group
|
||||
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
// Portal
|
||||
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
// Sub
|
||||
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
// RadioGroup
|
||||
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
// SubTrigger
|
||||
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubTrigger
|
||||
|
||||
@@ -4,6 +4,13 @@ import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdown, dropdownTransition } from '../../motion/transitions';
|
||||
|
||||
export {
|
||||
Root as Popover,
|
||||
Trigger as PopoverTrigger,
|
||||
Anchor as PopoverAnchor,
|
||||
Close as PopoverClose,
|
||||
} from '@radix-ui/react-popover';
|
||||
|
||||
// Close icon
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
@@ -17,18 +24,6 @@ const CloseIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root
|
||||
export const Popover = PopoverPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
// Anchor
|
||||
export const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
// Close
|
||||
export const PopoverClose = PopoverPrimitive.Close;
|
||||
|
||||
// Content
|
||||
export interface PopoverContentProps extends ComponentPropsWithoutRef<
|
||||
typeof PopoverPrimitive.Content
|
||||
|
||||
@@ -5,6 +5,8 @@ import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { dropdown, dropdownTransition } from '../../motion/transitions';
|
||||
|
||||
export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
|
||||
|
||||
// Icons
|
||||
const ChevronDownIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
|
||||
@@ -30,9 +32,6 @@ const CheckIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Root
|
||||
export const Select = SelectPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
|
||||
typeof SelectPrimitive.Trigger
|
||||
@@ -149,9 +148,6 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
|
||||
SelectItem.displayName = 'SelectItem';
|
||||
|
||||
// Group
|
||||
export const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
// Label
|
||||
export type SelectLabelProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Label>;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { useBackButton } from '@/platform';
|
||||
import {
|
||||
backdrop,
|
||||
backdropTransition,
|
||||
@@ -18,6 +17,12 @@ import {
|
||||
sheetTransition,
|
||||
} from '../../motion/transitions';
|
||||
|
||||
export {
|
||||
Trigger as SheetTrigger,
|
||||
Portal as SheetPortal,
|
||||
Close as SheetClose,
|
||||
} from '@radix-ui/react-dialog';
|
||||
|
||||
// Close icon
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
@@ -79,15 +84,6 @@ export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: Sheet
|
||||
);
|
||||
};
|
||||
|
||||
// Trigger
|
||||
export const SheetTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
// Portal
|
||||
export const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
// Close
|
||||
export const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
// Overlay
|
||||
export type SheetOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
|
||||
|
||||
@@ -138,9 +134,6 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
|
||||
const { open, onClose } = useContext(SheetContext);
|
||||
const { haptic } = usePlatform();
|
||||
const dragControls = useDragControls();
|
||||
// Back button integration
|
||||
useBackButton(open ? onClose : null);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
const velocity = info.velocity.y;
|
||||
@@ -157,7 +150,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetPortal forceMount>
|
||||
<DialogPrimitive.Portal forceMount>
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<>
|
||||
@@ -221,7 +214,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SheetPortal>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,14 +4,11 @@ import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react
|
||||
import { cn } from '@/lib/utils';
|
||||
import { tooltip, tooltipTransition } from '../../motion/transitions';
|
||||
|
||||
// Provider - wrap your app with this
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
// Root
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
// Trigger
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
export {
|
||||
Provider as TooltipProvider,
|
||||
Root as Tooltip,
|
||||
Trigger as TooltipTrigger,
|
||||
} from '@radix-ui/react-tooltip';
|
||||
|
||||
// Content
|
||||
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
|
||||
@@ -66,10 +63,10 @@ export const SimpleTooltip = ({
|
||||
delayDuration = 200,
|
||||
className,
|
||||
}: SimpleTooltipProps) => (
|
||||
<Tooltip delayDuration={delayDuration}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipPrimitive.Root delayDuration={delayDuration}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side={side} align={align} className={className}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { useHaptic } from '@/platform';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useBackButton } from '@/platform';
|
||||
import { useHaptic } from '@/platform';
|
||||
|
||||
export interface SheetProps {
|
||||
@@ -105,9 +104,6 @@ export function Sheet({
|
||||
|
||||
const haptic = useHaptic();
|
||||
|
||||
// BackButton integration
|
||||
useBackButton(isOpen ? onClose : null);
|
||||
|
||||
// Calculate current height based on snap point
|
||||
const currentHeight = `${snapPoints[currentSnapIndex] * 100}vh`;
|
||||
|
||||
|
||||
@@ -131,69 +131,3 @@ export function Skeleton({
|
||||
|
||||
return renderSkeleton(0);
|
||||
}
|
||||
|
||||
// Convenience components for common use cases
|
||||
export function TextSkeleton({
|
||||
lines = 1,
|
||||
className = '',
|
||||
lastLineWidth = '60%',
|
||||
}: {
|
||||
lines?: number;
|
||||
className?: string;
|
||||
lastLineWidth?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton key={i} variant="text" width={i === lines - 1 ? lastLineWidth : '100%'} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvatarSkeleton({
|
||||
size = 40,
|
||||
className = '',
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <Skeleton variant="avatar" width={size} height={size} className={className} />;
|
||||
}
|
||||
|
||||
export function CardSkeleton({
|
||||
count = 1,
|
||||
className = '',
|
||||
}: {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <Skeleton variant="card" count={count} className={className} />;
|
||||
}
|
||||
|
||||
export function ListSkeleton({
|
||||
count = 3,
|
||||
className = '',
|
||||
}: {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
<Skeleton variant="list" count={count} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BentoSkeleton({
|
||||
count = 1,
|
||||
className = '',
|
||||
}: {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <Skeleton variant="bento" count={count} className={className} />;
|
||||
}
|
||||
|
||||
// Default export for backwards compatibility
|
||||
export default BentoSkeleton;
|
||||
|
||||
Reference in New Issue
Block a user