From 6d88bac69301d3b355d642f8b78543f67f821084 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 21:10:20 +0500 Subject: [PATCH 1/9] feat(ui): implement Bento Grid design system - Add Urbanist font (replace Inter) - Add CSS variables: --bento-radius, --bento-gap, --bento-padding - Add Tailwind extend: rounded-bento, rounded-4xl, spacing.bento - Create BentoCard component with size variants (sm/md/lg/xl) - Add .bento-card, .bento-card-hover, .bento-card-glow classes - Add .bento-grid container with responsive columns - Implement floating TabBar (margin 16px, shadow, pill active state) - Apply bento styles to Dashboard cards - Support both dark and light themes --- .ai/BENTO_REFACTOR.md | 150 ++++++++++++++++++++++++++++++++ src/components/ui/BentoCard.tsx | 115 ++++++++++++++++++++++++ src/pages/Dashboard.tsx | 18 ++-- src/styles/globals.css | 108 ++++++++++++++++++++--- tailwind.config.js | 10 ++- 5 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 .ai/BENTO_REFACTOR.md create mode 100644 src/components/ui/BentoCard.tsx diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md new file mode 100644 index 0000000..93d1e71 --- /dev/null +++ b/.ai/BENTO_REFACTOR.md @@ -0,0 +1,150 @@ +# Bento Grids Refactor + +> **Ветка:** `bento-grids` +> **Статус:** В работе +> **Последнее обновление:** 2026-01-20 + +## Референс + +- **Стиль:** Bento Grids (карточки разных размеров в сетке) +- **Цветовая схема:** Зеленый неон на темном фоне, чистота +- **Шрифт:** Urbanist (современный гротеск) +- **Скругления:** Крупные (24-32px) + +--- + +## План рефакторинга + +### Этап 1: Фундамент (Vibe Check) +> Цель: Пустая страница уже выглядит "секси" + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 1.1 | Подключить Urbanist шрифт | `[x]` | Google Fonts, заменить Inter | +| 1.2 | Tailwind: bento radius/spacing | `[x]` | `rounded-bento`, `rounded-4xl`, `spacing.bento` | +| 1.3 | globals.css: CSS переменные Bento | `[x]` | `--bento-radius`, `--bento-gap`, `--bento-padding` | + +**Файлы:** +- `src/styles/globals.css` +- `tailwind.config.js` + +--- + +### Этап 2: Атом (BentoCard) +> Цель: Один идеальный компонент-карточка + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 2.1 | Создать BentoCard компонент | `[x]` | С вариантами размеров | +| 2.2 | Обновить .card в globals.css | `[x]` | Новые классы .bento-card, .bento-card-hover, .bento-card-glow | + +**Файлы:** +- `src/components/ui/BentoCard.tsx` (новый) +- `src/styles/globals.css` + +**API компонента:** +```tsx +type BentoSize = 'sm' | 'md' | 'lg' | 'xl' // 1x1, 2x1, 1x2, 2x2 + +interface BentoCardProps { + size?: BentoSize + children: React.ReactNode + className?: string + hover?: boolean + as?: 'div' | 'Link' | 'button' +} +``` + +--- + +### Этап 3: Скелет (Floating TabBar) +> Цель: Bottom navigation как "парящий остров" + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 3.1 | Floating island стиль | `[x]` | margin 16px от краев, shadow | +| 3.2 | Active state с pill | `[x]` | rounded-2xl фон под активной иконкой | +| 3.3 | Убрать border-top | `[x]` | Заменено на shadow | + +**Файлы:** +- `src/components/layout/Layout.tsx` (секция bottom-nav) +- `src/styles/globals.css` (`.bottom-nav` классы) + +--- + +### Этап 4: Мясо (Dashboard Grid) +> Цель: Главная страница в стиле Bento + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 4.1 | Создать .bento-grid контейнер | `[x]` | CSS Grid с gap, 2 колонки mobile, 4 desktop | +| 4.2 | Subscription Status → bento-card | `[x]` | Главная карточка подписки | +| 4.3 | Stats Grid → bento-grid + bento-card-hover | `[x]` | Balance, Subscription, Referrals, Earnings | +| 4.4 | Quick Actions → bento-card | `[x]` | Контейнер для кнопок | +| 4.5 | Trial + Wheel Banner → bento-card-glow/hover | `[x]` | Акцентные карточки | + +**Файлы:** +- `src/pages/Dashboard.tsx` +- `src/styles/globals.css` + +**Правило:** Логику JS/TS НЕ трогаем — только UI обёртки. + +--- + +## Решения и обоснования + +### Почему Urbanist, а не Inter? +- Urbanist более геометричный, лучше подходит под Bento-эстетику +- Хорошая читаемость на мобильных +- Бесплатный, Google Fonts + +### Почему rounded-3xl (24px)? +- Стандарт Bento — крупные скругления +- 16px (текущий rounded-2xl) выглядит слишком "приложенечно" +- 32px (rounded-4xl) для особо крупных элементов + +### Floating TabBar vs прилипший +- Floating выглядит премиально +- Отделяет навигацию от контента визуально +- Работает с safe-area на iOS + +--- + +## Что НЕ делаем + +- ❌ Модалки (ConnectionModal, TopUpModal) — вторично +- ❌ Страницы кроме Dashboard — после MVP +- ❌ Header — работает, не ломаем +- ❌ Рефакторинг логики — только UI + +--- + +## Прогресс + +``` +Этап 1: ██████████ 100% +Этап 2: ██████████ 100% +Этап 3: ██████████ 100% +Этап 4: ██████████ 100% +───────────────────── +Общий: ██████████ 100% +``` + +--- + +## Заметки для агентов + +**Контекст:** Это Mini App для Telegram (VPN кабинет). Мобильный фокус. + +**Текущий стек:** +- React + Vite + TypeScript +- Tailwind CSS +- React Query для данных + +**Ключевые файлы:** +- `tailwind.config.js` — цвета через CSS переменные +- `src/styles/globals.css` — компонентные классы (.card, .btn, etc) +- `src/components/layout/Layout.tsx` — шапка + таббар +- `src/pages/Dashboard.tsx` — главный экран (монстр, но логику не трогаем) + +**Темы:** Есть dark и light (champagne). Bento-стили должны работать в обеих. diff --git a/src/components/ui/BentoCard.tsx b/src/components/ui/BentoCard.tsx new file mode 100644 index 0000000..3f9d544 --- /dev/null +++ b/src/components/ui/BentoCard.tsx @@ -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 = { + 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((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 ( + + {children} + + ) + } + + if (props.as === 'button') { + const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps + return ( + + ) + } + + const { onClick } = props as BentoCardDivProps + return ( +
+ {children} +
+ ) +}) + +BentoCard.displayName = 'BentoCard' + +export default BentoCard diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 2c20823..99880ae 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -254,7 +254,7 @@ export default function Dashboard() { {/* Subscription Status - Main Card */} {subscription && ( -
+

{t('subscription.status')}

@@ -336,9 +336,9 @@ export default function Dashboard() { )} {/* Stats Grid */} -
+
{/* Balance */} - +
{t('balance.currentBalance')} @@ -352,7 +352,7 @@ export default function Dashboard() { {/* Subscription */} - +
{t('subscription.title')} @@ -388,7 +388,7 @@ export default function Dashboard() { {/* Referrals */} - +
{t('referral.stats.totalReferrals')} @@ -403,7 +403,7 @@ export default function Dashboard() { {/* Earnings */} - +
{t('referral.stats.totalEarnings')} @@ -422,7 +422,7 @@ export default function Dashboard() { {/* Trial Activation */} {hasNoSubscription && !trialLoading && trialInfo?.is_available && ( -
+
@@ -483,7 +483,7 @@ export default function Dashboard() { {wheelConfig?.is_enabled && (
{/* Emoji */} @@ -504,7 +504,7 @@ export default function Dashboard() { )} {/* Quick Actions */} -
+

{t('dashboard.quickActions')}

diff --git a/src/styles/globals.css b/src/styles/globals.css index e756480..d434854 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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=Urbanist:wght@400;500;600;700;800&display=swap'); @tailwind base; @tailwind components; @@ -8,6 +8,14 @@ :root { --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 */ /* Dark palette (RGB values) */ --color-dark-50: 248, 250, 252; @@ -327,6 +335,63 @@ } @layer components { + /* ========== BENTO DESIGN SYSTEM ========== */ + + .bento-card { + @apply bg-dark-900/70 border border-dark-700/40 + transition-all duration-300 ease-smooth; + border-radius: var(--bento-radius); + padding: var(--bento-padding); + transform: translateZ(0); + } + + @media (min-width: 1024px) { + .bento-card { + @apply bg-dark-900/50 backdrop-blur-sm; + } + } + + .bento-card-hover { + @apply bento-card cursor-pointer + hover:bg-dark-800/60 hover:border-dark-600/50 + hover:shadow-lg hover:scale-[1.01] active:scale-[0.99]; + } + + .bento-card-glow { + @apply bento-card hover:shadow-glow hover:border-accent-500/30 hover:scale-[1.01]; + } + + .bento-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--bento-gap); + } + + @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; + } + + @media (min-width: 1024px) { + .light .bento-card { + @apply bg-white/80 backdrop-blur-sm; + } + } + + .light .bento-card-hover { + @apply hover:bg-white hover:border-champagne-400/50 hover:shadow-md; + } + + .light .bento-card-glow { + @apply hover:shadow-lg hover:border-accent-400/40; + } + /* ========== DARK THEME COMPONENTS (default) ========== */ /* Cards - Dark (optimized for mobile) */ @@ -493,12 +558,17 @@ @apply nav-item text-accent-400 bg-accent-500/10; } - /* Bottom nav - Dark (optimized for mobile) */ .bottom-nav { - @apply fixed bottom-0 left-0 right-0 z-50 - bg-dark-900/95 border-t border-dark-800/50 - safe-area-pb; + @apply fixed z-50 bg-dark-900/95; + bottom: 16px; + left: 16px; + right: 16px; + border-radius: var(--bento-radius); + padding: 8px 4px; + padding-bottom: calc(8px + var(--safe-area-inset-bottom)); 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) { @@ -508,12 +578,17 @@ } .bottom-nav-item { - @apply flex flex-col items-center justify-center py-2 px-2 flex-1 min-w-[60px] - text-dark-500 transition-colors duration-200 shrink-0; + @apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px] + text-dark-500 transition-all duration-200 shrink-0 rounded-2xl; + } + + .bottom-nav-item:hover { + @apply text-dark-300; } .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 */ @@ -668,17 +743,28 @@ @apply text-champagne-800 bg-champagne-200/70; } - /* Bottom nav - Light */ .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 { @apply text-champagne-500; } + .light .bottom-nav-item:hover { + @apply text-champagne-700; + } + .light .bottom-nav-item-active { - @apply text-champagne-800; + @apply text-champagne-800 bg-champagne-300/40; } /* Divider - Light */ diff --git a/tailwind.config.js b/tailwind.config.js index b6d30c7..4460776 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -106,7 +106,15 @@ export default { }, }, fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + sans: ['Urbanist', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + }, + borderRadius: { + 'bento': '24px', + '4xl': '32px', + }, + spacing: { + 'bento': '16px', + 'bento-lg': '24px', }, fontSize: { '2xs': ['0.625rem', { lineHeight: '0.875rem' }], From b9b889b0bf23df1c9cd36cace0c0970879aa4a7b Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 21:11:26 +0500 Subject: [PATCH 2/9] docs: add Phase 2 roadmap to refactor plan --- .ai/BENTO_REFACTOR.md | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md index 93d1e71..8cbdfad 100644 --- a/.ai/BENTO_REFACTOR.md +++ b/.ai/BENTO_REFACTOR.md @@ -148,3 +148,58 @@ interface BentoCardProps { - `src/pages/Dashboard.tsx` — главный экран (монстр, но логику не трогаем) **Темы:** Есть dark и light (champagne). Bento-стили должны работать в обеих. + +--- + +## Следующие этапы (Phase 2) + +### Этап 5: Остальные страницы пользователя +> Применить bento-стили к основным страницам + +| # | Страница | Приоритет | Объём | +|---|----------|-----------|-------| +| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | +| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | +| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | +| 5.4 | Support.tsx | Medium | Малая — тикеты | +| 5.5 | Profile.tsx | Low | Малая — настройки | +| 5.6 | Info.tsx | Low | Малая — статичный контент | + +### Этап 6: Модалки +> Обновить модальные окна в bento-стиле + +| # | Компонент | Заметки | +|---|-----------|---------| +| 6.1 | ConnectionModal | QR-код, кнопки подключения | +| 6.2 | TopUpModal | Форма пополнения | +| 6.3 | InsufficientBalancePrompt | Промпт о недостатке средств | + +### Этап 7: Header +> Обновить шапку (опционально) + +| # | Задача | Заметки | +|---|--------|---------| +| 7.1 | Лого в bento-стиле | Скругления, тень | +| 7.2 | Mobile menu | Floating стиль? | + +### Этап 8: Полировка +> Финальные штрихи + +| # | Задача | +|---|--------| +| 8.1 | Анимации появления карточек (stagger) | +| 8.2 | Микро-взаимодействия (hover glow) | +| 8.3 | Тестирование на разных устройствах | +| 8.4 | Performance check (особенно blur на mobile) | + +--- + +## Changelog + +### 2026-01-20 — MVP Complete +- ✅ Urbanist шрифт +- ✅ CSS переменные Bento +- ✅ BentoCard компонент +- ✅ Floating TabBar +- ✅ Dashboard в bento-стиле +- ✅ Commit: `bf0bcfb` From c92a4e7704b6591d833a3ffc7778bbe0478aa7ef Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 22:26:13 +0500 Subject: [PATCH 3/9] feat: complete Phase 2 - refactor all user pages to Bento UI --- .ai/BENTO_REFACTOR.md | 37 +++++++++++++++++++++++++++++-------- src/pages/Balance.tsx | 14 +++++++------- src/pages/Info.tsx | 8 ++++---- src/pages/Profile.tsx | 6 +++--- src/pages/Referral.tsx | 16 ++++++++-------- src/pages/Subscription.tsx | 34 ++++++++++++++++------------------ src/pages/Support.tsx | 8 ++++---- src/styles/globals.css | 8 +++++++- 8 files changed, 78 insertions(+), 53 deletions(-) diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md index 8cbdfad..e9cf2f2 100644 --- a/.ai/BENTO_REFACTOR.md +++ b/.ai/BENTO_REFACTOR.md @@ -126,6 +126,7 @@ interface BentoCardProps { Этап 2: ██████████ 100% Этап 3: ██████████ 100% Этап 4: ██████████ 100% +Этап 5: ██████████ 100% ───────────────────── Общий: ██████████ 100% ``` @@ -156,14 +157,14 @@ interface BentoCardProps { ### Этап 5: Остальные страницы пользователя > Применить bento-стили к основным страницам -| # | Страница | Приоритет | Объём | -|---|----------|-----------|-------| -| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | -| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | -| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | -| 5.4 | Support.tsx | Medium | Малая — тикеты | -| 5.5 | Profile.tsx | Low | Малая — настройки | -| 5.6 | Info.tsx | Low | Малая — статичный контент | +| # | Страница | Приоритет | Объём | Статус | +|---|----------|-----------|-------|--------| +| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | `[x]` | +| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | `[x]` | +| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | `[x]` | +| 5.4 | Support.tsx | Medium | Малая — тикеты | `[x]` | +| 5.5 | Profile.tsx | Low | Малая — настройки | `[x]` | +| 5.6 | Info.tsx | Low | Малая — статичный контент | `[x]` | ### Этап 6: Модалки > Обновить модальные окна в bento-стиле @@ -203,3 +204,23 @@ interface BentoCardProps { - ✅ Floating TabBar - ✅ Dashboard в bento-стиле - ✅ Commit: `bf0bcfb` + +### 2026-01-20 — Subscription.tsx Refactor +- ✅ Все секции `card` → `bento-card` (6 шт): + - Current Subscription (line 429) + - Daily Pause (line 634) + - Additional Options (line 733) + - My Devices (line 1153) + - Tariffs section (line 1223) + - Classic mode purchase (line 1925) +- ✅ Tariff cards: `bento-card-hover` + `bento-card-glow` для выбранного +- ✅ Period selection cards: `bento-card-hover` + `bento-card-glow` +- ✅ Traffic selection cards: `bento-card-hover` + `bento-card-glow` +- ✅ Исправлен `.bento-grid` — добавлен breakpoint для xs (<375px) + +### 2026-01-20 — Phase 2 Complete (Все страницы пользователя) +- ✅ **Balance.tsx**: 4 карточки → `bento-card`, методы оплаты → `bento-card-hover` +- ✅ **Referral.tsx**: stats grid → `bento-grid` + `bento-card-hover`, 5 секций → `bento-card` +- ✅ **Support.tsx**: 3 карточки → `bento-card`, tickets list items → `rounded-bento` +- ✅ **Profile.tsx**: 3 карточки → `bento-card` +- ✅ **Info.tsx**: FAQ items, rules, privacy, offer → `bento-card` diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 4f3b5ee..eb8b5c2 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -122,7 +122,7 @@ export default function Balance() {

{t('balance.title')}

{/* Balance Card */} -
+
{t('balance.currentBalance')}
{formatAmount(balanceData?.balance_rubles || 0)} @@ -131,7 +131,7 @@ export default function Balance() {
{/* Promo Code Section */} -
+

{t('balance.promocode.title')}

0 && ( -
+

{t('balance.topUpBalance')}

{paymentMethods.map((method) => { @@ -181,10 +181,10 @@ export default function Balance() { key={method.id} disabled={!method.is_available} 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 - ? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer' - : 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed' + ? 'cursor-pointer' + : 'opacity-50 cursor-not-allowed' }`} >
{translatedName || method.name}
@@ -202,7 +202,7 @@ export default function Balance() { )} {/* Transaction History */} -
+

{t('balance.transactionHistory')}

{isLoading ? ( diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index 5e246ea..1ba1e16 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -166,7 +166,7 @@ export default function Info() { return (
{faqPages.map((faq: FaqPage) => ( -
+
+ ) +} + +function HSLSlider({ + label, + value, + onChange, + max, + gradient, + suffix = '', +}: { + label: string + value: number + onChange: (value: number) => void + max: number + gradient: string + suffix?: string +}) { + return ( +
+
+ + + {value} + {suffix} + +
+ onChange(parseInt(e.target.value))} + className="w-full h-2.5 rounded-full appearance-none cursor-pointer" + style={{ background: gradient }} + /> +
+ ) +} + +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 ( +
+ + )} +
+
+ ) +} + +function CollapsibleSection({ + title, + icon, + isOpen, + onToggle, + children, + badge, +}: { + title: string + icon: React.ReactNode + isOpen: boolean + onToggle: () => void + children: React.ReactNode + badge?: string +}) { + return ( +
+ + +
+
+
{children}
+
+
+
+ ) +} + +export function ThemeBentoPicker({ + currentColors, + onColorsChange, + onSave, + isSaving, +}: ThemeBentoPickerProps) { + const { t } = useTranslation() + + const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)) + const [hexInput, setHexInput] = useState(currentColors.accent) + const [hasChanges, setHasChanges] = 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 ( +
+
+

+ {t('admin.theme.quickPresets', 'Quick Presets')} +

+
+ {COLOR_PRESETS.map((preset, index) => ( +
+ handlePresetSelect(preset)} + /> +
+ ))} +
+
+ +
+

+ {t('admin.theme.customizeColors', 'Customize Colors')} +

+ + } + badge={hexInput.toUpperCase()} + isOpen={isAccentOpen} + onToggle={() => setIsAccentOpen(!isAccentOpen)} + > +
+
+
+
+ {hexInput.toUpperCase()} +
+
+ + updateAccentFromHsl({ ...hsl, h })} + max={360} + gradient={hueGradient} + suffix="°" + /> + + updateAccentFromHsl({ ...hsl, s })} + max={100} + gradient={saturationGradient} + suffix="%" + /> + + updateAccentFromHsl({ ...hsl, l })} + max={100} + gradient={lightnessGradient} + suffix="%" + /> + +
+ + handleHexInputChange(e.target.value)} + placeholder="#3b82f6" + maxLength={7} + className="input w-full text-sm font-mono uppercase" + /> +
+
+ + + } + isOpen={isDarkOpen} + onToggle={() => setIsDarkOpen(!isDarkOpen)} + > +
+ updateColor('darkBackground', c)} + /> + updateColor('darkSurface', c)} + /> + updateColor('darkText', c)} + /> + updateColor('darkTextSecondary', c)} + /> +
+
+ + } + isOpen={isLightOpen} + onToggle={() => setIsLightOpen(!isLightOpen)} + > +
+ updateColor('lightBackground', c)} + /> + updateColor('lightSurface', c)} + /> + updateColor('lightText', c)} + /> + updateColor('lightTextSecondary', c)} + /> +
+
+ + } + isOpen={isStatusOpen} + onToggle={() => setIsStatusOpen(!isStatusOpen)} + > +
+ updateColor('success', c)} + /> + updateColor('warning', c)} + /> + updateColor('error', c)} + /> +
+
+
+ +
+

+ {t('theme.preview', 'Preview')} +

+
+ + + {t('theme.success', 'Success')} + {t('theme.warning', 'Warning')} + {t('theme.error', 'Error')} +
+
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} diff --git a/src/data/colorPresets.ts b/src/data/colorPresets.ts new file mode 100644 index 0000000..f272f23 --- /dev/null +++ b/src/data/colorPresets.ts @@ -0,0 +1,179 @@ +import { ThemeColors } from '../types/theme' + +export interface ColorPreset { + id: string + name: string + nameRu: string + description: string + descriptionRu: string + colors: ThemeColors + // Preview colors for the card + 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: '#f8fafc', + lightSurface: '#ffffff', + lightText: '#0f172a', + lightTextSecondary: '#64748b', + 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: '#052e16', + 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: '#1e1b29', + lightTextSecondary: '#6b21a8', + 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: '#1c1917', + lightTextSecondary: '#c2410c', + 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: '#0f766e', + 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: '#F7E7CE', + lightSurface: '#FEF9F0', + lightText: '#1F1A12', + lightTextSecondary: '#7D6B48', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#F7E7CE', + accent: '#b8860b', + text: '#1F1A12', + }, + }, +] + +export function getPresetById(id: string): ColorPreset | undefined { + return COLOR_PRESETS.find((preset) => preset.id === id) +} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 69f1711..cbe668b 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -7,8 +7,8 @@ import { brandingApi, setCachedBranding } from '../api/branding' import { setCachedAnimationEnabled } from '../components/AnimatedBackground' import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp' import { themeColorsApi } from '../api/themeColors' -import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' -import { ColorPicker } from '../components/ColorPicker' +import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme' +import { ThemeBentoPicker } from '../components/ThemeBentoPicker' import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' @@ -858,6 +858,7 @@ export default function AdminSettings() { const [searchQuery, setSearchQuery] = useState('') const [editingName, setEditingName] = useState(false) const [newName, setNewName] = useState('') + const [localColors, setLocalColors] = useState(null) const fileInputRef = useRef(null) // Branding query and mutations @@ -1355,113 +1356,18 @@ export default function AdminSettings() { Включите нужные темы для пользователей. Минимум одна тема должна быть активна.

- {/* Accent Color */} - updateColorsMutation.mutate({ accent: color })} - disabled={updateColorsMutation.isPending} + + setLocalColors(colors)} + onSave={() => { + if (localColors) { + updateColorsMutation.mutate(localColors) + setLocalColors(null) + } + }} + isSaving={updateColorsMutation.isPending} /> - - {/* Dark Theme Section */} -
-

{t('theme.darkTheme')}

-
- updateColorsMutation.mutate({ darkBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Light Theme Section */} -
-

{t('theme.lightTheme')}

-
- updateColorsMutation.mutate({ lightBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Status Colors */} -
-

{t('theme.statusColors')}

-
- updateColorsMutation.mutate({ success: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ warning: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ error: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Preview */} -
-

{t('theme.preview')}

-
- - - {t('theme.success')} - {t('theme.warning')} - {t('theme.error')} -
-
diff --git a/src/utils/colorConversion.ts b/src/utils/colorConversion.ts new file mode 100644 index 0000000..acb39fb --- /dev/null +++ b/src/utils/colorConversion.ts @@ -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() +} From 7272f0a6c10af563c18c6ed8a666e79618bd8646 Mon Sep 17 00:00:00 2001 From: kinvsh Date: Wed, 21 Jan 2026 01:30:14 +0500 Subject: [PATCH 9/9] bento-grids v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit в целом все хорошо, немного доправить и переходить к light theme --- src/components/ConnectionModal.tsx | 7 +- src/components/ThemeBentoPicker.tsx | 16 ++- src/components/TopUpModal.tsx | 180 ++++++++++++++++++---------- src/data/colorPresets.ts | 133 +++++++++++++++++--- src/pages/Balance.tsx | 175 +++++++++++++++------------ 5 files changed, 347 insertions(+), 164 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 68cd8de..58662e6 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -386,8 +386,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Main view return ( - {/* Header - app selector */} -
+
+

{t('subscription.connection.title')}

+
+ +
-
- {/* Payment options */} - {hasOptions && method.options && ( -
- {method.options.map((opt) => ( - - ))} -
- )} - - {/* Amount input */} -
- setAmount(e.target.value)} - placeholder={`${formatAmount(minRubles, 0)} – ${formatAmount(maxRubles, 0)}`} - className="w-full h-12 px-4 pr-12 text-lg font-semibold bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500" - autoComplete="off" - /> - - {currencySymbol} - -
- - {/* Quick amounts */} +
{quickAmounts.length > 0 && ( -
+
{quickAmounts.map((a) => { const val = getQuickValue(a) + const isSelected = amount === val + return ( - + + {formatAmount(a, 0)} + + + {currencySymbol} + + ) })}
)} - {/* Error */} - {error && ( -
{error}
+
+
+ + + {formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol} + +
+ +
+
+
+ setAmount(e.target.value)} + placeholder="0" + className="w-full h-14 pl-5 pr-12 text-2xl font-bold bg-transparent text-dark-50 placeholder:text-dark-600 focus:outline-none" + autoComplete="off" + /> +
+ + {currencySymbol} + +
+
+
+
+ + {hasOptions && method.options && ( +
+ +
+ {method.options.map((opt) => { + const isSelected = selectedOption === opt.id + return ( + + ) + })} +
+
+ )} + + {error && ( +
+
+ + + +
+

{error}

+
)} - {/* Submit */}
@@ -285,3 +340,4 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top } return modalContent } + diff --git a/src/data/colorPresets.ts b/src/data/colorPresets.ts index f272f23..0cee321 100644 --- a/src/data/colorPresets.ts +++ b/src/data/colorPresets.ts @@ -7,7 +7,6 @@ export interface ColorPreset { description: string descriptionRu: string colors: ThemeColors - // Preview colors for the card preview: { background: string accent: string @@ -28,10 +27,10 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkSurface: '#0f172a', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', - lightBackground: '#f8fafc', + lightBackground: '#f1f5f9', lightSurface: '#ffffff', lightText: '#0f172a', - lightTextSecondary: '#64748b', + lightTextSecondary: '#475569', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -56,7 +55,7 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkTextSecondary: '#64748b', lightBackground: '#f0fdf4', lightSurface: '#ffffff', - lightText: '#052e16', + lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#eab308', @@ -82,8 +81,8 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkTextSecondary: '#a1a1aa', lightBackground: '#faf5ff', lightSurface: '#ffffff', - lightText: '#1e1b29', - lightTextSecondary: '#6b21a8', + lightText: '#3b0764', + lightTextSecondary: '#581c87', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -108,8 +107,8 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkTextSecondary: '#a3a3a3', lightBackground: '#fff7ed', lightSurface: '#ffffff', - lightText: '#1c1917', - lightTextSecondary: '#c2410c', + lightText: '#7c2d12', + lightTextSecondary: '#9a3412', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -135,7 +134,7 @@ export const COLOR_PRESETS: ColorPreset[] = [ lightBackground: '#f0fdfa', lightSurface: '#ffffff', lightText: '#134e4a', - lightTextSecondary: '#0f766e', + lightTextSecondary: '#115e59', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -158,18 +157,122 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkSurface: '#0f172a', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', - lightBackground: '#F7E7CE', - lightSurface: '#FEF9F0', - lightText: '#1F1A12', - lightTextSecondary: '#7D6B48', + lightBackground: '#fefce8', + lightSurface: '#ffffff', + lightText: '#713f12', + lightTextSecondary: '#92400e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', }, preview: { - background: '#F7E7CE', + background: '#0a0f1a', accent: '#b8860b', - text: '#1F1A12', + 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', }, }, ] diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index eb8b5c2..6e0c10e 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -32,6 +32,7 @@ export default function Balance() { const [promocodeSuccess, setPromocodeSuccess] = useState<{ message: string; amount: number } | null>(null) const [transactionsPage, setTransactionsPage] = useState(1) + const [isHistoryOpen, setIsHistoryOpen] = useState(false) const { data: transactions, isLoading } = useQuery>({ queryKey: ['transactions', transactionsPage], @@ -201,88 +202,104 @@ export default function Balance() {
)} - {/* Transaction History */} -
-

{t('balance.transactionHistory')}

+
+ - {isLoading ? ( -
-
-
- ) : transactions?.items && transactions.items.length > 0 ? ( -
- {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 ( -
-
-
- - {getTypeLabel(tx.type)} - - - {new Date(tx.created_at).toLocaleDateString()} - -
- {tx.description && ( -
{tx.description}
- )} -
-
- {sign}{formatAmount(displayAmount)} {currencySymbol} -
+ {isHistoryOpen && ( +
+ {isLoading ? ( +
+
- ) - })} -
- ) : ( -
-
- - - -
-
{t('balance.noTransactions')}
-
- )} + ) : transactions?.items && transactions.items.length > 0 ? ( +
+ {transactions.items.map((tx) => { + 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' - {transactions && transactions.pages > 1 && ( -
- -
- {t('balance.page', { current: transactions.page, total: transactions.pages })} -
- + return ( +
+
+
+ + {getTypeLabel(tx.type)} + + + {new Date(tx.created_at).toLocaleDateString()} + +
+ {tx.description && ( +
{tx.description}
+ )} +
+
+ {sign}{formatAmount(displayAmount)} {currencySymbol} +
+
+ ) + })} +
+ ) : ( +
+
+ + + +
+
{t('balance.noTransactions')}
+
+ )} + + {transactions && transactions.pages > 1 && ( +
+ +
+ {t('balance.page', { current: transactions.page, total: transactions.pages })} +
+ +
+ )}
)}