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:
2026-04-06 20:37:06 +03:00
parent 340b3ddec9
commit 6859817bdf
9 changed files with 798 additions and 70 deletions

View File

@@ -843,6 +843,33 @@ export const api = {
updateSettings: (slug: string, requirePaymentCheckin: 'none' | 'soft' | 'hard') =>
req<{ requirePaymentCheckin: string }>('PATCH', `/api/hotels/${slug}/payment-settings`, { require_payment_checkin: requirePaymentCheckin }),
},
paymentGateways: {
list: (slug: string) =>
req<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 ─────────────────────────────────────────────────────────────────
@@ -1561,6 +1588,64 @@ export interface BookingDeposit {
roomNumber?: string
}
export interface PaymentGateway {
id: string
provider: string
label: string
shopId: string | null
secretKey: string | null
currency: string
isActive: boolean
modules: string[]
createdAt: string
}
export interface PaymentGatewayPayload {
provider?: string
label: string
shopId?: string
secretKey?: string
currency?: string
isActive?: boolean
modules?: string[]
}
export interface WidgetRoom {
id: string
number: string
name: string
type: string
floor: number
maxGuests: number
baseRate: number
amenities: string[]
description: string
photos: string[]
}
export interface WidgetConfig {
hotelId: string
hotelName: string
slug: string
paymentEnabled: boolean
currency: string
rooms: WidgetRoom[]
}
export interface WidgetBookingPayload {
roomId: string
checkIn: string
checkOut: string
guestName: string
guestEmail?: string
guestPhone?: string
adults?: number
children?: number
totalAmount: number
notes?: string
services?: Array<{ name: string; price: number }>
}
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (h.name !== undefined) out.name = h.name

View File

@@ -1,14 +1,16 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
Code2, CreditCard, Globe, CalendarCheck2, Copy, CheckCheck,
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, ChevronUp,
Maximize2, Wifi, Tv2, Wind, Coffee, Bath, Mountain, Shirt, Shield,
Scan, Image,
Scan, Image, Zap, AlertCircle, Loader2,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext'
import { useAuth } from '../contexts/AuthContext'
import { api, type WidgetRoom, type PaymentGateway } from '../lib/api'
// ── Widget settings type ───────────────────────────────────────────────────────
@@ -159,7 +161,12 @@ const DEFAULT_SERVICES: AdditionalService[] = [
// ── Widget Preview Component ───────────────────────────────────────────────────
function WidgetPreview({ settings }: { settings: WidgetSettings }) {
function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
settings: WidgetSettings
slug?: string
realRooms?: WidgetRoom[]
paymentEnabled?: boolean
}) {
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
const [checkIn, setCheckIn] = useState('2026-03-20')
const [checkOut, setCheckOut] = useState('2026-03-22')
@@ -170,13 +177,15 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
const [submitting, setSubmitting] = useState(false)
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
// Form state
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [selectedServices, setSelectedServices] = useState<string[]>([])
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
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 [cardExpiry, setCardExpiry] = useState('')
const [cardCvv, setCardCvv] = useState('')
@@ -186,7 +195,17 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
: 0
const selectedRoom = MOCK_ROOMS.find(r => r.id === selected)
// Use real rooms if available, otherwise mock
const displayRooms = realRooms && realRooms.length > 0
? realRooms.map(r => ({
id: r.id, name: r.name || `Номер ${r.number}`, beds: 1,
guests: r.maxGuests, price: r.baseRate, area: 0,
description: r.description, photos: [], amenities: r.amenities,
has3dTour: false,
}))
: MOCK_ROOMS
const selectedRoom = displayRooms.find(r => r.id === selected)
const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0
const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
const servicesTotal = selectedServices.reduce((sum, sid) => {
@@ -196,25 +215,63 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const grandTotal = roomTotal + extraTotal + servicesTotal
const activeFields = settings.formFields.filter(f => f.enabled)
const needsPayment = settings.paymentProvider !== 'none'
const needsPayment = paymentEnabled ?? (settings.paymentProvider !== 'none')
const handleBook = () => {
if (!selected || nights === 0) return
setStep('form')
}
const handleSubmit = () => {
const handleSubmit = async () => {
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
if (needsPayment) {
setStep('payment')
if (slug && selected) {
// Real API call
setSubmitting(true)
try {
const services = selectedServices
.map(sid => settings.additionalServices.find(s => s.id === sid))
.filter(Boolean)
.map(s => ({ name: s!.name, price: s!.price }))
const result = await api.widget.createBooking(slug, {
roomId: selected,
checkIn, checkOut,
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
guestEmail: formValues['email'] ?? undefined,
guestPhone: formValues['phone'] ?? undefined,
adults: guests, children,
totalAmount: grandTotal,
notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
services,
})
if (result.confirmationUrl) {
setConfirmUrl(result.confirmationUrl)
setStep('payment')
} else {
setStep('success')
}
} catch {
// fallback — still show success in preview
setStep('success')
} finally {
setSubmitting(false)
}
} else {
setStep('success')
// Preview mode without real slug
if (needsPayment) {
setStep('payment')
} else {
setStep('success')
}
}
}
const handlePay = () => {
// Mock payment — just proceed to success
if (confirmUrl) {
window.open(confirmUrl, '_blank')
}
setStep('success')
}
@@ -529,10 +586,12 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<div className="px-5 pb-5 pt-2 space-y-2">
<button
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 }}
>
{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')}` : `Confirm · ${grandTotal.toLocaleString('ru-RU')}`)}
</button>
@@ -656,7 +715,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{children > 0 && ` · ${children} дет.`}
</p>
)}
{MOCK_ROOMS.map(room => {
{displayRooms.map(room => {
const isExpanded = expandedRoom === room.id
const isSelected = selected === room.id
const curPhoto = photoIndex[room.id] ?? 0
@@ -893,7 +952,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
>
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
{selected && nights > 0 && previewTab === 'rooms' && (() => {
const r = MOCK_ROOMS.find(r => r.id === selected)
const r = displayRooms.find(r => r.id === selected)
const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0
return total ? ` · ${total.toLocaleString('ru-RU')}` : ''
})()}
@@ -917,8 +976,28 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
export function BookingWidgetPage() {
const { statuses } = useModules()
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const rentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
// Real data for preview
const [realRooms, setRealRooms] = useState<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>({
hotelName: 'Grand Palace Hotel',
primaryColor: '#4F46E5',
@@ -1275,9 +1354,34 @@ export function BookingWidgetPage() {
<div className="flex items-center gap-2 mb-3">
<Eye size={14} className="text-slate-400" />
<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>
{/* 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">
<WidgetPreview settings={settings} />
<WidgetPreview
settings={settings}
slug={slug || undefined}
realRooms={realRooms.length > 0 ? realRooms : undefined}
paymentEnabled={gateway ? gateway.isActive : undefined}
/>
</div>
<p className="text-xs text-slate-400 mt-2 text-center">Так виджет будет выглядеть на сайте отеля</p>
</div>

View File

@@ -182,59 +182,17 @@ export function DepositSettingsPage() {
/>
</div>
<div className="space-y-3">
<div>
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm">ЮКасса (опционально)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты.
</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 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 text-violet-700 dark:text-violet-400">
Реквизиты ЮКасса (Shop ID и секретный ключ) теперь управляются централизованно.
Перейдите в <a href="/settings/payments" className="underline font-medium">Настройки оплаты Онлайн-оплата</a> и добавьте шлюз один раз он будет работать для депозита, онлайн-бронирования и Room Service.
</p>
</div>
</div>
{shopId && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
<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>
<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>
@@ -259,7 +217,6 @@ export function DepositSettingsPage() {
}
`}</style>
</div>
)}
</>
)}
</div>

View File

@@ -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>