feat: long-stay deposit charge + refunds + auto-confirm widget + white page fix
- Widget white page fix: fallback photo when real rooms have no photos array - Long-stay deposits: if booking > threshold days and toggle on → full charge instead of hold (bypasses YooKassa 7-day limit) - DepositSettingsPage: toggle + day threshold input for long-stay full payment - Refund endpoint: POST /deposit/refund supports full and partial refunds via YooKassa refunds API - DepositWidget: refund UI for yookassa_charge deposits (full/partial, shows remaining amount) - partially_refunded status support with badge and "Вернуть ещё" button - Auto-confirm: webhook now auto-confirms booking status on payment.succeeded for widget bookings - PaymentSettingsPage: auto-confirm toggle per gateway (shown when booking-widget module active) - Migration 067: long_stay columns on hotel_deposit_settings, auto_confirm_on_payment on gateways, refunded_amount on deposits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -719,7 +719,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
||||
const isExpanded = expandedRoom === room.id
|
||||
const isSelected = selected === room.id
|
||||
const curPhoto = photoIndex[room.id] ?? 0
|
||||
const photo = room.photos[curPhoto]
|
||||
const photo = room.photos[curPhoto] ?? { bg: 'from-slate-200 to-slate-300', emoji: '🛏️', label: 'Фото' }
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
|
||||
@@ -30,6 +30,8 @@ export function DepositSettingsPage() {
|
||||
const [isEnabled, setIsEnabled] = useState(false)
|
||||
const [amount, setAmount] = useState('5000')
|
||||
const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
|
||||
const [longStayFullPayment, setLongStayFullPayment] = useState(false)
|
||||
const [longStayThresholdDays, setLongStayThresholdDays] = useState('7')
|
||||
const [shopId, setShopId] = useState('')
|
||||
const [secretKey, setSecretKey] = useState('')
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
@@ -53,6 +55,8 @@ export function DepositSettingsPage() {
|
||||
setIsEnabled(s.isEnabled)
|
||||
setAmount(String(s.amount))
|
||||
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
|
||||
setLongStayFullPayment(s.longStayFullPayment ?? false)
|
||||
setLongStayThresholdDays(String(s.longStayThresholdDays ?? 7))
|
||||
setShopId(s.yookassaShopId ?? '')
|
||||
setSecretKey(s.yookassaSecretKey ?? '')
|
||||
setPresets(p)
|
||||
@@ -72,6 +76,8 @@ export function DepositSettingsPage() {
|
||||
is_enabled: isEnabled,
|
||||
amount: parseFloat(amount) || 5000,
|
||||
release_requires_checkout: releaseRequiresCheckout,
|
||||
long_stay_full_payment: longStayFullPayment,
|
||||
long_stay_threshold_days: parseInt(longStayThresholdDays) || 7,
|
||||
}
|
||||
if (shopId) payload.yookassa_shop_id = shopId
|
||||
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
|
||||
@@ -195,6 +201,29 @@ export function DepositSettingsPage() {
|
||||
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Длинная бронь — полная оплата</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Для броней длиннее указанного кол-ва ночей снимать депозит сразу (не замораживать). Обход 7-дневного лимита ЮКасса
|
||||
</p>
|
||||
{longStayFullPayment && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-slate-600 dark:text-slate-300">Порог:</span>
|
||||
<input
|
||||
type="number" min="1" max="365"
|
||||
value={longStayThresholdDays}
|
||||
onChange={e => setLongStayThresholdDays(e.target.value)}
|
||||
onFocus={e => e.target.select()}
|
||||
className="input w-20 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">ночей</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toggle on={longStayFullPayment} onChange={() => setLongStayFullPayment(v => !v)} />
|
||||
</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">
|
||||
|
||||
@@ -55,9 +55,10 @@ export function PaymentSettingsPage() {
|
||||
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)
|
||||
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
|
||||
const [gwAutoConfirm, setGwAutoConfirm] = useState(true)
|
||||
const [gwSaving, setGwSaving] = useState(false)
|
||||
const [gwSecretVisible, setGwSecretVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
@@ -137,9 +138,11 @@ export function PaymentSettingsPage() {
|
||||
if (gw) {
|
||||
setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '')
|
||||
setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service'])
|
||||
setGwAutoConfirm(gw.autoConfirmOnPayment ?? true)
|
||||
} else {
|
||||
setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('')
|
||||
setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service'])
|
||||
setGwAutoConfirm(true)
|
||||
}
|
||||
setGwFormOpen(true)
|
||||
setGwSecretVisible(false)
|
||||
@@ -149,7 +152,7 @@ export function PaymentSettingsPage() {
|
||||
if (!gwShopId.trim()) return
|
||||
setGwSaving(true)
|
||||
try {
|
||||
const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules }
|
||||
const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules, autoConfirmOnPayment: gwAutoConfirm }
|
||||
if (gwEditId) {
|
||||
const updated = await api.paymentGateways.update(slug, gwEditId, data)
|
||||
setGateways(p => p.map(g => g.id === gwEditId ? updated : g))
|
||||
@@ -429,6 +432,21 @@ export function PaymentSettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{gwModules.includes('booking-widget') && (
|
||||
<div className="flex items-center justify-between gap-4 p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-200">Авто-подтверждение брони при оплате</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Статус брони автоматически меняется на «Подтверждена» при успешной оплате через виджет</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGwAutoConfirm(v => !v)}
|
||||
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', gwAutoConfirm ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||
>
|
||||
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', gwAutoConfirm ? 'left-[20px]' : 'left-0.5')} />
|
||||
</button>
|
||||
</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} />}
|
||||
|
||||
Reference in New Issue
Block a user