diff --git a/src/App.tsx b/src/App.tsx index b18c1ff..a54ecc4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' import { ModulesProvider } from './contexts/ModulesContext' import { AmenitiesProvider } from './contexts/AmenitiesContext' +import { NotificationsProvider } from './contexts/NotificationsContext' import { AppLayout } from './layouts/AppLayout' import { LoginPage } from './pages/LoginPage' import { CalendarPage } from './pages/CalendarPage' @@ -39,6 +40,7 @@ export default function App() { + @@ -89,6 +91,7 @@ export default function App() { + diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index 4e26ecb..d803ceb 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -1,7 +1,7 @@ import { Sun, Moon, Bell, LogOut, ChevronDown, Menu, BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2, Sparkles, AlertTriangle, Star, CreditCard, Info, - ArrowRight, + ArrowRight, Wrench, } from 'lucide-react' import { useTheme } from '../../contexts/ThemeContext' import { useAuth } from '../../contexts/AuthContext' @@ -9,97 +9,7 @@ import { ROLE_LABELS } from '../../lib/utils' import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { cn } from '../../lib/utils' - -// ── Notification types ───────────────────────────────────────────────────────── - -type NotifType = - | 'booking_new' - | 'booking_cancelled' - | 'booking_checkin' - | 'booking_checkout' - | 'housekeeping' - | 'channel_error' - | 'payment' - | 'review' - | 'system' - -interface Notification { - id: string - type: NotifType - title: string - body: string - time: string // ISO string - isRead: boolean - link?: string -} - -// ── Mock data ────────────────────────────────────────────────────────────────── - -const MOCK_NOTIFICATIONS: Notification[] = [ - { - id: 'n1', type: 'booking_new', - title: 'Новое бронирование', - body: 'Алексей Смирнов забронировал номер 205 (Делюкс) на 14–18 марта', - time: new Date(Date.now() - 4 * 60000).toISOString(), - isRead: false, link: '/bookings', - }, - { - id: 'n2', type: 'channel_error', - title: 'Ошибка синхронизации', - body: 'Booking.com: не удалось обновить доступность. Проверьте подключение.', - time: new Date(Date.now() - 18 * 60000).toISOString(), - isRead: false, link: '/channels', - }, - { - id: 'n3', type: 'booking_checkin', - title: 'Заезд сегодня', - body: 'Мария Иванова · номер 101 · заезд в 14:00', - time: new Date(Date.now() - 45 * 60000).toISOString(), - isRead: false, link: '/bookings', - }, - { - id: 'n4', type: 'review', - title: 'Новый отзыв требует модерации', - body: 'Гость оставил оценку 2/5 — отзыв ждёт вашего ответа', - time: new Date(Date.now() - 2 * 3600000).toISOString(), - isRead: false, link: '/reviews', - }, - { - id: 'n5', type: 'housekeeping', - title: 'Уборка завершена', - body: 'Горничная Козлова Н. завершила уборку номеров 101, 102, 203', - time: new Date(Date.now() - 3 * 3600000).toISOString(), - isRead: true, link: '/housekeeping', - }, - { - id: 'n6', type: 'payment', - title: 'Оплата получена', - body: 'Иван Петров оплатил бронирование #B-2847 — 12 400 ₽', - time: new Date(Date.now() - 5 * 3600000).toISOString(), - isRead: true, - }, - { - id: 'n7', type: 'booking_cancelled', - title: 'Бронирование отменено', - body: 'Дмитрий Волков отменил бронь номера 304 на 20–22 марта', - time: new Date(Date.now() - 26 * 3600000).toISOString(), - isRead: true, link: '/bookings', - }, - { - id: 'n8', type: 'booking_checkout', - title: 'Выезд завершён', - body: 'Семья Соколовых выехала из номера 206. Нужна уборка.', - time: new Date(Date.now() - 28 * 3600000).toISOString(), - isRead: true, link: '/housekeeping', - }, - { - id: 'n9', type: 'system', - title: 'Обновление системы', - body: 'HotelSync обновлён до версии 0.2.0. Что нового — в журнале изменений.', - time: new Date(Date.now() - 3 * 86400000).toISOString(), - isRead: true, - }, -] +import { useNotifications, type NotifType, type Notification } from '../../contexts/NotificationsContext' // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -108,15 +18,16 @@ const NOTIF_META: Record = { - booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' }, - booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' }, + booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' }, + booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' }, booking_checkin: { icon: CalendarCheck2, iconBg: 'bg-emerald-100 dark:bg-emerald-900/30', iconColor: 'text-emerald-600 dark:text-emerald-400' }, - booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' }, - housekeeping: { icon: Sparkles, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' }, - channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' }, - payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' }, - review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' }, - system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' }, + booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' }, + housekeeping: { icon: Sparkles, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' }, + channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' }, + payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' }, + review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' }, + system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' }, + maintenance: { icon: Wrench, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' }, } function relativeTime(iso: string): string { @@ -132,20 +43,11 @@ function relativeTime(iso: string): string { function NotificationsPanel({ onClose }: { onClose: () => void }) { const navigate = useNavigate() - const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS) + const { notifications, markRead, markAllRead, removeNotif } = useNotifications() const [tab, setTab] = useState<'all' | 'unread'>('all') const unreadCount = notifications.filter(n => !n.isRead).length - const markRead = (id: string) => - setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n)) - - const markAllRead = () => - setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))) - - const removeNotif = (id: string) => - setNotifications(prev => prev.filter(n => n.id !== id)) - const handleClick = (n: Notification) => { markRead(n.id) if (n.link) { navigate(n.link); onClose() } @@ -305,10 +207,10 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) { const { theme, toggle } = useTheme() const { user, logout } = useAuth() const navigate = useNavigate() + const { notifications } = useNotifications() - const [userMenuOpen, setUserMenuOpen] = useState(false) - const [notifPanelOpen, setNotifPanelOpen] = useState(false) - const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS) + const [userMenuOpen, setUserMenuOpen] = useState(false) + const [notifPanelOpen, setNotifPanelOpen] = useState(false) const unreadCount = notifications.filter(n => !n.isRead).length @@ -348,9 +250,7 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) { <>
setNotifPanelOpen(false)} />
- setNotifPanelOpen(false)} - /> + setNotifPanelOpen(false)} />
)} diff --git a/src/contexts/NotificationsContext.tsx b/src/contexts/NotificationsContext.tsx new file mode 100644 index 0000000..4ee74ac --- /dev/null +++ b/src/contexts/NotificationsContext.tsx @@ -0,0 +1,133 @@ +import { createContext, useContext, useState, type ReactNode } from 'react' + +export type NotifType = + | 'booking_new' + | 'booking_cancelled' + | 'booking_checkin' + | 'booking_checkout' + | 'housekeeping' + | 'channel_error' + | 'payment' + | 'review' + | 'system' + | 'maintenance' + +export interface Notification { + id: string + type: NotifType + title: string + body: string + time: string + isRead: boolean + link?: string +} + +const INITIAL_NOTIFICATIONS: Notification[] = [ + { + id: 'n1', type: 'booking_new', + title: 'Новое бронирование', + body: 'Алексей Смирнов забронировал номер 205 (Делюкс) на 14–18 марта', + time: new Date(Date.now() - 4 * 60000).toISOString(), + isRead: false, link: '/bookings', + }, + { + id: 'n2', type: 'channel_error', + title: 'Ошибка синхронизации', + body: 'Booking.com: не удалось обновить доступность. Проверьте подключение.', + time: new Date(Date.now() - 18 * 60000).toISOString(), + isRead: false, link: '/channels', + }, + { + id: 'n3', type: 'booking_checkin', + title: 'Заезд сегодня', + body: 'Мария Иванова · номер 101 · заезд в 14:00', + time: new Date(Date.now() - 45 * 60000).toISOString(), + isRead: false, link: '/bookings', + }, + { + id: 'n4', type: 'review', + title: 'Новый отзыв требует модерации', + body: 'Гость оставил оценку 2/5 — отзыв ждёт вашего ответа', + time: new Date(Date.now() - 2 * 3600000).toISOString(), + isRead: false, link: '/reviews', + }, + { + id: 'n5', type: 'housekeeping', + title: 'Уборка завершена', + body: 'Горничная Козлова Н. завершила уборку номеров 101, 102, 203', + time: new Date(Date.now() - 3 * 3600000).toISOString(), + isRead: true, link: '/housekeeping', + }, + { + id: 'n6', type: 'payment', + title: 'Оплата получена', + body: 'Иван Петров оплатил бронирование #B-2847 — 12 400 ₽', + time: new Date(Date.now() - 5 * 3600000).toISOString(), + isRead: true, + }, + { + id: 'n7', type: 'booking_cancelled', + title: 'Бронирование отменено', + body: 'Дмитрий Волков отменил бронь номера 304 на 20–22 марта', + time: new Date(Date.now() - 26 * 3600000).toISOString(), + isRead: true, link: '/bookings', + }, + { + id: 'n8', type: 'booking_checkout', + title: 'Выезд завершён', + body: 'Семья Соколовых выехала из номера 206. Нужна уборка.', + time: new Date(Date.now() - 28 * 3600000).toISOString(), + isRead: true, link: '/housekeeping', + }, + { + id: 'n9', type: 'system', + title: 'Обновление системы', + body: 'HotelSync обновлён до версии 0.2.0. Что нового — в журнале изменений.', + time: new Date(Date.now() - 3 * 86400000).toISOString(), + isRead: true, + }, +] + +interface NotificationsContextValue { + notifications: Notification[] + addNotification: (n: Omit) => void + markRead: (id: string) => void + markAllRead: () => void + removeNotif: (id: string) => void +} + +const NotificationsContext = createContext(null) + +export function NotificationsProvider({ children }: { children: ReactNode }) { + const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS) + + const addNotification = (n: Omit) => { + setNotifications(prev => [{ + ...n, + id: `n-${Date.now()}`, + time: new Date().toISOString(), + isRead: false, + }, ...prev]) + } + + const markRead = (id: string) => + setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n)) + + const markAllRead = () => + setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))) + + const removeNotif = (id: string) => + setNotifications(prev => prev.filter(n => n.id !== id)) + + return ( + + {children} + + ) +} + +export function useNotifications() { + const ctx = useContext(NotificationsContext) + if (!ctx) throw new Error('useNotifications must be used within NotificationsProvider') + return ctx +} diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx index da21d6b..65df7b3 100644 --- a/src/pages/BookingWidgetPage.tsx +++ b/src/pages/BookingWidgetPage.tsx @@ -95,11 +95,18 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) { const [extraBeds, setExtraBeds] = useState(0) const [children, setChildren] = useState(0) const [selected, setSelected] = useState(null) - const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse') + const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse') // Form state const [formValues, setFormValues] = useState>({}) const [selectedServices, setSelectedServices] = useState([]) + // Hourly service time selections: { serviceId: { date, timeFrom, timeTo } } + const [serviceSchedule, setServiceSchedule] = useState>({}) + // Payment state + const [cardNumber, setCardNumber] = useState('') + const [cardExpiry, setCardExpiry] = useState('') + const [cardCvv, setCardCvv] = useState('') + const [cardName, setCardName] = useState('') const nights = checkIn && checkOut ? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000) @@ -115,6 +122,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) { const grandTotal = roomTotal + extraTotal + servicesTotal const activeFields = settings.formFields.filter(f => f.enabled) + const needsPayment = settings.paymentProvider !== 'none' const handleBook = () => { if (!selected || nights === 0) return @@ -122,18 +130,36 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) { } const handleSubmit = () => { - // Check required fields const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim()) if (missing.length > 0) return + if (needsPayment) { + setStep('payment') + } else { + setStep('success') + } + } + + const handlePay = () => { + // Mock payment — just proceed to success setStep('success') } const handleBack = () => { + if (step === 'payment') { setStep('form'); return } setStep('browse') setFormValues({}) setSelectedServices([]) + setServiceSchedule({}) + setCardNumber(''); setCardExpiry(''); setCardCvv(''); setCardName('') } + const formatCard = (v: string) => v.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim() + const formatExpiry = (v: string) => { + const d = v.replace(/\D/g, '').slice(0, 4) + return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d + } + + // ── Success screen ── if (step === 'success') { return (
@@ -145,7 +171,11 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {

Бронирование принято!

-

Подтверждение придёт на email в течение нескольких минут

+

+ {needsPayment + ? 'Оплата прошла успешно. Подтверждение придёт на email.' + : 'Оплата на месте при заезде. Подтверждение придёт на email.'} +

Номер: {selectedRoom?.name}

Заезд: {checkIn}

@@ -164,6 +194,103 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) { ) } + // ── Payment screen ── + if (step === 'payment') { + const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa' + : settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay' + : 'CloudPayments' + + return ( +
+
+ +
+

