- Rename white_label feature to remove_branding across frontend/backend - Update billing catalog, plan features, and permission checks - Add dark mode support to Recharts tooltips with useDarkMode hook - Create embeddable booking widget with EmbedBooking page - Add EmbedWidgetSettings for generating embed code - Fix Appearance settings page permission check - Update test files for new feature naming - Add notes field to User model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
/**
|
|
* Embed Widget Settings Page
|
|
*
|
|
* Generate embeddable booking widget code for third-party websites.
|
|
*/
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useOutletContext } from 'react-router-dom';
|
|
import {
|
|
Code2,
|
|
Copy,
|
|
CheckCircle,
|
|
Settings2,
|
|
Palette,
|
|
ExternalLink,
|
|
AlertCircle,
|
|
Info,
|
|
} from 'lucide-react';
|
|
import { Business, User } from '../../types';
|
|
|
|
const EmbedWidgetSettings: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { business, user } = useOutletContext<{
|
|
business: Business;
|
|
user: User;
|
|
updateBusiness: (updates: Partial<Business>) => void;
|
|
}>();
|
|
|
|
// Configuration state
|
|
const [showPrices, setShowPrices] = useState(true);
|
|
const [showDuration, setShowDuration] = useState(true);
|
|
const [hideDeposits, setHideDeposits] = useState(false);
|
|
const [primaryColor, setPrimaryColor] = useState('#6366f1');
|
|
const [width, setWidth] = useState('100%');
|
|
const [height, setHeight] = useState('600');
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const isOwner = user.role === 'owner';
|
|
|
|
// Build the embed URL
|
|
const embedUrl = useMemo(() => {
|
|
const baseUrl = `https://${business.subdomain}.smoothschedule.com/embed`;
|
|
const params = new URLSearchParams();
|
|
|
|
if (!showPrices) params.set('prices', 'false');
|
|
if (!showDuration) params.set('duration', 'false');
|
|
if (hideDeposits) params.set('hideDeposits', 'true');
|
|
if (primaryColor !== '#6366f1') params.set('color', primaryColor.replace('#', ''));
|
|
|
|
const queryString = params.toString();
|
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
|
}, [business.subdomain, showPrices, showDuration, hideDeposits, primaryColor]);
|
|
|
|
// Generate the embed code
|
|
const embedCode = useMemo(() => {
|
|
return `<!-- SmoothSchedule Booking Widget -->
|
|
<iframe
|
|
src="${embedUrl}"
|
|
width="${width}"
|
|
height="${height}"
|
|
frameborder="0"
|
|
style="border: none; border-radius: 8px;"
|
|
allow="payment"
|
|
title="Book an appointment with ${business.name}"
|
|
></iframe>
|
|
<script>
|
|
// Optional: Auto-resize iframe based on content
|
|
window.addEventListener('message', function(e) {
|
|
if (e.data.type === 'smoothschedule-embed-height') {
|
|
var iframe = document.querySelector('iframe[src*="${business.subdomain}.smoothschedule.com/embed"]');
|
|
if (iframe) iframe.style.height = e.data.height + 'px';
|
|
}
|
|
});
|
|
</script>`;
|
|
}, [embedUrl, width, height, business.name, business.subdomain]);
|
|
|
|
// Simple embed code (without auto-resize)
|
|
const simpleEmbedCode = useMemo(() => {
|
|
return `<iframe src="${embedUrl}" width="${width}" height="${height}" frameborder="0" style="border: none; border-radius: 8px;"></iframe>`;
|
|
}, [embedUrl, width, height]);
|
|
|
|
const handleCopy = (code: string) => {
|
|
navigator.clipboard.writeText(code);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
if (!isOwner) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
{t('settings.embedWidget.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
|
<Code2 className="text-brand-500" />
|
|
{t('settings.embedWidget.title', 'Embed Widget')}
|
|
</h2>
|
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
|
{t('settings.embedWidget.description', 'Add a booking widget to your website or any third-party site')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Payment Notice */}
|
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
|
<div className="flex gap-3">
|
|
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-medium text-amber-800 dark:text-amber-200">
|
|
{t('settings.embedWidget.paymentNotice', 'Payment Handling')}
|
|
</h4>
|
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
|
{t('settings.embedWidget.paymentNoticeText', 'Services that require a deposit cannot be booked through the embedded widget due to payment security restrictions. Customers will be redirected to your main booking page for those services, or you can hide them from the widget entirely.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Configuration */}
|
|
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<Settings2 size={20} className="text-brand-500" />
|
|
{t('settings.embedWidget.configuration', 'Configuration')}
|
|
</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* Show Prices */}
|
|
<label className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('settings.embedWidget.showPrices', 'Show service prices')}
|
|
</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={showPrices}
|
|
onChange={(e) => setShowPrices(e.target.checked)}
|
|
className="w-4 h-4 text-brand-600 bg-gray-100 border-gray-300 rounded focus:ring-brand-500"
|
|
/>
|
|
</label>
|
|
|
|
{/* Show Duration */}
|
|
<label className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('settings.embedWidget.showDuration', 'Show service duration')}
|
|
</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={showDuration}
|
|
onChange={(e) => setShowDuration(e.target.checked)}
|
|
className="w-4 h-4 text-brand-600 bg-gray-100 border-gray-300 rounded focus:ring-brand-500"
|
|
/>
|
|
</label>
|
|
|
|
{/* Hide Deposits */}
|
|
<label className="flex items-center justify-between">
|
|
<div>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('settings.embedWidget.hideDeposits', 'Hide services requiring deposits')}
|
|
</span>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t('settings.embedWidget.hideDepositsHint', 'Only show services that can be booked without payment')}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
checked={hideDeposits}
|
|
onChange={(e) => setHideDeposits(e.target.checked)}
|
|
className="w-4 h-4 text-brand-600 bg-gray-100 border-gray-300 rounded focus:ring-brand-500"
|
|
/>
|
|
</label>
|
|
|
|
<hr className="border-gray-200 dark:border-gray-700" />
|
|
|
|
{/* Primary Color */}
|
|
<div>
|
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 mb-2">
|
|
<Palette size={16} />
|
|
{t('settings.embedWidget.primaryColor', 'Primary color')}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="color"
|
|
value={primaryColor}
|
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
|
className="w-10 h-10 rounded cursor-pointer border border-gray-300"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={primaryColor}
|
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm font-mono"
|
|
placeholder="#6366f1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<hr className="border-gray-200 dark:border-gray-700" />
|
|
|
|
{/* Dimensions */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('settings.embedWidget.width', 'Width')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={width}
|
|
onChange={(e) => setWidth(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
|
placeholder="100%"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('settings.embedWidget.height', 'Height (px)')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={height}
|
|
onChange={(e) => setHeight(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
|
placeholder="600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Preview */}
|
|
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<ExternalLink size={20} className="text-brand-500" />
|
|
{t('settings.embedWidget.preview', 'Preview')}
|
|
</h3>
|
|
|
|
<div className="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
|
|
<iframe
|
|
src={embedUrl.replace('https://', `${window.location.protocol}//`).replace('.smoothschedule.com', `.${window.location.host.split('.').slice(-2).join('.')}`)}
|
|
width="100%"
|
|
height="400"
|
|
frameBorder="0"
|
|
style={{ border: 'none', borderRadius: '8px' }}
|
|
title="Booking widget preview"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 flex justify-end">
|
|
<a
|
|
href={embedUrl.replace('https://', `${window.location.protocol}//`).replace('.smoothschedule.com', `.${window.location.host.split('.').slice(-2).join('.')}`)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
|
>
|
|
{t('settings.embedWidget.openInNewTab', 'Open in new tab')}
|
|
<ExternalLink size={14} />
|
|
</a>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Embed Code */}
|
|
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
|
<Code2 size={20} className="text-brand-500" />
|
|
{t('settings.embedWidget.embedCode', 'Embed Code')}
|
|
</h3>
|
|
|
|
{/* Simple Code */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t('settings.embedWidget.simpleCode', 'Simple (iframe only)')}
|
|
</label>
|
|
<button
|
|
onClick={() => handleCopy(simpleEmbedCode)}
|
|
className="inline-flex items-center gap-1.5 text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700"
|
|
>
|
|
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
|
|
{copied ? t('settings.embedWidget.copied', 'Copied!') : t('settings.embedWidget.copy', 'Copy')}
|
|
</button>
|
|
</div>
|
|
<div className="relative">
|
|
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto font-mono">
|
|
{simpleEmbedCode}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Full Code with auto-resize */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t('settings.embedWidget.fullCode', 'With auto-resize')}
|
|
</label>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
<Info size={12} />
|
|
{t('settings.embedWidget.recommended', 'Recommended')}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleCopy(embedCode)}
|
|
className="inline-flex items-center gap-1.5 text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700"
|
|
>
|
|
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
|
|
{copied ? t('settings.embedWidget.copied', 'Copied!') : t('settings.embedWidget.copy', 'Copy')}
|
|
</button>
|
|
</div>
|
|
<div className="relative">
|
|
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto font-mono whitespace-pre-wrap">
|
|
{embedCode}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Instructions */}
|
|
<section className="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
{t('settings.embedWidget.howToUse', 'How to Use')}
|
|
</h3>
|
|
<ol className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xs font-semibold">1</span>
|
|
<span>{t('settings.embedWidget.step1', 'Configure the widget options above to match your website\'s style.')}</span>
|
|
</li>
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xs font-semibold">2</span>
|
|
<span>{t('settings.embedWidget.step2', 'Copy the embed code and paste it into your website\'s HTML where you want the booking widget to appear.')}</span>
|
|
</li>
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 flex items-center justify-center text-xs font-semibold">3</span>
|
|
<span>{t('settings.embedWidget.step3', 'For platforms like WordPress, Squarespace, or Wix, look for an "HTML" or "Embed" block and paste the code there.')}</span>
|
|
</li>
|
|
</ol>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EmbedWidgetSettings;
|