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:
2026-04-06 11:22:55 +03:00
parent b91859218a
commit 754f03b7fa
7 changed files with 470 additions and 2 deletions

View File

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

View 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>
)
}