diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md
deleted file mode 100644
index 3fab39c..0000000
--- a/.ai/BENTO_REFACTOR.md
+++ /dev/null
@@ -1,297 +0,0 @@
-# Bento Grids Refactor
-
-> **Ветка:** `bento-grids`
-> **Статус:** В работе
-> **Последнее обновление:** 2026-01-20
-
-## Референс
-
-- **Стиль:** Bento Grids (карточки разных размеров в сетке)
-- **Цветовая схема:** Зеленый неон на темном фоне, чистота
-- **Шрифт:** Manrope (современный гротеск, заменён с 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)~~ — Phase 6 complete
-- ✅ ~~Страницы кроме Dashboard~~ — Phase 5 complete
-- ✅ ~~Header~~ — Phase 7 complete
-- ❌ Рефакторинг логики — только UI
-
----
-
-## Прогресс
-
-```
-Этап 1: ██████████ 100%
-Этап 2: ██████████ 100%
-Этап 3: ██████████ 100%
-Этап 4: ██████████ 100%
-Этап 5: ██████████ 100%
-Этап 6: ██████████ 100%
-Этап 7: ██████████ 100%
-Этап 8: ██████████ 100%
-Этап 9: ██████████ 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-стили должны работать в обеих.
-
----
-
-## Следующие этапы (Phase 2)
-
-### Этап 5: Остальные страницы пользователя
-> Применить bento-стили к основным страницам
-
-| # | Страница | Приоритет | Объём | Статус |
-|---|----------|-----------|-------|--------|
-| 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-стиле
-
-| # | Компонент | Статус | Заметки |
-|---|-----------|--------|---------|
-| 6.1 | ConnectionModal | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl (desktop + mobile) |
-| 6.2 | TopUpModal | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl |
-| 6.3 | InsufficientBalancePrompt | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl (PaymentMethodModal тоже) |
-| 6.4 | Wheel Result Modal | `[x]` | ✅ z-[60] |
-| 6.5 | Contests Modal | `[x]` | ✅ z-[60], bento-card, backdrop-blur-sm |
-
-**Выборка модалок (select/close overlap):** Не найдено — все селекты в Subscription.tsx используют кнопки вместо `
- {/* 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()
+}