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>
This commit is contained in:
232
src/pages/ModulesPage.tsx
Normal file
232
src/pages/ModulesPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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">
|
||||
₽ {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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user