feat: deposit module — QR flow, PayDepositPage, BookingDetailPanel widget
- Backend: GET /api/pay/:slug (public) — returns active hold confirmation URL - PayDepositPage: public page /:slug/pay for guests (no login required) - DepositSettingsPage: QR code section with print button - BookingDetailPanel: DepositWidget in payment tab - Наличными / ЮКасса (QR) buttons - hold_created: copy link + refresh - hold_confirmed: release form (вернуть / списать) - Final status badges Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -288,6 +288,33 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/pay/:slug — public, no auth ─────────────────────────────────
|
||||
fastify.get<{ Params: { slug: string } }>(
|
||||
'/api/pay/:slug',
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT d.yookassa_confirmation_url, d.amount, h.name AS hotel_name
|
||||
FROM booking_deposits d
|
||||
JOIN hotels h ON h.id = d.hotel_id
|
||||
WHERE d.hotel_id = $1 AND d.status = 'hold_created'
|
||||
ORDER BY d.created_at DESC LIMIT 1`,
|
||||
[hotelId],
|
||||
)
|
||||
if (!rows[0]?.yookassa_confirmation_url) {
|
||||
return reply.code(404).send({ error: 'No active payment' })
|
||||
}
|
||||
return {
|
||||
confirmationUrl: rows[0].yookassa_confirmation_url,
|
||||
amount: rows[0].amount,
|
||||
hotelName: rows[0].hotel_name,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
|
||||
fastify.post<{ Body: { event: string; object: { id: string; status: string } } }>(
|
||||
'/api/webhooks/yookassa',
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
@@ -2264,6 +2265,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
|
||||
@@ -47,6 +47,7 @@ import { TTLockPage } from './pages/TTLockPage'
|
||||
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
|
||||
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
|
||||
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
||||
import { PayDepositPage } from './pages/PayDepositPage'
|
||||
import { ModuleGuard } from './components/ModuleGuard'
|
||||
|
||||
export default function App() {
|
||||
@@ -64,6 +65,7 @@ export default function App() {
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
||||
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
||||
<Route path="/:slug/pay" element={<PayDepositPage />} />
|
||||
|
||||
{/* PMS routes */}
|
||||
<Route element={<AppLayout />}>
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
|
||||
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
|
||||
Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
|
||||
ShieldCheck, QrCode, Copy, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
|
||||
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
|
||||
} from '../../lib/utils'
|
||||
import type { Booking, Room } from '../../types'
|
||||
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType } from '../../lib/api'
|
||||
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings } from '../../lib/api'
|
||||
import { getIdentity, type AgentIdentity } from '../../lib/agent'
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
@@ -54,6 +55,267 @@ const DOCUMENTS = [
|
||||
{ id: 'act', label: 'Акт об оказании услуг', desc: 'Закрывающий документ при выезде', icon: FileCheck, always: false },
|
||||
]
|
||||
|
||||
// ─── DepositWidget ────────────────────────────────────────────────────────────
|
||||
|
||||
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
|
||||
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
|
||||
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
|
||||
const [depositLoading, setDepositLoading] = useState(true)
|
||||
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
|
||||
const [depositReleasing, setDepositReleasing] = useState(false)
|
||||
const [captureAmount, setCaptureAmount] = useState('0')
|
||||
const [retentionReason, setRetentionReason] = useState('')
|
||||
const [showReleaseForm, setShowReleaseForm] = useState(false)
|
||||
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
|
||||
const [releaseError, setReleaseError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.deposits.getSettings(slug),
|
||||
api.deposits.getBookingDeposit(slug, bookingId).catch(() => null),
|
||||
]).then(([settings, dep]) => {
|
||||
setDepositSettings(settings)
|
||||
setDeposit(dep)
|
||||
}).catch(() => {
|
||||
// silently ignore — deposit module may not be available
|
||||
}).finally(() => setDepositLoading(false))
|
||||
}, [slug, bookingId])
|
||||
|
||||
const handleCreateCash = async () => {
|
||||
setDepositCreating('cash')
|
||||
try {
|
||||
const dep = await api.deposits.payByCash(slug, bookingId)
|
||||
setDeposit(dep)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDepositCreating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateYookassa = async () => {
|
||||
setDepositCreating('yookassa')
|
||||
try {
|
||||
const dep = await api.deposits.createYookassaHold(slug, bookingId)
|
||||
setDeposit(dep)
|
||||
setYookassaMsg('QR-код активирован. Гость может сканировать QR на стойке ресепшена.')
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDepositCreating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
const dep = await api.deposits.getBookingDeposit(slug, bookingId)
|
||||
setDeposit(dep)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleRelease = async () => {
|
||||
setDepositReleasing(true)
|
||||
setReleaseError(null)
|
||||
try {
|
||||
const captured = parseFloat(captureAmount) || 0
|
||||
const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined)
|
||||
setDeposit(dep)
|
||||
setShowReleaseForm(false)
|
||||
} catch {
|
||||
setReleaseError('Не удалось выполнить операцию')
|
||||
} finally {
|
||||
setDepositReleasing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyPayLink = () => {
|
||||
navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {})
|
||||
}
|
||||
|
||||
if (depositLoading) {
|
||||
return <Loader2 size={14} className="animate-spin text-slate-400 mt-3" />
|
||||
}
|
||||
|
||||
if (!depositSettings?.isEnabled) return null
|
||||
|
||||
const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mt-4 mb-2 flex items-center gap-1">
|
||||
<ShieldCheck size={14} /> Депозит
|
||||
</p>
|
||||
|
||||
{yookassaMsg && (
|
||||
<div className="mb-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 p-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
{yookassaMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No deposit yet */}
|
||||
{deposit === null && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Сумма: {formatCurrency(depositSettings.amount)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreateCash}
|
||||
disabled={depositCreating !== null}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{depositCreating === 'cash' ? <Loader2 size={12} className="animate-spin" /> : <Banknote size={12} />}
|
||||
Наличными
|
||||
</button>
|
||||
{depositSettings.yookassaShopId && (
|
||||
<button
|
||||
onClick={handleCreateYookassa}
|
||||
disabled={depositCreating !== null}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{depositCreating === 'yookassa' ? <Loader2 size={12} className="animate-spin" /> : <QrCode size={12} />}
|
||||
ЮКасса (QR)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hold_created — awaiting card payment */}
|
||||
{deposit?.status === 'hold_created' && (
|
||||
<div className="space-y-2">
|
||||
<span className={cn(badgeBase, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
|
||||
Ожидает оплаты по карте
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-xs text-slate-500 font-mono truncate flex-1">
|
||||
https://app.hotelsync.ru/{slug}/pay
|
||||
</span>
|
||||
<button
|
||||
onClick={copyPayLink}
|
||||
className="shrink-0 p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
title="Скопировать ссылку"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">QR-код на ресепшене активен</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<RefreshCw size={11} /> Обновить статус
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hold_confirmed — held on card */}
|
||||
{deposit?.status === 'hold_confirmed' && (
|
||||
<div className="space-y-2">
|
||||
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
|
||||
✓ Холд подтверждён
|
||||
</span>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
||||
{!showReleaseForm && (
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(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>
|
||||
)}
|
||||
|
||||
{/* paid_cash */}
|
||||
{deposit?.status === 'paid_cash' && (
|
||||
<div className="space-y-2">
|
||||
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
|
||||
✓ Наличными
|
||||
</span>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
||||
{!showReleaseForm && (
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(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>
|
||||
)}
|
||||
|
||||
{/* Release form */}
|
||||
{showReleaseForm && deposit && (deposit.status === 'hold_confirmed' || deposit.status === 'paid_cash') && (
|
||||
<div className="mt-2 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 dark:text-slate-400 mb-0.5">
|
||||
Сумма удержания (0 = полный возврат), ₽
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={captureAmount}
|
||||
onChange={e => setCaptureAmount(e.target.value)}
|
||||
className="input text-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Причина (необязательно)</label>
|
||||
<textarea
|
||||
value={retentionReason}
|
||||
onChange={e => setRetentionReason(e.target.value)}
|
||||
rows={2}
|
||||
className="input text-sm w-full resize-none"
|
||||
placeholder="Повреждение имущества…"
|
||||
/>
|
||||
</div>
|
||||
{releaseError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{releaseError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setShowReleaseForm(false); setReleaseError(null) }}
|
||||
className="btn-secondary flex-1 justify-center text-xs py-1.5"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRelease}
|
||||
disabled={depositReleasing}
|
||||
className="btn-primary flex-1 justify-center text-xs py-1.5 flex items-center gap-1"
|
||||
>
|
||||
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
|
||||
Подтвердить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final statuses */}
|
||||
{deposit?.status === 'captured' && (
|
||||
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
|
||||
Списано {formatCurrency(deposit.capturedAmount ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
{deposit?.status === 'refunded' && (
|
||||
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
|
||||
Возвращён
|
||||
</span>
|
||||
)}
|
||||
{deposit?.status === 'cancelled' && (
|
||||
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
|
||||
Отменён
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BookingDetailPanelProps {
|
||||
@@ -1309,6 +1571,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deposit widget */}
|
||||
{slug && (
|
||||
<DepositWidget slug={slug} bookingId={booking.id} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { Save, Eye, EyeOff, Loader2, ShieldCheck, Printer } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { api, type DepositSettings } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
@@ -172,6 +173,52 @@ export function DepositSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR code section — only when shopId is configured */}
|
||||
{shopId && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
||||
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm mb-1">QR-код для гостей</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-4">
|
||||
Распечатайте и разместите на стойке ресепшена. Гость сканирует код и оплачивает депозит картой.
|
||||
</p>
|
||||
<div className="print-qr-section flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200 dark:border-slate-600 inline-block">
|
||||
<QRCodeSVG
|
||||
value={`https://app.hotelsync.ru/${slug}/pay`}
|
||||
size={160}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-mono break-all text-center">
|
||||
https://app.hotelsync.ru/{slug}/pay
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.print()}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Printer size={15} />
|
||||
Распечатать QR-код
|
||||
</button>
|
||||
</div>
|
||||
<style>{`
|
||||
@media print {
|
||||
body > * { display: none !important; }
|
||||
.print-qr-section,
|
||||
.print-qr-section * { display: flex !important; }
|
||||
.print-qr-section {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
button { display: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
114
src/pages/PayDepositPage.tsx
Normal file
114
src/pages/PayDepositPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { ShieldCheck, Loader2, CreditCard } from 'lucide-react'
|
||||
|
||||
interface PayInfo {
|
||||
confirmationUrl: string
|
||||
amount: number
|
||||
hotelName: string
|
||||
}
|
||||
|
||||
type PageState = 'loading' | 'ready' | 'empty' | 'error'
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(amount)
|
||||
}
|
||||
|
||||
export function PayDepositPage() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const [state, setState] = useState<PageState>('loading')
|
||||
const [info, setInfo] = useState<PayInfo | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setState('empty')
|
||||
return
|
||||
}
|
||||
fetch(`https://api.hotelsync.ru/api/pay/${slug}`)
|
||||
.then(async res => {
|
||||
if (res.status === 404) {
|
||||
setState('empty')
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setInfo({
|
||||
confirmationUrl: data.confirmationUrl ?? data.confirmation_url,
|
||||
amount: data.amount,
|
||||
hotelName: data.hotelName ?? data.hotel_name ?? '',
|
||||
})
|
||||
setState('ready')
|
||||
})
|
||||
.catch(err => {
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||
setState('error')
|
||||
})
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
{state === 'loading' && (
|
||||
<div className="flex flex-col items-center gap-3 text-slate-500">
|
||||
<Loader2 size={32} className="animate-spin text-brand-600" />
|
||||
<p className="text-sm">Загружаем данные о депозите…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<div className="max-w-sm w-full bg-white rounded-2xl shadow-md p-8 text-center space-y-3">
|
||||
<ShieldCheck size={40} className="mx-auto text-slate-300" />
|
||||
<p className="text-slate-700 font-medium">Нет активного платежа</p>
|
||||
<p className="text-sm text-slate-500">Обратитесь к администратору ресепшена.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div className="max-w-sm w-full bg-white rounded-2xl shadow-md p-8 text-center space-y-3">
|
||||
<p className="text-red-600 font-medium">Ошибка загрузки</p>
|
||||
{errorMsg && <p className="text-sm text-slate-500">{errorMsg}</p>}
|
||||
<p className="text-sm text-slate-500">Попробуйте обновить страницу или обратитесь к администратору.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && info && (
|
||||
<div className="max-w-sm w-full bg-white rounded-2xl shadow-md overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-brand-600 px-6 py-5 text-white text-center">
|
||||
<ShieldCheck size={32} className="mx-auto mb-2 opacity-90" />
|
||||
<p className="font-bold text-lg">{info.hotelName || 'Отель'}</p>
|
||||
<p className="text-brand-100 text-sm mt-0.5">Оплата депозита</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Amount */}
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-slate-900">{formatCurrency(info.amount)}</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Сумма будет заморожена на карте и возвращена при выезде
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<a
|
||||
href={info.confirmationUrl}
|
||||
className="flex items-center justify-center gap-2 w-full bg-brand-600 hover:bg-brand-700 text-white font-semibold py-3 rounded-xl transition-colors text-base"
|
||||
>
|
||||
<CreditCard size={18} />
|
||||
Оплатить картой →
|
||||
</a>
|
||||
|
||||
{/* Trust */}
|
||||
<p className="text-center text-xs text-slate-400 flex items-center justify-center gap-1">
|
||||
<ShieldCheck size={12} />
|
||||
Защищено ЮКасса
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user