feat: collapsible sidebar groups + favorites, chat hide setting, billing page, module route guards

This commit is contained in:
2026-03-26 10:47:54 +03:00
parent cbddbe6027
commit 15fec905e1
4 changed files with 589 additions and 10 deletions

561
src/pages/BillingPage.tsx Normal file
View File

@@ -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 (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-lg" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<Receipt size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">Счёт на оплату</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">Банковский перевод</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Summary */}
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600 dark:text-slate-400">Тариф {plan.name}</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{plan.price ? `${plan.price.toLocaleString('ru-RU')}` : 'Договорная'}
</span>
</div>
{modules.map(m => (
<div key={m.name} className="flex justify-between text-sm">
<span className="text-slate-600 dark:text-slate-400">{m.name}</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{m.price.toLocaleString('ru-RU')} </span>
</div>
))}
<div className="pt-2 border-t border-slate-200 dark:border-slate-600 flex justify-between font-bold">
<span className="text-slate-900 dark:text-slate-100">Итого/месяц</span>
<span className="text-brand-600 dark:text-brand-400">{totalMonthly.toLocaleString('ru-RU')} </span>
</div>
</div>
{/* Bank details */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Банковские реквизиты</p>
<button
onClick={copyDetails}
className="flex items-center gap-1.5 text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 transition-colors"
>
{copied ? <CheckCircle2 size={13} /> : <Copy size={13} />}
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
<pre className="text-xs bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono leading-relaxed border border-slate-200 dark:border-slate-600">
{BANK_DETAILS}
</pre>
</div>
<div className="flex items-start gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
<AlertCircle size={14} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-400">
После оплаты отправьте подтверждение на <strong>billing@hotelsync.ru</strong> активация в течение 1 рабочего дня
</p>
</div>
</div>
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex justify-end">
<button onClick={onClose} className="btn-secondary">Закрыть</button>
</div>
</div>
</div>
)
}
// ─── 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 (
<div className={cn(
'flex items-center gap-4 px-5 py-4 transition-colors',
active ? 'hover:bg-slate-50 dark:hover:bg-slate-700/30' : 'opacity-60 hover:bg-slate-50 dark:hover:bg-slate-700/30',
)}>
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center shrink-0', mod.iconBg)}>
<Icon size={18} className={mod.iconColor} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{mod.name}</p>
{status === 'trial' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
Пробный
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{mod.tagline}</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<span className={cn(
'text-sm font-semibold',
mod.price === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-700 dark:text-slate-300',
)}>
{mod.price === 0 ? 'Включён' : `${mod.price.toLocaleString('ru-RU')} ₽/мес`}
</span>
{mod.price > 0 ? (
<button
onClick={onToggle}
className="relative shrink-0 transition-colors"
title={active ? 'Отключить' : 'Подключить'}
>
{active
? <ToggleRight size={28} className="text-brand-600 dark:text-brand-400" />
: <ToggleLeft size={28} className="text-slate-300 dark:text-slate-600" />
}
</button>
) : (
<div className="w-7 h-4 flex items-center justify-center">
<Check size={14} className="text-emerald-600 dark:text-emerald-400" />
</div>
)}
</div>
</div>
)
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
type PageTab = 'plans' | 'modules' | 'history'
export function BillingPage() {
const [tab, setTab] = useState<PageTab>('plans')
const [showInvoice, setShowInvoice] = useState(false)
const [invoicePlan, setInvoicePlan] = useState<Plan | null>(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 (
<div className="p-4 md:p-6 space-y-5 max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Тарифы и оплата</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Управление подпиской, модули и история платежей
</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-xs text-slate-500 dark:text-slate-400">Ежемесячный платёж</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{totalMonthly > 0 ? `${totalMonthly.toLocaleString('ru-RU')}` : 'Бесплатно'}
</p>
</div>
<button
onClick={() => currentPlan && openInvoice(currentPlan)}
className="btn-secondary flex items-center gap-2"
>
<Receipt size={14} />
Выставить счёт
</button>
</div>
</div>
{/* Current plan banner */}
<div className="card p-4 flex items-center gap-4 border-brand-200 dark:border-brand-800 bg-gradient-to-r from-brand-50 to-transparent dark:from-brand-900/10 dark:to-transparent">
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center shrink-0">
<currentPlan.icon size={20} className="text-white" />
</div>
<div className="flex-1">
<p className="text-sm text-brand-600 dark:text-brand-400 font-medium">Текущий тариф</p>
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">{currentPlan.name}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500 dark:text-slate-400">Следующее списание</p>
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">1 апреля 2026</p>
</div>
<div className="shrink-0">
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
Активен
</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{([
{ id: 'plans', label: 'Тарифные планы' },
{ id: 'modules', label: 'Модули' },
{ id: 'history', label: 'История платежей' },
] as { id: PageTab; label: string }[]).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{t.label}
</button>
))}
</div>
{/* ── Plans Tab ── */}
{tab === 'plans' && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{PLANS.map(plan => {
const isCurrent = plan.id === CURRENT_PLAN_ID
const Icon = plan.icon
return (
<div
key={plan.id}
className={cn(
'card flex flex-col overflow-hidden transition-shadow hover:shadow-lg',
isCurrent && 'ring-2 ring-brand-500 dark:ring-brand-400',
)}
>
{plan.popular && (
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
)}
<div className="p-5 flex flex-col flex-1 gap-4">
<div>
<div className="flex items-center justify-between mb-2">
<Icon size={20} className={plan.color} />
{isCurrent && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-400">
Текущий
</span>
)}
{plan.badge && !isCurrent && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
{plan.badge}
</span>
)}
</div>
<h3 className="text-base font-bold text-slate-900 dark:text-slate-100">{plan.name}</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{plan.description}</p>
</div>
<div>
<div className="flex items-baseline gap-1">
{plan.price !== null ? (
<>
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{plan.price === 0 ? 'Бесплатно' : `${plan.price.toLocaleString('ru-RU')}`}
</span>
{plan.price > 0 && <span className="text-sm text-slate-500 dark:text-slate-400">/мес</span>}
</>
) : (
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">Договорная</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{plan.rooms}</p>
</div>
<ul className="space-y-1.5 flex-1">
{plan.features.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-600 dark:text-slate-400">
<Check size={12} className="text-emerald-500 mt-0.5 shrink-0" />
{f}
</li>
))}
</ul>
<div className="space-y-2 pt-2">
{isCurrent ? (
<div className="text-center py-2 text-xs font-medium text-brand-600 dark:text-brand-400">
Текущий тариф
</div>
) : (
<>
<button
onClick={() => openInvoice(plan)}
className="w-full btn-secondary text-sm flex items-center justify-center gap-1.5"
>
<Receipt size={13} />
Выставить счёт
</button>
{plan.id === 'enterprise' ? (
<button className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors">
Связаться с нами <ArrowRight size={13} />
</button>
) : (
<button className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors">
Перейти на тариф <ChevronRight size={13} />
</button>
)}
</>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{/* ── Modules Tab ── */}
{tab === 'modules' && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 rounded-xl bg-sky-50 dark:bg-sky-900/10 border border-sky-200 dark:border-sky-800">
<AlertCircle size={14} className="text-sky-600 dark:text-sky-400 mt-0.5 shrink-0" />
<p className="text-xs text-sky-700 dark:text-sky-400">
Подключённые модули автоматически отображаются в меню. Отключённые модули скрываются и становятся недоступны для всех сотрудников.
</p>
</div>
<div className="card overflow-hidden">
<div className="px-5 py-3 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Модуль</p>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Стоимость</p>
</div>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{MODULES_DATA.map(mod => (
<ModuleRow
key={mod.id}
mod={mod}
status={statuses[mod.id] ?? 'inactive'}
onToggle={() => toggleModule(mod.id)}
/>
))}
</div>
<div className="px-5 py-3 bg-slate-50 dark:bg-slate-700/30 border-t border-slate-200 dark:border-slate-700 flex items-center justify-between">
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Итого за модули</p>
<p className="text-sm font-bold text-slate-900 dark:text-slate-100">
{paidActiveModules.reduce((s, m) => s + m.price, 0).toLocaleString('ru-RU')} /мес
</p>
</div>
</div>
{/* YuKassa placeholder */}
<div className="card p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<Lock size={16} className="text-violet-600 dark:text-violet-400" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Онлайн-оплата через ЮKassa</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Карты, СБП, Яндекс Пэй скоро</p>
</div>
<span className="ml-auto px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">
Скоро
</span>
</div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-dashed border-slate-300 dark:border-slate-600">
<ExternalLink size={14} className="text-slate-400 shrink-0" />
<p className="text-xs text-slate-500 dark:text-slate-400">
Интеграция с ЮKassa позволит оплачивать подписку картой или через СБП прямо в личном кабинете
</p>
</div>
</div>
</div>
)}
{/* ── History Tab ── */}
{tab === 'history' && (
<div className="card overflow-hidden">
<div className="px-5 py-3 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">История платежей</p>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{[
{ 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) => (
<div key={i} className="px-5 py-3.5 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{item.desc}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{item.date}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{item.amount.toLocaleString('ru-RU')}
</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
Оплачен
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Invoice Modal */}
{showInvoice && invoicePlan && (
<InvoiceModal
plan={invoicePlan}
modules={paidActiveModules.map(m => ({ name: m.name, price: m.price }))}
totalMonthly={totalMonthly}
onClose={() => setShowInvoice(false)}
/>
)}
</div>
)
}