feat: long-stay deposit charge + refunds + auto-confirm widget + white page fix
- Widget white page fix: fallback photo when real rooms have no photos array - Long-stay deposits: if booking > threshold days and toggle on → full charge instead of hold (bypasses YooKassa 7-day limit) - DepositSettingsPage: toggle + day threshold input for long-stay full payment - Refund endpoint: POST /deposit/refund supports full and partial refunds via YooKassa refunds API - DepositWidget: refund UI for yookassa_charge deposits (full/partial, shows remaining amount) - partially_refunded status support with badge and "Вернуть ещё" button - Auto-confirm: webhook now auto-confirms booking status on payment.succeeded for widget bookings - PaymentSettingsPage: auto-confirm toggle per gateway (shown when booking-widget module active) - Migration 067: long_stay columns on hotel_deposit_settings, auto_confirm_on_payment on gateways, refunded_amount on deposits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
backend/migrations/067_long_stay_refund.sql
Normal file
12
backend/migrations/067_long_stay_refund.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Long-stay: charge full deposit amount instead of hold (bypass 7-day YooKassa limit)
|
||||||
|
ALTER TABLE hotel_deposit_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS long_stay_full_payment BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS long_stay_threshold_days INT NOT NULL DEFAULT 7;
|
||||||
|
|
||||||
|
-- Auto-confirm booking on successful online payment (per payment gateway)
|
||||||
|
ALTER TABLE hotel_payment_gateways
|
||||||
|
ADD COLUMN IF NOT EXISTS auto_confirm_on_payment BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- Track refunded amount for captured (charged) deposits
|
||||||
|
ALTER TABLE booking_deposits
|
||||||
|
ADD COLUMN IF NOT EXISTS refunded_amount NUMERIC(12,2);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { createHold, capturePayment, cancelPayment } from '../services/yookassa'
|
import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa'
|
||||||
import { transporter } from '../email'
|
import { transporter } from '../email'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
@@ -37,7 +37,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
if (!rows[0]) {
|
if (!rows[0]) {
|
||||||
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false }
|
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false, longStayFullPayment: false, longStayThresholdDays: 7 }
|
||||||
}
|
}
|
||||||
// Mask secret key
|
// Mask secret key
|
||||||
const row = rows[0]
|
const row = rows[0]
|
||||||
@@ -54,6 +54,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
is_enabled?: boolean; amount?: number
|
is_enabled?: boolean; amount?: number
|
||||||
yookassa_shop_id?: string; yookassa_secret_key?: string
|
yookassa_shop_id?: string; yookassa_secret_key?: string
|
||||||
release_requires_checkout?: boolean
|
release_requires_checkout?: boolean
|
||||||
|
long_stay_full_payment?: boolean; long_stay_threshold_days?: number
|
||||||
} }>(
|
} }>(
|
||||||
'/api/hotels/:slug/deposit/settings',
|
'/api/hotels/:slug/deposit/settings',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -65,21 +66,24 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout } = request.body
|
const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout,
|
||||||
|
long_stay_full_payment, long_stay_threshold_days } = request.body
|
||||||
|
|
||||||
// Upsert settings
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, updated_at)
|
`INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, long_stay_full_payment, long_stay_threshold_days, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||||
is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled),
|
is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled),
|
||||||
amount = COALESCE($3, hotel_deposit_settings.amount),
|
amount = COALESCE($3, hotel_deposit_settings.amount),
|
||||||
yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id),
|
yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id),
|
||||||
yookassa_secret_key = CASE WHEN $5 IS NOT NULL AND $5 != '••••••••' THEN $5 ELSE hotel_deposit_settings.yookassa_secret_key END,
|
yookassa_secret_key = CASE WHEN $5 IS NOT NULL AND $5 != '••••••••' THEN $5 ELSE hotel_deposit_settings.yookassa_secret_key END,
|
||||||
release_requires_checkout = COALESCE($6, hotel_deposit_settings.release_requires_checkout),
|
release_requires_checkout = COALESCE($6, hotel_deposit_settings.release_requires_checkout),
|
||||||
|
long_stay_full_payment = COALESCE($7, hotel_deposit_settings.long_stay_full_payment),
|
||||||
|
long_stay_threshold_days = COALESCE($8, hotel_deposit_settings.long_stay_threshold_days),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null, release_requires_checkout ?? null],
|
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null,
|
||||||
|
release_requires_checkout ?? null, long_stay_full_payment ?? null, long_stay_threshold_days ?? null],
|
||||||
)
|
)
|
||||||
return rows[0]
|
return rows[0]
|
||||||
},
|
},
|
||||||
@@ -166,14 +170,28 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get booking for description
|
// Get booking dates + guest name
|
||||||
const { rows: bRows } = await db.query(
|
const { rows: bRows } = await db.query(
|
||||||
'SELECT id, guest_name FROM bookings WHERE id = $1 AND hotel_id = $2',
|
'SELECT id, guest_name, check_in, check_out FROM bookings WHERE id = $1 AND hotel_id = $2',
|
||||||
[bookingId, hotelId],
|
[bookingId, hotelId],
|
||||||
)
|
)
|
||||||
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||||||
|
|
||||||
const payment = await createHold({
|
const nights = Math.ceil(
|
||||||
|
(new Date(bRows[0].check_out).getTime() - new Date(bRows[0].check_in).getTime()) / 86400000,
|
||||||
|
)
|
||||||
|
const threshold = settings.long_stay_threshold_days ?? 7
|
||||||
|
const useCharge = settings.long_stay_full_payment && nights > threshold
|
||||||
|
|
||||||
|
const payment = useCharge
|
||||||
|
? await createCharge({
|
||||||
|
shopId: settings.yookassa_shop_id,
|
||||||
|
secretKey: settings.yookassa_secret_key,
|
||||||
|
amount: Number(settings.amount),
|
||||||
|
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
|
||||||
|
returnUrl: `${appUrl()}/${slug}/bookings`,
|
||||||
|
})
|
||||||
|
: await createHold({
|
||||||
shopId: settings.yookassa_shop_id,
|
shopId: settings.yookassa_shop_id,
|
||||||
secretKey: settings.yookassa_secret_key,
|
secretKey: settings.yookassa_secret_key,
|
||||||
amount: Number(settings.amount),
|
amount: Number(settings.amount),
|
||||||
@@ -181,12 +199,14 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
returnUrl: `${appUrl()}/${slug}/bookings`,
|
returnUrl: `${appUrl()}/${slug}/bookings`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const paymentMethod = useCharge ? 'yookassa_charge' : 'yookassa_hold'
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO booking_deposits
|
`INSERT INTO booking_deposits
|
||||||
(hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url)
|
(hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url)
|
||||||
VALUES ($1, $2, $3, 'hold_created', 'yookassa_hold', $4, $5)
|
VALUES ($1, $2, $3, 'hold_created', $4, $5, $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[hotelId, bookingId, settings.amount, payment.id,
|
[hotelId, bookingId, settings.amount, paymentMethod, payment.id,
|
||||||
payment.confirmation?.confirmation_url ?? null],
|
payment.confirmation?.confirmation_url ?? null],
|
||||||
)
|
)
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
@@ -373,6 +393,67 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/refund ────────────
|
||||||
|
// Partial or full refund of a captured (yookassa_charge) deposit
|
||||||
|
fastify.post<SlugBookingParam & { Body: { amount?: number; reason?: string } }>(
|
||||||
|
'/api/hotels/:slug/bookings/:bookingId/deposit/refund',
|
||||||
|
{ onRequest: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { slug, bookingId } = request.params
|
||||||
|
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) {
|
||||||
|
return reply.code(403).send({ error: 'Forbidden' })
|
||||||
|
}
|
||||||
|
const hotelId = await getHotelId(slug)
|
||||||
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
|
const { amount: reqAmount, reason } = request.body
|
||||||
|
|
||||||
|
const { rows: depRows } = await db.query(
|
||||||
|
`SELECT * FROM booking_deposits
|
||||||
|
WHERE booking_id = $1 AND hotel_id = $2
|
||||||
|
AND payment_method = 'yookassa_charge' AND status = 'captured'
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[bookingId, hotelId],
|
||||||
|
)
|
||||||
|
if (!depRows[0]) return reply.code(404).send({ error: 'No refundable deposit found' })
|
||||||
|
const dep = depRows[0]
|
||||||
|
|
||||||
|
const { rows: settingsRows } = await db.query(
|
||||||
|
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId],
|
||||||
|
)
|
||||||
|
const settings = settingsRows[0]
|
||||||
|
if (!settings?.yookassa_shop_id || !settings?.yookassa_secret_key) {
|
||||||
|
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const depositAmount = Number(dep.amount)
|
||||||
|
const alreadyRefunded = Number(dep.refunded_amount ?? 0)
|
||||||
|
const remaining = depositAmount - alreadyRefunded
|
||||||
|
const refundAmount = reqAmount !== undefined ? Math.min(reqAmount, remaining) : remaining
|
||||||
|
|
||||||
|
if (refundAmount <= 0) return reply.code(400).send({ error: 'Nothing to refund' })
|
||||||
|
|
||||||
|
await createRefund({
|
||||||
|
shopId: settings.yookassa_shop_id,
|
||||||
|
secretKey: settings.yookassa_secret_key,
|
||||||
|
paymentId: dep.yookassa_payment_id,
|
||||||
|
amount: refundAmount,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newRefunded = alreadyRefunded + refundAmount
|
||||||
|
const isFullRefund = newRefunded >= depositAmount - 0.01
|
||||||
|
const newStatus = isFullRefund ? 'refunded' : 'partially_refunded'
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`UPDATE booking_deposits SET
|
||||||
|
status = $1, refunded_amount = $2, retention_reason = $3, released_at = COALESCE(released_at, NOW())
|
||||||
|
WHERE id = $4 RETURNING *`,
|
||||||
|
[newStatus, newRefunded.toFixed(2), reason ?? null, dep.id],
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ── GET /api/pay/:slug — public, no auth ─────────────────────────────────
|
// ── GET /api/pay/:slug — public, no auth ─────────────────────────────────
|
||||||
fastify.get<{ Params: { slug: string } }>(
|
fastify.get<{ Params: { slug: string } }>(
|
||||||
'/api/pay/:slug',
|
'/api/pay/:slug',
|
||||||
@@ -632,8 +713,8 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
|
|||||||
canceled: 'cancelled',
|
canceled: 'cancelled',
|
||||||
}
|
}
|
||||||
const newStatus = statusMap[object.status]
|
const newStatus = statusMap[object.status]
|
||||||
if (!newStatus) return { ok: true }
|
|
||||||
|
|
||||||
|
if (newStatus) {
|
||||||
const card = object.payment_method?.card
|
const card = object.payment_method?.card
|
||||||
if (card?.last4) {
|
if (card?.last4) {
|
||||||
await db.query(
|
await db.query(
|
||||||
@@ -648,6 +729,38 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
|
|||||||
[newStatus, object.id],
|
[newStatus, object.id],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-confirm booking on successful online widget payment
|
||||||
|
if (object.status === 'succeeded') {
|
||||||
|
const { rows: obRows } = await db.query(
|
||||||
|
`SELECT ob.booking_id, ob.hotel_id
|
||||||
|
FROM online_bookings ob
|
||||||
|
WHERE ob.yookassa_payment_id = $1`,
|
||||||
|
[object.id],
|
||||||
|
)
|
||||||
|
if (obRows[0]?.booking_id) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE online_bookings SET yookassa_status = 'succeeded' WHERE yookassa_payment_id = $1`,
|
||||||
|
[object.id],
|
||||||
|
)
|
||||||
|
// Check gateway auto_confirm_on_payment
|
||||||
|
const { rows: gwRows } = await db.query(
|
||||||
|
`SELECT auto_confirm_on_payment FROM hotel_payment_gateways
|
||||||
|
WHERE hotel_id = $1 AND is_active = true
|
||||||
|
ORDER BY created_at LIMIT 1`,
|
||||||
|
[obRows[0].hotel_id],
|
||||||
|
)
|
||||||
|
const autoConfirm = gwRows[0]?.auto_confirm_on_payment !== false // default true
|
||||||
|
if (autoConfirm) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE bookings SET status = 'confirmed' WHERE id = $1 AND status = 'inquiry'`,
|
||||||
|
[obRows[0].booking_id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
|
|||||||
shopId: r.shop_id, secretKey: r.secret_key,
|
shopId: r.shop_id, secretKey: r.secret_key,
|
||||||
currency: r.currency, isActive: r.is_active,
|
currency: r.currency, isActive: r.is_active,
|
||||||
modules: r.modules ?? MODULES, createdAt: r.created_at,
|
modules: r.modules ?? MODULES, createdAt: r.created_at,
|
||||||
|
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,18 +56,19 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
|
|||||||
const r = rows[0]
|
const r = rows[0]
|
||||||
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
|
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
|
||||||
secretKey: r.secret_key ? '••••••••' : null,
|
secretKey: r.secret_key ? '••••••••' : null,
|
||||||
currency: r.currency, isActive: r.is_active, modules: r.modules }
|
currency: r.currency, isActive: r.is_active, modules: r.modules,
|
||||||
|
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── PATCH /api/hotels/:slug/payment-gateways/:id ───────────────────────────
|
// ── PATCH /api/hotels/:slug/payment-gateways/:id ───────────────────────────
|
||||||
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[] } }>(
|
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[]; autoConfirmOnPayment?: boolean } }>(
|
||||||
'/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
'/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||||
const { slug, id } = req.params
|
const { slug, id } = req.params
|
||||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||||||
const { label, shopId, secretKey, currency, isActive, modules } = req.body
|
const { label, shopId, secretKey, currency, isActive, modules, autoConfirmOnPayment } = req.body
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`UPDATE hotel_payment_gateways SET
|
`UPDATE hotel_payment_gateways SET
|
||||||
label = COALESCE($1, label),
|
label = COALESCE($1, label),
|
||||||
@@ -74,16 +76,19 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
|
|||||||
secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END,
|
secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END,
|
||||||
currency = COALESCE($4, currency),
|
currency = COALESCE($4, currency),
|
||||||
is_active = COALESCE($5, is_active),
|
is_active = COALESCE($5, is_active),
|
||||||
modules = COALESCE($6::jsonb, modules)
|
modules = COALESCE($6::jsonb, modules),
|
||||||
WHERE id = $7 AND hotel_id = $8 RETURNING *`,
|
auto_confirm_on_payment = COALESCE($7, auto_confirm_on_payment)
|
||||||
|
WHERE id = $8 AND hotel_id = $9 RETURNING *`,
|
||||||
[label ?? null, shopId ?? null, secretKey ?? null, currency ?? null,
|
[label ?? null, shopId ?? null, secretKey ?? null, currency ?? null,
|
||||||
isActive ?? null, modules ? JSON.stringify(modules) : null, id, hotelId],
|
isActive ?? null, modules ? JSON.stringify(modules) : null,
|
||||||
|
autoConfirmOnPayment ?? null, id, hotelId],
|
||||||
)
|
)
|
||||||
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
||||||
const r = rows[0]
|
const r = rows[0]
|
||||||
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
|
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
|
||||||
secretKey: r.secret_key ? '••••••••' : null,
|
secretKey: r.secret_key ? '••••••••' : null,
|
||||||
currency: r.currency, isActive: r.is_active, modules: r.modules }
|
currency: r.currency, isActive: r.is_active, modules: r.modules,
|
||||||
|
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ export async function capturePayment(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full or partial refund of a captured (charged) payment
|
||||||
|
export async function createRefund(params: {
|
||||||
|
shopId: string
|
||||||
|
secretKey: string
|
||||||
|
paymentId: string
|
||||||
|
amount: number
|
||||||
|
}): Promise<void> {
|
||||||
|
const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64')
|
||||||
|
const res = await fetch('https://api.yookassa.ru/v3/refunds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Idempotence-Key': randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
payment_id: params.paymentId,
|
||||||
|
amount: { value: params.amount.toFixed(2), currency: 'RUB' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`YooKassa refund error ${res.status}: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelPayment(params: {
|
export async function cancelPayment(params: {
|
||||||
shopId: string
|
shopId: string
|
||||||
secretKey: string
|
secretKey: string
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
|
|||||||
const [releaseComment, setReleaseComment] = useState('')
|
const [releaseComment, setReleaseComment] = useState('')
|
||||||
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
|
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
|
||||||
const [releaseError, setReleaseError] = useState<string | null>(null)
|
const [releaseError, setReleaseError] = useState<string | null>(null)
|
||||||
|
const [showRefundForm, setShowRefundForm] = useState(false)
|
||||||
|
const [refundAmount, setRefundAmount] = useState('')
|
||||||
|
const [refundComment, setRefundComment] = useState('')
|
||||||
|
const [refunding, setRefunding] = useState(false)
|
||||||
const [presets, setPresets] = useState<DepositPreset[]>([])
|
const [presets, setPresets] = useState<DepositPreset[]>([])
|
||||||
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([])
|
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([])
|
||||||
const [minibarTotal, setMinibarTotal] = useState(0)
|
const [minibarTotal, setMinibarTotal] = useState(0)
|
||||||
@@ -164,6 +168,22 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRefund = async (full: boolean) => {
|
||||||
|
setRefunding(true)
|
||||||
|
try {
|
||||||
|
const amt = full ? undefined : (parseFloat(refundAmount) || undefined)
|
||||||
|
const dep = await api.deposits.refund(slug, bookingId, amt, refundComment || undefined)
|
||||||
|
setDeposit(dep)
|
||||||
|
setShowRefundForm(false)
|
||||||
|
setRefundAmount('')
|
||||||
|
setRefundComment('')
|
||||||
|
} catch {
|
||||||
|
setReleaseError('Не удалось выполнить возврат')
|
||||||
|
} finally {
|
||||||
|
setRefunding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addReleaseItem = (name: string, amount: number) => {
|
const addReleaseItem = (name: string, amount: number) => {
|
||||||
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
|
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
|
||||||
}
|
}
|
||||||
@@ -491,11 +511,93 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Final statuses */}
|
{/* captured (hold → taken or charge → taken) */}
|
||||||
{deposit?.status === 'captured' && (
|
{deposit?.status === 'captured' && (
|
||||||
|
<div className="space-y-2">
|
||||||
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
|
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
|
||||||
Списано {formatCurrency(deposit.capturedAmount ?? 0)}
|
{deposit.paymentMethod === 'yookassa_charge' ? '✓ Оплачен' : 'Списано'} {formatCurrency(Number(deposit.amount))}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Refund UI for charge-based deposits */}
|
||||||
|
{deposit.paymentMethod === 'yookassa_charge' && !showRefundForm && !releaseBlocked && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRefundForm(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Вернуть депозит
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{deposit.paymentMethod === 'yookassa_charge' && !showRefundForm && releaseBlocked && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Ban size={11} /> Возврат заблокирован до выезда
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{showRefundForm && deposit.paymentMethod === 'yookassa_charge' && (
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-2 bg-slate-50 dark:bg-slate-800/40">
|
||||||
|
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат депозита</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
|
Сумма возврата (макс. {formatCurrency(Number(deposit.amount) - Number(deposit.refundedAmount ?? 0))})
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="100"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={e => setRefundAmount(e.target.value)}
|
||||||
|
onFocus={e => e.target.select()}
|
||||||
|
placeholder="Полный возврат"
|
||||||
|
className="input text-sm w-40"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={refundComment}
|
||||||
|
onChange={e => setRefundComment(e.target.value)}
|
||||||
|
placeholder="Комментарий (необязательно)"
|
||||||
|
className="input text-sm w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{releaseError && <p className="text-xs text-red-500">{releaseError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRefund(!refundAmount)}
|
||||||
|
disabled={refunding}
|
||||||
|
className="btn-primary py-1.5 px-3 text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{refunding ? <Loader2 size={11} className="animate-spin" /> : null}
|
||||||
|
{refundAmount ? `Вернуть ${formatCurrency(parseFloat(refundAmount) || 0)}` : 'Вернуть полностью'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowRefundForm(false); setReleaseError(null) }} className="btn-secondary py-1.5 px-3 text-xs">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deposit.refundedAmount && Number(deposit.refundedAmount) > 0 && (
|
||||||
|
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
Возвращено: {formatCurrency(Number(deposit.refundedAmount))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deposit?.status === 'partially_refunded' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className={cn(badgeBase, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
|
||||||
|
Частичный возврат
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Возвращено: {formatCurrency(Number(deposit.refundedAmount ?? 0))} из {formatCurrency(Number(deposit.amount))}
|
||||||
|
</p>
|
||||||
|
{!showRefundForm && !releaseBlocked && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRefundForm(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Вернуть ещё
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{deposit?.status === 'refunded' && (
|
{deposit?.status === 'refunded' && (
|
||||||
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
|
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
|
||||||
|
|||||||
@@ -805,6 +805,9 @@ export const api = {
|
|||||||
cancel: (slug: string, bookingId: string) =>
|
cancel: (slug: string, bookingId: string) =>
|
||||||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
|
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
|
||||||
|
|
||||||
|
refund: (slug: string, bookingId: string, amount?: number, reason?: string) =>
|
||||||
|
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/refund`, { amount, reason }),
|
||||||
|
|
||||||
getMinibarForBooking: (slug: string, bookingId: string) =>
|
getMinibarForBooking: (slug: string, bookingId: string) =>
|
||||||
req<{ items: Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>; total: number }>(
|
req<{ items: Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>; total: number }>(
|
||||||
'GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit/minibar`,
|
'GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit/minibar`,
|
||||||
@@ -1555,6 +1558,8 @@ export interface DepositSettings {
|
|||||||
yookassaShopId: string | null
|
yookassaShopId: string | null
|
||||||
yookassaSecretKey: string | null
|
yookassaSecretKey: string | null
|
||||||
releaseRequiresCheckout: boolean
|
releaseRequiresCheckout: boolean
|
||||||
|
longStayFullPayment: boolean
|
||||||
|
longStayThresholdDays: number
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,6 +1569,8 @@ export interface DepositSettingsPayload {
|
|||||||
yookassa_shop_id?: string
|
yookassa_shop_id?: string
|
||||||
yookassa_secret_key?: string
|
yookassa_secret_key?: string
|
||||||
release_requires_checkout?: boolean
|
release_requires_checkout?: boolean
|
||||||
|
long_stay_full_payment?: boolean
|
||||||
|
long_stay_threshold_days?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingDeposit {
|
export interface BookingDeposit {
|
||||||
@@ -1576,6 +1583,7 @@ export interface BookingDeposit {
|
|||||||
yookassaPaymentId: string | null
|
yookassaPaymentId: string | null
|
||||||
yookassaConfirmationUrl: string | null
|
yookassaConfirmationUrl: string | null
|
||||||
capturedAmount: number | null
|
capturedAmount: number | null
|
||||||
|
refundedAmount: number | null
|
||||||
retentionReason: string | null
|
retentionReason: string | null
|
||||||
guestEmailSent: boolean
|
guestEmailSent: boolean
|
||||||
cardLast4: string | null
|
cardLast4: string | null
|
||||||
@@ -1600,6 +1608,7 @@ export interface PaymentGateway {
|
|||||||
currency: string
|
currency: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
modules: string[]
|
modules: string[]
|
||||||
|
autoConfirmOnPayment: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1611,6 +1620,7 @@ export interface PaymentGatewayPayload {
|
|||||||
currency?: string
|
currency?: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
modules?: string[]
|
modules?: string[]
|
||||||
|
autoConfirmOnPayment?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetRoom {
|
export interface WidgetRoom {
|
||||||
|
|||||||
@@ -719,7 +719,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
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
|
||||||
const photo = room.photos[curPhoto]
|
const photo = room.photos[curPhoto] ?? { bg: 'from-slate-200 to-slate-300', emoji: '🛏️', label: 'Фото' }
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={room.id}
|
key={room.id}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export function DepositSettingsPage() {
|
|||||||
const [isEnabled, setIsEnabled] = useState(false)
|
const [isEnabled, setIsEnabled] = useState(false)
|
||||||
const [amount, setAmount] = useState('5000')
|
const [amount, setAmount] = useState('5000')
|
||||||
const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
|
const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
|
||||||
|
const [longStayFullPayment, setLongStayFullPayment] = useState(false)
|
||||||
|
const [longStayThresholdDays, setLongStayThresholdDays] = useState('7')
|
||||||
const [shopId, setShopId] = useState('')
|
const [shopId, setShopId] = useState('')
|
||||||
const [secretKey, setSecretKey] = useState('')
|
const [secretKey, setSecretKey] = useState('')
|
||||||
const [showSecret, setShowSecret] = useState(false)
|
const [showSecret, setShowSecret] = useState(false)
|
||||||
@@ -53,6 +55,8 @@ export function DepositSettingsPage() {
|
|||||||
setIsEnabled(s.isEnabled)
|
setIsEnabled(s.isEnabled)
|
||||||
setAmount(String(s.amount))
|
setAmount(String(s.amount))
|
||||||
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
|
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
|
||||||
|
setLongStayFullPayment(s.longStayFullPayment ?? false)
|
||||||
|
setLongStayThresholdDays(String(s.longStayThresholdDays ?? 7))
|
||||||
setShopId(s.yookassaShopId ?? '')
|
setShopId(s.yookassaShopId ?? '')
|
||||||
setSecretKey(s.yookassaSecretKey ?? '')
|
setSecretKey(s.yookassaSecretKey ?? '')
|
||||||
setPresets(p)
|
setPresets(p)
|
||||||
@@ -72,6 +76,8 @@ export function DepositSettingsPage() {
|
|||||||
is_enabled: isEnabled,
|
is_enabled: isEnabled,
|
||||||
amount: parseFloat(amount) || 5000,
|
amount: parseFloat(amount) || 5000,
|
||||||
release_requires_checkout: releaseRequiresCheckout,
|
release_requires_checkout: releaseRequiresCheckout,
|
||||||
|
long_stay_full_payment: longStayFullPayment,
|
||||||
|
long_stay_threshold_days: parseInt(longStayThresholdDays) || 7,
|
||||||
}
|
}
|
||||||
if (shopId) payload.yookassa_shop_id = shopId
|
if (shopId) payload.yookassa_shop_id = shopId
|
||||||
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
|
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
|
||||||
@@ -195,6 +201,29 @@ export function DepositSettingsPage() {
|
|||||||
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
|
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Длинная бронь — полная оплата</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
Для броней длиннее указанного кол-ва ночей снимать депозит сразу (не замораживать). Обход 7-дневного лимита ЮКасса
|
||||||
|
</p>
|
||||||
|
{longStayFullPayment && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="text-xs text-slate-600 dark:text-slate-300">Порог:</span>
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="365"
|
||||||
|
value={longStayThresholdDays}
|
||||||
|
onChange={e => setLongStayThresholdDays(e.target.value)}
|
||||||
|
onFocus={e => e.target.select()}
|
||||||
|
className="input w-20 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-500">ночей</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Toggle on={longStayFullPayment} onChange={() => setLongStayFullPayment(v => !v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<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 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">
|
||||||
<p className="text-xs font-semibold text-violet-800 dark:text-violet-300">ЮКасса настраивается в одном месте</p>
|
<p className="text-xs font-semibold text-violet-800 dark:text-violet-300">ЮКасса настраивается в одном месте</p>
|
||||||
<p className="text-xs text-violet-700 dark:text-violet-400">
|
<p className="text-xs text-violet-700 dark:text-violet-400">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function PaymentSettingsPage() {
|
|||||||
const [gwSecretKey, setGwSecretKey] = useState('')
|
const [gwSecretKey, setGwSecretKey] = useState('')
|
||||||
const [gwCurrency, setGwCurrency] = useState('RUB')
|
const [gwCurrency, setGwCurrency] = useState('RUB')
|
||||||
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
|
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
|
||||||
|
const [gwAutoConfirm, setGwAutoConfirm] = useState(true)
|
||||||
const [gwSaving, setGwSaving] = useState(false)
|
const [gwSaving, setGwSaving] = useState(false)
|
||||||
const [gwSecretVisible, setGwSecretVisible] = useState(false)
|
const [gwSecretVisible, setGwSecretVisible] = useState(false)
|
||||||
|
|
||||||
@@ -137,9 +138,11 @@ export function PaymentSettingsPage() {
|
|||||||
if (gw) {
|
if (gw) {
|
||||||
setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '')
|
setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '')
|
||||||
setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service'])
|
setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service'])
|
||||||
|
setGwAutoConfirm(gw.autoConfirmOnPayment ?? true)
|
||||||
} else {
|
} else {
|
||||||
setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('')
|
setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('')
|
||||||
setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service'])
|
setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service'])
|
||||||
|
setGwAutoConfirm(true)
|
||||||
}
|
}
|
||||||
setGwFormOpen(true)
|
setGwFormOpen(true)
|
||||||
setGwSecretVisible(false)
|
setGwSecretVisible(false)
|
||||||
@@ -149,7 +152,7 @@ export function PaymentSettingsPage() {
|
|||||||
if (!gwShopId.trim()) return
|
if (!gwShopId.trim()) return
|
||||||
setGwSaving(true)
|
setGwSaving(true)
|
||||||
try {
|
try {
|
||||||
const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules }
|
const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules, autoConfirmOnPayment: gwAutoConfirm }
|
||||||
if (gwEditId) {
|
if (gwEditId) {
|
||||||
const updated = await api.paymentGateways.update(slug, gwEditId, data)
|
const updated = await api.paymentGateways.update(slug, gwEditId, data)
|
||||||
setGateways(p => p.map(g => g.id === gwEditId ? updated : g))
|
setGateways(p => p.map(g => g.id === gwEditId ? updated : g))
|
||||||
@@ -429,6 +432,21 @@ export function PaymentSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{gwModules.includes('booking-widget') && (
|
||||||
|
<div className="flex items-center justify-between gap-4 p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-700 dark:text-slate-200">Авто-подтверждение брони при оплате</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Статус брони автоматически меняется на «Подтверждена» при успешной оплате через виджет</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGwAutoConfirm(v => !v)}
|
||||||
|
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', gwAutoConfirm ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||||
|
>
|
||||||
|
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', gwAutoConfirm ? 'left-[20px]' : 'left-0.5')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 pt-1">
|
<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">
|
<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} />}
|
{gwSaving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user