From 6859817bdf51ca1f3d1f4e0d6f55a9262928f4cb Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 6 Apr 2026 20:37:06 +0300 Subject: [PATCH] feat: centralized payment gateways + booking widget API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/migrations/065_payment_gateways.sql | 52 +++++ backend/src/app.ts | 4 + backend/src/routes/paymentGateways.ts | 112 +++++++++++ backend/src/routes/publicWidget.ts | 209 ++++++++++++++++++++ backend/src/services/yookassa.ts | 31 +++ src/lib/api.ts | 85 ++++++++ src/pages/BookingWidgetPage.tsx | 136 +++++++++++-- src/pages/DepositSettingsPage.tsx | 59 +----- src/pages/PaymentSettingsPage.tsx | 180 ++++++++++++++++- 9 files changed, 798 insertions(+), 70 deletions(-) create mode 100644 backend/migrations/065_payment_gateways.sql create mode 100644 backend/src/routes/paymentGateways.ts create mode 100644 backend/src/routes/publicWidget.ts diff --git a/backend/migrations/065_payment_gateways.sql b/backend/migrations/065_payment_gateways.sql new file mode 100644 index 0000000..bbc0c7c --- /dev/null +++ b/backend/migrations/065_payment_gateways.sql @@ -0,0 +1,52 @@ +-- Centralized payment gateways (replaces per-module YooKassa config) +CREATE TABLE IF NOT EXISTS hotel_payment_gateways ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'yookassa', -- yookassa | stripe | tinkoff + label TEXT NOT NULL DEFAULT 'ЮКасса', + shop_id TEXT, + secret_key TEXT, + currency TEXT NOT NULL DEFAULT 'RUB', + is_active BOOLEAN NOT NULL DEFAULT true, + -- Which modules use this gateway (JSON array) + modules JSONB NOT NULL DEFAULT '["deposit","booking-widget","room-service"]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Migrate existing YooKassa credentials from hotel_deposit_settings +INSERT INTO hotel_payment_gateways (hotel_id, provider, label, shop_id, secret_key, currency, modules) +SELECT + hotel_id, + 'yookassa', + 'ЮКасса', + yookassa_shop_id, + yookassa_secret_key, + 'RUB', + '["deposit","booking-widget","room-service"]'::jsonb +FROM hotel_deposit_settings +WHERE yookassa_shop_id IS NOT NULL +ON CONFLICT DO NOTHING; + +-- Online bookings table (from widget) +CREATE TABLE IF NOT EXISTS online_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + room_id UUID REFERENCES rooms(id) ON DELETE SET NULL, + guest_name TEXT NOT NULL, + guest_email TEXT, + guest_phone TEXT, + check_in DATE NOT NULL, + check_out DATE NOT NULL, + adults INTEGER NOT NULL DEFAULT 1, + children INTEGER NOT NULL DEFAULT 0, + total_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + notes TEXT, + services JSONB DEFAULT '[]'::jsonb, + status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | confirmed | cancelled + payment_method TEXT DEFAULT 'none', -- none | yookassa + yookassa_payment_id TEXT, + yookassa_confirmation_url TEXT, + yookassa_status TEXT, + booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/app.ts b/backend/src/app.ts index 52ebc88..f0d4692 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -43,6 +43,8 @@ import minibarRoutes from './routes/minibar' import depositRoutes from './routes/deposit' import paymentsRoutes from './routes/payments' import paymentMethodsRoutes from './routes/paymentMethods' +import paymentGatewaysRoutes from './routes/paymentGateways' +import publicWidgetRoutes from './routes/publicWidget' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -138,6 +140,8 @@ export async function buildApp() { await fastify.register(depositRoutes) await fastify.register(paymentsRoutes) await fastify.register(paymentMethodsRoutes) + await fastify.register(paymentGatewaysRoutes) + await fastify.register(publicWidgetRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/routes/paymentGateways.ts b/backend/src/routes/paymentGateways.ts new file mode 100644 index 0000000..8729bbd --- /dev/null +++ b/backend/src/routes/paymentGateways.ts @@ -0,0 +1,112 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const MODULES = ['deposit', 'booking-widget', 'room-service'] as const + +function canAccess(userSlug: string | null, role: string, slug: string) { + if (role === 'super_admin') return true + return userSlug === slug +} + +const paymentGateways: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + // ── GET /api/hotels/:slug/payment-gateways ───────────────────────────────── + fastify.get('/api/hotels/:slug/payment-gateways', { onRequest: [fastify.authenticate] }, async (req, reply) => { + const { slug } = req.params + if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Not found' }) + + const { rows } = await db.query( + `SELECT id, provider, label, shop_id, + CASE WHEN secret_key IS NOT NULL THEN '••••••••' ELSE NULL END AS secret_key, + currency, is_active, modules, created_at + FROM hotel_payment_gateways WHERE hotel_id = $1 ORDER BY created_at`, + [hotelId], + ) + return rows.map(r => ({ + id: r.id, provider: r.provider, label: r.label, + shopId: r.shop_id, secretKey: r.secret_key, + currency: r.currency, isActive: r.is_active, + modules: r.modules ?? MODULES, createdAt: r.created_at, + })) + }) + + // ── POST /api/hotels/:slug/payment-gateways ──────────────────────────────── + fastify.post( + '/api/hotels/:slug/payment-gateways', { onRequest: [fastify.authenticate] }, async (req, reply) => { + const { slug } = req.params + if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Not found' }) + const { provider = 'yookassa', label, shopId, secretKey, currency = 'RUB', modules = MODULES } = req.body + const { rows } = await db.query( + `INSERT INTO hotel_payment_gateways (hotel_id, provider, label, shop_id, secret_key, currency, modules) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [hotelId, provider, label, shopId || null, secretKey || null, currency, JSON.stringify(modules)], + ) + const r = rows[0] + return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id, + secretKey: r.secret_key ? '••••••••' : null, + currency: r.currency, isActive: r.is_active, modules: r.modules } + } + ) + + // ── PATCH /api/hotels/:slug/payment-gateways/:id ─────────────────────────── + fastify.patch( + '/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => { + const { slug, id } = req.params + if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Not found' }) + const { label, shopId, secretKey, currency, isActive, modules } = req.body + const { rows } = await db.query( + `UPDATE hotel_payment_gateways SET + label = COALESCE($1, label), + shop_id = COALESCE($2, shop_id), + secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END, + currency = COALESCE($4, currency), + is_active = COALESCE($5, is_active), + modules = COALESCE($6::jsonb, modules) + WHERE id = $7 AND hotel_id = $8 RETURNING *`, + [label ?? null, shopId ?? null, secretKey ?? null, currency ?? null, + isActive ?? null, modules ? JSON.stringify(modules) : null, id, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + const r = rows[0] + return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id, + secretKey: r.secret_key ? '••••••••' : null, + currency: r.currency, isActive: r.is_active, modules: r.modules } + } + ) + + // ── DELETE /api/hotels/:slug/payment-gateways/:id ────────────────────────── + fastify.delete('/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => { + const { slug, id } = req.params + if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Not found' }) + await db.query('DELETE FROM hotel_payment_gateways WHERE id = $1 AND hotel_id = $2', [id, hotelId]) + return { ok: true } + }) +} + +export default paymentGateways + +// Helper: get active gateway for a given module +export async function getGatewayForModule(hotelId: string, module: string) { + const { rows } = await db.query( + `SELECT * FROM hotel_payment_gateways + WHERE hotel_id = $1 AND is_active = true AND modules @> $2::jsonb + ORDER BY created_at LIMIT 1`, + [hotelId, JSON.stringify([module])], + ) + return rows[0] ?? null +} diff --git a/backend/src/routes/publicWidget.ts b/backend/src/routes/publicWidget.ts new file mode 100644 index 0000000..c07735b --- /dev/null +++ b/backend/src/routes/publicWidget.ts @@ -0,0 +1,209 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' +import { getGatewayForModule } from './paymentGateways' +import { createCharge } from '../services/yookassa' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; bookingId: string } } + +const publicWidget: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string) => { + const { rows } = await db.query('SELECT id, name FROM hotels WHERE slug = $1', [slug]) + return rows[0] ?? null + } + + // ── GET /api/widget/:slug/config ────────────────────────────────────────── + // Returns hotel info + widget settings (no auth) + fastify.get('/api/widget/:slug/config', async (req, reply) => { + const hotel = await getHotelId(req.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows: rooms } = await db.query( + `SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos, + allow_hourly, hourly_rate, extra_place, child_policy, status + FROM rooms + WHERE hotel_id = $1 AND status != 'inactive' + ORDER BY sort_order, number`, + [hotel.id], + ) + + // Check if YooKassa gateway is configured for booking-widget + const gateway = await getGatewayForModule(hotel.id, 'booking-widget') + + return { + hotelId: hotel.id, + hotelName: hotel.name, + slug: req.params.slug, + paymentEnabled: !!(gateway?.shop_id && gateway?.secret_key), + currency: gateway?.currency ?? 'RUB', + rooms: rooms.map(r => ({ + id: r.id, + number: r.number, + name: r.name || `Номер ${r.number}`, + type: r.type, + floor: r.floor, + maxGuests: r.max_guests, + baseRate: Number(r.base_rate), + amenities: r.amenities ?? [], + description: r.description ?? '', + photos: r.photos ?? [], + allowHourly: r.allow_hourly, + hourlyRate: r.hourly_rate ? Number(r.hourly_rate) : null, + })), + } + }) + + // ── GET /api/widget/:slug/availability ──────────────────────────────────── + // Returns available rooms for given dates + fastify.get( + '/api/widget/:slug/availability', async (req, reply) => { + const hotel = await getHotelId(req.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + const { checkIn, checkOut } = req.query + if (!checkIn || !checkOut) return reply.code(400).send({ error: 'checkIn and checkOut required' }) + + // Rooms occupied during requested dates + const { rows: occupied } = await db.query( + `SELECT DISTINCT room_id FROM bookings + WHERE hotel_id = $1 + AND status NOT IN ('cancelled','no_show','checked_out') + AND check_in < $3 + AND check_out > $2`, + [hotel.id, checkIn, checkOut], + ) + const occupiedIds = new Set(occupied.map((r: any) => r.room_id)) + + const { rows: rooms } = await db.query( + `SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos + FROM rooms WHERE hotel_id = $1 AND status = 'available' + ORDER BY sort_order, number`, + [hotel.id], + ) + + return rooms + .filter((r: any) => !occupiedIds.has(r.id)) + .map((r: any) => ({ + id: r.id, + number: r.number, + name: r.name || `Номер ${r.number}`, + type: r.type, + floor: r.floor, + maxGuests: r.max_guests, + baseRate: Number(r.base_rate), + amenities: r.amenities ?? [], + description: r.description ?? '', + photos: r.photos ?? [], + })) + } + ) + + // ── POST /api/widget/:slug/bookings ─────────────────────────────────────── + // Create an online booking (no auth) + fastify.post + } + }>('/api/widget/:slug/bookings', async (req, reply) => { + const hotel = await getHotelId(req.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { roomId, checkIn, checkOut, guestName, guestEmail, guestPhone, + adults = 1, children = 0, totalAmount, notes, services } = req.body + + if (!roomId || !checkIn || !checkOut || !guestName || !totalAmount) { + return reply.code(400).send({ error: 'Missing required fields' }) + } + + // Check availability + const { rows: conflict } = await db.query( + `SELECT id FROM bookings + WHERE hotel_id = $1 AND room_id = $2 + AND status NOT IN ('cancelled','no_show','checked_out') + AND check_in < $4 AND check_out > $3`, + [hotel.id, roomId, checkIn, checkOut], + ) + if (conflict.length > 0) { + return reply.code(409).send({ error: 'Room not available for selected dates' }) + } + + // Check if gateway configured + const gateway = await getGatewayForModule(hotel.id, 'booking-widget') + const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none' + + // Create online_booking record + const { rows } = await db.query( + `INSERT INTO online_bookings + (hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out, + adults, children, total_amount, notes, services, payment_method) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`, + [hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null, + checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null, + JSON.stringify(services ?? []), paymentMethod], + ) + const onlineBookingId = rows[0].id + + // Also create draft booking in main bookings table + const { rows: bRows } = await db.query( + `INSERT INTO bookings + (hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out, + adults, children, total_amount, source, status, notes) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website','inquiry',$11) RETURNING id`, + [hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null, + checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null], + ) + const bookingId = bRows[0].id + + // Update online_booking with the main booking id + await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2', + [bookingId, onlineBookingId]).catch(() => {}) // column may not exist yet, ignore + + if (paymentMethod === 'yookassa') { + // Create YooKassa payment + try { + const nights = Math.ceil((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000) + const result = await createCharge({ + shopId: gateway.shop_id, + secretKey: gateway.secret_key, + amount: totalAmount, + description: `Бронирование: ${guestName}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`, + returnUrl: `https://app.hotelsync.ru/booking-confirm/${onlineBookingId}`, + }) + await db.query( + `UPDATE online_bookings SET yookassa_payment_id = $1, yookassa_confirmation_url = $2 + WHERE id = $3`, + [result.id, result.confirmation?.confirmation_url ?? null, onlineBookingId], + ) + return { + bookingId: onlineBookingId, + status: 'pending_payment', + confirmationUrl: result.confirmation?.confirmation_url, + } + } catch (err) { + // Payment creation failed — still return booking as pending + return { bookingId: onlineBookingId, status: 'pending', confirmationUrl: null } + } + } + + return { bookingId: onlineBookingId, status: 'confirmed', confirmationUrl: null } + }) + + // ── GET /api/widget/:slug/bookings/:bookingId/status ────────────────────── + fastify.get('/api/widget/:slug/bookings/:bookingId/status', async (req, reply) => { + const { slug, bookingId } = req.params + const hotel = await getHotelId(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + 'SELECT status, yookassa_status, yookassa_confirmation_url FROM online_bookings WHERE id = $1 AND hotel_id = $2', + [bookingId, hotel.id], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' }) + return rows[0] + }) +} + +export default publicWidget diff --git a/backend/src/services/yookassa.ts b/backend/src/services/yookassa.ts index e863612..73cbe34 100644 --- a/backend/src/services/yookassa.ts +++ b/backend/src/services/yookassa.ts @@ -36,6 +36,37 @@ export async function createHold(params: { return res.json() as Promise } +// Immediate charge (capture=true) — for online bookings +export async function createCharge(params: { + shopId: string + secretKey: string + amount: number + description: string + returnUrl: string + idempotenceKey?: string +}): Promise { + const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64') + const res = await fetch('https://api.yookassa.ru/v3/payments', { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + 'Idempotence-Key': params.idempotenceKey ?? randomUUID(), + }, + body: JSON.stringify({ + amount: { value: params.amount.toFixed(2), currency: 'RUB' }, + capture: true, + confirmation: { type: 'redirect', return_url: params.returnUrl }, + description: params.description, + }), + }) + if (!res.ok) { + const err = await res.text() + throw new Error(`YooKassa error ${res.status}: ${err}`) + } + return res.json() as Promise +} + export async function capturePayment(params: { shopId: string secretKey: string diff --git a/src/lib/api.ts b/src/lib/api.ts index 0b60f1c..1bd7216 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -843,6 +843,33 @@ export const api = { updateSettings: (slug: string, requirePaymentCheckin: 'none' | 'soft' | 'hard') => req<{ requirePaymentCheckin: string }>('PATCH', `/api/hotels/${slug}/payment-settings`, { require_payment_checkin: requirePaymentCheckin }), }, + + paymentGateways: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/payment-gateways`), + create: (slug: string, data: PaymentGatewayPayload) => + req('POST', `/api/hotels/${slug}/payment-gateways`, data), + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/payment-gateways/${id}`, data), + remove: (slug: string, id: string) => + req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/payment-gateways/${id}`), + }, + + // Public widget API (no auth) + widget: { + getConfig: (slug: string) => + fetch(`${BASE}/api/widget/${slug}/config`).then(r => r.json()) as Promise, + getAvailability: (slug: string, checkIn: string, checkOut: string) => + fetch(`${BASE}/api/widget/${slug}/availability?checkIn=${checkIn}&checkOut=${checkOut}`).then(r => r.json()) as Promise, + createBooking: (slug: string, data: WidgetBookingPayload) => + fetch(`${BASE}/api/widget/${slug}/bookings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }).then(r => r.json()) as Promise<{ bookingId: string; status: string; confirmationUrl: string | null }>, + getBookingStatus: (slug: string, bookingId: string) => + fetch(`${BASE}/api/widget/${slug}/bookings/${bookingId}/status`).then(r => r.json()), + }, } // ── Schedule ───────────────────────────────────────────────────────────────── @@ -1561,6 +1588,64 @@ export interface BookingDeposit { roomNumber?: string } +export interface PaymentGateway { + id: string + provider: string + label: string + shopId: string | null + secretKey: string | null + currency: string + isActive: boolean + modules: string[] + createdAt: string +} + +export interface PaymentGatewayPayload { + provider?: string + label: string + shopId?: string + secretKey?: string + currency?: string + isActive?: boolean + modules?: string[] +} + +export interface WidgetRoom { + id: string + number: string + name: string + type: string + floor: number + maxGuests: number + baseRate: number + amenities: string[] + description: string + photos: string[] +} + +export interface WidgetConfig { + hotelId: string + hotelName: string + slug: string + paymentEnabled: boolean + currency: string + rooms: WidgetRoom[] +} + +export interface WidgetBookingPayload { + roomId: string + checkIn: string + checkOut: string + guestName: string + guestEmail?: string + guestPhone?: string + adults?: number + children?: number + totalAmount: number + notes?: string + services?: Array<{ name: string; price: number }> +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx index f1e0bca..ccb8adb 100644 --- a/src/pages/BookingWidgetPage.tsx +++ b/src/pages/BookingWidgetPage.tsx @@ -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(null) const [photoIndex, setPhotoIndex] = useState>({}) const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse') + const [submitting, setSubmitting] = useState(false) + const [confirmUrl, setConfirmUrl] = useState(null) // Form state const [formValues, setFormValues] = useState>({}) const [selectedServices, setSelectedServices] = useState([]) // Hourly service time selections: { serviceId: { date, timeFrom, timeTo } } const [serviceSchedule, setServiceSchedule] = useState>({}) - // 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 }) {
@@ -656,7 +715,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) { {children > 0 && ` · ${children} дет.`}

)} - {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([]) + const [gateway, setGateway] = useState(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({ hotelName: 'Grand Palace Hotel', primaryColor: '#4F46E5', @@ -1275,9 +1354,34 @@ export function BookingWidgetPage() {

Предпросмотр виджета

+ {gatewayLoading && }
+ + {/* Gateway status banner */} + {!gatewayLoading && slug && ( + gateway ? ( +
+ + Онлайн-оплата активна: {gateway.label} ({gateway.currency}) + · {realRooms.length > 0 ? `${realRooms.length} номеров в превью` : 'данные отеля загружены'} +
+ ) : ( +
+ + Онлайн-оплата не настроена. Добавьте шлюз ЮКасса в{' '} + Настройки оплаты + {' '}и укажите модуль «Онлайн-бронирование». +
+ ) + )} +
- + 0 ? realRooms : undefined} + paymentEnabled={gateway ? gateway.isActive : undefined} + />

Так виджет будет выглядеть на сайте отеля

diff --git a/src/pages/DepositSettingsPage.tsx b/src/pages/DepositSettingsPage.tsx index b533269..08d726b 100644 --- a/src/pages/DepositSettingsPage.tsx +++ b/src/pages/DepositSettingsPage.tsx @@ -182,59 +182,17 @@ export function DepositSettingsPage() { /> -
-
-

ЮКасса (опционально)

-

- Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты. -

-
-
- - setShopId(e.target.value)} placeholder="123456" className="input w-full" /> -
-
- -
- setSecretKey(e.target.value)} - placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'} - className="input w-full pr-10" - /> - -
- {settings?.yookassaSecretKey && ( -

Ключ уже сохранён. Оставьте поле пустым, чтобы не менять.

- )} -
+
+

ЮКасса настраивается в одном месте

+

+ Реквизиты ЮКасса (Shop ID и секретный ключ) теперь управляются централизованно. + Перейдите в Настройки оплаты → Онлайн-оплата и добавьте шлюз один раз — он будет работать для депозита, онлайн-бронирования и Room Service. +

- {shopId && ( -
-
-

- Настройте webhook в ЮКасса -

-

- Чтобы статус депозита обновлялся автоматически, добавьте URL в личном кабинете ЮКасса (Настройки → HTTP-уведомления): -

- - https://api.hotelsync.ru/api/webhooks/yookassa - -

- Подписки: payment.waiting_for_capture, payment.succeeded, payment.canceled -

-
-
- )} - - {shopId && ( -
-

QR-код для гостей

+
+

QR-код для гостей

Распечатайте и разместите на стойке ресепшена.

@@ -259,7 +217,6 @@ export function DepositSettingsPage() { } `}
- )} )}
diff --git a/src/pages/PaymentSettingsPage.tsx b/src/pages/PaymentSettingsPage.tsx index b2e26a1..488cb88 100644 --- a/src/pages/PaymentSettingsPage.tsx +++ b/src/pages/PaymentSettingsPage.tsx @@ -1,9 +1,15 @@ import { useState, useEffect } from 'react' -import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown } from 'lucide-react' -import { api, type HotelPaymentMethod } from '../lib/api' +import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown, Zap, Eye, EyeOff } from 'lucide-react' +import { api, type HotelPaymentMethod, type PaymentGateway } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { cn } from '../lib/utils' +const GATEWAY_MODULES = [ + { id: 'deposit', label: 'Депозит (QR)' }, + { id: 'booking-widget', label: 'Онлайн-бронирование' }, + { id: 'room-service', label: 'Room Service' }, +] + const CURRENCIES = ['RUB', 'USD', 'EUR', 'GBP', 'CNY', 'AED', 'KZT', 'BYN', 'AMD', 'GEL'] const METHOD_TYPES: Array<{ id: HotelPaymentMethod['type']; label: string }> = [ @@ -41,14 +47,28 @@ export function PaymentSettingsPage() { const [newState, setNewState] = useState({ name: '', currency: 'RUB', type: 'cash' }) const [addingRow, setAddingRow] = useState(false) + // Payment gateways + const [gateways, setGateways] = useState([]) + const [gwFormOpen, setGwFormOpen] = useState(false) + const [gwEditId, setGwEditId] = useState(null) + const [gwLabel, setGwLabel] = useState('ЮКасса') + const [gwShopId, setGwShopId] = useState('') + const [gwSecretKey, setGwSecretKey] = useState('') + const [gwCurrency, setGwCurrency] = useState('RUB') + const [gwModules, setGwModules] = useState(['deposit', 'booking-widget', 'room-service']) + const [gwSaving, setGwSaving] = useState(false) + const [gwSecretVisible, setGwSecretVisible] = useState(false) + useEffect(() => { if (!slug) return Promise.all([ api.paymentMethods.list(slug), api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })), - ]).then(([ms, s]) => { + api.paymentGateways.list(slug).catch(() => []), + ]).then(([ms, s, gws]) => { setMethods(ms) setRequireCheckin(s.requirePaymentCheckin) + setGateways(gws) }).finally(() => setLoading(false)) }, [slug]) @@ -113,6 +133,44 @@ export function PaymentSettingsPage() { setMethods(p => p.filter(m => m.id !== id)) } + const openGwForm = (gw?: PaymentGateway) => { + if (gw) { + setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '') + setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service']) + } else { + setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('') + setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service']) + } + setGwFormOpen(true) + setGwSecretVisible(false) + } + + const saveGateway = async () => { + if (!gwShopId.trim()) return + setGwSaving(true) + try { + const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules } + if (gwEditId) { + const updated = await api.paymentGateways.update(slug, gwEditId, data) + setGateways(p => p.map(g => g.id === gwEditId ? updated : g)) + } else { + const created = await api.paymentGateways.create(slug, { ...data, provider: 'yookassa' }) + setGateways(p => [...p, created]) + } + setGwFormOpen(false) + } catch { /* ignore */ } finally { setGwSaving(false) } + } + + const deleteGateway = async (id: string) => { + if (!confirm('Удалить платёжный шлюз?')) return + await api.paymentGateways.remove(slug, id).catch(() => {}) + setGateways(p => p.filter(g => g.id !== id)) + } + + const toggleGwModule = (mod: string) => { + setGwModules(p => p.includes(mod) ? p.filter(m => m !== mod) : [...p, mod]) + } + const moveItem = async (id: string, dir: -1 | 1) => { const idx = methods.findIndex(m => m.id === id) if (idx < 0) return @@ -296,6 +354,122 @@ export function PaymentSettingsPage() { + {/* Payment gateways */} +
+
+
+ + Онлайн-оплата (ЮКасса) + +

Один раз настройте шлюз — выберите в каких модулях он работает

+
+ {!gwFormOpen && ( + + )} +
+ + {gateways.length === 0 && !gwFormOpen && ( +

Нет платёжных шлюзов. Нажмите «Добавить».

+ )} + + {/* Add/Edit form */} + {gwFormOpen && ( +
+

{gwEditId ? 'Редактировать шлюз' : 'Новый шлюз'}

+
+
+ + setGwLabel(e.target.value)} className="input py-1.5 text-sm" placeholder="ЮКасса" /> +
+
+ + +
+
+ + setGwShopId(e.target.value)} className="input py-1.5 text-sm font-mono" placeholder="123456" /> +
+
+ +
+ setGwSecretKey(e.target.value)} + className="input py-1.5 text-sm font-mono pr-8" + placeholder={gwEditId ? '••••••••' : 'test_...'} + /> + +
+
+
+
+

Использовать в модулях:

+
+ {GATEWAY_MODULES.map(m => ( + + ))} +
+
+
+ + +
+
+ )} + +
+ {gateways.map(gw => ( +
+
+ +
+
+
+ {gw.label} + + {gw.isActive ? 'Активен' : 'Отключён'} + +
+

Shop ID: {gw.shopId ?? '—'} · {gw.currency}

+
+ {(gw.modules ?? []).map(m => ( + + {GATEWAY_MODULES.find(x => x.id === m)?.label ?? m} + + ))} +
+
+
+ + +
+
+ ))} +
+
+
Способы оплаты появляются в панели бронирования при приёме платежей. Скрытые методы не отображаются.