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:
52
backend/migrations/065_payment_gateways.sql
Normal file
52
backend/migrations/065_payment_gateways.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
@@ -43,6 +43,8 @@ import minibarRoutes from './routes/minibar'
|
|||||||
import depositRoutes from './routes/deposit'
|
import depositRoutes from './routes/deposit'
|
||||||
import paymentsRoutes from './routes/payments'
|
import paymentsRoutes from './routes/payments'
|
||||||
import paymentMethodsRoutes from './routes/paymentMethods'
|
import paymentMethodsRoutes from './routes/paymentMethods'
|
||||||
|
import paymentGatewaysRoutes from './routes/paymentGateways'
|
||||||
|
import publicWidgetRoutes from './routes/publicWidget'
|
||||||
import { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
@@ -138,6 +140,8 @@ export async function buildApp() {
|
|||||||
await fastify.register(depositRoutes)
|
await fastify.register(depositRoutes)
|
||||||
await fastify.register(paymentsRoutes)
|
await fastify.register(paymentsRoutes)
|
||||||
await fastify.register(paymentMethodsRoutes)
|
await fastify.register(paymentMethodsRoutes)
|
||||||
|
await fastify.register(paymentGatewaysRoutes)
|
||||||
|
await fastify.register(publicWidgetRoutes)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
112
backend/src/routes/paymentGateways.ts
Normal file
112
backend/src/routes/paymentGateways.ts
Normal file
@@ -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<string | null> => {
|
||||||
|
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<SlugParam>('/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<SlugParam & { Body: { provider: string; label: string; shopId: string; secretKey: string; currency: string; modules: string[] } }>(
|
||||||
|
'/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<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[] } }>(
|
||||||
|
'/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<SlugIdParam>('/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
|
||||||
|
}
|
||||||
209
backend/src/routes/publicWidget.ts
Normal file
209
backend/src/routes/publicWidget.ts
Normal file
@@ -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<SlugParam>('/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<SlugParam & { Querystring: { checkIn: string; checkOut: string } }>(
|
||||||
|
'/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<SlugParam & {
|
||||||
|
Body: {
|
||||||
|
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 }>
|
||||||
|
}
|
||||||
|
}>('/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<SlugIdParam>('/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
|
||||||
@@ -36,6 +36,37 @@ export async function createHold(params: {
|
|||||||
return res.json() as Promise<YooKassaPayment>
|
return res.json() as Promise<YooKassaPayment>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<YooKassaPayment> {
|
||||||
|
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<YooKassaPayment>
|
||||||
|
}
|
||||||
|
|
||||||
export async function capturePayment(params: {
|
export async function capturePayment(params: {
|
||||||
shopId: string
|
shopId: string
|
||||||
secretKey: string
|
secretKey: string
|
||||||
|
|||||||
@@ -843,6 +843,33 @@ export const api = {
|
|||||||
updateSettings: (slug: string, requirePaymentCheckin: 'none' | 'soft' | 'hard') =>
|
updateSettings: (slug: string, requirePaymentCheckin: 'none' | 'soft' | 'hard') =>
|
||||||
req<{ requirePaymentCheckin: string }>('PATCH', `/api/hotels/${slug}/payment-settings`, { require_payment_checkin: requirePaymentCheckin }),
|
req<{ requirePaymentCheckin: string }>('PATCH', `/api/hotels/${slug}/payment-settings`, { require_payment_checkin: requirePaymentCheckin }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
paymentGateways: {
|
||||||
|
list: (slug: string) =>
|
||||||
|
req<PaymentGateway[]>('GET', `/api/hotels/${slug}/payment-gateways`),
|
||||||
|
create: (slug: string, data: PaymentGatewayPayload) =>
|
||||||
|
req<PaymentGateway>('POST', `/api/hotels/${slug}/payment-gateways`, data),
|
||||||
|
update: (slug: string, id: string, data: Partial<PaymentGatewayPayload>) =>
|
||||||
|
req<PaymentGateway>('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<WidgetConfig>,
|
||||||
|
getAvailability: (slug: string, checkIn: string, checkOut: string) =>
|
||||||
|
fetch(`${BASE}/api/widget/${slug}/availability?checkIn=${checkIn}&checkOut=${checkOut}`).then(r => r.json()) as Promise<WidgetRoom[]>,
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Schedule ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1561,6 +1588,64 @@ export interface BookingDeposit {
|
|||||||
roomNumber?: string
|
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<string, unknown> {
|
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {}
|
const out: Record<string, unknown> = {}
|
||||||
if (h.name !== undefined) out.name = h.name
|
if (h.name !== undefined) out.name = h.name
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Code2, CreditCard, Globe, CalendarCheck2, Copy, CheckCheck,
|
Code2, CreditCard, Globe, CalendarCheck2, Copy, CheckCheck,
|
||||||
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
|
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
|
||||||
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
|
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
|
||||||
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, ChevronUp,
|
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, ChevronUp,
|
||||||
Maximize2, Wifi, Tv2, Wind, Coffee, Bath, Mountain, Shirt, Shield,
|
Maximize2, Wifi, Tv2, Wind, Coffee, Bath, Mountain, Shirt, Shield,
|
||||||
Scan, Image,
|
Scan, Image, Zap, AlertCircle, Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { useModules } from '../contexts/ModulesContext'
|
import { useModules } from '../contexts/ModulesContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { api, type WidgetRoom, type PaymentGateway } from '../lib/api'
|
||||||
|
|
||||||
// ── Widget settings type ───────────────────────────────────────────────────────
|
// ── Widget settings type ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -159,7 +161,12 @@ const DEFAULT_SERVICES: AdditionalService[] = [
|
|||||||
|
|
||||||
// ── Widget Preview Component ───────────────────────────────────────────────────
|
// ── 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 [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
|
||||||
const [checkIn, setCheckIn] = useState('2026-03-20')
|
const [checkIn, setCheckIn] = useState('2026-03-20')
|
||||||
const [checkOut, setCheckOut] = useState('2026-03-22')
|
const [checkOut, setCheckOut] = useState('2026-03-22')
|
||||||
@@ -170,13 +177,15 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
|
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
|
||||||
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
|
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
|
||||||
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
|
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
||||||
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||||||
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
|
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
|
||||||
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
|
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 [cardNumber, setCardNumber] = useState('')
|
||||||
const [cardExpiry, setCardExpiry] = useState('')
|
const [cardExpiry, setCardExpiry] = useState('')
|
||||||
const [cardCvv, setCardCvv] = 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)
|
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
||||||
: 0
|
: 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 roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0
|
||||||
const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
|
const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
|
||||||
const servicesTotal = selectedServices.reduce((sum, sid) => {
|
const servicesTotal = selectedServices.reduce((sum, sid) => {
|
||||||
@@ -196,25 +215,63 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
const grandTotal = roomTotal + extraTotal + servicesTotal
|
const grandTotal = roomTotal + extraTotal + servicesTotal
|
||||||
|
|
||||||
const activeFields = settings.formFields.filter(f => f.enabled)
|
const activeFields = settings.formFields.filter(f => f.enabled)
|
||||||
const needsPayment = settings.paymentProvider !== 'none'
|
const needsPayment = paymentEnabled ?? (settings.paymentProvider !== 'none')
|
||||||
|
|
||||||
const handleBook = () => {
|
const handleBook = () => {
|
||||||
if (!selected || nights === 0) return
|
if (!selected || nights === 0) return
|
||||||
setStep('form')
|
setStep('form')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
||||||
if (missing.length > 0) return
|
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 {
|
} else {
|
||||||
setStep('success')
|
// Preview mode without real slug
|
||||||
|
if (needsPayment) {
|
||||||
|
setStep('payment')
|
||||||
|
} else {
|
||||||
|
setStep('success')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePay = () => {
|
const handlePay = () => {
|
||||||
// Mock payment — just proceed to success
|
if (confirmUrl) {
|
||||||
|
window.open(confirmUrl, '_blank')
|
||||||
|
}
|
||||||
setStep('success')
|
setStep('success')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,10 +586,12 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
<div className="px-5 pb-5 pt-2 space-y-2">
|
<div className="px-5 pb-5 pt-2 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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 }}
|
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')} ₽` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')} ₽`)
|
||||||
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Confirm · ${grandTotal.toLocaleString('ru-RU')} ₽`)}
|
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Confirm · ${grandTotal.toLocaleString('ru-RU')} ₽`)}
|
||||||
</button>
|
</button>
|
||||||
@@ -656,7 +715,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
{children > 0 && ` · ${children} дет.`}
|
{children > 0 && ` · ${children} дет.`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{MOCK_ROOMS.map(room => {
|
{displayRooms.map(room => {
|
||||||
const isExpanded = expandedRoom === room.id
|
const isExpanded = expandedRoom === room.id
|
||||||
const isSelected = selected === room.id
|
const isSelected = selected === room.id
|
||||||
const curPhoto = photoIndex[room.id] ?? 0
|
const curPhoto = photoIndex[room.id] ?? 0
|
||||||
@@ -893,7 +952,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
>
|
>
|
||||||
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
||||||
{selected && nights > 0 && previewTab === 'rooms' && (() => {
|
{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
|
const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0
|
||||||
return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : ''
|
return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : ''
|
||||||
})()}
|
})()}
|
||||||
@@ -917,8 +976,28 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
|
|
||||||
export function BookingWidgetPage() {
|
export function BookingWidgetPage() {
|
||||||
const { statuses } = useModules()
|
const { statuses } = useModules()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
const rentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
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>({
|
const [settings, setSettings] = useState<WidgetSettings>({
|
||||||
hotelName: 'Grand Palace Hotel',
|
hotelName: 'Grand Palace Hotel',
|
||||||
primaryColor: '#4F46E5',
|
primaryColor: '#4F46E5',
|
||||||
@@ -1275,9 +1354,34 @@ export function BookingWidgetPage() {
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Eye size={14} className="text-slate-400" />
|
<Eye size={14} className="text-slate-400" />
|
||||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Предпросмотр виджета</p>
|
<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>
|
</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">
|
<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>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2 text-center">Так виджет будет выглядеть на сайте отеля</p>
|
<p className="text-xs text-slate-400 mt-2 text-center">Так виджет будет выглядеть на сайте отеля</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,59 +182,17 @@ export function DepositSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="rounded-lg bg-violet-50 dark:bg-violet-900/10 border border-violet-200 dark:border-violet-700 p-3 space-y-1.5">
|
||||||
<div>
|
<p className="text-xs font-semibold text-violet-800 dark:text-violet-300">ЮКасса настраивается в одном месте</p>
|
||||||
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm">ЮКасса (опционально)</p>
|
<p className="text-xs text-violet-700 dark:text-violet-400">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
Реквизиты ЮКасса (Shop ID и секретный ключ) теперь управляются централизованно.
|
||||||
Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты.
|
Перейдите в <a href="/settings/payments" className="underline font-medium">Настройки оплаты → Онлайн-оплата</a> и добавьте шлюз один раз — он будет работать для депозита, онлайн-бронирования и Room Service.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">ID магазина (shopId)</label>
|
|
||||||
<input value={shopId} onChange={e => setShopId(e.target.value)} placeholder="123456" className="input w-full" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Секретный ключ</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showSecret ? 'text' : 'password'}
|
|
||||||
value={secretKey} onChange={e => setSecretKey(e.target.value)}
|
|
||||||
placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'}
|
|
||||||
className="input w-full pr-10"
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={() => setShowSecret(s => !s)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
|
||||||
{showSecret ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{settings?.yookassaSecretKey && (
|
|
||||||
<p className="text-xs text-slate-400 mt-1">Ключ уже сохранён. Оставьте поле пустым, чтобы не менять.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shopId && (
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
||||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm mb-1">QR-код для гостей</p>
|
||||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 space-y-1.5">
|
|
||||||
<p className="text-xs font-semibold text-blue-800 dark:text-blue-300 flex items-center gap-1.5">
|
|
||||||
<Info size={13} /> Настройте webhook в ЮКасса
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-700 dark:text-blue-400">
|
|
||||||
Чтобы статус депозита обновлялся автоматически, добавьте URL в личном кабинете ЮКасса (Настройки → HTTP-уведомления):
|
|
||||||
</p>
|
|
||||||
<code className="block text-xs font-mono bg-blue-100 dark:bg-blue-900/40 text-blue-900 dark:text-blue-200 rounded px-2 py-1 break-all select-all">
|
|
||||||
https://api.hotelsync.ru/api/webhooks/yookassa
|
|
||||||
</code>
|
|
||||||
<p className="text-xs text-blue-600 dark:text-blue-400">
|
|
||||||
Подписки: <strong>payment.waiting_for_capture</strong>, <strong>payment.succeeded</strong>, <strong>payment.canceled</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{shopId && (
|
|
||||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
|
||||||
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm mb-1">QR-код для гостей</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-4">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-4">
|
||||||
Распечатайте и разместите на стойке ресепшена.
|
Распечатайте и разместите на стойке ресепшена.
|
||||||
</p>
|
</p>
|
||||||
@@ -259,7 +217,6 @@ export function DepositSettingsPage() {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown } from 'lucide-react'
|
import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown, Zap, Eye, EyeOff } from 'lucide-react'
|
||||||
import { api, type HotelPaymentMethod } from '../lib/api'
|
import { api, type HotelPaymentMethod, type PaymentGateway } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { cn } from '../lib/utils'
|
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 CURRENCIES = ['RUB', 'USD', 'EUR', 'GBP', 'CNY', 'AED', 'KZT', 'BYN', 'AMD', 'GEL']
|
||||||
|
|
||||||
const METHOD_TYPES: Array<{ id: HotelPaymentMethod['type']; label: string }> = [
|
const METHOD_TYPES: Array<{ id: HotelPaymentMethod['type']; label: string }> = [
|
||||||
@@ -41,14 +47,28 @@ export function PaymentSettingsPage() {
|
|||||||
const [newState, setNewState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
|
const [newState, setNewState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
|
||||||
const [addingRow, setAddingRow] = useState(false)
|
const [addingRow, setAddingRow] = useState(false)
|
||||||
|
|
||||||
|
// Payment gateways
|
||||||
|
const [gateways, setGateways] = useState<PaymentGateway[]>([])
|
||||||
|
const [gwFormOpen, setGwFormOpen] = useState(false)
|
||||||
|
const [gwEditId, setGwEditId] = useState<string | null>(null)
|
||||||
|
const [gwLabel, setGwLabel] = useState('ЮКасса')
|
||||||
|
const [gwShopId, setGwShopId] = useState('')
|
||||||
|
const [gwSecretKey, setGwSecretKey] = useState('')
|
||||||
|
const [gwCurrency, setGwCurrency] = useState('RUB')
|
||||||
|
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
|
||||||
|
const [gwSaving, setGwSaving] = useState(false)
|
||||||
|
const [gwSecretVisible, setGwSecretVisible] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.paymentMethods.list(slug),
|
api.paymentMethods.list(slug),
|
||||||
api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })),
|
api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })),
|
||||||
]).then(([ms, s]) => {
|
api.paymentGateways.list(slug).catch(() => []),
|
||||||
|
]).then(([ms, s, gws]) => {
|
||||||
setMethods(ms)
|
setMethods(ms)
|
||||||
setRequireCheckin(s.requirePaymentCheckin)
|
setRequireCheckin(s.requirePaymentCheckin)
|
||||||
|
setGateways(gws)
|
||||||
}).finally(() => setLoading(false))
|
}).finally(() => setLoading(false))
|
||||||
}, [slug])
|
}, [slug])
|
||||||
|
|
||||||
@@ -113,6 +133,44 @@ export function PaymentSettingsPage() {
|
|||||||
setMethods(p => p.filter(m => m.id !== id))
|
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 moveItem = async (id: string, dir: -1 | 1) => {
|
||||||
const idx = methods.findIndex(m => m.id === id)
|
const idx = methods.findIndex(m => m.id === id)
|
||||||
if (idx < 0) return
|
if (idx < 0) return
|
||||||
@@ -296,6 +354,122 @@ export function PaymentSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment gateways */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm flex items-center gap-1.5">
|
||||||
|
<Zap size={14} className="text-violet-500" /> Онлайн-оплата (ЮКасса)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Один раз настройте шлюз — выберите в каких модулях он работает</p>
|
||||||
|
</div>
|
||||||
|
{!gwFormOpen && (
|
||||||
|
<button onClick={() => openGwForm()} className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5">
|
||||||
|
<Plus size={14} /> Добавить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gateways.length === 0 && !gwFormOpen && (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-slate-400">Нет платёжных шлюзов. Нажмите «Добавить».</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit form */}
|
||||||
|
{gwFormOpen && (
|
||||||
|
<div className="px-4 py-4 bg-violet-50 dark:bg-violet-900/10 border-b border-slate-100 dark:border-slate-700 space-y-3">
|
||||||
|
<p className="text-xs font-semibold text-violet-700 dark:text-violet-300">{gwEditId ? 'Редактировать шлюз' : 'Новый шлюз'}</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">Название</label>
|
||||||
|
<input value={gwLabel} onChange={e => setGwLabel(e.target.value)} className="input py-1.5 text-sm" placeholder="ЮКасса" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">Валюта</label>
|
||||||
|
<select value={gwCurrency} onChange={e => setGwCurrency(e.target.value)} className="input py-1.5 text-sm">
|
||||||
|
{CURRENCIES.map(c => <option key={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">Shop ID</label>
|
||||||
|
<input value={gwShopId} onChange={e => setGwShopId(e.target.value)} className="input py-1.5 text-sm font-mono" placeholder="123456" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">Секретный ключ</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={gwSecretVisible ? 'text' : 'password'}
|
||||||
|
value={gwSecretKey}
|
||||||
|
onChange={e => setGwSecretKey(e.target.value)}
|
||||||
|
className="input py-1.5 text-sm font-mono pr-8"
|
||||||
|
placeholder={gwEditId ? '••••••••' : 'test_...'}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setGwSecretVisible(v => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||||
|
{gwSecretVisible ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1.5">Использовать в модулях:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{GATEWAY_MODULES.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGwModule(m.id)}
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-1 rounded-full text-xs font-medium border transition-colors',
|
||||||
|
gwModules.includes(m.id)
|
||||||
|
? 'bg-violet-600 border-violet-600 text-white'
|
||||||
|
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-violet-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button onClick={saveGateway} disabled={!gwShopId.trim() || gwSaving} className="btn-primary py-1.5 px-4 text-sm flex items-center gap-1.5">
|
||||||
|
{gwSaving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setGwFormOpen(false)} className="btn-secondary py-1.5 px-3 text-sm">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
|
{gateways.map(gw => (
|
||||||
|
<div key={gw.id} className="px-4 py-3 flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0">
|
||||||
|
<Zap size={14} className="text-violet-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{gw.label}</span>
|
||||||
|
<span className={cn('text-xs px-1.5 py-0.5 rounded-full', gw.isActive ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-500')}>
|
||||||
|
{gw.isActive ? 'Активен' : 'Отключён'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Shop ID: {gw.shopId ?? '—'} · {gw.currency}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{(gw.modules ?? []).map(m => (
|
||||||
|
<span key={m} className="text-[11px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
|
||||||
|
{GATEWAY_MODULES.find(x => x.id === m)?.label ?? m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<button onClick={() => openGwForm(gw)} className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400"><Pencil size={13} /></button>
|
||||||
|
<button onClick={() => deleteGateway(gw.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500"><Trash2 size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 text-xs text-blue-700 dark:text-blue-400">
|
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 text-xs text-blue-700 dark:text-blue-400">
|
||||||
Способы оплаты появляются в панели бронирования при приёме платежей. Скрытые методы не отображаются.
|
Способы оплаты появляются в панели бронирования при приёме платежей. Скрытые методы не отображаются.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user