feat: blacklist, guest lookup, bank transfer payment page
- Add is_blacklisted column to guests (migration 068) - POST /widget/bookings: block blacklisted guests (403, generic message) - GET /widget/:slug/guests/lookup: find existing guest by email/phone for auto-fill - Widget form: debounce guest lookup on email/phone input, show "Заполнить" suggestion - Widget payment screen: replace fake card form with proper YooKassa redirect screen or bank transfer details page - Add bank transfer details field to widget settings (saved as widget_bank_details) - GuestsPage: blacklist toggle button in edit modal + 🚫 ЧС badge in table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
8
backend/migrations/068_guests_blacklist.sql
Normal file
8
backend/migrations/068_guests_blacklist.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration 068 — Add is_blacklisted flag to guests
|
||||||
|
|
||||||
|
ALTER TABLE guests
|
||||||
|
ADD COLUMN IF NOT EXISTS is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_blacklisted
|
||||||
|
ON guests(hotel_id, is_blacklisted)
|
||||||
|
WHERE is_blacklisted = TRUE;
|
||||||
@@ -10,7 +10,7 @@ const GUEST_FIELDS = `
|
|||||||
g.passport, g.passport_series, g.passport_number,
|
g.passport, g.passport_series, g.passport_number,
|
||||||
g.passport_issued_by, g.passport_issue_date,
|
g.passport_issued_by, g.passport_issue_date,
|
||||||
g.birth_date, g.nationality, g.gender, g.city, g.notes, g.tags,
|
g.birth_date, g.nationality, g.gender, g.city, g.notes, g.tags,
|
||||||
g.loyalty_tier, g.loyalty_points, g.rating,
|
g.loyalty_tier, g.loyalty_points, g.rating, g.is_blacklisted,
|
||||||
g.created_at, g.updated_at`
|
g.created_at, g.updated_at`
|
||||||
|
|
||||||
const STATS_JOIN = `
|
const STATS_JOIN = `
|
||||||
@@ -212,7 +212,7 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
passport: string; passport_series: string; passport_number: string
|
passport: string; passport_series: string; passport_number: string
|
||||||
passport_issued_by: string; passport_issue_date: string
|
passport_issued_by: string; passport_issue_date: string
|
||||||
birth_date: string; nationality: string; gender: string; city: string
|
birth_date: string; nationality: string; gender: string; city: string
|
||||||
notes: string; tags: string[]; rating: number
|
notes: string; tags: string[]; rating: number; is_blacklisted?: boolean
|
||||||
}> }>(
|
}> }>(
|
||||||
'/api/hotels/:slug/guests/:id',
|
'/api/hotels/:slug/guests/:id',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -242,8 +242,9 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
add('gender', b.gender)
|
add('gender', b.gender)
|
||||||
add('city', b.city)
|
add('city', b.city)
|
||||||
add('notes', b.notes)
|
add('notes', b.notes)
|
||||||
add('tags', b.tags)
|
add('tags', b.tags)
|
||||||
add('rating', b.rating)
|
add('rating', b.rating)
|
||||||
|
add('is_blacklisted', b.is_blacklisted)
|
||||||
// Шифруем чувствительные поля
|
// Шифруем чувствительные поля
|
||||||
if (b.passport !== undefined) add('passport', encryptField(b.passport))
|
if (b.passport !== undefined) add('passport', encryptField(b.passport))
|
||||||
if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series))
|
if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series))
|
||||||
|
|||||||
@@ -42,12 +42,32 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Check if YooKassa gateway is configured for booking-widget
|
// Check if YooKassa gateway is configured for booking-widget
|
||||||
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
|
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
|
||||||
|
|
||||||
|
// Load saved widget settings
|
||||||
|
const { rows: wsRows } = await db.query(
|
||||||
|
`SELECT key, value FROM hotel_settings WHERE hotel_id = $1 AND key LIKE 'widget_%'`,
|
||||||
|
[hotel.id],
|
||||||
|
)
|
||||||
|
const ws: Record<string, unknown> = {}
|
||||||
|
for (const row of wsRows) ws[row.key] = row.value
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hotelId: hotel.id,
|
hotelId: hotel.id,
|
||||||
hotelName: hotel.name,
|
hotelName: hotel.name,
|
||||||
slug: req.params.slug,
|
slug: req.params.slug,
|
||||||
paymentEnabled: !!(gateway?.shop_id && gateway?.secret_key),
|
paymentEnabled: !!(gateway?.shop_id && gateway?.secret_key),
|
||||||
currency: gateway?.currency ?? 'RUB',
|
currency: gateway?.currency ?? 'RUB',
|
||||||
|
widgetSettings: {
|
||||||
|
primaryColor: (ws.widget_color as string) ?? '#4F46E5',
|
||||||
|
language: (ws.widget_lang as string) ?? 'ru',
|
||||||
|
roomDisplayMode: (ws.widget_room_mode as string) ?? 'rooms',
|
||||||
|
minNights: (ws.widget_min_nights as number) ?? 1,
|
||||||
|
showRental: (ws.widget_show_rental as boolean) ?? false,
|
||||||
|
showPromo: (ws.widget_show_promo as boolean) ?? true,
|
||||||
|
allowExtraBeds: (ws.widget_extra_beds as boolean) ?? true,
|
||||||
|
allowChildren: (ws.widget_children as boolean) ?? true,
|
||||||
|
hotelName: (ws.widget_hotel_name as string) ?? hotel.name,
|
||||||
|
bankDetails: (ws.widget_bank_details as string) ?? '',
|
||||||
|
},
|
||||||
rooms: rooms.map(r => ({
|
rooms: rooms.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
number: r.number,
|
number: r.number,
|
||||||
@@ -75,6 +95,43 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── GET /api/widget/:slug/guests/lookup ───────────────────────────────────
|
||||||
|
// Lookup existing guest by email or phone (for auto-fill, no auth)
|
||||||
|
fastify.get<SlugParam & { Querystring: { email?: string; phone?: string } }>(
|
||||||
|
'/api/widget/:slug/guests/lookup', async (req, reply) => {
|
||||||
|
const hotel = await getHotelId(req.params.slug)
|
||||||
|
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
|
const { email, phone } = req.query
|
||||||
|
if (!email && !phone) return reply.code(400).send({ error: 'email or phone required' })
|
||||||
|
|
||||||
|
const conditions: string[] = []
|
||||||
|
const params: unknown[] = [hotel.id]
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
params.push(email.toLowerCase().trim())
|
||||||
|
conditions.push(`LOWER(g.email) = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (phone) {
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '')
|
||||||
|
params.push(cleanPhone)
|
||||||
|
conditions.push(`REGEXP_REPLACE(g.phone, '\\D', '', 'g') = $${params.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT g.first_name, g.last_name, g.middle_name, g.email, g.phone, g.is_blacklisted
|
||||||
|
FROM guests g
|
||||||
|
WHERE g.hotel_id = $1 AND (${conditions.join(' OR ')})
|
||||||
|
ORDER BY g.updated_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rows[0]) return reply.code(404).send({ error: 'Guest not found' })
|
||||||
|
return rows[0]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ── GET /api/widget/:slug/availability ────────────────────────────────────
|
// ── GET /api/widget/:slug/availability ────────────────────────────────────
|
||||||
// Returns available rooms for given dates
|
// Returns available rooms for given dates
|
||||||
fastify.get<SlugParam & { Querystring: { checkIn: string; checkOut: string } }>(
|
fastify.get<SlugParam & { Querystring: { checkIn: string; checkOut: string } }>(
|
||||||
@@ -221,6 +278,28 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(409).send({ error: 'Room not available for selected dates' })
|
return reply.code(409).send({ error: 'Room not available for selected dates' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blacklist check
|
||||||
|
if (guestEmail || guestPhone) {
|
||||||
|
const blConditions: string[] = []
|
||||||
|
const blParams: unknown[] = [hotel.id]
|
||||||
|
if (guestEmail) {
|
||||||
|
blParams.push(guestEmail.toLowerCase().trim())
|
||||||
|
blConditions.push(`LOWER(email) = $${blParams.length}`)
|
||||||
|
}
|
||||||
|
if (guestPhone) {
|
||||||
|
const cleanPhone = guestPhone.replace(/\D/g, '')
|
||||||
|
blParams.push(cleanPhone)
|
||||||
|
blConditions.push(`REGEXP_REPLACE(phone, '\\D', '', 'g') = $${blParams.length}`)
|
||||||
|
}
|
||||||
|
const { rows: blRows } = await db.query(
|
||||||
|
`SELECT id FROM guests WHERE hotel_id = $1 AND is_blacklisted = TRUE AND (${blConditions.join(' OR ')}) LIMIT 1`,
|
||||||
|
blParams,
|
||||||
|
)
|
||||||
|
if (blRows.length > 0) {
|
||||||
|
return reply.code(403).send({ error: 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if gateway configured
|
// Check if gateway configured
|
||||||
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
|
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
|
||||||
const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none'
|
const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none'
|
||||||
|
|||||||
@@ -869,9 +869,22 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}).then(r => r.json()) as Promise<{ bookingId: string; status: string; confirmationUrl: string | null }>,
|
}).then(async r => {
|
||||||
|
const json = await r.json()
|
||||||
|
if (!r.ok) throw { status: r.status, ...json }
|
||||||
|
return json as { bookingId: string; status: string; confirmationUrl: string | null }
|
||||||
|
}),
|
||||||
getBookingStatus: (slug: string, bookingId: string) =>
|
getBookingStatus: (slug: string, bookingId: string) =>
|
||||||
fetch(`${BASE}/api/widget/${slug}/bookings/${bookingId}/status`).then(r => r.json()),
|
fetch(`${BASE}/api/widget/${slug}/bookings/${bookingId}/status`).then(r => r.json()),
|
||||||
|
lookupGuest: (slug: string, params: { email?: string; phone?: string }) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.email) qs.set('email', params.email)
|
||||||
|
if (params.phone) qs.set('phone', params.phone)
|
||||||
|
return fetch(`${BASE}/api/widget/${slug}/guests/lookup?${qs}`).then(async r => {
|
||||||
|
if (!r.ok) return null
|
||||||
|
return r.json() as Promise<{ first_name: string; last_name: string; middle_name?: string; email?: string; phone?: string; is_blacklisted: boolean }>
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,6 +1056,7 @@ export interface GuestApiType {
|
|||||||
loyaltyTier: string
|
loyaltyTier: string
|
||||||
loyaltyPoints: number
|
loyaltyPoints: number
|
||||||
rating: number
|
rating: number
|
||||||
|
isBlacklisted: boolean
|
||||||
totalStays: number
|
totalStays: number
|
||||||
totalSpent: number
|
totalSpent: number
|
||||||
lastVisit: string | null
|
lastVisit: string | null
|
||||||
@@ -1076,6 +1090,7 @@ export interface GuestPayload {
|
|||||||
notes?: string
|
notes?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
rating?: number
|
rating?: number
|
||||||
|
is_blacklisted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RentalObjectApi {
|
export interface RentalObjectApi {
|
||||||
@@ -1665,6 +1680,7 @@ export interface WidgetConfig {
|
|||||||
allowExtraBeds: boolean
|
allowExtraBeds: boolean
|
||||||
allowChildren: boolean
|
allowChildren: boolean
|
||||||
hotelName: string
|
hotelName: string
|
||||||
|
bankDetails: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface WidgetSettings {
|
|||||||
showPromo: boolean
|
showPromo: boolean
|
||||||
allowExtraBeds: boolean
|
allowExtraBeds: boolean
|
||||||
allowChildren: boolean
|
allowChildren: boolean
|
||||||
|
bankDetails: string
|
||||||
formFields: FormField[]
|
formFields: FormField[]
|
||||||
additionalServices: AdditionalService[]
|
additionalServices: AdditionalService[]
|
||||||
}
|
}
|
||||||
@@ -212,11 +213,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||||||
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
|
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
|
||||||
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
|
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
|
||||||
// Payment state (kept for mock display)
|
const [bookingError, setBookingError] = useState<string | null>(null)
|
||||||
const [cardNumber, setCardNumber] = useState('')
|
// Guest lookup
|
||||||
const [cardExpiry, setCardExpiry] = useState('')
|
const [guestLookupTimer, setGuestLookupTimer] = useState<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [cardCvv, setCardCvv] = useState('')
|
const [guestSuggestion, setGuestSuggestion] = useState<{ first_name: string; last_name: string; middle_name?: string; phone?: string; email?: string } | null>(null)
|
||||||
const [cardName, setCardName] = useState('')
|
|
||||||
|
|
||||||
const nights = checkIn && checkOut
|
const nights = checkIn && checkOut
|
||||||
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
||||||
@@ -264,6 +264,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
||||||
if (missing.length > 0) return
|
if (missing.length > 0) return
|
||||||
|
setBookingError(null)
|
||||||
|
|
||||||
if (slug && selected) {
|
if (slug && selected) {
|
||||||
// Real API call
|
// Real API call
|
||||||
@@ -288,18 +289,23 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
if (result.confirmationUrl) {
|
if (result.confirmationUrl) {
|
||||||
setConfirmUrl(result.confirmationUrl)
|
setConfirmUrl(result.confirmationUrl)
|
||||||
setStep('payment')
|
setStep('payment')
|
||||||
|
} else if (settings.bankDetails) {
|
||||||
|
setStep('payment')
|
||||||
|
} else {
|
||||||
|
setStep('success')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.status === 403) {
|
||||||
|
setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.')
|
||||||
} else {
|
} else {
|
||||||
setStep('success')
|
setStep('success')
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// fallback — still show success in preview
|
|
||||||
setStep('success')
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Preview mode without real slug
|
// Preview mode without real slug
|
||||||
if (needsPayment) {
|
if (needsPayment || settings.bankDetails) {
|
||||||
setStep('payment')
|
setStep('payment')
|
||||||
} else {
|
} else {
|
||||||
setStep('success')
|
setStep('success')
|
||||||
@@ -309,9 +315,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
|
|
||||||
const handlePay = () => {
|
const handlePay = () => {
|
||||||
if (confirmUrl) {
|
if (confirmUrl) {
|
||||||
window.open(confirmUrl, '_blank')
|
window.location.href = confirmUrl
|
||||||
|
} else {
|
||||||
|
setStep('success')
|
||||||
}
|
}
|
||||||
setStep('success')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -320,13 +327,18 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
setFormValues({})
|
setFormValues({})
|
||||||
setSelectedServices([])
|
setSelectedServices([])
|
||||||
setServiceSchedule({})
|
setServiceSchedule({})
|
||||||
setCardNumber(''); setCardExpiry(''); setCardCvv(''); setCardName('')
|
setBookingError(null)
|
||||||
|
setGuestSuggestion(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCard = (v: string) => v.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
|
const doGuestLookup = (email?: string, phone?: string) => {
|
||||||
const formatExpiry = (v: string) => {
|
if (!slug || (!email && !phone)) return
|
||||||
const d = v.replace(/\D/g, '').slice(0, 4)
|
if (guestLookupTimer) clearTimeout(guestLookupTimer)
|
||||||
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d
|
const t = setTimeout(async () => {
|
||||||
|
const g = await api.widget.lookupGuest(slug, { email, phone })
|
||||||
|
if (g && !g.is_blacklisted) setGuestSuggestion(g)
|
||||||
|
}, 600)
|
||||||
|
setGuestLookupTimer(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Success screen ──
|
// ── Success screen ──
|
||||||
@@ -366,9 +378,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
|
|
||||||
// ── Payment screen ──
|
// ── Payment screen ──
|
||||||
if (step === 'payment') {
|
if (step === 'payment') {
|
||||||
const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa'
|
const isYookassa = !!confirmUrl
|
||||||
: settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay'
|
|
||||||
: 'CloudPayments'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
@@ -378,83 +388,64 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold">{settings.language === 'ru' ? 'Оплата' : 'Payment'}</p>
|
<p className="font-bold">{settings.language === 'ru' ? 'Оплата' : 'Payment'}</p>
|
||||||
<p className="text-xs opacity-80">{payLabel} · {grandTotal.toLocaleString('ru-RU')} ₽</p>
|
<p className="text-xs opacity-80">{selectedName} · {grandTotal.toLocaleString('ru-RU')} ₽</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
{/* Amount summary */}
|
{/* Amount */}
|
||||||
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
|
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-600">{selectedName} · {nights} ноч.</span>
|
<span className="text-sm text-slate-600">{selectedName} · {nights} ноч.</span>
|
||||||
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} ₽</span>
|
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card form */}
|
{isYookassa ? (
|
||||||
<div className="space-y-3">
|
/* YooKassa redirect */
|
||||||
<div>
|
<div className="text-center space-y-3 py-2">
|
||||||
<label className="block text-xs text-slate-500 mb-1">Номер карты</label>
|
<div className="w-14 h-14 rounded-full flex items-center justify-center mx-auto bg-slate-100">
|
||||||
<input
|
<CreditCard size={28} className="text-slate-500" />
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="0000 0000 0000 0000"
|
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono tracking-wider"
|
|
||||||
value={cardNumber}
|
|
||||||
onChange={e => setCardNumber(formatCard(e.target.value))}
|
|
||||||
maxLength={19}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">Срок действия</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="ММ/ГГ"
|
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
|
|
||||||
value={cardExpiry}
|
|
||||||
onChange={e => setCardExpiry(formatExpiry(e.target.value))}
|
|
||||||
maxLength={5}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p className="text-sm text-slate-700 font-medium">
|
||||||
<label className="block text-xs text-slate-500 mb-1">CVV</label>
|
{settings.language === 'ru' ? 'Для оплаты вы будете перенаправлены на страницу ЮKassa' : 'You will be redirected to YooKassa to complete payment'}
|
||||||
<input
|
</p>
|
||||||
type="password"
|
<p className="text-xs text-slate-400">🔒 Защищённое соединение · ЮKassa</p>
|
||||||
inputMode="numeric"
|
</div>
|
||||||
placeholder="•••"
|
) : (
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
|
/* Bank transfer details */
|
||||||
value={cardCvv}
|
<div className="space-y-3">
|
||||||
onChange={e => setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 3))}
|
<p className="text-sm font-medium text-slate-700">
|
||||||
maxLength={3}
|
{settings.language === 'ru' ? 'Реквизиты для оплаты' : 'Payment details'}
|
||||||
/>
|
</p>
|
||||||
|
<div className="bg-slate-50 rounded-xl p-4 text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{settings.bankDetails || 'Реквизиты не указаны'}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{settings.language === 'ru'
|
||||||
|
? 'После перевода отель подтвердит бронирование. Укажите в назначении платежа вашу фамилию и даты.'
|
||||||
|
: 'After transfer, the hotel will confirm your booking. Include your name and dates in the payment reference.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label className="block text-xs text-slate-500 mb-1">Имя на карте</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="IVAN PETROV"
|
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none uppercase"
|
|
||||||
value={cardName}
|
|
||||||
onChange={e => setCardName(e.target.value.toUpperCase())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] text-slate-400 text-center flex items-center justify-center gap-1">
|
|
||||||
🔒 Платёж защищён 3-D Secure · {payLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pb-5">
|
<div className="px-5 pb-5">
|
||||||
<button
|
{isYookassa ? (
|
||||||
onClick={handlePay}
|
<button
|
||||||
disabled={cardNumber.length < 19 || cardExpiry.length < 5 || cardCvv.length < 3 || !cardName.trim()}
|
onClick={handlePay}
|
||||||
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
||||||
style={{ background: settings.primaryColor }}
|
style={{ background: settings.primaryColor }}
|
||||||
>
|
>
|
||||||
{settings.language === 'ru' ? `Оплатить ${grandTotal.toLocaleString('ru-RU')} ₽` : `Pay ${grandTotal.toLocaleString('ru-RU')} ₽`}
|
{settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Proceed to pay · ${grandTotal.toLocaleString('ru-RU')} ₽`}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('success')}
|
||||||
|
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: settings.primaryColor }}
|
||||||
|
>
|
||||||
|
{settings.language === 'ru' ? 'Я перевёл оплату' : 'I have paid'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -615,14 +606,51 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
type={field.type}
|
type={field.type}
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none"
|
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none"
|
||||||
value={formValues[field.id] ?? ''}
|
value={formValues[field.id] ?? ''}
|
||||||
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
onChange={e => {
|
||||||
|
setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))
|
||||||
|
if (field.id === 'email') doGuestLookup(e.target.value, formValues['phone'])
|
||||||
|
if (field.id === 'phone') doGuestLookup(formValues['email'], e.target.value)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Guest suggestion */}
|
||||||
|
{guestSuggestion && (
|
||||||
|
<div className="flex items-center justify-between bg-indigo-50 border border-indigo-200 rounded-xl px-3 py-2">
|
||||||
|
<div className="text-xs text-indigo-700">
|
||||||
|
<span className="font-medium">Гость найден: </span>
|
||||||
|
{guestSuggestion.last_name} {guestSuggestion.first_name} {guestSuggestion.middle_name ?? ''}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-xs font-semibold text-indigo-600 hover:text-indigo-800 ml-2 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
const g = guestSuggestion
|
||||||
|
const fullName = [g.last_name, g.first_name, g.middle_name].filter(Boolean).join(' ')
|
||||||
|
setFormValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: fullName,
|
||||||
|
full_name: fullName,
|
||||||
|
...(g.email && { email: g.email }),
|
||||||
|
...(g.phone && { phone: g.phone }),
|
||||||
|
}))
|
||||||
|
setGuestSuggestion(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Заполнить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pb-5 pt-2 space-y-2">
|
<div className="px-5 pb-5 pt-2 space-y-2">
|
||||||
|
{bookingError && (
|
||||||
|
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
|
||||||
|
<AlertCircle size={14} className="text-red-500 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-red-700">{bookingError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
@@ -630,11 +658,11 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
|
|||||||
style={{ background: settings.primaryColor }}
|
style={{ background: settings.primaryColor }}
|
||||||
>
|
>
|
||||||
{submitting && <Loader2 size={14} className="animate-spin" />}
|
{submitting && <Loader2 size={14} className="animate-spin" />}
|
||||||
{submitting ? 'Отправляем...' : needsPayment
|
{submitting ? 'Отправляем...' : (needsPayment || settings.bankDetails)
|
||||||
? (settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')} ₽`)
|
? (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')} ₽`)}
|
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Confirm · ${grandTotal.toLocaleString('ru-RU')} ₽`)}
|
||||||
</button>
|
</button>
|
||||||
{!needsPayment && (
|
{!needsPayment && !settings.bankDetails && (
|
||||||
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
|
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1122,6 +1150,7 @@ export function BookingWidgetPage() {
|
|||||||
showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo,
|
showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo,
|
||||||
allowExtraBeds: (hs as any).widgetExtraBeds !== undefined ? Boolean((hs as any).widgetExtraBeds) : prev.allowExtraBeds,
|
allowExtraBeds: (hs as any).widgetExtraBeds !== undefined ? Boolean((hs as any).widgetExtraBeds) : prev.allowExtraBeds,
|
||||||
allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren,
|
allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren,
|
||||||
|
bankDetails: String((hs as any).widgetBankDetails ?? prev.bankDetails ?? ''),
|
||||||
}))
|
}))
|
||||||
setSettingsReady(true)
|
setSettingsReady(true)
|
||||||
}).finally(() => setGatewayLoading(false))
|
}).finally(() => setGatewayLoading(false))
|
||||||
@@ -1139,6 +1168,7 @@ export function BookingWidgetPage() {
|
|||||||
showPromo: true,
|
showPromo: true,
|
||||||
allowExtraBeds: true,
|
allowExtraBeds: true,
|
||||||
allowChildren: true,
|
allowChildren: true,
|
||||||
|
bankDetails: '',
|
||||||
formFields: DEFAULT_FORM_FIELDS,
|
formFields: DEFAULT_FORM_FIELDS,
|
||||||
additionalServices: DEFAULT_SERVICES,
|
additionalServices: DEFAULT_SERVICES,
|
||||||
})
|
})
|
||||||
@@ -1168,6 +1198,7 @@ export function BookingWidgetPage() {
|
|||||||
widget_show_promo: s.showPromo,
|
widget_show_promo: s.showPromo,
|
||||||
widget_extra_beds: s.allowExtraBeds,
|
widget_extra_beds: s.allowExtraBeds,
|
||||||
widget_children: s.allowChildren,
|
widget_children: s.allowChildren,
|
||||||
|
widget_bank_details: s.bankDetails,
|
||||||
})
|
})
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
@@ -1507,6 +1538,24 @@ export function BookingWidgetPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bank transfer details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard size={14} className="text-slate-400" />
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Реквизиты для оплаты переводом</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Если ЮKassa не подключена, гость увидит эти реквизиты после бронирования. Оставьте пустым если оплата на месте.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
className="input w-full text-sm resize-none font-mono"
|
||||||
|
placeholder={'Номер карты: 4276 1234 5678 9012\nПолучатель: Иванов Иван Иванович\nБанк: Сбербанк\nКомментарий: укажите даты и фамилию'}
|
||||||
|
value={settings.bankDetails}
|
||||||
|
onChange={e => set('bankDetails', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Save button */}
|
{/* Save button */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function BookingWidgetStandalonePage() {
|
|||||||
showPromo: searchParams.get('promo') !== 'false',
|
showPromo: searchParams.get('promo') !== 'false',
|
||||||
allowExtraBeds: searchParams.get('extra-beds') !== 'false',
|
allowExtraBeds: searchParams.get('extra-beds') !== 'false',
|
||||||
allowChildren: searchParams.get('children') !== 'false',
|
allowChildren: searchParams.get('children') !== 'false',
|
||||||
|
bankDetails: '',
|
||||||
formFields: DEFAULT_FORM_FIELDS,
|
formFields: DEFAULT_FORM_FIELDS,
|
||||||
additionalServices: DEFAULT_SERVICES,
|
additionalServices: DEFAULT_SERVICES,
|
||||||
})
|
})
|
||||||
@@ -47,6 +48,7 @@ export function BookingWidgetStandalonePage() {
|
|||||||
showPromo: ws.showPromo ?? prev.showPromo,
|
showPromo: ws.showPromo ?? prev.showPromo,
|
||||||
allowExtraBeds: ws.allowExtraBeds ?? prev.allowExtraBeds,
|
allowExtraBeds: ws.allowExtraBeds ?? prev.allowExtraBeds,
|
||||||
allowChildren: ws.allowChildren ?? prev.allowChildren,
|
allowChildren: ws.allowChildren ?? prev.allowChildren,
|
||||||
|
bankDetails: ws.bankDetails ?? prev.bankDetails,
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
setSettings(prev => ({ ...prev, hotelName: config.hotelName ?? prev.hotelName }))
|
setSettings(prev => ({ ...prev, hotelName: config.hotelName ?? prev.hotelName }))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import {
|
import {
|
||||||
Search, Star, Phone, Mail, User, TrendingUp, Award, Users,
|
Search, Star, Phone, Mail, User, TrendingUp, Award, Users,
|
||||||
Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag,
|
Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag,
|
||||||
Repeat2, ShieldCheck, Plus, ChevronsUpDown, AlertCircle, Loader2,
|
Repeat2, Shield, ShieldCheck, Plus, ChevronsUpDown, AlertCircle, Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { api, type GuestApiType } from '../lib/api'
|
import { api, type GuestApiType } from '../lib/api'
|
||||||
@@ -100,6 +100,7 @@ function GuestModal({ guestId, slug, onClose, onUpdated }: GuestModalProps) {
|
|||||||
const [rating, setRating] = useState(3)
|
const [rating, setRating] = useState(3)
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
const [tags, setTags] = useState<string[]>([])
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
const [isBlacklisted, setIsBlacklisted] = useState(false)
|
||||||
// Passport
|
// Passport
|
||||||
const [passportSeries, setPassportSeries] = useState('')
|
const [passportSeries, setPassportSeries] = useState('')
|
||||||
const [passportNumber, setPassportNumber] = useState('')
|
const [passportNumber, setPassportNumber] = useState('')
|
||||||
@@ -120,6 +121,7 @@ function GuestModal({ guestId, slug, onClose, onUpdated }: GuestModalProps) {
|
|||||||
setRating(g.rating)
|
setRating(g.rating)
|
||||||
setNotes(g.notes ?? '')
|
setNotes(g.notes ?? '')
|
||||||
setTags(g.tags ?? [])
|
setTags(g.tags ?? [])
|
||||||
|
setIsBlacklisted(g.isBlacklisted ?? false)
|
||||||
setPassportSeries(g.passportSeries ?? '')
|
setPassportSeries(g.passportSeries ?? '')
|
||||||
setPassportNumber(g.passportNumber ?? '')
|
setPassportNumber(g.passportNumber ?? '')
|
||||||
setBirthDate(g.birthDate?.slice(0, 10) ?? '')
|
setBirthDate(g.birthDate?.slice(0, 10) ?? '')
|
||||||
@@ -143,6 +145,7 @@ function GuestModal({ guestId, slug, onClose, onUpdated }: GuestModalProps) {
|
|||||||
rating,
|
rating,
|
||||||
notes,
|
notes,
|
||||||
tags,
|
tags,
|
||||||
|
is_blacklisted: isBlacklisted,
|
||||||
passport_series: passportSeries || undefined,
|
passport_series: passportSeries || undefined,
|
||||||
passport_number: passportNumber || undefined,
|
passport_number: passportNumber || undefined,
|
||||||
birth_date: birthDate || undefined,
|
birth_date: birthDate || undefined,
|
||||||
@@ -317,6 +320,30 @@ function GuestModal({ guestId, slug, onClose, onUpdated }: GuestModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Blacklist */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBlacklisted(v => !v)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-colors',
|
||||||
|
isBlacklisted
|
||||||
|
? 'bg-red-50 border-red-300 text-red-700 dark:bg-red-900/20 dark:border-red-700 dark:text-red-400'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-red-300 hover:text-red-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Shield size={14} className={isBlacklisted ? 'text-red-500' : 'text-slate-400'} />
|
||||||
|
{isBlacklisted ? '🚫 Гость в чёрном списке' : 'Добавить в чёрный список'}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs opacity-60">{isBlacklisted ? 'Нажмите чтобы убрать' : 'Нажмите чтобы добавить'}</span>
|
||||||
|
</button>
|
||||||
|
{isBlacklisted && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1 px-1">
|
||||||
|
Этот гость не сможет забронировать номер через виджет онлайн-бронирования.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
@@ -915,6 +942,11 @@ export function GuestsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 hidden sm:table-cell">
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(guest as any).isBlacklisted && (
|
||||||
|
<span className="flex items-center gap-0.5 px-2 py-0.5 rounded-md text-xs font-medium border bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800">
|
||||||
|
🚫 ЧС
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{guest.tags.map(tag => (
|
{guest.tags.map(tag => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
|
|||||||
Reference in New Issue
Block a user