From 15fec905e1f7a0e2ea8c130a9b6a9dd939df485d Mon Sep 17 00:00:00 2001 From: HotelSync Date: Thu, 26 Mar 2026 10:47:54 +0300 Subject: [PATCH] feat: collapsible sidebar groups + favorites, chat hide setting, billing page, module route guards --- src/App.tsx | 15 +- src/components/ModuleGuard.tsx | 14 + src/components/layout/Sidebar.tsx | 9 +- src/pages/BillingPage.tsx | 561 ++++++++++++++++++++++++++++++ 4 files changed, 589 insertions(+), 10 deletions(-) create mode 100644 src/components/ModuleGuard.tsx create mode 100644 src/pages/BillingPage.tsx diff --git a/src/App.tsx b/src/App.tsx index a0e4d5e..071e440 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,8 @@ import { ResetPasswordPage } from './pages/ResetPasswordPage' import { TvWelcomePage } from './pages/TvWelcomePage' import { TechnicalPage } from './pages/TechnicalPage' import { SchedulePage } from './pages/SchedulePage' +import { BillingPage } from './pages/BillingPage' +import { ModuleGuard } from './components/ModuleGuard' export default function App() { return ( @@ -62,8 +64,8 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> @@ -73,10 +75,10 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -89,6 +91,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/ModuleGuard.tsx b/src/components/ModuleGuard.tsx new file mode 100644 index 0000000..2bed9a2 --- /dev/null +++ b/src/components/ModuleGuard.tsx @@ -0,0 +1,14 @@ +import { Navigate } from 'react-router-dom' +import { useModules } from '../contexts/ModulesContext' + +interface ModuleGuardProps { + moduleId: string + children: React.ReactNode + redirectTo?: string +} + +export function ModuleGuard({ moduleId, children, redirectTo = '/modules' }: ModuleGuardProps) { + const { isActive } = useModules() + if (!isActive(moduleId)) return + return <>{children} +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 767178f..3c8fc28 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import { CalendarDays, BookOpen, BedDouble, Globe, Settings, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, - TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, + TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], - settingsGroup:['/modules', '/settings'], + settingsGroup:['/modules', '/settings', '/billing'], devGroup: ['/api-docs'], } @@ -410,8 +410,9 @@ export function Sidebar({ open, onClose }: SidebarProps) { toggleGroup('settingsGroup')} /> {groups.settingsGroup && (
- - + + +
)} diff --git a/src/pages/BillingPage.tsx b/src/pages/BillingPage.tsx new file mode 100644 index 0000000..d07f9fa --- /dev/null +++ b/src/pages/BillingPage.tsx @@ -0,0 +1,561 @@ +import { useState } from 'react' +import { + Check, CreditCard, Building2, Zap, Crown, ChevronRight, + Copy, CheckCircle2, AlertCircle, ExternalLink, ToggleLeft, ToggleRight, + Receipt, ArrowRight, Lock, +} from 'lucide-react' +import { cn } from '../lib/utils' +import { MODULES_DATA } from '../data/modulesData' +import { useModules } from '../contexts/ModulesContext' +import type { ModuleStatus } from '../data/modulesData' + +// ─── Plans ──────────────────────────────────────────────────────────────────── + +interface Plan { + id: string + name: string + price: number | null + period: string + rooms: string + description: string + features: string[] + icon: React.ElementType + color: string + badge?: string + popular?: boolean +} + +const PLANS: Plan[] = [ + { + id: 'starter', + name: 'Стартовый', + price: 0, + period: 'бесплатно', + rooms: 'до 5 номеров', + description: 'Идеально для небольших гостиниц и хостелов', + icon: Zap, + color: 'text-slate-600 dark:text-slate-400', + features: [ + 'Шахматка и бронирования', + 'Управление номерами', + 'До 3 сотрудников', + 'Базовые отчёты', + ], + }, + { + id: 'pro', + name: 'Стандарт', + price: 2990, + period: 'в месяц', + rooms: 'до 30 номеров', + description: 'Для растущих отелей с командой', + icon: Building2, + color: 'text-brand-600 dark:text-brand-400', + popular: true, + badge: 'Популярный', + features: [ + 'Всё из Стартового', + 'До 30 номеров', + 'Неограниченно сотрудников', + 'Модуль уборки включён', + 'Управление гостями', + 'Email-уведомления', + ], + }, + { + id: 'business', + name: 'Профессиональный', + price: 5990, + period: 'в месяц', + rooms: 'до 100 номеров', + description: 'Полный набор инструментов для отелей', + icon: Crown, + color: 'text-violet-600 dark:text-violet-400', + features: [ + 'Всё из Стандарта', + 'До 100 номеров', + 'Менеджер каналов включён', + 'Динамическое ценообразование', + 'Программа лояльности', + 'Приоритетная поддержка', + ], + }, + { + id: 'enterprise', + name: 'Корпоративный', + price: null, + period: 'договорная цена', + rooms: 'без ограничений', + description: 'Для сетей отелей и крупных объектов', + icon: CreditCard, + color: 'text-amber-600 dark:text-amber-400', + features: [ + 'Всё из Профессионального', + 'Без ограничений по номерам', + 'White-label / ваш бренд', + 'API-интеграции', + 'Выделенный менеджер', + 'SLA-соглашение', + ], + }, +] + +const CURRENT_PLAN_ID = 'business' // demo: all active + +// ─── Invoice Modal ───────────────────────────────────────────────────────────── + +function InvoiceModal({ + plan, + modules, + totalMonthly, + onClose, +}: { + plan: Plan + modules: { name: string; price: number }[] + totalMonthly: number + onClose: () => void +}) { + const [copied, setCopied] = useState(false) + + const BANK_DETAILS = `Получатель: ООО «ХотелСинк» +ИНН: 7701234567 +КПП: 770101001 +Р/с: 40702810000000000001 +Банк: АО «Тинькофф Банк» +БИК: 044525974 +К/с: 30101810145250000974 +Назначение: Оплата подписки HotelSync, ${plan.name}, ${totalMonthly.toLocaleString('ru-RU')} ₽/мес` + + const copyDetails = () => { + navigator.clipboard.writeText(BANK_DETAILS).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }).catch(() => {}) + } + + return ( +
+
e.stopPropagation()}> +
+
+
+ +
+
+

Счёт на оплату

+

Банковский перевод

+
+
+
+ +
+ {/* Summary */} +
+
+ Тариф {plan.name} + + {plan.price ? `${plan.price.toLocaleString('ru-RU')} ₽` : 'Договорная'} + +
+ {modules.map(m => ( +
+ {m.name} + {m.price.toLocaleString('ru-RU')} ₽ +
+ ))} +
+ Итого/месяц + {totalMonthly.toLocaleString('ru-RU')} ₽ +
+
+ + {/* Bank details */} +
+
+

Банковские реквизиты

+ +
+
+{BANK_DETAILS}
+            
+
+ +
+ +

+ После оплаты отправьте подтверждение на billing@hotelsync.ru — активация в течение 1 рабочего дня +

+
+
+ +
+ +
+
+
+ ) +} + +// ─── Module Row ──────────────────────────────────────────────────────────────── + +function ModuleRow({ + mod, + status, + onToggle, +}: { + mod: (typeof MODULES_DATA)[0] + status: ModuleStatus + onToggle: () => void +}) { + const active = status === 'active' || status === 'trial' + const Icon = mod.icon + + return ( +
+
+ +
+
+
+

