feat: widget code tab — 3 install modes + width customization + better expand button

1. Expand button now shows "X фото / Детали ▾" and "Скрыть ▴" instead of bare chevron
2. Code tab: 3 install modes
   - Встроенный (iframe) — width presets 400/500/700px/full-width + custom input
   - Плавающая кнопка — floating button script with custom text, color from settings
   - По классу — script that opens modal on click of .hotelsync-book (or custom class)
3. Floating/trigger modes share modal logic, generate ready-to-paste <script> tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 00:39:27 +03:00
parent 66cab853f1
commit 60346f3580

View File

@@ -1034,9 +1034,13 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, realR
{/* Expand toggle */} {/* Expand toggle */}
<button <button
onClick={e => { e.stopPropagation(); setExpandedRoom(isExpanded ? null : room.id) }} onClick={e => { e.stopPropagation(); setExpandedRoom(isExpanded ? null : room.id) }}
className="p-1 rounded-lg hover:bg-slate-100 text-slate-400 shrink-0 transition-colors" className="flex items-center gap-0.5 text-[11px] font-medium text-slate-500 hover:text-brand-600 transition-colors shrink-0 px-1.5 py-1 rounded-lg hover:bg-slate-50"
> >
{isExpanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />} {isExpanded ? (
<>Скрыть <ChevronUp size={12} /></>
) : (
<>{hasRealPhotos ? `${room.photoUrls.length} фото` : 'Детали'} <ChevronDown size={12} /></>
)}
</button> </button>
</div> </div>
@@ -1375,6 +1379,10 @@ export function BookingWidgetPage() {
}) })
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [codeMode, setCodeMode] = useState<'iframe' | 'floating' | 'trigger'>('iframe')
const [widgetMaxWidth, setWidgetMaxWidth] = useState<string>('500px')
const [triggerClass, setTriggerClass] = useState('hotelsync-book')
const [floatBtnText, setFloatBtnText] = useState('Забронировать')
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [settingsReady, setSettingsReady] = useState(false) const [settingsReady, setSettingsReady] = useState(false)
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor') const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
@@ -1489,18 +1497,48 @@ export function BookingWidgetPage() {
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}` 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}`
const snippet = `<iframe const iframeSnippet = widgetMaxWidth === '100%'
src="${widgetUrl}" ? `<iframe\n src="${widgetUrl}"\n width="100%"\n height="680"\n frameborder="0"\n style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);display:block;"\n allow="payment"\n loading="lazy"\n></iframe>`
width="100%" : `<div style="max-width:${widgetMaxWidth};margin:0 auto;">\n <iframe\n src="${widgetUrl}"\n width="100%"\n height="680"\n frameborder="0"\n style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);"\n allow="payment"\n loading="lazy"\n ></iframe>\n</div>`
height="680"
frameborder="0" const modalScript = `(function(){
style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);" var W='${widgetUrl}';
allow="payment" var m=document.createElement('div');
loading="lazy" m.id='hs-modal';
></iframe>` m.style.cssText='display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:99998;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;';
m.innerHTML='<div style="position:relative;width:100%;max-width:500px;max-height:90vh;overflow-y:auto;border-radius:16px;">'
+'<button onclick="document.getElementById(\\'hs-modal\\').style.display=\\'none\\'" style="position:absolute;top:8px;right:8px;z-index:1;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.5);color:#fff;border:none;cursor:pointer;font-size:20px;line-height:1;display:flex;align-items:center;justify-content:center;">×</button>'
+'<iframe src="'+W+'" style="width:100%;height:680px;border:none;border-radius:16px;display:block;" allow="payment"></iframe>'
+'</div>';
m.addEventListener('click',function(e){if(e.target===m)m.style.display='none';});
document.body.appendChild(m);
window.hsOpenWidget=function(){m.style.display='flex';};
})()`
const floatingSnippet = `<script>
${modalScript}
;(function(){
var b=document.createElement('button');
b.textContent='${floatBtnText}';
b.style.cssText='position:fixed;bottom:24px;right:24px;background:${settings.primaryColor};color:#fff;border:none;padding:14px 24px;border-radius:50px;font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 4px 20px rgba(0,0,0,.25);z-index:99997;transition:transform .15s,box-shadow .15s;';
b.onmouseenter=function(){b.style.transform='scale(1.05)';b.style.boxShadow='0 6px 28px rgba(0,0,0,.35)';};
b.onmouseleave=function(){b.style.transform='scale(1)';b.style.boxShadow='0 4px 20px rgba(0,0,0,.25)';};
b.onclick=function(){window.hsOpenWidget();};
document.body.appendChild(b);
})();
</script>`
const triggerSnippet = `<script>
${modalScript}
;document.addEventListener('click',function(e){
if(e.target.closest('.${triggerClass}')){e.preventDefault();window.hsOpenWidget();}
});
</script>`
const activeSnippet = codeMode === 'iframe' ? iframeSnippet : codeMode === 'floating' ? floatingSnippet : triggerSnippet
const copyCode = () => { const copyCode = () => {
navigator.clipboard.writeText(snippet).catch(() => {}) navigator.clipboard.writeText(activeSnippet).catch(() => {})
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
@@ -1854,11 +1892,124 @@ export function BookingWidgetPage() {
{/* ── Code ── */} {/* ── Code ── */}
{pageTab === 'code' && ( {pageTab === 'code' && (
<div className="max-w-2xl space-y-5"> <div className="max-w-2xl space-y-5">
{/* Install mode selector */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Способ установки</h3>
<div className="grid grid-cols-3 gap-3">
{([
{ key: 'iframe', icon: Code2, title: 'Встроенный', desc: 'Вставить блоком на страницу' },
{ key: 'floating', icon: Zap, title: 'Плавающая кнопка', desc: 'Кнопка в углу сайта' },
{ key: 'trigger', icon: Scan, title: 'По классу', desc: 'Клик на любой элемент сайта' },
] as const).map(m => (
<button
key={m.key}
onClick={() => setCodeMode(m.key)}
className={cn(
'flex flex-col items-center gap-2 p-3 rounded-xl border-2 text-center transition-all',
codeMode === m.key
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/10'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
)}
>
<m.icon size={18} className={codeMode === m.key ? 'text-brand-600' : 'text-slate-400'} />
<div>
<p className={cn('text-xs font-semibold', codeMode === m.key ? 'text-brand-700 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300')}>{m.title}</p>
<p className="text-[10px] text-slate-400 mt-0.5 leading-tight">{m.desc}</p>
</div>
</button>
))}
</div>
</div>
{/* Options per mode */}
{codeMode === 'iframe' && (
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Ширина виджета</h3>
<div className="flex flex-wrap gap-2">
{[
{ label: 'Узкий (400px)', value: '400px' },
{ label: 'Стандарт (500px)', value: '500px' },
{ label: 'Широкий (700px)', value: '700px' },
{ label: 'Во всю ширину', value: '100%' },
].map(opt => (
<button
key={opt.value}
onClick={() => setWidgetMaxWidth(opt.value)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
widgetMaxWidth === opt.value
? 'bg-brand-600 text-white border-brand-600'
: 'border-slate-200 text-slate-600 hover:border-brand-400 dark:border-slate-600 dark:text-slate-300',
)}
>
{opt.label}
</button>
))}
<input
value={widgetMaxWidth}
onChange={e => setWidgetMaxWidth(e.target.value)}
className="w-24 text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1.5 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300"
placeholder="700px"
/>
</div>
<p className="text-[11px] text-slate-400">Виджет автоматически отцентрируется на странице</p>
</div>
)}
{codeMode === 'floating' && (
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Настройки кнопки</h3>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-xs text-slate-500 mb-1 block">Текст кнопки</label>
<input
value={floatBtnText}
onChange={e => setFloatBtnText(e.target.value)}
className="w-full text-sm border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-1.5 bg-white dark:bg-slate-800"
placeholder="Забронировать"
/>
</div>
<div className="shrink-0 mt-4">
<button
className="px-4 py-2 rounded-full text-sm font-semibold text-white shadow-lg"
style={{ background: settings.primaryColor }}
>
{floatBtnText || 'Забронировать'}
</button>
</div>
</div>
<p className="text-[11px] text-slate-400">Кнопка появится в правом нижнем углу сайта. Цвет берётся из настроек виджета.</p>
</div>
)}
{codeMode === 'trigger' && (
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">CSS-класс триггера</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">.</span>
<input
value={triggerClass}
onChange={e => setTriggerClass(e.target.value.replace(/[^a-z0-9-_]/gi, ''))}
className="flex-1 text-sm border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-1.5 bg-white dark:bg-slate-800 font-mono"
placeholder="hotelsync-book"
/>
</div>
<p className="text-[11px] text-slate-400">
Добавьте этот класс любой кнопке или ссылке на сайте при клике откроется виджет:
</p>
<pre className="bg-slate-900 text-emerald-400 text-xs rounded-lg p-3 font-mono">{`<button class="${triggerClass || 'hotelsync-book'}">Забронировать номер</button>`}</pre>
</div>
)}
{/* Code block */}
<div className="card p-5 space-y-3"> <div className="card p-5 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Code2 size={16} className="text-indigo-500" /> <Code2 size={16} className="text-indigo-500" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Код для вставки на сайт</h3> <h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">
{codeMode === 'iframe' ? 'Код для вставки' : codeMode === 'floating' ? 'Скрипт плавающей кнопки' : 'Скрипт триггера по классу'}
</h3>
</div> </div>
<button <button
onClick={copyCode} onClick={copyCode}
@@ -1868,31 +2019,21 @@ export function BookingWidgetPage() {
{copied ? 'Скопировано' : 'Копировать'} {copied ? 'Скопировано' : 'Копировать'}
</button> </button>
</div> </div>
<pre className="bg-slate-900 text-emerald-400 text-xs rounded-xl p-4 overflow-x-auto font-mono leading-relaxed"> <pre className="bg-slate-900 text-emerald-400 text-xs rounded-xl p-4 overflow-x-auto font-mono leading-relaxed whitespace-pre-wrap break-all">
{snippet} {activeSnippet}
</pre> </pre>
</div> <p className="text-[11px] text-slate-400">
{codeMode === 'iframe'
<div className="card p-5 space-y-3"> ? 'Вставьте в любое место страницы где должен отображаться виджет.'
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Инструкция по установке</h3> : 'Вставьте перед закрывающим тегом </body>.'}
{[ </p>
{ step: '1', text: 'Скопируйте код выше' },
{ step: '2', text: 'Вставьте перед закрывающим тегом </body> на страницах, где хотите показать виджет' },
{ step: '3', text: 'Виджет автоматически добавит кнопку "Забронировать" или встроится в нужный блок' },
{ step: '4', text: 'Настройте цвета и параметры в конструкторе — изменения применяются сразу' },
].map(s => (
<div key={s.step} className="flex items-start gap-3">
<span className="w-6 h-6 rounded-full bg-indigo-600 text-white text-xs font-bold flex items-center justify-center shrink-0">{s.step}</span>
<p className="text-sm text-slate-600 dark:text-slate-400 pt-0.5">{s.text}</p>
</div>
))}
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ icon: CalendarCheck2, title: 'Мгновенное подтверждение', desc: 'Email + SMS гостю' }, { icon: CalendarCheck2, title: 'Мгновенное подтверждение', desc: 'Email гостю' },
{ icon: CreditCard, title: 'Онлайн-оплата', desc: 'ЮKassa, Тинькофф, СБП' }, { icon: CreditCard, title: 'Онлайн-оплата', desc: 'ЮKassa, Тинькофф, СБП' },
{ icon: Globe, title: 'Без комиссии OTA', desc: 'Только платёжная комиссия ~1.5%' }, { icon: Globe, title: 'Без комиссии OTA', desc: 'Только ~1.5% платёжной комиссии' },
].map(f => ( ].map(f => (
<div key={f.title} className="card p-4 text-center"> <div key={f.title} className="card p-4 text-center">
<f.icon size={22} className="text-indigo-500 mx-auto mb-2" /> <f.icon size={22} className="text-indigo-500 mx-auto mb-2" />