feat: centralized payment gateways + booking widget API
Backend:
- Migration 065: hotel_payment_gateways table (migrates YooKassa from deposit_settings)
- online_bookings table for widget submissions
- Routes: CRUD /api/hotels/:slug/payment-gateways
- Public widget API: /api/widget/:slug/{config,availability,bookings}
- yookassa.ts: add createCharge() for immediate capture
Frontend:
- PaymentSettingsPage: add 'Онлайн-оплата' section with gateway CRUD and module toggles
- DepositSettingsPage: replace YooKassa fields with link to centralized settings
- BookingWidgetPage: connect to real API, show gateway status, real booking submit
- api.ts: add PaymentGateway types, paymentGateways and widget API methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import { api, type HotelPaymentMethod } from '../lib/api'
|
||||
import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown, Zap, Eye, EyeOff } from 'lucide-react'
|
||||
import { api, type HotelPaymentMethod, type PaymentGateway } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const GATEWAY_MODULES = [
|
||||
{ id: 'deposit', label: 'Депозит (QR)' },
|
||||
{ id: 'booking-widget', label: 'Онлайн-бронирование' },
|
||||
{ id: 'room-service', label: 'Room Service' },
|
||||
]
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'GBP', 'CNY', 'AED', 'KZT', 'BYN', 'AMD', 'GEL']
|
||||
|
||||
const METHOD_TYPES: Array<{ id: HotelPaymentMethod['type']; label: string }> = [
|
||||
@@ -41,14 +47,28 @@ export function PaymentSettingsPage() {
|
||||
const [newState, setNewState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
|
||||
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(() => {
|
||||
if (!slug) return
|
||||
Promise.all([
|
||||
api.paymentMethods.list(slug),
|
||||
api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })),
|
||||
]).then(([ms, s]) => {
|
||||
api.paymentGateways.list(slug).catch(() => []),
|
||||
]).then(([ms, s, gws]) => {
|
||||
setMethods(ms)
|
||||
setRequireCheckin(s.requirePaymentCheckin)
|
||||
setGateways(gws)
|
||||
}).finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
@@ -113,6 +133,44 @@ export function PaymentSettingsPage() {
|
||||
setMethods(p => p.filter(m => m.id !== id))
|
||||
}
|
||||
|
||||
const openGwForm = (gw?: PaymentGateway) => {
|
||||
if (gw) {
|
||||
setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '')
|
||||
setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service'])
|
||||
} else {
|
||||
setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('')
|
||||
setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service'])
|
||||
}
|
||||
setGwFormOpen(true)
|
||||
setGwSecretVisible(false)
|
||||
}
|
||||
|
||||
const saveGateway = async () => {
|
||||
if (!gwShopId.trim()) return
|
||||
setGwSaving(true)
|
||||
try {
|
||||
const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules }
|
||||
if (gwEditId) {
|
||||
const updated = await api.paymentGateways.update(slug, gwEditId, data)
|
||||
setGateways(p => p.map(g => g.id === gwEditId ? updated : g))
|
||||
} else {
|
||||
const created = await api.paymentGateways.create(slug, { ...data, provider: 'yookassa' })
|
||||
setGateways(p => [...p, created])
|
||||
}
|
||||
setGwFormOpen(false)
|
||||
} catch { /* ignore */ } finally { setGwSaving(false) }
|
||||
}
|
||||
|
||||
const deleteGateway = async (id: string) => {
|
||||
if (!confirm('Удалить платёжный шлюз?')) return
|
||||
await api.paymentGateways.remove(slug, id).catch(() => {})
|
||||
setGateways(p => p.filter(g => g.id !== id))
|
||||
}
|
||||
|
||||
const toggleGwModule = (mod: string) => {
|
||||
setGwModules(p => p.includes(mod) ? p.filter(m => m !== mod) : [...p, mod])
|
||||
}
|
||||
|
||||
const moveItem = async (id: string, dir: -1 | 1) => {
|
||||
const idx = methods.findIndex(m => m.id === id)
|
||||
if (idx < 0) return
|
||||
@@ -296,6 +354,122 @@ export function PaymentSettingsPage() {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user