{mod.name}

+ {status === 'trial' && ( + + Пробный + + )} +
+

{mod.tagline}

+
+
+ + {mod.price === 0 ? 'Включён' : `${mod.price.toLocaleString('ru-RU')} ₽/мес`} + + {mod.price > 0 ? ( + + ) : ( +
+ +
+ )} +
+
+ ) +} + +// ─── Main Page ───────────────────────────────────────────────────────────────── + +type PageTab = 'plans' | 'modules' | 'history' + +export function BillingPage() { + const [tab, setTab] = useState('plans') + const [showInvoice, setShowInvoice] = useState(false) + const [invoicePlan, setInvoicePlan] = useState(null) + const { statuses, setStatus } = useModules() + + const currentPlan = PLANS.find(p => p.id === CURRENT_PLAN_ID)! + + const toggleModule = (id: string) => { + const cur = statuses[id] ?? 'inactive' + const next: ModuleStatus = (cur === 'active' || cur === 'trial') ? 'inactive' : 'active' + setStatus(id, next) + } + + const paidActiveModules = MODULES_DATA.filter(m => { + const active = statuses[m.id] === 'active' || statuses[m.id] === 'trial' + return active && m.price > 0 + }) + + const totalMonthly = (currentPlan.price ?? 0) + paidActiveModules.reduce((s, m) => s + m.price, 0) + + const openInvoice = (plan: Plan) => { + setInvoicePlan(plan) + setShowInvoice(true) + } + + return ( +
+ {/* Header */} +
+
+

