Files
hotelsync/src/pages/PaymentSettingsPage.tsx
HotelSync 6859817bdf 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>
2026-04-06 20:37:06 +03:00

479 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
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 }> = [
{ id: 'cash', label: 'Наличные' },
{ id: 'card', label: 'Банковская карта' },
{ id: 'transfer', label: 'Перевод / СБП' },
{ id: 'other', label: 'Другое' },
]
const CHECKIN_OPTIONS: Array<{ id: 'none' | 'soft' | 'hard'; label: string; desc: string; color: string }> = [
{ id: 'none', label: 'Не контролировать', desc: 'Заселение без проверки оплаты', color: 'border-slate-200 dark:border-slate-600' },
{ id: 'soft', label: 'Предупреждение', desc: 'Показать предупреждение, но разрешить заселить', color: 'border-amber-400 dark:border-amber-500' },
{ id: 'hard', label: 'Блокировать', desc: 'Запретить заселение пока не оплачено', color: 'border-red-400 dark:border-red-500' },
]
interface EditState {
name: string
currency: string
type: HotelPaymentMethod['type']
}
export function PaymentSettingsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [methods, setMethods] = useState<HotelPaymentMethod[]>([])
const [loading, setLoading] = useState(true)
const [requireCheckin, setRequireCheckin] = useState<'none' | 'soft' | 'hard'>('none')
const [savingCheckin, setSavingCheckin] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editState, setEditState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
const [adding, setAdding] = useState(false)
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 })),
api.paymentGateways.list(slug).catch(() => []),
]).then(([ms, s, gws]) => {
setMethods(ms)
setRequireCheckin(s.requirePaymentCheckin)
setGateways(gws)
}).finally(() => setLoading(false))
}, [slug])
const handleCheckinChange = async (val: 'none' | 'soft' | 'hard') => {
setRequireCheckin(val)
setSavingCheckin(true)
try {
await api.paymentMethods.updateSettings(slug, val)
} catch {
// revert would need old value — just keep it
} finally {
setSavingCheckin(false)
}
}
const handleAdd = async () => {
if (!newState.name.trim()) return
setAddingRow(true)
try {
const m = await api.paymentMethods.create(slug, {
name: newState.name.trim(),
currency: newState.currency,
type: newState.type,
sortOrder: methods.length,
})
setMethods(p => [...p, m])
setAdding(false)
setNewState({ name: '', currency: 'RUB', type: 'cash' })
} catch { /* ignore */ } finally {
setAddingRow(false)
}
}
const startEdit = (m: HotelPaymentMethod) => {
setEditingId(m.id)
setEditState({ name: m.name, currency: m.currency, type: m.type })
}
const saveEdit = async (id: string) => {
if (!editState.name.trim()) return
try {
const updated = await api.paymentMethods.update(slug, id, {
name: editState.name.trim(),
currency: editState.currency,
type: editState.type,
})
setMethods(p => p.map(m => m.id === id ? updated : m))
} catch { /* ignore */ }
setEditingId(null)
}
const toggleActive = async (m: HotelPaymentMethod) => {
try {
const updated = await api.paymentMethods.update(slug, m.id, { isActive: !m.isActive })
setMethods(p => p.map(x => x.id === m.id ? updated : x))
} catch { /* ignore */ }
}
const handleDelete = async (id: string) => {
if (!confirm('Удалить способ оплаты?')) return
await api.paymentMethods.remove(slug, id).catch(() => {})
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
const newIdx = idx + dir
if (newIdx < 0 || newIdx >= methods.length) return
const updated = [...methods]
;[updated[idx], updated[newIdx]] = [updated[newIdx], updated[idx]]
setMethods(updated)
// Update sort_order for both
await Promise.all([
api.paymentMethods.update(slug, updated[idx].id, { sortOrder: idx }),
api.paymentMethods.update(slug, updated[newIdx].id, { sortOrder: newIdx }),
]).catch(() => {})
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<CreditCard size={22} className="text-brand-600" />
Настройки оплаты
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Способы оплаты и контроль расчётов при заселении.
</p>
</div>
{/* Check-in payment control */}
<div className="card p-5 space-y-3">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Контроль оплаты при заселении</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Что делать если гость ещё не оплатил при нажатии кнопки «Заселить»
</p>
</div>
<div className="grid grid-cols-1 gap-2">
{CHECKIN_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => handleCheckinChange(opt.id)}
className={cn(
'flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-colors',
requireCheckin === opt.id
? opt.color + ' bg-slate-50 dark:bg-slate-800'
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
)}
>
<div className={cn(
'w-4 h-4 rounded-full border-2 shrink-0 flex items-center justify-center',
requireCheckin === opt.id ? 'border-brand-600' : 'border-slate-300 dark:border-slate-600',
)}>
{requireCheckin === opt.id && <div className="w-2 h-2 rounded-full bg-brand-600" />}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{opt.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{opt.desc}</p>
</div>
{savingCheckin && requireCheckin === opt.id && <Loader2 size={14} className="animate-spin text-brand-600 shrink-0" />}
</button>
))}
</div>
</div>
{/* Payment methods */}
<div className="card overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Способы оплаты</span>
{!adding && (
<button onClick={() => setAdding(true)} className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5">
<Plus size={14} /> Добавить
</button>
)}
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{/* Add form */}
{adding && (
<div className="px-4 py-3 bg-brand-50 dark:bg-brand-900/10">
<div className="grid grid-cols-[1fr_100px_160px_auto] gap-2 items-center">
<input
autoFocus
value={newState.name}
onChange={e => setNewState(p => ({ ...p, name: e.target.value }))}
placeholder="Название (напр. Наличные USD)"
className="input py-1.5 text-sm"
onKeyDown={e => e.key === 'Enter' && handleAdd()}
/>
<select value={newState.currency} onChange={e => setNewState(p => ({ ...p, currency: e.target.value }))} className="input py-1.5 text-sm">
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select value={newState.type} onChange={e => setNewState(p => ({ ...p, type: e.target.value as HotelPaymentMethod['type'] }))} className="input py-1.5 text-sm">
{METHOD_TYPES.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
<div className="flex gap-1">
<button onClick={handleAdd} disabled={!newState.name.trim() || addingRow} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
{addingRow ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button onClick={() => { setAdding(false); setNewState({ name: '', currency: 'RUB', type: 'cash' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
<X size={14} />
</button>
</div>
</div>
</div>
)}
{methods.length === 0 && !adding && (
<p className="px-4 py-8 text-center text-sm text-slate-400">
Нет способов оплаты. Нажмите «Добавить».
</p>
)}
{methods.map((m, idx) => (
<div key={m.id} className={cn('px-4 py-3 group', !m.isActive && 'opacity-50')}>
{editingId === m.id ? (
<div className="grid grid-cols-[1fr_100px_160px_auto] gap-2 items-center">
<input
autoFocus
value={editState.name}
onChange={e => setEditState(p => ({ ...p, name: e.target.value }))}
className="input py-1.5 text-sm"
onKeyDown={e => e.key === 'Enter' && saveEdit(m.id)}
/>
<select value={editState.currency} onChange={e => setEditState(p => ({ ...p, currency: e.target.value }))} className="input py-1.5 text-sm">
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select value={editState.type} onChange={e => setEditState(p => ({ ...p, type: e.target.value as HotelPaymentMethod['type'] }))} className="input py-1.5 text-sm">
{METHOD_TYPES.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
<div className="flex gap-1">
<button onClick={() => saveEdit(m.id)} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
<Check size={14} />
</button>
<button onClick={() => setEditingId(null)} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
<X size={14} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => moveItem(m.id, -1)} disabled={idx === 0} className="p-0.5 text-slate-300 hover:text-slate-500 disabled:opacity-20">
<ArrowUp size={11} />
</button>
<button onClick={() => moveItem(m.id, 1)} disabled={idx === methods.length - 1} className="p-0.5 text-slate-300 hover:text-slate-500 disabled:opacity-20">
<ArrowDown size={11} />
</button>
</div>
<GripVertical size={14} className="text-slate-300 dark:text-slate-600 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{m.name}</span>
<span className="ml-2 text-xs text-slate-400">{m.currency}</span>
<span className="ml-1 text-xs text-slate-400">·</span>
<span className="ml-1 text-xs text-slate-400">{METHOD_TYPES.find(t => t.id === m.type)?.label}</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => toggleActive(m)}
className={cn('text-xs px-2 py-0.5 rounded-full font-medium transition-colors', m.isActive ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400')}
>
{m.isActive ? 'Активен' : 'Скрыт'}
</button>
<button onClick={() => startEdit(m)} className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
<Pencil size={13} />
</button>
<button onClick={() => handleDelete(m.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>
{/* 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>
</div>
)
}