feat: widget iframe embed — standalone /:slug/book page
This commit is contained in:
@@ -51,6 +51,7 @@ import { PaymentSettingsPage } from './pages/PaymentSettingsPage'
|
|||||||
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
||||||
import { DepositHistoryPage } from './pages/DepositHistoryPage'
|
import { DepositHistoryPage } from './pages/DepositHistoryPage'
|
||||||
import { PayDepositPage } from './pages/PayDepositPage'
|
import { PayDepositPage } from './pages/PayDepositPage'
|
||||||
|
import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage'
|
||||||
import { ModuleGuard } from './components/ModuleGuard'
|
import { ModuleGuard } from './components/ModuleGuard'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
||||||
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
||||||
<Route path="/:slug/pay" element={<PayDepositPage />} />
|
<Route path="/:slug/pay" element={<PayDepositPage />} />
|
||||||
|
<Route path="/:slug/book" element={<BookingWidgetStandalonePage />} />
|
||||||
|
|
||||||
{/* PMS routes */}
|
{/* PMS routes */}
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { api, type WidgetRoom, type WidgetCategory, type PaymentGateway } from '
|
|||||||
|
|
||||||
// ── Widget settings type ───────────────────────────────────────────────────────
|
// ── Widget settings type ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FormField {
|
export interface FormField {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
type: 'text' | 'email' | 'phone' | 'textarea' | 'select'
|
type: 'text' | 'email' | 'phone' | 'textarea' | 'select'
|
||||||
@@ -23,7 +23,7 @@ interface FormField {
|
|||||||
isCustom?: boolean
|
isCustom?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdditionalService {
|
export interface AdditionalService {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -32,7 +32,7 @@ interface AdditionalService {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WidgetSettings {
|
export interface WidgetSettings {
|
||||||
hotelName: string
|
hotelName: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
language: 'ru' | 'en'
|
language: 'ru' | 'en'
|
||||||
@@ -170,7 +170,7 @@ const AMENITY_ICONS: Record<string, React.ElementType> = {
|
|||||||
|
|
||||||
const EXTRA_BED_PRICE = 1500
|
const EXTRA_BED_PRICE = 1500
|
||||||
|
|
||||||
const DEFAULT_FORM_FIELDS: FormField[] = [
|
export const DEFAULT_FORM_FIELDS: FormField[] = [
|
||||||
{ id: 'name', label: 'Имя и фамилия', type: 'text', required: true, enabled: true },
|
{ id: 'name', label: 'Имя и фамилия', type: 'text', required: true, enabled: true },
|
||||||
{ id: 'email', label: 'Email', type: 'email', required: true, enabled: true },
|
{ id: 'email', label: 'Email', type: 'email', required: true, enabled: true },
|
||||||
{ id: 'phone', label: 'Телефон', type: 'phone', required: true, enabled: true },
|
{ id: 'phone', label: 'Телефон', type: 'phone', required: true, enabled: true },
|
||||||
@@ -178,7 +178,7 @@ const DEFAULT_FORM_FIELDS: FormField[] = [
|
|||||||
{ id: 'arrival', label: 'Время заезда', type: 'select', required: false, enabled: false },
|
{ id: 'arrival', label: 'Время заезда', type: 'select', required: false, enabled: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEFAULT_SERVICES: AdditionalService[] = [
|
export const DEFAULT_SERVICES: AdditionalService[] = [
|
||||||
{ id: 's1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч', enabled: true },
|
{ id: 's1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч', enabled: true },
|
||||||
{ id: 's2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч', enabled: true },
|
{ id: 's2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч', enabled: true },
|
||||||
{ id: 's3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день',enabled: false },
|
{ id: 's3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день',enabled: false },
|
||||||
@@ -187,7 +187,7 @@ const DEFAULT_SERVICES: AdditionalService[] = [
|
|||||||
|
|
||||||
// ── Widget Preview Component ───────────────────────────────────────────────────
|
// ── Widget Preview Component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: {
|
export function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: {
|
||||||
settings: WidgetSettings
|
settings: WidgetSettings
|
||||||
slug?: string
|
slug?: string
|
||||||
realRooms?: WidgetRoom[]
|
realRooms?: WidgetRoom[]
|
||||||
@@ -1170,14 +1170,17 @@ export function BookingWidgetPage() {
|
|||||||
formFields: prev.formFields.filter(f => f.id !== id),
|
formFields: prev.formFields.filter(f => f.id !== id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const snippet = `<script src="https://widget.hotelsync.ru/v1/embed.js"
|
const widgetUrl = `https://app.hotelsync.ru/${slug || 'grand-palace'}/book?color=${encodeURIComponent(settings.primaryColor)}&lang=${settings.language}&mode=${settings.roomDisplayMode}&min-nights=${settings.minNights}&rental=${settings.showRental}&promo=${settings.showPromo}&extra-beds=${settings.allowExtraBeds}&children=${settings.allowChildren}`
|
||||||
data-hotel="grand-palace"
|
|
||||||
data-color="${settings.primaryColor}"
|
const snippet = `<iframe
|
||||||
data-lang="${settings.language}"
|
src="${widgetUrl}"
|
||||||
data-rental="${settings.showRental}"
|
width="100%"
|
||||||
data-min-nights="${settings.minNights}"
|
height="680"
|
||||||
data-payment="${settings.paymentProvider}">
|
frameborder="0"
|
||||||
</script>`
|
style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);"
|
||||||
|
allow="payment"
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>`
|
||||||
|
|
||||||
const copyCode = () => {
|
const copyCode = () => {
|
||||||
navigator.clipboard.writeText(snippet).catch(() => {})
|
navigator.clipboard.writeText(snippet).catch(() => {})
|
||||||
|
|||||||
66
src/pages/BookingWidgetStandalonePage.tsx
Normal file
66
src/pages/BookingWidgetStandalonePage.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { api, type WidgetRoom, type WidgetCategory } from '../lib/api'
|
||||||
|
import { WidgetPreview, DEFAULT_FORM_FIELDS, DEFAULT_SERVICES } from './BookingWidgetPage'
|
||||||
|
import type { WidgetSettings } from './BookingWidgetPage'
|
||||||
|
|
||||||
|
export function BookingWidgetStandalonePage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const color = searchParams.get('color') ?? '#4F46E5'
|
||||||
|
const lang = (searchParams.get('lang') ?? 'ru') as 'ru' | 'en'
|
||||||
|
const mode = (searchParams.get('mode') ?? 'rooms') as 'rooms' | 'categories'
|
||||||
|
const minNights = parseInt(searchParams.get('min-nights') ?? '1') || 1
|
||||||
|
const showRental = searchParams.get('rental') === 'true'
|
||||||
|
const showPromo = searchParams.get('promo') !== 'false'
|
||||||
|
const extraBeds = searchParams.get('extra-beds') !== 'false'
|
||||||
|
const children = searchParams.get('children') !== 'false'
|
||||||
|
|
||||||
|
const [realRooms, setRealRooms] = useState<WidgetRoom[]>([])
|
||||||
|
const [realCategories, setRealCategories] = useState<WidgetCategory[]>([])
|
||||||
|
const [hotelName, setHotelName] = useState('')
|
||||||
|
const [paymentEnabled, setPaymentEnabled] = useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
api.widget.getConfig(slug)
|
||||||
|
.then(config => {
|
||||||
|
setRealRooms(config.rooms ?? [])
|
||||||
|
setRealCategories(config.categories ?? [])
|
||||||
|
setHotelName(config.hotelName ?? '')
|
||||||
|
setPaymentEnabled(config.paymentEnabled)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const settings: WidgetSettings = {
|
||||||
|
hotelName,
|
||||||
|
primaryColor: color,
|
||||||
|
language: lang,
|
||||||
|
showRooms: true,
|
||||||
|
showRental,
|
||||||
|
roomDisplayMode: mode,
|
||||||
|
minNights,
|
||||||
|
paymentProvider: 'yukassa',
|
||||||
|
showPromo,
|
||||||
|
allowExtraBeds: extraBeds,
|
||||||
|
allowChildren: children,
|
||||||
|
formFields: DEFAULT_FORM_FIELDS,
|
||||||
|
additionalServices: DEFAULT_SERVICES,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex items-start justify-center py-6 px-3">
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<WidgetPreview
|
||||||
|
settings={settings}
|
||||||
|
slug={slug}
|
||||||
|
realRooms={realRooms.length > 0 ? realRooms : undefined}
|
||||||
|
realCategories={realCategories.length > 0 ? realCategories : undefined}
|
||||||
|
paymentEnabled={paymentEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user