Files
smoothschedule/frontend/src/pages/settings/BrandingSettings.tsx
poduck 5cef01ad0d feat: Reorganize settings sidebar and add plan-based feature locking
- 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>
2025-12-03 01:35:59 -05:00

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;