- Add locked state to Plugins sidebar item with plan feature check - Create Branding section in settings with Appearance, Email Templates, Custom Domains - Split Domains page into Booking (URLs, redirects) and Custom Domains (BYOD, purchase) - Add booking_return_url field to Tenant model for customer redirects - Update SidebarItem component to support locked prop with lock icon - Move Email Templates from main sidebar to Settings > Branding - Add communication credits hooks and payment form updates - Add timezone fields migration and various UI improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
/**
|
|
* Branding Settings Page
|
|
*
|
|
* Logo uploads, colors, and display preferences.
|
|
* Features live preview of color changes that revert on navigation/reload if not saved.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useOutletContext } from 'react-router-dom';
|
|
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
|
|
import { Business, User } from '../../types';
|
|
import { applyBrandColors } from '../../utils/colorUtils';
|
|
import { UpgradePrompt } from '../../components/UpgradePrompt';
|
|
import { FeatureKey } from '../../hooks/usePlanFeatures';
|
|
|
|
// Color palette options
|
|
const colorPalettes = [
|
|
{ name: 'Ocean Blue', primary: '#2563eb', secondary: '#0ea5e9' },
|
|
{ name: 'Sky Blue', primary: '#0ea5e9', secondary: '#38bdf8' },
|
|
{ name: 'Mint Green', primary: '#10b981', secondary: '#34d399' },
|
|
{ name: 'Coral Reef', primary: '#f97316', secondary: '#fb923c' },
|
|
{ name: 'Lavender', primary: '#a78bfa', secondary: '#c4b5fd' },
|
|
{ name: 'Rose Pink', primary: '#ec4899', secondary: '#f472b6' },
|
|
{ name: 'Forest Green', primary: '#059669', secondary: '#10b981' },
|
|
{ name: 'Royal Purple', primary: '#7c3aed', secondary: '#a78bfa' },
|
|
{ name: 'Slate Gray', primary: '#475569', secondary: '#64748b' },
|
|
{ name: 'Crimson Red', primary: '#dc2626', secondary: '#ef4444' },
|
|
];
|
|
|
|
const BrandingSettings: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { business, updateBusiness, user, isFeatureLocked, lockedFeature } = useOutletContext<{
|
|
business: Business;
|
|
updateBusiness: (updates: Partial<Business>) => void;
|
|
user: User;
|
|
isFeatureLocked?: boolean;
|
|
lockedFeature?: FeatureKey;
|
|
}>();
|
|
|
|
const [formState, setFormState] = useState({
|
|
logoUrl: business.logoUrl,
|
|
emailLogoUrl: business.emailLogoUrl,
|
|
logoDisplayMode: business.logoDisplayMode || 'text-only',
|
|
primaryColor: business.primaryColor,
|
|
secondaryColor: business.secondaryColor || business.primaryColor,
|
|
});
|
|
const [showToast, setShowToast] = useState(false);
|
|
|
|
// Store the original saved colors to restore on unmount/navigation
|
|
const savedColorsRef = useRef({
|
|
primary: business.primaryColor,
|
|
secondary: business.secondaryColor || business.primaryColor,
|
|
});
|
|
|
|
// Live preview: Update CSS variables as user cycles through palettes
|
|
useEffect(() => {
|
|
applyBrandColors(formState.primaryColor, formState.secondaryColor);
|
|
|
|
// Cleanup: Restore saved colors when component unmounts (navigation away)
|
|
return () => {
|
|
applyBrandColors(savedColorsRef.current.primary, savedColorsRef.current.secondary);
|
|
};
|
|
}, [formState.primaryColor, formState.secondaryColor]);
|
|
|
|
// Update savedColorsRef when business data changes (after successful save)
|
|
useEffect(() => {
|
|
savedColorsRef.current = {
|
|
primary: business.primaryColor,
|
|
secondary: business.secondaryColor || business.primaryColor,
|
|
};
|
|
}, [business.primaryColor, business.secondaryColor]);
|
|
|
|
const handleSave = async () => {
|
|
await updateBusiness(formState);
|
|
// Update the saved reference so cleanup doesn't revert
|
|
savedColorsRef.current = {
|
|
primary: formState.primaryColor,
|
|
secondary: formState.secondaryColor,
|
|
};
|
|
setShowToast(true);
|
|
setTimeout(() => setShowToast(false), 3000);
|
|
};
|
|
|
|
const selectPalette = (primary: string, secondary: string) => {
|
|
setFormState(prev => ({ ...prev, primaryColor: primary, secondaryColor: secondary }));
|
|
};
|
|
|
|
const isOwner = user.role === 'owner';
|
|
|
|
if (!isOwner) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
Only the business owner can access these settings.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show upgrade prompt if feature is locked
|
|
if (isFeatureLocked && lockedFeature) {
|
|
return <UpgradePrompt feature={lockedFeature} />;
|
|
}
|
|
|
|
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">
|
|
<Palette className="text-purple-500" />
|
|
{t('settings.branding.title', 'Branding')}
|
|
</h2>
|
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
|
Customize your business appearance with logos and colors.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Logo Section */}
|
|
<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">
|
|
Brand Logos
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
|
Upload your logos for different purposes. PNG with transparent background recommended.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Website Logo */}
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Website Logo</h5>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
Used in sidebar and customer-facing pages. Recommended: 500x500px
|
|
</p>
|
|
<div className="flex items-center gap-4">
|
|
{formState.logoUrl ? (
|
|
<div className="relative">
|
|
<img
|
|
src={formState.logoUrl}
|
|
alt="Logo"
|
|
className="w-20 h-20 object-contain border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2"
|
|
/>
|
|
<button
|
|
onClick={() => setFormState(prev => ({ ...prev, logoUrl: undefined }))}
|
|
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="w-20 h-20 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-gray-400">
|
|
<ImageIcon size={24} />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<input
|
|
type="file"
|
|
id="logo-upload"
|
|
className="hidden"
|
|
accept="image/png,image/jpeg,image/svg+xml"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor="logo-upload"
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer text-sm font-medium"
|
|
>
|
|
<Upload size={16} />
|
|
{formState.logoUrl ? 'Change' : 'Upload'}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Display Mode */}
|
|
<div className="mt-4">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Display Mode
|
|
</label>
|
|
<select
|
|
value={formState.logoDisplayMode}
|
|
onChange={(e) => setFormState(prev => ({ ...prev, logoDisplayMode: e.target.value as any }))}
|
|
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"
|
|
>
|
|
<option value="text-only">Text Only</option>
|
|
<option value="logo-only">Logo Only</option>
|
|
<option value="logo-and-text">Logo and Text</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Email Logo */}
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Email Logo</h5>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
Used in email notifications. Recommended: 600x200px wide
|
|
</p>
|
|
<div className="flex items-center gap-4">
|
|
{formState.emailLogoUrl ? (
|
|
<div className="relative">
|
|
<img
|
|
src={formState.emailLogoUrl}
|
|
alt="Email Logo"
|
|
className="w-32 h-12 object-contain border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2"
|
|
/>
|
|
<button
|
|
onClick={() => setFormState(prev => ({ ...prev, emailLogoUrl: undefined }))}
|
|
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="w-32 h-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-gray-400">
|
|
<ImageIcon size={20} />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<input
|
|
type="file"
|
|
id="email-logo-upload"
|
|
className="hidden"
|
|
accept="image/png,image/jpeg,image/svg+xml"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor="email-logo-upload"
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer text-sm font-medium"
|
|
>
|
|
<Upload size={16} />
|
|
{formState.emailLogoUrl ? 'Change' : 'Upload'}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Colors Section */}
|
|
<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">
|
|
Brand Colors
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
|
Choose a color palette or customize your own colors.
|
|
</p>
|
|
|
|
{/* Palette Grid */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
|
|
{colorPalettes.map((palette) => (
|
|
<button
|
|
key={palette.name}
|
|
onClick={() => selectPalette(palette.primary, palette.secondary)}
|
|
className={`p-3 rounded-lg border-2 transition-all ${
|
|
formState.primaryColor === palette.primary && formState.secondaryColor === palette.secondary
|
|
? 'border-gray-900 dark:border-white ring-2 ring-offset-2 ring-gray-900 dark:ring-white'
|
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
<div
|
|
className="h-8 rounded-md mb-2"
|
|
style={{ background: `linear-gradient(to right, ${palette.primary}, ${palette.secondary})` }}
|
|
/>
|
|
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 text-center truncate">
|
|
{palette.name}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Custom Colors */}
|
|
<div className="flex items-center gap-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Primary Color
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={formState.primaryColor}
|
|
onChange={(e) => setFormState(prev => ({ ...prev, primaryColor: e.target.value }))}
|
|
className="w-10 h-10 rounded cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={formState.primaryColor}
|
|
onChange={(e) => setFormState(prev => ({ ...prev, primaryColor: e.target.value }))}
|
|
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Secondary Color
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={formState.secondaryColor}
|
|
onChange={(e) => setFormState(prev => ({ ...prev, secondaryColor: e.target.value }))}
|
|
className="w-10 h-10 rounded cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={formState.secondaryColor}
|
|
onChange={(e) => setFormState(prev => ({ ...prev, secondaryColor: e.target.value }))}
|
|
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Preview
|
|
</label>
|
|
<div
|
|
className="h-10 rounded-lg"
|
|
style={{ background: `linear-gradient(to right, ${formState.primaryColor}, ${formState.secondaryColor})` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Save Button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleSave}
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
<Save size={18} />
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
|
|
{/* Toast */}
|
|
{showToast && (
|
|
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
|
|
<Check size={18} />
|
|
Changes saved successfully
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BrandingSettings;
|