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