Files
hotelsync/src/pages/ModulesPage.tsx
HotelSync b277e3e38d Add Availability Calendar, Modules system, and route refactoring
- Add AvailabilityPage with price grid (categories × dates), drag-select,
  channel prices, EditPanel, PeriodModal, and rate periods tab
- Add ModulesContext with localStorage persistence and dynamic sidebar items
- Add ModulesPage with WiFi Auth, Payments, TV Welcome, Smart Locks,
  OLAP Reports, Website Builder, Booking Widget modules
- Add ReportsPage (OLAP analytics with KPI cards, charts, tables)
- Add WebsitePage and BookingWidgetPage placeholders
- Remove hotel slug from URL routing; hotel context from JWT
- Add Доступность nav item in Sidebar under Управление
- Fix LoginPage redirects and HotelSync branding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:43:18 +03:00

233 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import {
CheckCircle2, Lock, Zap, ChevronDown,
Star, ArrowUpRight, Puzzle,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { MODULES_DATA } from '../data/modulesData'
import { useModules } from '../contexts/ModulesContext'
import type { ModuleDef } from '../data/modulesData'
import type { ModuleStatus } from '../data/modulesData'
// ─── Status config ────────────────────────────────────────────────────────────
const STATUS_CFG: Record<ModuleStatus, { label: string; pill: string }> = {
active: { label: 'Подключён', pill: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400' },
trial: { label: 'Пробный', pill: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400' },
inactive: { label: 'Не подключён', pill: 'bg-slate-100 text-slate-500 dark:bg-slate-700/60 dark:text-slate-400' },
}
// ─── Card ─────────────────────────────────────────────────────────────────────
function ModuleCard({ mod, status }: { mod: ModuleDef; status: ModuleStatus }) {
const [open, setOpen] = useState(false)
const Icon = mod.icon
const cfg = STATUS_CFG[status]
return (
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<div className={cn('h-1 w-full shrink-0', mod.accentColor)} />
<div className="p-5 flex flex-col flex-1 gap-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', mod.iconBg)}>
<Icon size={20} className={mod.iconColor} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-slate-900 dark:text-slate-100 leading-tight">
{mod.name}
</span>
{mod.badge && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-600 dark:bg-brand-900/30 dark:text-brand-400 border border-brand-200 dark:border-brand-800">
<Star size={9} />{mod.badge}
</span>
)}
{mod.sidebarItem && status === 'active' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">
В меню
</span>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{mod.tagline}</p>
</div>
</div>
<span className={cn('shrink-0 text-xs font-medium px-2.5 py-1 rounded-full whitespace-nowrap', cfg.pill)}>
{cfg.label}
</span>
</div>
{/* Description */}
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{mod.description}
</p>
{/* Stats */}
{mod.stats && status !== 'inactive' && (
<div className="grid grid-cols-3 gap-2 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-100 dark:border-slate-700">
{mod.stats.map(s => (
<div key={s.label} className="text-center">
<p className="text-base font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500 leading-tight mt-0.5">{s.label}</p>
</div>
))}
</div>
)}
{/* Trial banner */}
{status === 'trial' && mod.trialDays !== undefined && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/60 text-sm text-amber-700 dark:text-amber-400">
<Zap size={13} className="shrink-0" />
Пробный период: осталось{' '}
<strong className="font-semibold">{mod.trialDays} дней</strong>
</div>
)}
{/* Sidebar hint for inactive modules with sidebar */}
{mod.sidebarItem && status === 'inactive' && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400">
<Lock size={11} className="shrink-0" />
После подключения появится раздел <strong className="text-slate-700 dark:text-slate-300">«{mod.sidebarItem.label}»</strong> в боковом меню
</div>
)}
<div className="flex-1" />
{/* Features accordion */}
<div className="border-t border-slate-100 dark:border-slate-700 pt-3">
<button
onClick={() => setOpen(v => !v)}
className="flex items-center justify-between w-full text-sm text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
<span className="font-medium">Возможности модуля</span>
<ChevronDown size={15} className={cn('transition-transform duration-200', open && 'rotate-180')} />
</button>
{open && (
<ul className="mt-3 space-y-2">
{mod.features.map(f => (
<li key={f} className="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
<CheckCircle2 size={13} className="mt-0.5 shrink-0 text-emerald-500" />
{f}
</li>
))}
</ul>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
&nbsp;{mod.price.toLocaleString('ru-RU')}
</span>
<span className="text-sm text-slate-400">/мес</span>
</div>
{status === 'active' && (
<button className="btn-secondary text-sm flex items-center gap-1.5 py-1.5 px-3">
Настройки <ArrowUpRight size={13} />
</button>
)}
{status === 'trial' && (
<button className="btn-primary text-sm flex items-center gap-1.5 py-1.5 px-3">
Подключить <ArrowUpRight size={13} />
</button>
)}
{status === 'inactive' && (
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<Lock size={12} /> Подключить
</button>
)}
</div>
</div>
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function ModulesPage() {
const { statuses } = useModules()
const activeCount = MODULES_DATA.filter(m => statuses[m.id] === 'active').length
const trialCount = MODULES_DATA.filter(m => statuses[m.id] === 'trial').length
const totalCount = MODULES_DATA.length
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center shrink-0">
<Puzzle size={20} className="text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Модули</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Дополнительные возможности для вашего отеля
</p>
</div>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-3">
{[
{
value: activeCount,
label: 'Активных',
sub: `из ${totalCount} доступных`,
color: 'text-emerald-600 dark:text-emerald-400',
},
{
value: trialCount,
label: 'На пробном периоде',
sub: 'бесплатно до окончания',
color: 'text-amber-500 dark:text-amber-400',
},
{
value: totalCount - activeCount - trialCount,
label: 'Не подключено',
sub: 'доступны к активации',
color: 'text-slate-400 dark:text-slate-500',
},
].map(s => (
<div key={s.label} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-3">
<div className="flex items-baseline gap-2">
<span className={cn('text-2xl font-bold', s.color)}>{s.value}</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{s.label}</span>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{s.sub}</p>
</div>
))}
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{MODULES_DATA.map(mod => (
<ModuleCard key={mod.id} mod={mod} status={statuses[mod.id] ?? 'inactive'} />
))}
</div>
{/* CTA */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-brand-600 to-brand-700 p-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="absolute right-0 top-0 w-48 h-48 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/4 pointer-events-none" />
<div className="absolute right-12 bottom-0 w-32 h-32 bg-white/5 rounded-full translate-y-1/2 pointer-events-none" />
<div className="relative">
<p className="font-semibold text-white text-base">Нужен индивидуальный модуль?</p>
<p className="text-brand-200 text-sm mt-1">
Разработаем интеграцию под ваши задачи CRM, программа лояльности, своя кассовая система.
</p>
</div>
<button className="relative shrink-0 px-5 py-2.5 rounded-xl bg-white text-brand-700 font-medium text-sm hover:bg-brand-50 transition-colors shadow-sm">
Связаться с нами
</button>
</div>
</div>
)
}