Тарифы и оплата

+

+ Управление подпиской, модули и история платежей +

+
+
+
+

Ежемесячный платёж

+

+ {totalMonthly > 0 ? `${totalMonthly.toLocaleString('ru-RU')} ₽` : 'Бесплатно'} +

+
+ +
+
+ + {/* Current plan banner */} +
+
+ +
+
+

Текущий тариф

+

{currentPlan.name}

+
+
+

Следующее списание

+

1 апреля 2026

+
+
+ + Активен + +
+
+ + {/* Tabs */} +
+ {([ + { id: 'plans', label: 'Тарифные планы' }, + { id: 'modules', label: 'Модули' }, + { id: 'history', label: 'История платежей' }, + ] as { id: PageTab; label: string }[]).map(t => ( + + ))} +
+ + {/* ── Plans Tab ── */} + {tab === 'plans' && ( +
+ {PLANS.map(plan => { + const isCurrent = plan.id === CURRENT_PLAN_ID + const Icon = plan.icon + return ( +
+ {plan.popular && ( +
+ )} +
+
+
+ + {isCurrent && ( + + Текущий + + )} + {plan.badge && !isCurrent && ( + + {plan.badge} + + )} +
+

{plan.name}

+

{plan.description}

+
+ +
+
+ {plan.price !== null ? ( + <> + + {plan.price === 0 ? 'Бесплатно' : `${plan.price.toLocaleString('ru-RU')} ₽`} + + {plan.price > 0 && /мес} + + ) : ( + Договорная + )} +
+

{plan.rooms}

+
+ +
    + {plan.features.map((f, i) => ( +
  • + + {f} +
  • + ))} +
+ +
+ {isCurrent ? ( +
+ Текущий тариф ✓ +
+ ) : ( + <> + + {plan.id === 'enterprise' ? ( + + ) : ( + + )} + + )} +
+
+
+ ) + })} +
+ )} + + {/* ── Modules Tab ── */} + {tab === 'modules' && ( +
+
+ +

+ Подключённые модули автоматически отображаются в меню. Отключённые модули скрываются и становятся недоступны для всех сотрудников. +

+
+ +
+
+
+

Модуль

+

Стоимость

+
+
+
+ {MODULES_DATA.map(mod => ( + toggleModule(mod.id)} + /> + ))} +
+
+

Итого за модули

+

+ {paidActiveModules.reduce((s, m) => s + m.price, 0).toLocaleString('ru-RU')} ₽/мес +

+
+
+ + {/* YuKassa placeholder */} +
+
+
+ +
+
+

Онлайн-оплата через ЮKassa

+

Карты, СБП, Яндекс Пэй — скоро

+
+ + Скоро + +
+
+ +

+ Интеграция с ЮKassa позволит оплачивать подписку картой или через СБП прямо в личном кабинете +

+
+
+
+ )} + + {/* ── History Tab ── */} + {tab === 'history' && ( +
+
+

История платежей

+
+
+ {[ + { date: '01.03.2026', desc: 'Тариф Профессиональный', amount: 5990, status: 'paid' }, + { date: '01.02.2026', desc: 'Тариф Профессиональный', amount: 5990, status: 'paid' }, + { date: '01.01.2026', desc: 'Тариф Стандарт + Менеджер каналов', amount: 5490, status: 'paid' }, + { date: '01.12.2025', desc: 'Тариф Стандарт', amount: 2990, status: 'paid' }, + ].map((item, i) => ( +
+
+

{item.desc}

+

{item.date}

+
+
+ + {item.amount.toLocaleString('ru-RU')} ₽ + + + Оплачен + +
+
+ ))} +
+
+ )} + + {/* Invoice Modal */} + {showInvoice && invoicePlan && ( + ({ name: m.name, price: m.price }))} + totalMonthly={totalMonthly} + onClose={() => setShowInvoice(false)} + /> + )} +
+ ) +}