mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Update UI components and styles for Bento design system
- Added new BentoCard and BentoSkeleton components for consistent card styling. - Refactored existing components to utilize BentoCard for improved visual consistency. - Updated Tailwind configuration to include new spacing and border radius utilities. - Enhanced global styles with custom scrollbar and noise texture for better aesthetics. - Introduced color conversion utilities for improved color handling across components.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,3 +43,6 @@ coverage/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
miniapp/
|
miniapp/
|
||||||
|
|
||||||
|
# AI & Agents context
|
||||||
|
.ai/
|
||||||
|
|||||||
@@ -145,23 +145,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
queryFn: () => subscriptionApi.getAppConfig(),
|
queryFn: () => subscriptionApi.getAppConfig(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Detect platform ONCE on mount (stable reference)
|
|
||||||
const detectedPlatform = useMemo(() => detectPlatform(), [])
|
const detectedPlatform = useMemo(() => detectPlatform(), [])
|
||||||
|
|
||||||
// Set initial app based on detected platform - AFTER appConfig loads
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appConfig?.platforms || selectedApp) return
|
if (!appConfig?.platforms || selectedApp) return
|
||||||
|
|
||||||
// Priority: detected platform > first available platform
|
|
||||||
let platform = detectedPlatform
|
let platform = detectedPlatform
|
||||||
if (!platform || !appConfig.platforms[platform]?.length) {
|
if (!platform || !appConfig.platforms[platform]?.length) {
|
||||||
platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null
|
platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!platform || !appConfig.platforms[platform]?.length) return
|
if (!platform || !appConfig.platforms[platform]?.length) return
|
||||||
|
|
||||||
const apps = appConfig.platforms[platform]
|
const apps = appConfig.platforms[platform]
|
||||||
// Select featured app or first app for the detected platform
|
|
||||||
const app = apps.find(a => a.isFeatured) || apps[0]
|
const app = apps.find(a => a.isFeatured) || apps[0]
|
||||||
if (app) setSelectedApp(app)
|
if (app) setSelectedApp(app)
|
||||||
}, [appConfig, detectedPlatform, selectedApp])
|
}, [appConfig, detectedPlatform, selectedApp])
|
||||||
@@ -174,7 +167,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
setShowAppSelector(false)
|
setShowAppSelector(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard: Escape to close (PC)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -187,7 +179,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleClose, handleBack, showAppSelector])
|
}, [handleClose, handleBack, showAppSelector])
|
||||||
|
|
||||||
// Telegram back button (Android)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webApp?.BackButton) return
|
if (!webApp?.BackButton) return
|
||||||
const handler = showAppSelector ? handleBack : handleClose
|
const handler = showAppSelector ? handleBack : handleClose
|
||||||
@@ -199,7 +190,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
}
|
}
|
||||||
}, [webApp, handleClose, handleBack, showAppSelector])
|
}, [webApp, handleClose, handleBack, showAppSelector])
|
||||||
|
|
||||||
// Scroll lock
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
return () => { document.body.style.overflow = '' }
|
return () => { document.body.style.overflow = '' }
|
||||||
@@ -214,7 +204,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
const availablePlatforms = useMemo(() => {
|
const availablePlatforms = useMemo(() => {
|
||||||
if (!appConfig?.platforms) return []
|
if (!appConfig?.platforms) return []
|
||||||
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
|
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
|
||||||
// Put detected platform first
|
|
||||||
if (detectedPlatform && available.includes(detectedPlatform)) {
|
if (detectedPlatform && available.includes(detectedPlatform)) {
|
||||||
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
|
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
|
||||||
}
|
}
|
||||||
@@ -256,7 +245,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
// Wrapper component
|
// Wrapper component
|
||||||
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// Mobile fullscreen
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col"
|
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col"
|
||||||
@@ -272,7 +260,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop centered
|
// Desktop centered - positioned higher
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-start justify-center p-4 pt-[8vh]" onClick={handleClose}>
|
<div className="fixed inset-0 bg-black/60 z-[60] flex items-start justify-center p-4 pt-[8vh]" onClick={handleClose}>
|
||||||
<div
|
<div
|
||||||
@@ -330,24 +318,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
|
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
|
||||||
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
|
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectApp')}</h2>
|
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectApp')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apps grouped by platform */}
|
|
||||||
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-5`}>
|
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-5`}>
|
||||||
{availablePlatforms.map(platform => {
|
{availablePlatforms.map(platform => {
|
||||||
const apps = appConfig.platforms[platform]
|
const apps = appConfig.platforms[platform]
|
||||||
if (!apps?.length) return null
|
if (!apps?.length) return null
|
||||||
const isCurrentPlatform = platform === detectedPlatform
|
const isCurrentPlatform = platform === detectedPlatform
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={platform}>
|
<div key={platform}>
|
||||||
{/* Platform header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2 px-1">
|
<div className="flex items-center gap-2 mb-2 px-1">
|
||||||
<span className={`text-sm font-semibold ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-400'}`}>
|
<span className={`text-sm font-semibold ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-400'}`}>
|
||||||
{platformNames[platform] || platform}
|
{platformNames[platform] || platform}
|
||||||
@@ -358,8 +341,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apps for this platform */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{apps.map(app => (
|
{apps.map(app => (
|
||||||
<button
|
<button
|
||||||
@@ -396,7 +377,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
// Main view
|
// Main view
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b border-dark-800">
|
<div className="p-4 border-b border-dark-800">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
|
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
|
||||||
@@ -404,8 +384,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* App selector button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAppSelector(true)}
|
onClick={() => setShowAppSelector(true)}
|
||||||
className="w-full flex items-center gap-3 p-3 rounded-xl bg-dark-800/50 hover:bg-dark-800 transition-colors"
|
className="w-full flex items-center gap-3 p-3 rounded-xl bg-dark-800/50 hover:bg-dark-800 transition-colors"
|
||||||
@@ -421,9 +399,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<div className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} overflow-y-auto p-4 space-y-4`}>
|
<div className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} overflow-y-auto p-4 space-y-4`}>
|
||||||
{/* Step 1: Install */}
|
|
||||||
{selectedApp?.installationStep && (
|
{selectedApp?.installationStep && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -449,7 +425,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Add subscription */}
|
|
||||||
{selectedApp?.addSubscriptionStep && (
|
{selectedApp?.addSubscriptionStep && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -457,9 +432,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
<span className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</span>
|
<span className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
|
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
|
||||||
|
|
||||||
<div className="space-y-2 ml-8">
|
<div className="space-y-2 ml-8">
|
||||||
{/* Connect button */}
|
|
||||||
{selectedApp.deepLink && (
|
{selectedApp.deepLink && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleConnect(selectedApp)}
|
onClick={() => handleConnect(selectedApp)}
|
||||||
@@ -469,8 +442,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
|
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Copy link */}
|
|
||||||
<button
|
<button
|
||||||
onClick={copySubscriptionLink}
|
onClick={copySubscriptionLink}
|
||||||
className={`w-full h-11 rounded-xl border transition-all flex items-center justify-center gap-2 text-sm font-medium ${
|
className={`w-full h-11 rounded-xl border transition-all flex items-center justify-center gap-2 text-sm font-medium ${
|
||||||
@@ -486,7 +457,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Connect */}
|
|
||||||
{selectedApp?.connectAndUseStep && (
|
{selectedApp?.connectAndUseStep && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -145,10 +145,10 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
|
|||||||
const { formatAmount, currencySymbol } = useCurrency()
|
const { formatAmount, currencySymbol } = useCurrency()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
||||||
<div className="absolute inset-0" onClick={onClose} />
|
<div className="absolute inset-0" onClick={onClose} />
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
|
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export default function LanguageSwitcher() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 transition-all text-sm"
|
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 hover:bg-dark-700 transition-all text-sm"
|
||||||
aria-label="Change language"
|
aria-label="Change language"
|
||||||
>
|
>
|
||||||
<span>{currentLang.flag}</span>
|
<span>{currentLang.flag}</span>
|
||||||
<span className="font-medium text-dark-200">{currentLang.name}</span>
|
<span className="font-medium text-dark-200">{currentLang.name}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
className={`w-3.5 h-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
559
src/components/ThemeBentoPicker.tsx
Normal file
559
src/components/ThemeBentoPicker.tsx
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
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="w-4 h-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="w-5 h-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="w-4 h-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="w-4 h-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="w-4 h-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="w-4 h-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={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
|
||||||
|
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0"
|
||||||
|
style={{ backgroundColor: preset.preview.background }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md"
|
||||||
|
style={{ backgroundColor: preset.preview.accent }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60"
|
||||||
|
style={{ backgroundColor: preset.preview.text }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-5 right-2 w-7 h-1 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="text-xs font-semibold text-dark-100 truncate">
|
||||||
|
{isRu ? preset.nameRu : preset.name}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0">
|
||||||
|
<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="text-xs text-dark-500 font-mono tabular-nums">
|
||||||
|
{value}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||||
|
className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
|
||||||
|
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="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105"
|
||||||
|
style={{ backgroundColor: value }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||||
|
autoFocus
|
||||||
|
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{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="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<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="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono">
|
||||||
|
{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="px-4 pb-4 pt-1 border-t border-dark-700/30">{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 grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-1">
|
||||||
|
{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 text-dark-400 uppercase tracking-wide">
|
||||||
|
{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="w-full h-14 rounded-xl shadow-inner relative overflow-hidden"
|
||||||
|
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 text-white/80 text-xs font-mono 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="text-xs font-medium text-dark-300 mb-1.5 block">
|
||||||
|
{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 text-sm font-mono 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 bg-dark-900/50 border border-dark-700/40 p-4">
|
||||||
|
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
|
||||||
|
{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 justify-end animate-fade-in">
|
||||||
|
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
||||||
|
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -198,7 +198,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
{/* Bell button */}
|
{/* Bell button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="relative p-2.5 rounded-xl transition-all duration-200 hover:bg-dark-800/50 text-dark-400 hover:text-dark-100"
|
className="relative p-2 rounded-xl transition-all duration-200 bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50 text-dark-400 hover:text-accent-400"
|
||||||
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
||||||
>
|
>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCurrency } from '../hooks/useCurrency'
|
|||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
||||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
||||||
import type { PaymentMethod } from '../types'
|
import type { PaymentMethod } from '../types'
|
||||||
|
import BentoCard from './ui/BentoCard'
|
||||||
|
|
||||||
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
|
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
|
||||||
const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url)
|
const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url)
|
||||||
@@ -249,11 +250,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
// Auto-focus input - works on mobile in Telegram WebApp
|
// Auto-focus input - works on mobile in Telegram WebApp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Small delay to ensure DOM is ready
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus()
|
inputRef.current.focus()
|
||||||
// For iOS Safari - scroll input into view to trigger keyboard
|
|
||||||
if (isMobileScreen) {
|
if (isMobileScreen) {
|
||||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}
|
}
|
||||||
@@ -347,23 +346,31 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
{/* Quick amount buttons */}
|
{/* Quick amount buttons */}
|
||||||
{quickAmounts.length > 0 && (
|
{quickAmounts.length > 0 && (
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{quickAmounts.map((a) => {
|
{quickAmounts.map((a) => {
|
||||||
const val = getQuickValue(a)
|
const val = getQuickValue(a)
|
||||||
const isSelected = amount === val
|
const isSelected = amount === val
|
||||||
return (
|
return (
|
||||||
<button
|
<BentoCard
|
||||||
key={a}
|
key={a}
|
||||||
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
||||||
className={`flex-1 py-3 rounded-xl text-sm font-bold transition-all duration-200 ${
|
hover
|
||||||
|
glow={isSelected}
|
||||||
|
className={`flex flex-col items-center justify-center py-3 px-2 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/40'
|
? 'border-accent-500/50 bg-accent-500/10'
|
||||||
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-800 hover:text-dark-300 border border-dark-700/30'
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatAmount(a, 0)}
|
<span className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}>
|
||||||
</button>
|
{formatAmount(a, 0)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs mt-0.5 ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}>
|
||||||
|
{currencySymbol}
|
||||||
|
</span>
|
||||||
|
</BentoCard>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header
|
<header
|
||||||
className="fixed top-0 left-0 right-0 z-50 glass border-b border-dark-800/50"
|
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10"
|
||||||
style={{
|
style={{
|
||||||
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
||||||
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
|
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
|
||||||
@@ -363,9 +363,9 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<div className="flex justify-between items-center h-16 lg:h-20">
|
<div className="flex justify-between items-center h-16 lg:h-20">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative">
|
<div className="w-10 h-10 sm:w-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative">
|
||||||
{/* Always show letter as fallback */}
|
{/* Always show letter as fallback */}
|
||||||
<span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
<span className={`text-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||||
{logoLetter}
|
{logoLetter}
|
||||||
</span>
|
</span>
|
||||||
{/* Logo image with smooth fade-in */}
|
{/* Logo image with smooth fade-in */}
|
||||||
@@ -423,7 +423,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
{/* Theme toggle button - only show if both themes are enabled */}
|
{/* Theme toggle button - only show if both themes are enabled */}
|
||||||
{canToggle && (
|
{canToggle && (
|
||||||
<button
|
<button
|
||||||
@@ -431,9 +431,10 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
toggleTheme()
|
toggleTheme()
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false)
|
||||||
}}
|
}}
|
||||||
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
|
className="relative p-2 rounded-xl transition-all duration-200
|
||||||
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
|
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
|
||||||
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
|
dark:text-dark-400 dark:hover:text-accent-400
|
||||||
|
text-champagne-500 hover:text-champagne-800"
|
||||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||||
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
|
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
|
||||||
>
|
>
|
||||||
|
|||||||
115
src/components/ui/BentoCard.tsx
Normal file
115
src/components/ui/BentoCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
|
||||||
|
interface BentoCardBaseProps {
|
||||||
|
size?: BentoSize
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
hover?: boolean
|
||||||
|
glow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BentoCardDivProps extends BentoCardBaseProps {
|
||||||
|
as?: 'div'
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BentoCardLinkProps extends BentoCardBaseProps {
|
||||||
|
as: 'link'
|
||||||
|
to: string
|
||||||
|
state?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BentoCardButtonProps extends BentoCardBaseProps {
|
||||||
|
as: 'button'
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
type?: 'button' | 'submit'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps
|
||||||
|
|
||||||
|
const sizeClasses: Record<BentoSize, string> = {
|
||||||
|
sm: '',
|
||||||
|
md: 'col-span-2',
|
||||||
|
lg: 'row-span-2',
|
||||||
|
xl: 'col-span-2 row-span-2',
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
bento-card
|
||||||
|
rounded-[var(--bento-radius)]
|
||||||
|
p-[var(--bento-padding)]
|
||||||
|
bg-dark-900/70
|
||||||
|
border border-dark-700/40
|
||||||
|
transition-all duration-300 ease-smooth
|
||||||
|
`
|
||||||
|
|
||||||
|
const hoverClasses = `
|
||||||
|
cursor-pointer
|
||||||
|
hover:bg-dark-800/60
|
||||||
|
hover:border-dark-600/50
|
||||||
|
hover:shadow-lg
|
||||||
|
hover:scale-[1.01]
|
||||||
|
active:scale-[0.99]
|
||||||
|
`
|
||||||
|
|
||||||
|
const glowClasses = `
|
||||||
|
hover:shadow-glow
|
||||||
|
hover:border-accent-500/30
|
||||||
|
`
|
||||||
|
|
||||||
|
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
size = 'sm',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
hover = false,
|
||||||
|
glow = false,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const classes = [
|
||||||
|
baseClasses,
|
||||||
|
sizeClasses[size],
|
||||||
|
hover && hoverClasses,
|
||||||
|
glow && glowClasses,
|
||||||
|
className,
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
if (props.as === 'link') {
|
||||||
|
const { to, state } = props as BentoCardLinkProps
|
||||||
|
return (
|
||||||
|
<Link to={to} state={state} className={classes}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.as === 'button') {
|
||||||
|
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={classes}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClick } = props as BentoCardDivProps
|
||||||
|
return (
|
||||||
|
<div ref={ref} onClick={onClick} className={classes}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
BentoCard.displayName = 'BentoCard'
|
||||||
|
|
||||||
|
export default BentoCard
|
||||||
28
src/components/ui/BentoSkeleton.tsx
Normal file
28
src/components/ui/BentoSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
interface BentoSkeletonProps {
|
||||||
|
className?: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
|
||||||
|
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`
|
||||||
|
|
||||||
|
if (count > 1) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={baseClasses}
|
||||||
|
style={{ '--stagger': i } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={baseClasses}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
282
src/data/colorPresets.ts
Normal file
282
src/data/colorPresets.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { ThemeColors } from '../types/theme'
|
||||||
|
|
||||||
|
export interface ColorPreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameRu: string
|
||||||
|
description: string
|
||||||
|
descriptionRu: string
|
||||||
|
colors: ThemeColors
|
||||||
|
preview: {
|
||||||
|
background: string
|
||||||
|
accent: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_PRESETS: ColorPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'electric-blue',
|
||||||
|
name: 'Electric Blue',
|
||||||
|
nameRu: 'Электрик',
|
||||||
|
description: 'Classic tech blue, reliable and clean',
|
||||||
|
descriptionRu: 'Классический технологичный синий',
|
||||||
|
colors: {
|
||||||
|
accent: '#3b82f6',
|
||||||
|
darkBackground: '#0a0f1a',
|
||||||
|
darkSurface: '#0f172a',
|
||||||
|
darkText: '#f1f5f9',
|
||||||
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#f1f5f9',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#0f172a',
|
||||||
|
lightTextSecondary: '#475569',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0a0f1a',
|
||||||
|
accent: '#3b82f6',
|
||||||
|
text: '#f1f5f9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toxic-neon',
|
||||||
|
name: 'Toxic Neon',
|
||||||
|
nameRu: 'Токсичный неон',
|
||||||
|
description: 'Cyberpunk vibes, high energy',
|
||||||
|
descriptionRu: 'Киберпанк атмосфера, высокая энергия',
|
||||||
|
colors: {
|
||||||
|
accent: '#22c55e',
|
||||||
|
darkBackground: '#030712',
|
||||||
|
darkSurface: '#0a0f14',
|
||||||
|
darkText: '#e2e8f0',
|
||||||
|
darkTextSecondary: '#64748b',
|
||||||
|
lightBackground: '#f0fdf4',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#14532d',
|
||||||
|
lightTextSecondary: '#166534',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#eab308',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#030712',
|
||||||
|
accent: '#22c55e',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'royal-purple',
|
||||||
|
name: 'Royal Purple',
|
||||||
|
nameRu: 'Королевский пурпур',
|
||||||
|
description: 'Premium, sophisticated, Stripe-like',
|
||||||
|
descriptionRu: 'Премиальный, утончённый, как Stripe',
|
||||||
|
colors: {
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
darkBackground: '#0c0a14',
|
||||||
|
darkSurface: '#13111c',
|
||||||
|
darkText: '#f1f0f5',
|
||||||
|
darkTextSecondary: '#a1a1aa',
|
||||||
|
lightBackground: '#faf5ff',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#3b0764',
|
||||||
|
lightTextSecondary: '#581c87',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0c0a14',
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
text: '#f1f0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunset-orange',
|
||||||
|
name: 'Sunset Orange',
|
||||||
|
nameRu: 'Закатный оранж',
|
||||||
|
description: 'Warm, energetic, action-oriented',
|
||||||
|
descriptionRu: 'Тёплый, энергичный, призыв к действию',
|
||||||
|
colors: {
|
||||||
|
accent: '#f97316',
|
||||||
|
darkBackground: '#0f0906',
|
||||||
|
darkSurface: '#1a120d',
|
||||||
|
darkText: '#fef3e2',
|
||||||
|
darkTextSecondary: '#a3a3a3',
|
||||||
|
lightBackground: '#fff7ed',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#7c2d12',
|
||||||
|
lightTextSecondary: '#9a3412',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0f0906',
|
||||||
|
accent: '#f97316',
|
||||||
|
text: '#fef3e2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ocean-teal',
|
||||||
|
name: 'Ocean Teal',
|
||||||
|
nameRu: 'Океанский бирюзовый',
|
||||||
|
description: 'Calm, trustworthy, health-tech',
|
||||||
|
descriptionRu: 'Спокойный, надёжный, медтех',
|
||||||
|
colors: {
|
||||||
|
accent: '#14b8a6',
|
||||||
|
darkBackground: '#042f2e',
|
||||||
|
darkSurface: '#0d3d3b',
|
||||||
|
darkText: '#f0fdfa',
|
||||||
|
darkTextSecondary: '#5eead4',
|
||||||
|
lightBackground: '#f0fdfa',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#134e4a',
|
||||||
|
lightTextSecondary: '#115e59',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#042f2e',
|
||||||
|
accent: '#14b8a6',
|
||||||
|
text: '#f0fdfa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'champagne-gold',
|
||||||
|
name: 'Champagne Gold',
|
||||||
|
nameRu: 'Шампанское золото',
|
||||||
|
description: 'Luxury, premium, elegant',
|
||||||
|
descriptionRu: 'Роскошный, премиальный, элегантный',
|
||||||
|
colors: {
|
||||||
|
accent: '#b8860b',
|
||||||
|
darkBackground: '#0a0f1a',
|
||||||
|
darkSurface: '#0f172a',
|
||||||
|
darkText: '#f1f5f9',
|
||||||
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#fefce8',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#713f12',
|
||||||
|
lightTextSecondary: '#92400e',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0a0f1a',
|
||||||
|
accent: '#b8860b',
|
||||||
|
text: '#f1f5f9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quantum-teal',
|
||||||
|
name: 'Quantum Teal',
|
||||||
|
nameRu: 'Квантовый бирюзовый',
|
||||||
|
description: 'Bio-synthetic, futuristic fintech',
|
||||||
|
descriptionRu: 'Био-синтетический, футуристичный финтех',
|
||||||
|
colors: {
|
||||||
|
accent: '#0d9488',
|
||||||
|
darkBackground: '#0f172a',
|
||||||
|
darkSurface: '#1e293b',
|
||||||
|
darkText: '#f1f5f9',
|
||||||
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#f0fdfa',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#134e4a',
|
||||||
|
lightTextSecondary: '#0f766e',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0f172a',
|
||||||
|
accent: '#0d9488',
|
||||||
|
text: '#f1f5f9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cosmic-violet',
|
||||||
|
name: 'Cosmic Violet',
|
||||||
|
nameRu: 'Космический фиолет',
|
||||||
|
description: 'Digital lavender, wellness vibes',
|
||||||
|
descriptionRu: 'Цифровая лаванда, атмосфера спокойствия',
|
||||||
|
colors: {
|
||||||
|
accent: '#7c3aed',
|
||||||
|
darkBackground: '#0b0d10',
|
||||||
|
darkSurface: '#18181b',
|
||||||
|
darkText: '#f4f4f5',
|
||||||
|
darkTextSecondary: '#a1a1aa',
|
||||||
|
lightBackground: '#faf5ff',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#3b0764',
|
||||||
|
lightTextSecondary: '#5b21b6',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0b0d10',
|
||||||
|
accent: '#7c3aed',
|
||||||
|
text: '#f4f4f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'solar-coral',
|
||||||
|
name: 'Solar Coral',
|
||||||
|
nameRu: 'Солнечный коралл',
|
||||||
|
description: 'Hyper-coral, high energy social',
|
||||||
|
descriptionRu: 'Гипер-коралловый, энергия соцсетей',
|
||||||
|
colors: {
|
||||||
|
accent: '#ea580c',
|
||||||
|
darkBackground: '#18181b',
|
||||||
|
darkSurface: '#27272a',
|
||||||
|
darkText: '#fafafa',
|
||||||
|
darkTextSecondary: '#a1a1aa',
|
||||||
|
lightBackground: '#fff7ed',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#7c2d12',
|
||||||
|
lightTextSecondary: '#9a3412',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#18181b',
|
||||||
|
accent: '#ea580c',
|
||||||
|
text: '#fafafa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frost-blue',
|
||||||
|
name: 'Frost Blue',
|
||||||
|
nameRu: 'Морозный синий',
|
||||||
|
description: 'Liquid chrome, enterprise trust',
|
||||||
|
descriptionRu: 'Жидкий хром, корпоративное доверие',
|
||||||
|
colors: {
|
||||||
|
accent: '#0284c7',
|
||||||
|
darkBackground: '#0b0d10',
|
||||||
|
darkSurface: '#1e293b',
|
||||||
|
darkText: '#f1f5f9',
|
||||||
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#f0f9ff',
|
||||||
|
lightSurface: '#ffffff',
|
||||||
|
lightText: '#0c4a6e',
|
||||||
|
lightTextSecondary: '#075985',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
background: '#0b0d10',
|
||||||
|
accent: '#0284c7',
|
||||||
|
text: '#f1f5f9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPresetById(id: string): ColorPreset | undefined {
|
||||||
|
return COLOR_PRESETS.find((preset) => preset.id === id)
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import { brandingApi, setCachedBranding } from '../api/branding'
|
|||||||
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
|
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
|
||||||
import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp'
|
import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp'
|
||||||
import { themeColorsApi } from '../api/themeColors'
|
import { themeColorsApi } from '../api/themeColors'
|
||||||
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme'
|
||||||
import { ColorPicker } from '../components/ColorPicker'
|
import { ThemeBentoPicker } from '../components/ThemeBentoPicker'
|
||||||
import { applyThemeColors } from '../hooks/useThemeColors'
|
import { applyThemeColors } from '../hooks/useThemeColors'
|
||||||
import { updateEnabledThemesCache } from '../hooks/useTheme'
|
import { updateEnabledThemesCache } from '../hooks/useTheme'
|
||||||
|
|
||||||
@@ -858,6 +858,7 @@ export default function AdminSettings() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
|
const [localColors, setLocalColors] = useState<ThemeColors | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Branding query and mutations
|
// Branding query and mutations
|
||||||
@@ -1355,113 +1356,18 @@ export default function AdminSettings() {
|
|||||||
Включите нужные темы для пользователей. Минимум одна тема должна быть активна.
|
Включите нужные темы для пользователей. Минимум одна тема должна быть активна.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Accent Color */}
|
|
||||||
<ColorPicker
|
<ThemeBentoPicker
|
||||||
label={t('theme.accent')}
|
currentColors={localColors || themeColors || DEFAULT_THEME_COLORS}
|
||||||
description={t('theme.accentDescription')}
|
onColorsChange={(colors) => setLocalColors(colors)}
|
||||||
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
onSave={() => {
|
||||||
onChange={(color) => updateColorsMutation.mutate({ accent: color })}
|
if (localColors) {
|
||||||
disabled={updateColorsMutation.isPending}
|
updateColorsMutation.mutate(localColors)
|
||||||
|
setLocalColors(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isSaving={updateColorsMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dark Theme Section */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.darkTheme')}</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.background')}
|
|
||||||
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkBackground: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.surface')}
|
|
||||||
value={themeColors?.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkSurface: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.text')}
|
|
||||||
value={themeColors?.darkText || DEFAULT_THEME_COLORS.darkText}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkText: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.textSecondary')}
|
|
||||||
value={themeColors?.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ darkTextSecondary: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Light Theme Section */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.lightTheme')}</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.background')}
|
|
||||||
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightBackground: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.surface')}
|
|
||||||
value={themeColors?.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightSurface: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.text')}
|
|
||||||
value={themeColors?.lightText || DEFAULT_THEME_COLORS.lightText}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightText: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.textSecondary')}
|
|
||||||
value={themeColors?.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ lightTextSecondary: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Colors */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.statusColors')}</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.success')}
|
|
||||||
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ success: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.warning')}
|
|
||||||
value={themeColors?.warning || DEFAULT_THEME_COLORS.warning}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ warning: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
label={t('theme.error')}
|
|
||||||
value={themeColors?.error || DEFAULT_THEME_COLORS.error}
|
|
||||||
onChange={(color) => updateColorsMutation.mutate({ error: color })}
|
|
||||||
disabled={updateColorsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="p-4 bg-dark-800/50 rounded-xl">
|
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('theme.preview')}</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button className="btn-primary text-sm">{t('theme.previewButton')}</button>
|
|
||||||
<button className="btn-secondary text-sm">{t('theme.previewSecondary')}</button>
|
|
||||||
<span className="badge-success">{t('theme.success')}</span>
|
|
||||||
<span className="badge-warning">{t('theme.warning')}</span>
|
|
||||||
<span className="badge-error">{t('theme.error')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default function Balance() {
|
|||||||
const [promocodeSuccess, setPromocodeSuccess] =
|
const [promocodeSuccess, setPromocodeSuccess] =
|
||||||
useState<{ message: string; amount: number } | null>(null)
|
useState<{ message: string; amount: number } | null>(null)
|
||||||
const [transactionsPage, setTransactionsPage] = useState(1)
|
const [transactionsPage, setTransactionsPage] = useState(1)
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||||
|
|
||||||
const { data: transactions, isLoading } = useQuery<PaginatedResponse<Transaction>>({
|
const { data: transactions, isLoading } = useQuery<PaginatedResponse<Transaction>>({
|
||||||
queryKey: ['transactions', transactionsPage],
|
queryKey: ['transactions', transactionsPage],
|
||||||
@@ -122,7 +123,7 @@ export default function Balance() {
|
|||||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('balance.title')}</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('balance.title')}</h1>
|
||||||
|
|
||||||
{/* Balance Card */}
|
{/* Balance Card */}
|
||||||
<div className="card bg-gradient-to-br from-accent-500/10 to-transparent border-accent-500/20">
|
<div className="bento-card bento-card-glow bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||||
<div className="text-sm text-dark-400 mb-2">{t('balance.currentBalance')}</div>
|
<div className="text-sm text-dark-400 mb-2">{t('balance.currentBalance')}</div>
|
||||||
<div className="text-4xl sm:text-5xl font-bold text-dark-50">
|
<div className="text-4xl sm:text-5xl font-bold text-dark-50">
|
||||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||||
@@ -131,7 +132,7 @@ export default function Balance() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promo Code Section */}
|
{/* Promo Code Section */}
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.promocode.title')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.promocode.title')}</h2>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -168,7 +169,7 @@ export default function Balance() {
|
|||||||
|
|
||||||
{/* Payment Methods */}
|
{/* Payment Methods */}
|
||||||
{paymentMethods && paymentMethods.length > 0 && (
|
{paymentMethods && paymentMethods.length > 0 && (
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.topUpBalance')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.topUpBalance')}</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{paymentMethods.map((method) => {
|
{paymentMethods.map((method) => {
|
||||||
@@ -181,10 +182,10 @@ export default function Balance() {
|
|||||||
key={method.id}
|
key={method.id}
|
||||||
disabled={!method.is_available}
|
disabled={!method.is_available}
|
||||||
onClick={() => method.is_available && setSelectedMethod(method)}
|
onClick={() => method.is_available && setSelectedMethod(method)}
|
||||||
className={`p-4 rounded-xl border text-left transition-all ${
|
className={`bento-card-hover p-4 text-left transition-all ${
|
||||||
method.is_available
|
method.is_available
|
||||||
? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer'
|
? 'cursor-pointer'
|
||||||
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
: 'opacity-50 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
|
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
|
||||||
@@ -201,88 +202,104 @@ export default function Balance() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transaction History */}
|
<div className="bento-card overflow-hidden">
|
||||||
<div className="card">
|
<button
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.transactionHistory')}</h2>
|
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||||
|
className="w-full flex items-center justify-between text-left"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100">{t('balance.transactionHistory')}</h2>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
|
||||||
{isLoading ? (
|
{isHistoryOpen && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="mt-4">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
{isLoading ? (
|
||||||
</div>
|
<div className="flex items-center justify-center py-12">
|
||||||
) : transactions?.items && transactions.items.length > 0 ? (
|
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||||
<div className="space-y-3">
|
|
||||||
{transactions.items.map((tx) => {
|
|
||||||
// API returns negative values for debits, positive for credits
|
|
||||||
const isPositive = tx.amount_rubles >= 0
|
|
||||||
const displayAmount = Math.abs(tx.amount_rubles)
|
|
||||||
const sign = isPositive ? '+' : '-'
|
|
||||||
const colorClass = isPositive ? 'text-success-400' : 'text-error-400'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tx.id}
|
|
||||||
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<span className={getTypeBadge(tx.type)}>
|
|
||||||
{getTypeLabel(tx.type)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-dark-500">
|
|
||||||
{new Date(tx.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{tx.description && (
|
|
||||||
<div className="text-sm text-dark-400">{tx.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={`text-lg font-semibold ${colorClass}`}>
|
|
||||||
{sign}{formatAmount(displayAmount)} {currencySymbol}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
) : transactions?.items && transactions.items.length > 0 ? (
|
||||||
})}
|
<div className="space-y-3">
|
||||||
</div>
|
{transactions.items.map((tx) => {
|
||||||
) : (
|
const isPositive = tx.amount_rubles >= 0
|
||||||
<div className="text-center py-12">
|
const displayAmount = Math.abs(tx.amount_rubles)
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
const sign = isPositive ? '+' : '-'
|
||||||
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
const colorClass = isPositive ? 'text-success-400' : 'text-error-400'
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transactions && transactions.pages > 1 && (
|
return (
|
||||||
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
|
<div
|
||||||
<button
|
key={tx.id}
|
||||||
type="button"
|
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||||
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
>
|
||||||
disabled={transactions.page <= 1}
|
<div className="flex-1">
|
||||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
|
<div className="flex items-center gap-3 mb-1">
|
||||||
transactions.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
|
<span className={getTypeBadge(tx.type)}>
|
||||||
}`}
|
{getTypeLabel(tx.type)}
|
||||||
>
|
</span>
|
||||||
{t('common.back')}
|
<span className="text-xs text-dark-500">
|
||||||
</button>
|
{new Date(tx.created_at).toLocaleDateString()}
|
||||||
<div className="flex-1 text-center">
|
</span>
|
||||||
{t('balance.page', { current: transactions.page, total: transactions.pages })}
|
</div>
|
||||||
</div>
|
{tx.description && (
|
||||||
<button
|
<div className="text-sm text-dark-400">{tx.description}</div>
|
||||||
type="button"
|
)}
|
||||||
onClick={() =>
|
</div>
|
||||||
setTransactionsPage((prev) =>
|
<div className={`text-lg font-semibold ${colorClass}`}>
|
||||||
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
|
{sign}{formatAmount(displayAmount)} {currencySymbol}
|
||||||
)
|
</div>
|
||||||
}
|
</div>
|
||||||
disabled={transactions.page >= transactions.pages}
|
)
|
||||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
|
})}
|
||||||
transactions.page >= transactions.pages ? 'opacity-50 cursor-not-allowed' : ''
|
</div>
|
||||||
}`}
|
) : (
|
||||||
>
|
<div className="text-center py-12">
|
||||||
{t('common.next')}
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||||
</button>
|
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transactions && transactions.pages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={transactions.page <= 1}
|
||||||
|
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
|
||||||
|
transactions.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
{t('balance.page', { current: transactions.page, total: transactions.pages })}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setTransactionsPage((prev) =>
|
||||||
|
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={transactions.page >= transactions.pages}
|
||||||
|
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
|
||||||
|
transactions.page >= transactions.pages ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('common.next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export default function Contests() {
|
|||||||
|
|
||||||
{/* Game Modal */}
|
{/* Game Modal */}
|
||||||
{selectedContest && (
|
{selectedContest && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
|
<div className="fixed inset-0 z-[60] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
|
<div className="bento-card max-w-lg w-full max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-xl font-bold">{selectedContest.name}</h2>
|
<h2 className="text-xl font-bold">{selectedContest.name}</h2>
|
||||||
<button onClick={handleCloseGame} className="text-dark-400 hover:text-dark-200">
|
<button onClick={handleCloseGame} className="text-dark-400 hover:text-dark-200">
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Subscription Status - Main Card */}
|
{/* Subscription Status - Main Card */}
|
||||||
{subscription && (
|
{subscription && (
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
||||||
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
|
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
|
||||||
@@ -336,9 +336,9 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="bento-grid">
|
||||||
{/* Balance */}
|
{/* Balance */}
|
||||||
<Link to="/balance" className="card-hover group" data-onboarding="balance">
|
<Link to="/balance" className="bento-card-hover group" data-onboarding="balance">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-dark-400 text-sm">{t('balance.currentBalance')}</span>
|
<span className="text-dark-400 text-sm">{t('balance.currentBalance')}</span>
|
||||||
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
||||||
@@ -352,7 +352,7 @@ export default function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Subscription */}
|
{/* Subscription */}
|
||||||
<Link to="/subscription" className="card-hover group" data-onboarding="subscription-status">
|
<Link to="/subscription" className="bento-card-hover group" data-onboarding="subscription-status">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-dark-400 text-sm">{t('subscription.title')}</span>
|
<span className="text-dark-400 text-sm">{t('subscription.title')}</span>
|
||||||
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
||||||
@@ -388,7 +388,7 @@ export default function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Referrals */}
|
{/* Referrals */}
|
||||||
<Link to="/referral" className="card-hover group">
|
<Link to="/referral" className="bento-card-hover group">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-dark-400 text-sm">{t('referral.stats.totalReferrals')}</span>
|
<span className="text-dark-400 text-sm">{t('referral.stats.totalReferrals')}</span>
|
||||||
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
||||||
@@ -403,7 +403,7 @@ export default function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Earnings */}
|
{/* Earnings */}
|
||||||
<Link to="/referral" className="card-hover group">
|
<Link to="/referral" className="bento-card-hover group">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-dark-400 text-sm">{t('referral.stats.totalEarnings')}</span>
|
<span className="text-dark-400 text-sm">{t('referral.stats.totalEarnings')}</span>
|
||||||
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
|
||||||
@@ -422,7 +422,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Trial Activation */}
|
{/* Trial Activation */}
|
||||||
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
||||||
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
<div className="bento-card-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
<SparklesIcon />
|
<SparklesIcon />
|
||||||
@@ -483,7 +483,7 @@ export default function Dashboard() {
|
|||||||
{wheelConfig?.is_enabled && (
|
{wheelConfig?.is_enabled && (
|
||||||
<Link
|
<Link
|
||||||
to="/wheel"
|
to="/wheel"
|
||||||
className="group card-hover flex items-center justify-between"
|
className="group bento-card-hover flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Emoji */}
|
{/* Emoji */}
|
||||||
@@ -504,7 +504,7 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="card" data-onboarding="quick-actions">
|
<div className="bento-card" data-onboarding="quick-actions">
|
||||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('dashboard.quickActions')}</h3>
|
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('dashboard.quickActions')}</h3>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<Link to="/balance" className="btn-secondary justify-center text-center text-sm py-2.5">
|
<Link to="/balance" className="btn-secondary justify-center text-center text-sm py-2.5">
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function Info() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{faqPages.map((faq: FaqPage) => (
|
{faqPages.map((faq: FaqPage) => (
|
||||||
<div key={faq.id} className="card p-0 overflow-hidden">
|
<div key={faq.id} className="bento-card p-0 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFaq(faq.id)}
|
onClick={() => toggleFaq(faq.id)}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-dark-800/50 transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-dark-800/50 transition-colors"
|
||||||
@@ -203,7 +203,7 @@ export default function Info() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card prose prose-invert max-w-none">
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }} />
|
<div dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }} />
|
||||||
{rules.updated_at && (
|
{rules.updated_at && (
|
||||||
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
|
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
|
||||||
@@ -232,7 +232,7 @@ export default function Info() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card prose prose-invert max-w-none">
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }} />
|
<div dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }} />
|
||||||
{privacy.updated_at && (
|
{privacy.updated_at && (
|
||||||
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
|
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
|
||||||
@@ -261,7 +261,7 @@ export default function Info() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card prose prose-invert max-w-none">
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }} />
|
<div dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }} />
|
||||||
{offer.updated_at && (
|
{offer.updated_at && (
|
||||||
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
|
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function Profile() {
|
|||||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('profile.title')}</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('profile.title')}</h1>
|
||||||
|
|
||||||
{/* User Info Card */}
|
{/* User Info Card */}
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.accountInfo')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.accountInfo')}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
|
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
|
||||||
@@ -126,7 +126,7 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Section */}
|
{/* Email Section */}
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
|
||||||
|
|
||||||
{user?.email ? (
|
{user?.email ? (
|
||||||
@@ -250,7 +250,7 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification Settings */}
|
{/* Notification Settings */}
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.notifications.title')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.notifications.title')}</h2>
|
||||||
|
|
||||||
{notificationsLoading ? (
|
{notificationsLoading ? (
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export default function Referral() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4'>
|
<div className='bento-grid'>
|
||||||
<div className='card'>
|
<div className='bento-card-hover'>
|
||||||
<div className='text-sm text-dark-400'>
|
<div className='text-sm text-dark-400'>
|
||||||
{t('referral.stats.totalReferrals')}
|
{t('referral.stats.totalReferrals')}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +155,7 @@ export default function Referral() {
|
|||||||
{t('referral.stats.activeReferrals').toLowerCase()}
|
{t('referral.stats.activeReferrals').toLowerCase()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='card'>
|
<div className='bento-card-hover'>
|
||||||
<div className='text-sm text-dark-400'>
|
<div className='text-sm text-dark-400'>
|
||||||
{t('referral.stats.totalEarnings')}
|
{t('referral.stats.totalEarnings')}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +163,7 @@ export default function Referral() {
|
|||||||
{formatPositive(info?.total_earnings_rubles || 0)}
|
{formatPositive(info?.total_earnings_rubles || 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='card col-span-2 sm:col-span-1'>
|
<div className='bento-card-hover col-span-2 sm:col-span-1'>
|
||||||
<div className='text-sm text-dark-400'>
|
<div className='text-sm text-dark-400'>
|
||||||
{t('referral.stats.commissionRate')}
|
{t('referral.stats.commissionRate')}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +174,7 @@ export default function Referral() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Referral Link */}
|
{/* Referral Link */}
|
||||||
<div className='card'>
|
<div className='bento-card'>
|
||||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
{t('referral.yourLink')}
|
{t('referral.yourLink')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -217,7 +217,7 @@ export default function Referral() {
|
|||||||
|
|
||||||
{/* Program Terms */}
|
{/* Program Terms */}
|
||||||
{terms && (
|
{terms && (
|
||||||
<div className='card'>
|
<div className='bento-card'>
|
||||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
{t('referral.terms.title')}
|
{t('referral.terms.title')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -259,7 +259,7 @@ export default function Referral() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Referrals List */}
|
{/* Referrals List */}
|
||||||
<div className='card'>
|
<div className='bento-card'>
|
||||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
{t('referral.yourReferrals')}
|
{t('referral.yourReferrals')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -314,7 +314,7 @@ export default function Referral() {
|
|||||||
|
|
||||||
{/* Earnings History */}
|
{/* Earnings History */}
|
||||||
{earnings?.items && earnings.items.length > 0 && (
|
{earnings?.items && earnings.items.length > 0 && (
|
||||||
<div className='card'>
|
<div className='bento-card'>
|
||||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
{t('referral.earningsHistory')}
|
{t('referral.earningsHistory')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export default function Subscription() {
|
|||||||
|
|
||||||
{/* Current Subscription */}
|
{/* Current Subscription */}
|
||||||
{subscription ? (
|
{subscription ? (
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.currentPlan')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.currentPlan')}</h2>
|
||||||
@@ -631,7 +631,7 @@ export default function Subscription() {
|
|||||||
|
|
||||||
{/* Daily Subscription Pause */}
|
{/* Daily Subscription Pause */}
|
||||||
{subscription && subscription.is_daily && !subscription.is_trial && (
|
{subscription && subscription.is_daily && !subscription.is_trial && (
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.pause.title')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.pause.title')}</h2>
|
||||||
@@ -730,7 +730,7 @@ export default function Subscription() {
|
|||||||
|
|
||||||
{/* Additional Options (Buy Devices) */}
|
{/* Additional Options (Buy Devices) */}
|
||||||
{subscription && subscription.is_active && !subscription.is_trial && (
|
{subscription && subscription.is_active && !subscription.is_trial && (
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">Дополнительные опции</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-4">Дополнительные опции</h2>
|
||||||
|
|
||||||
{/* Buy Devices */}
|
{/* Buy Devices */}
|
||||||
@@ -1150,7 +1150,7 @@ export default function Subscription() {
|
|||||||
|
|
||||||
{/* My Devices Section */}
|
{/* My Devices Section */}
|
||||||
{subscription && (
|
{subscription && (
|
||||||
<div className="card">
|
<div className="bento-card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.myDevices')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.myDevices')}</h2>
|
||||||
{devicesData && devicesData.devices.length > 0 && (
|
{devicesData && devicesData.devices.length > 0 && (
|
||||||
@@ -1220,7 +1220,7 @@ export default function Subscription() {
|
|||||||
|
|
||||||
{/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */}
|
{/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */}
|
||||||
{isTariffsMode && tariffs.length > 0 && (
|
{isTariffsMode && tariffs.length > 0 && (
|
||||||
<div ref={tariffsCardRef} className="card">
|
<div ref={tariffsCardRef} className="bento-card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
{subscription?.is_daily && !subscription?.is_trial
|
{subscription?.is_daily && !subscription?.is_trial
|
||||||
@@ -1375,10 +1375,10 @@ export default function Subscription() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tariff.id}
|
key={tariff.id}
|
||||||
className={`p-5 rounded-xl border text-left transition-all ${
|
className={`bento-card-hover p-5 text-left transition-all ${
|
||||||
isCurrentTariff
|
isCurrentTariff
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'bento-card-glow border-accent-500'
|
||||||
: 'border-dark-700/50 bg-dark-800/30'
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
@@ -1922,7 +1922,7 @@ export default function Subscription() {
|
|||||||
|
|
||||||
{/* Purchase/Extend Section - Classic Mode */}
|
{/* Purchase/Extend Section - Classic Mode */}
|
||||||
{classicOptions && classicOptions.periods.length > 0 && (
|
{classicOptions && classicOptions.periods.length > 0 && (
|
||||||
<div ref={tariffsCardRef} className="card">
|
<div ref={tariffsCardRef} className="bento-card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
{subscription && !subscription.is_trial ? t('subscription.extend') : t('subscription.getSubscription')}
|
{subscription && !subscription.is_trial ? t('subscription.extend') : t('subscription.getSubscription')}
|
||||||
@@ -1984,10 +1984,10 @@ export default function Subscription() {
|
|||||||
setSelectedDevices(period.devices.current)
|
setSelectedDevices(period.devices.current)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`p-4 rounded-xl border text-left transition-all relative ${
|
className={`bento-card-hover p-4 text-left transition-all relative ${
|
||||||
selectedPeriod?.id === period.id
|
selectedPeriod?.id === period.id
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'bento-card-glow border-accent-500'
|
||||||
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayDiscount && displayDiscount > 0 && (
|
{displayDiscount && displayDiscount > 0 && (
|
||||||
@@ -2022,13 +2022,11 @@ export default function Subscription() {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => setSelectedTraffic(option.value)}
|
onClick={() => setSelectedTraffic(option.value)}
|
||||||
disabled={!option.is_available}
|
disabled={!option.is_available}
|
||||||
className={`p-4 rounded-xl border text-center transition-all relative ${
|
className={`bento-card-hover p-4 text-center transition-all relative ${
|
||||||
selectedTraffic === option.value
|
selectedTraffic === option.value
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'bento-card-glow border-accent-500'
|
||||||
: option.is_available
|
: ''
|
||||||
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
} ${!option.is_available ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{promoTraffic.percent && (
|
{promoTraffic.percent && (
|
||||||
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">
|
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export default function Support() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-12">
|
<div className="max-w-md mx-auto mt-12">
|
||||||
<div className="card text-center">
|
<div className="bento-card text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||||
<svg className="w-8 h-8 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-8 h-8 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||||
@@ -454,7 +454,7 @@ export default function Support() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Tickets List */}
|
{/* Tickets List */}
|
||||||
<div className="lg:col-span-1 card">
|
<div className="lg:col-span-1 bento-card">
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('support.yourTickets')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('support.yourTickets')}</h2>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -471,7 +471,7 @@ export default function Support() {
|
|||||||
setShowCreateForm(false)
|
setShowCreateForm(false)
|
||||||
setReplyAttachment(null)
|
setReplyAttachment(null)
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
className={`w-full text-left p-4 rounded-bento border transition-all ${
|
||||||
selectedTicket?.id === ticket.id
|
selectedTicket?.id === ticket.id
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
||||||
@@ -502,7 +502,7 @@ export default function Support() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ticket Detail / Create Form */}
|
{/* Ticket Detail / Create Form */}
|
||||||
<div className="lg:col-span-2 card">
|
<div className="lg:col-span-2 bento-card">
|
||||||
{showCreateForm ? (
|
{showCreateForm ? (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('support.createTicket')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('support.createTicket')}</h2>
|
||||||
|
|||||||
@@ -692,7 +692,7 @@ export default function Wheel() {
|
|||||||
|
|
||||||
{/* Result Modal */}
|
{/* Result Modal */}
|
||||||
{showResultModal && spinResult && (
|
{showResultModal && spinResult && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
|
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
|
||||||
<div
|
<div
|
||||||
className={`relative w-full max-w-md rounded-3xl p-8 text-center overflow-hidden ${
|
className={`relative w-full max-w-md rounded-3xl p-8 text-center overflow-hidden ${
|
||||||
spinResult.success
|
spinResult.success
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -8,6 +8,14 @@
|
|||||||
:root {
|
:root {
|
||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
|
||||||
|
/* Bento Design System */
|
||||||
|
--bento-radius: 24px;
|
||||||
|
--bento-radius-lg: 32px;
|
||||||
|
--bento-gap: 16px;
|
||||||
|
--bento-gap-lg: 24px;
|
||||||
|
--bento-padding: 24px;
|
||||||
|
--bento-padding-lg: 32px;
|
||||||
|
|
||||||
/* Theme colors - RGB format for opacity support */
|
/* Theme colors - RGB format for opacity support */
|
||||||
/* Dark palette (RGB values) */
|
/* Dark palette (RGB values) */
|
||||||
--color-dark-50: 248, 250, 252;
|
--color-dark-50: 248, 250, 252;
|
||||||
@@ -102,6 +110,34 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(var(--color-dark-700)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(var(--color-dark-700));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(var(--color-dark-600));
|
||||||
|
}
|
||||||
|
|
||||||
|
.light ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(var(--color-champagne-400));
|
||||||
|
}
|
||||||
|
|
||||||
|
.light ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(var(--color-champagne-500));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scroll only on desktop - mobile uses native */
|
/* Smooth scroll only on desktop - mobile uses native */
|
||||||
@@ -117,6 +153,19 @@
|
|||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
/* iOS smooth scrolling */
|
/* iOS smooth scrolling */
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Noise Texture */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.03;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optimize main scrollable content */
|
/* Optimize main scrollable content */
|
||||||
@@ -327,6 +376,135 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* ========== BENTO DESIGN SYSTEM ========== */
|
||||||
|
|
||||||
|
.bento-card {
|
||||||
|
@apply bg-dark-900/70 border border-dark-700/40;
|
||||||
|
border-radius: var(--bento-radius);
|
||||||
|
padding: var(--bento-padding);
|
||||||
|
transform: translateZ(0);
|
||||||
|
/* Glass Border - Inset Highlight */
|
||||||
|
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
|
/* Stagger animation support */
|
||||||
|
animation: bentoFadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
animation-delay: calc(var(--stagger, 0) * 50ms);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.bento-card {
|
||||||
|
@apply bg-dark-900/50 backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable animation if user prefers reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bento-card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-hover {
|
||||||
|
@apply bento-card cursor-pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-hover:hover {
|
||||||
|
@apply bg-dark-800/60 border-dark-600/50 shadow-lg;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
/* Intensify Glass Border & Add Top Spotlight */
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
||||||
|
0 10px 40px -10px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Spotlight Effect via pseudo-element */
|
||||||
|
.bento-card-hover::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-hover:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-hover:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
transition-duration: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-glow {
|
||||||
|
@apply bento-card cursor-pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-glow:hover {
|
||||||
|
@apply shadow-glow border-accent-500/30;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-card-glow:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
transition-duration: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bento-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--bento-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 375px) {
|
||||||
|
.bento-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bento-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--bento-gap-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .bento-card {
|
||||||
|
@apply bg-white/90 border-champagne-300/50 shadow-sm;
|
||||||
|
/* Light Theme Glass Border */
|
||||||
|
box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.light .bento-card {
|
||||||
|
@apply bg-white/80 backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .bento-card-hover:hover {
|
||||||
|
@apply bg-white border-champagne-400/50 shadow-md;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
/* Intensify Light Theme Glass Border */
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 0 rgba(0, 0, 0, 0.06),
|
||||||
|
0 10px 40px -10px rgba(160, 139, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust spotlight for light theme to be a warm glow */
|
||||||
|
.light .bento-card-hover::after {
|
||||||
|
background: radial-gradient(circle at top, rgba(255, 253, 249, 0.8), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .bento-card-glow:hover {
|
||||||
|
@apply shadow-lg border-accent-400/40;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== DARK THEME COMPONENTS (default) ========== */
|
/* ========== DARK THEME COMPONENTS (default) ========== */
|
||||||
|
|
||||||
/* Cards - Dark (optimized for mobile) */
|
/* Cards - Dark (optimized for mobile) */
|
||||||
@@ -493,12 +671,17 @@
|
|||||||
@apply nav-item text-accent-400 bg-accent-500/10;
|
@apply nav-item text-accent-400 bg-accent-500/10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom nav - Dark (optimized for mobile) */
|
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
@apply fixed bottom-0 left-0 right-0 z-50
|
@apply fixed z-50 bg-dark-900/95;
|
||||||
bg-dark-900/95 border-t border-dark-800/50
|
bottom: 16px;
|
||||||
safe-area-pb;
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
border-radius: var(--bento-radius);
|
||||||
|
padding: 8px 4px;
|
||||||
|
padding-bottom: calc(8px + var(--safe-area-inset-bottom));
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -508,12 +691,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item {
|
.bottom-nav-item {
|
||||||
@apply flex flex-col items-center justify-center py-2 px-2 flex-1 min-w-[60px]
|
@apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px]
|
||||||
text-dark-500 transition-colors duration-200 shrink-0;
|
text-dark-500 transition-all duration-200 shrink-0 rounded-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item:hover {
|
||||||
|
@apply text-dark-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item-active {
|
.bottom-nav-item-active {
|
||||||
@apply bottom-nav-item text-accent-400;
|
@apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px]
|
||||||
|
text-accent-400 bg-accent-500/15 rounded-2xl transition-all duration-200 shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Divider - Dark */
|
/* Divider - Dark */
|
||||||
@@ -668,17 +856,28 @@
|
|||||||
@apply text-champagne-800 bg-champagne-200/70;
|
@apply text-champagne-800 bg-champagne-200/70;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom nav - Light */
|
|
||||||
.light .bottom-nav {
|
.light .bottom-nav {
|
||||||
@apply bg-white/90 backdrop-blur-xl border-t border-champagne-200;
|
@apply bg-white/95;
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.light .bottom-nav {
|
||||||
|
@apply bg-white/80 backdrop-blur-xl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .bottom-nav-item {
|
.light .bottom-nav-item {
|
||||||
@apply text-champagne-500;
|
@apply text-champagne-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.light .bottom-nav-item:hover {
|
||||||
|
@apply text-champagne-700;
|
||||||
|
}
|
||||||
|
|
||||||
.light .bottom-nav-item-active {
|
.light .bottom-nav-item-active {
|
||||||
@apply text-champagne-800;
|
@apply text-champagne-800 bg-champagne-300/40;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Divider - Light */
|
/* Divider - Light */
|
||||||
@@ -924,6 +1123,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
|
@keyframes bentoFadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% { background-position: -200% 0; }
|
||||||
100% { background-position: 200% 0; }
|
100% { background-position: 200% 0; }
|
||||||
|
|||||||
102
src/utils/colorConversion.ts
Normal file
102
src/utils/colorConversion.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export interface HSLColor {
|
||||||
|
h: number
|
||||||
|
s: number
|
||||||
|
l: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RGBColor {
|
||||||
|
r: number
|
||||||
|
g: number
|
||||||
|
b: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): RGBColor {
|
||||||
|
if (hex.length === 4) {
|
||||||
|
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||||
|
}
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return { r, g, b }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return (
|
||||||
|
'#' +
|
||||||
|
[r, g, b]
|
||||||
|
.map((x) => {
|
||||||
|
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16)
|
||||||
|
return hex.length === 1 ? '0' + hex : hex
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToHsl(hex: string): HSLColor {
|
||||||
|
const { r, g, b } = hexToRgb(hex)
|
||||||
|
const rNorm = r / 255
|
||||||
|
const gNorm = g / 255
|
||||||
|
const bNorm = b / 255
|
||||||
|
|
||||||
|
const max = Math.max(rNorm, gNorm, bNorm)
|
||||||
|
const min = Math.min(rNorm, gNorm, bNorm)
|
||||||
|
let h = 0
|
||||||
|
let s = 0
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||||
|
|
||||||
|
switch (max) {
|
||||||
|
case rNorm:
|
||||||
|
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
|
||||||
|
break
|
||||||
|
case gNorm:
|
||||||
|
h = ((bNorm - rNorm) / d + 2) / 6
|
||||||
|
break
|
||||||
|
case bNorm:
|
||||||
|
h = ((rNorm - gNorm) / d + 4) / 6
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
l: Math.round(l * 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hslToRgb(h: number, s: number, l: number): RGBColor {
|
||||||
|
const sNorm = s / 100
|
||||||
|
const lNorm = l / 100
|
||||||
|
|
||||||
|
const a = sNorm * Math.min(lNorm, 1 - lNorm)
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12
|
||||||
|
const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||||
|
return Math.round(255 * color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { r: f(0), g: f(8), b: f(4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
const { r, g, b } = hslToRgb(h, s, l)
|
||||||
|
return rgbToHex(r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidHex(hex: string): boolean {
|
||||||
|
return /^#[0-9A-Fa-f]{6}$/.test(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHex(hex: string): string {
|
||||||
|
if (!hex.startsWith('#')) {
|
||||||
|
hex = '#' + hex
|
||||||
|
}
|
||||||
|
if (hex.length === 4) {
|
||||||
|
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||||
|
}
|
||||||
|
return hex.toLowerCase()
|
||||||
|
}
|
||||||
@@ -106,7 +106,15 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
sans: ['Manrope', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'bento': '24px',
|
||||||
|
'4xl': '32px',
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'bento': '16px',
|
||||||
|
'bento-lg': '24px',
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
|
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
|
||||||
|
|||||||
Reference in New Issue
Block a user