feat: Email templates, bulk delete, communication credits, plan features
- Add email template presets for Browse Templates tab (12 templates) - Add bulk selection and deletion for My Templates tab - Add communication credits system with Twilio integration - Add payment views for credit purchases and auto-reload - Add SMS reminder and masked calling plan permissions - Fix appointment status mapping (frontend/backend mismatch) - Clear masquerade stack on login/logout for session hygiene - Update platform settings with credit configuration - Add new migrations for Twilio and Stripe payment fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
321
frontend/src/pages/settings/BrandingSettings.tsx
Normal file
321
frontend/src/pages/settings/BrandingSettings.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Branding Settings Page
|
||||
*
|
||||
* Logo uploads, colors, and display preferences.
|
||||
*/
|
||||
|
||||
import React, { useState } 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';
|
||||
|
||||
// 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 } = useOutletContext<{
|
||||
business: Business;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
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);
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateBusiness(formState);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user