feat: centralized payment gateways + booking widget API
Backend:
- Migration 065: hotel_payment_gateways table (migrates YooKassa from deposit_settings)
- online_bookings table for widget submissions
- Routes: CRUD /api/hotels/:slug/payment-gateways
- Public widget API: /api/widget/:slug/{config,availability,bookings}
- yookassa.ts: add createCharge() for immediate capture
Frontend:
- PaymentSettingsPage: add 'Онлайн-оплата' section with gateway CRUD and module toggles
- DepositSettingsPage: replace YooKassa fields with link to centralized settings
- BookingWidgetPage: connect to real API, show gateway status, real booking submit
- api.ts: add PaymentGateway types, paymentGateways and widget API methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Code2, CreditCard, Globe, CalendarCheck2, Copy, CheckCheck,
|
||||
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
|
||||
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
|
||||
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, ChevronUp,
|
||||
Maximize2, Wifi, Tv2, Wind, Coffee, Bath, Mountain, Shirt, Shield,
|
||||
Scan, Image,
|
||||
Scan, Image, Zap, AlertCircle, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { useModules } from '../contexts/ModulesContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { api, type WidgetRoom, type PaymentGateway } from '../lib/api'
|
||||
|
||||
// ── Widget settings type ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -159,7 +161,12 @@ const DEFAULT_SERVICES: AdditionalService[] = [
|
||||
|
||||
// ── Widget Preview Component ───────────────────────────────────────────────────
|
||||
|
||||
function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
||||
settings: WidgetSettings
|
||||
slug?: string
|
||||
realRooms?: WidgetRoom[]
|
||||
paymentEnabled?: boolean
|
||||
}) {
|
||||
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
|
||||
const [checkIn, setCheckIn] = useState('2026-03-20')
|
||||
const [checkOut, setCheckOut] = useState('2026-03-22')
|
||||
@@ -170,13 +177,15 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
|
||||
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
|
||||
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
||||
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||||
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
|
||||
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
|
||||
// Payment state
|
||||
// Payment state (kept for mock display)
|
||||
const [cardNumber, setCardNumber] = useState('')
|
||||
const [cardExpiry, setCardExpiry] = useState('')
|
||||
const [cardCvv, setCardCvv] = useState('')
|
||||
@@ -186,7 +195,17 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
||||
: 0
|
||||
|
||||
const selectedRoom = MOCK_ROOMS.find(r => r.id === selected)
|
||||
// Use real rooms if available, otherwise mock
|
||||
const displayRooms = realRooms && realRooms.length > 0
|
||||
? realRooms.map(r => ({
|
||||
id: r.id, name: r.name || `Номер ${r.number}`, beds: 1,
|
||||
guests: r.maxGuests, price: r.baseRate, area: 0,
|
||||
description: r.description, photos: [], amenities: r.amenities,
|
||||
has3dTour: false,
|
||||
}))
|
||||
: MOCK_ROOMS
|
||||
|
||||
const selectedRoom = displayRooms.find(r => r.id === selected)
|
||||
const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0
|
||||
const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
|
||||
const servicesTotal = selectedServices.reduce((sum, sid) => {
|
||||
@@ -196,25 +215,63 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
const grandTotal = roomTotal + extraTotal + servicesTotal
|
||||
|
||||
const activeFields = settings.formFields.filter(f => f.enabled)
|
||||
const needsPayment = settings.paymentProvider !== 'none'
|
||||
const needsPayment = paymentEnabled ?? (settings.paymentProvider !== 'none')
|
||||
|
||||
const handleBook = () => {
|
||||
if (!selected || nights === 0) return
|
||||
setStep('form')
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
||||
if (missing.length > 0) return
|
||||
if (needsPayment) {
|
||||
setStep('payment')
|
||||
|
||||
if (slug && selected) {
|
||||
// Real API call
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const services = selectedServices
|
||||
.map(sid => settings.additionalServices.find(s => s.id === sid))
|
||||
.filter(Boolean)
|
||||
.map(s => ({ name: s!.name, price: s!.price }))
|
||||
|
||||
const result = await api.widget.createBooking(slug, {
|
||||
roomId: selected,
|
||||
checkIn, checkOut,
|
||||
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
|
||||
guestEmail: formValues['email'] ?? undefined,
|
||||
guestPhone: formValues['phone'] ?? undefined,
|
||||
adults: guests, children,
|
||||
totalAmount: grandTotal,
|
||||
notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
|
||||
services,
|
||||
})
|
||||
if (result.confirmationUrl) {
|
||||
setConfirmUrl(result.confirmationUrl)
|
||||
setStep('payment')
|
||||
} else {
|
||||
setStep('success')
|
||||
}
|
||||
} catch {
|
||||
// fallback — still show success in preview
|
||||
setStep('success')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
} else {
|
||||
setStep('success')
|
||||
// Preview mode without real slug
|
||||
if (needsPayment) {
|
||||
setStep('payment')
|
||||
} else {
|
||||
setStep('success')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePay = () => {
|
||||
// Mock payment — just proceed to success
|
||||
if (confirmUrl) {
|
||||
window.open(confirmUrl, '_blank')
|
||||
}
|
||||
setStep('success')
|
||||
}
|
||||
|
||||
@@ -529,10 +586,12 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
<div className="px-5 pb-5 pt-2 space-y-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
||||
disabled={submitting}
|
||||
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90 disabled:opacity-70 flex items-center justify-center gap-2"
|
||||
style={{ background: settings.primaryColor }}
|
||||
>
|
||||
{needsPayment
|
||||
{submitting && <Loader2 size={14} className="animate-spin" />}
|
||||
{submitting ? 'Отправляем...' : needsPayment
|
||||
? (settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')} ₽`)
|
||||
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Confirm · ${grandTotal.toLocaleString('ru-RU')} ₽`)}
|
||||
</button>
|
||||
@@ -656,7 +715,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
{children > 0 && ` · ${children} дет.`}
|
||||
</p>
|
||||
)}
|
||||
{MOCK_ROOMS.map(room => {
|
||||
{displayRooms.map(room => {
|
||||
const isExpanded = expandedRoom === room.id
|
||||
const isSelected = selected === room.id
|
||||
const curPhoto = photoIndex[room.id] ?? 0
|
||||
@@ -893,7 +952,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
>
|
||||
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
||||
{selected && nights > 0 && previewTab === 'rooms' && (() => {
|
||||
const r = MOCK_ROOMS.find(r => r.id === selected)
|
||||
const r = displayRooms.find(r => r.id === selected)
|
||||
const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0
|
||||
return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : ''
|
||||
})()}
|
||||
@@ -917,8 +976,28 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
||||
|
||||
export function BookingWidgetPage() {
|
||||
const { statuses } = useModules()
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
const rentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
||||
|
||||
// Real data for preview
|
||||
const [realRooms, setRealRooms] = useState<WidgetRoom[]>([])
|
||||
const [gateway, setGateway] = useState<PaymentGateway | null>(null)
|
||||
const [gatewayLoading, setGatewayLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
setGatewayLoading(true)
|
||||
Promise.all([
|
||||
api.widget.getConfig(slug).catch(() => null),
|
||||
api.paymentGateways.list(slug).catch(() => [] as PaymentGateway[]),
|
||||
]).then(([config, gws]) => {
|
||||
if (config?.rooms) setRealRooms(config.rooms)
|
||||
const widgetGw = gws.find(g => g.isActive && g.modules?.includes('booking-widget'))
|
||||
setGateway(widgetGw ?? null)
|
||||
}).finally(() => setGatewayLoading(false))
|
||||
}, [slug])
|
||||
|
||||
const [settings, setSettings] = useState<WidgetSettings>({
|
||||
hotelName: 'Grand Palace Hotel',
|
||||
primaryColor: '#4F46E5',
|
||||
@@ -1275,9 +1354,34 @@ export function BookingWidgetPage() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye size={14} className="text-slate-400" />
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Предпросмотр виджета</p>
|
||||
{gatewayLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
|
||||
{/* Gateway status banner */}
|
||||
{!gatewayLoading && slug && (
|
||||
gateway ? (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-700 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
<Zap size={12} className="shrink-0" />
|
||||
Онлайн-оплата активна: <strong>{gateway.label}</strong> ({gateway.currency})
|
||||
· {realRooms.length > 0 ? `${realRooms.length} номеров в превью` : 'данные отеля загружены'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-400">
|
||||
<AlertCircle size={12} className="shrink-0" />
|
||||
Онлайн-оплата не настроена. Добавьте шлюз ЮКасса в{' '}
|
||||
<a href="/settings/payments" className="underline font-medium">Настройки оплаты</a>
|
||||
{' '}и укажите модуль «Онлайн-бронирование».
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-100 dark:bg-slate-700/50">
|
||||
<WidgetPreview settings={settings} />
|
||||
<WidgetPreview
|
||||
settings={settings}
|
||||
slug={slug || undefined}
|
||||
realRooms={realRooms.length > 0 ? realRooms : undefined}
|
||||
paymentEnabled={gateway ? gateway.isActive : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2 text-center">Так виджет будет выглядеть на сайте отеля</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user