{settings.language === 'ru' ? 'Оплата' : 'Payment'}

+

{payLabel} · {grandTotal.toLocaleString('ru-RU')} ₽

+
+
+ +
+ {/* Amount summary */} +
+ {selectedRoom?.name} · {nights} ноч. + {grandTotal.toLocaleString('ru-RU')} ₽ +
+ + {/* Card form */} +
+
+ + setCardNumber(formatCard(e.target.value))} + maxLength={19} + /> +
+
+
+ + setCardExpiry(formatExpiry(e.target.value))} + maxLength={5} + /> +
+
+ + setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 3))} + maxLength={3} + /> +
+
+
+ + setCardName(e.target.value.toUpperCase())} + /> +
+
+ +

+ 🔒 Платёж защищён 3-D Secure · {payLabel} +

+
+ +
+ +
+
+ ) + } + + // ── Guest form screen ── if (step === 'form') { return (
@@ -175,6 +302,14 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {

{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}

{selectedRoom?.name} · {nights} ноч.

+ {/* Step indicator */} +
+ {[1, 2, needsPayment ? 3 : null].filter(Boolean).map((s, i) => ( +
+ ))} +
+ {needsPayment &&
} +
@@ -211,21 +346,71 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {

{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}

-
- {settings.additionalServices.filter(s => s.enabled).map(s => ( -
)} @@ -240,7 +425,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {