diff --git a/backend/migrations/068_guests_blacklist.sql b/backend/migrations/068_guests_blacklist.sql new file mode 100644 index 0000000..bd718a0 --- /dev/null +++ b/backend/migrations/068_guests_blacklist.sql @@ -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; diff --git a/backend/src/routes/guests.ts b/backend/src/routes/guests.ts index bf6d7f5..38a05ae 100644 --- a/backend/src/routes/guests.ts +++ b/backend/src/routes/guests.ts @@ -10,7 +10,7 @@ const GUEST_FIELDS = ` g.passport, g.passport_series, g.passport_number, g.passport_issued_by, g.passport_issue_date, 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` const STATS_JOIN = ` @@ -212,7 +212,7 @@ const guests: FastifyPluginAsync = async (fastify) => { passport: string; passport_series: string; passport_number: string passport_issued_by: string; passport_issue_date: 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', { onRequest: [fastify.authenticate] }, @@ -242,8 +242,9 @@ const guests: FastifyPluginAsync = async (fastify) => { add('gender', b.gender) add('city', b.city) add('notes', b.notes) - add('tags', b.tags) - add('rating', b.rating) + add('tags', b.tags) + add('rating', b.rating) + add('is_blacklisted', b.is_blacklisted) // Шифруем чувствительные поля if (b.passport !== undefined) add('passport', encryptField(b.passport)) if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series)) diff --git a/backend/src/routes/publicWidget.ts b/backend/src/routes/publicWidget.ts index 77f8717..797530b 100644 --- a/backend/src/routes/publicWidget.ts +++ b/backend/src/routes/publicWidget.ts @@ -42,12 +42,32 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { // Check if YooKassa gateway is configured for 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 = {} + for (const row of wsRows) ws[row.key] = row.value + return { hotelId: hotel.id, hotelName: hotel.name, slug: req.params.slug, paymentEnabled: !!(gateway?.shop_id && gateway?.secret_key), 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 => ({ id: r.id, 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( + '/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 ──────────────────────────────────── // Returns available rooms for given dates fastify.get( @@ -221,6 +278,28 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { 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 const gateway = await getGatewayForModule(hotel.id, 'booking-widget') const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none' diff --git a/src/lib/api.ts b/src/lib/api.ts index 576dbc2..6dc3abf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -869,9 +869,22 @@ export const api = { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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) => 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 loyaltyPoints: number rating: number + isBlacklisted: boolean totalStays: number totalSpent: number lastVisit: string | null @@ -1076,6 +1090,7 @@ export interface GuestPayload { notes?: string tags?: string[] rating?: number + is_blacklisted?: boolean } export interface RentalObjectApi { @@ -1665,6 +1680,7 @@ export interface WidgetConfig { allowExtraBeds: boolean allowChildren: boolean hotelName: string + bankDetails: string } } diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx index 800eefc..d2103f1 100644 --- a/src/pages/BookingWidgetPage.tsx +++ b/src/pages/BookingWidgetPage.tsx @@ -44,6 +44,7 @@ export interface WidgetSettings { showPromo: boolean allowExtraBeds: boolean allowChildren: boolean + bankDetails: string formFields: FormField[] additionalServices: AdditionalService[] } @@ -212,11 +213,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme const [selectedServices, setSelectedServices] = useState([]) // Hourly service time selections: { serviceId: { date, timeFrom, timeTo } } const [serviceSchedule, setServiceSchedule] = useState>({}) - // Payment state (kept for mock display) - const [cardNumber, setCardNumber] = useState('') - const [cardExpiry, setCardExpiry] = useState('') - const [cardCvv, setCardCvv] = useState('') - const [cardName, setCardName] = useState('') + const [bookingError, setBookingError] = useState(null) + // Guest lookup + const [guestLookupTimer, setGuestLookupTimer] = useState | null>(null) + const [guestSuggestion, setGuestSuggestion] = useState<{ first_name: string; last_name: string; middle_name?: string; phone?: string; email?: string } | null>(null) const nights = checkIn && checkOut ? 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 missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim()) if (missing.length > 0) return + setBookingError(null) if (slug && selected) { // Real API call @@ -288,18 +289,23 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme if (result.confirmationUrl) { setConfirmUrl(result.confirmationUrl) setStep('payment') + } else if (settings.bankDetails) { + setStep('payment') + } else { + setStep('success') + } + } catch (err: any) { + if (err?.status === 403) { + setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.') } else { setStep('success') } - } catch { - // fallback — still show success in preview - setStep('success') } finally { setSubmitting(false) } } else { // Preview mode without real slug - if (needsPayment) { + if (needsPayment || settings.bankDetails) { setStep('payment') } else { setStep('success') @@ -309,9 +315,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme const handlePay = () => { if (confirmUrl) { - window.open(confirmUrl, '_blank') + window.location.href = confirmUrl + } else { + setStep('success') } - setStep('success') } const handleBack = () => { @@ -320,13 +327,18 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme setFormValues({}) setSelectedServices([]) 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 formatExpiry = (v: string) => { - const d = v.replace(/\D/g, '').slice(0, 4) - return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d + const doGuestLookup = (email?: string, phone?: string) => { + if (!slug || (!email && !phone)) return + if (guestLookupTimer) clearTimeout(guestLookupTimer) + 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 ── @@ -366,9 +378,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme // ── Payment screen ── if (step === 'payment') { - const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa' - : settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay' - : 'CloudPayments' + const isYookassa = !!confirmUrl return (
@@ -378,83 +388,64 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme

{settings.language === 'ru' ? 'Оплата' : 'Payment'}

-

{payLabel} · {grandTotal.toLocaleString('ru-RU')} ₽

+

{selectedName} · {grandTotal.toLocaleString('ru-RU')} ₽

- {/* Amount summary */} + {/* Amount */}
{selectedName} · {nights} ноч. {grandTotal.toLocaleString('ru-RU')} ₽
- {/* Card form */} -
-
- - setCardNumber(formatCard(e.target.value))} - maxLength={19} - /> -
-
-
- - setCardExpiry(formatExpiry(e.target.value))} - maxLength={5} - /> + {isYookassa ? ( + /* YooKassa redirect */ +
+
+
-
- - setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 3))} - maxLength={3} - /> +

+ {settings.language === 'ru' ? 'Для оплаты вы будете перенаправлены на страницу ЮKassa' : 'You will be redirected to YooKassa to complete payment'} +

+

🔒 Защищённое соединение · ЮKassa

+
+ ) : ( + /* Bank transfer details */ +
+

+ {settings.language === 'ru' ? 'Реквизиты для оплаты' : 'Payment details'} +

+
+ {settings.bankDetails || 'Реквизиты не указаны'}
+

+ {settings.language === 'ru' + ? 'После перевода отель подтвердит бронирование. Укажите в назначении платежа вашу фамилию и даты.' + : 'After transfer, the hotel will confirm your booking. Include your name and dates in the payment reference.'} +

-
- - setCardName(e.target.value.toUpperCase())} - /> -
-
- -

- 🔒 Платёж защищён 3-D Secure · {payLabel} -

+ )}
- + {isYookassa ? ( + + ) : ( + + )}
) @@ -615,14 +606,51 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme type={field.type} className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none" 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) + }} /> )}
))} + + {/* Guest suggestion */} + {guestSuggestion && ( +
+
+ Гость найден: + {guestSuggestion.last_name} {guestSuggestion.first_name} {guestSuggestion.middle_name ?? ''} +
+ +
+ )}
+ {bookingError && ( +
+ +

{bookingError}

+
+ )} - {!needsPayment && ( + {!needsPayment && !settings.bankDetails && (

Оплата на месте при заезде

)}
@@ -1122,6 +1150,7 @@ export function BookingWidgetPage() { 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, allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren, + bankDetails: String((hs as any).widgetBankDetails ?? prev.bankDetails ?? ''), })) setSettingsReady(true) }).finally(() => setGatewayLoading(false)) @@ -1139,6 +1168,7 @@ export function BookingWidgetPage() { showPromo: true, allowExtraBeds: true, allowChildren: true, + bankDetails: '', formFields: DEFAULT_FORM_FIELDS, additionalServices: DEFAULT_SERVICES, }) @@ -1168,6 +1198,7 @@ export function BookingWidgetPage() { widget_show_promo: s.showPromo, widget_extra_beds: s.allowExtraBeds, widget_children: s.allowChildren, + widget_bank_details: s.bankDetails, }) setSaved(true) setTimeout(() => setSaved(false), 2000) @@ -1507,6 +1538,24 @@ export function BookingWidgetPage() { )} + {/* Bank transfer details */} +
+
+ +

Реквизиты для оплаты переводом

+
+

+ Если ЮKassa не подключена, гость увидит эти реквизиты после бронирования. Оставьте пустым если оплата на месте. +

+