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:
poduck
2025-12-02 01:42:38 -05:00
parent 8038f67183
commit 05ebd0f2bb
77 changed files with 14185 additions and 1394 deletions

View 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;