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,339 @@
/**
* Credit Payment Form Component
*
* Uses Stripe Elements for secure card collection when purchasing
* communication credits.
*/
import React, { useState, useEffect } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
Elements,
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react';
import { useCreatePaymentIntent, useConfirmPayment } from '../hooks/useCommunicationCredits';
// Initialize Stripe
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
interface PaymentFormProps {
amountCents: number;
onSuccess: () => void;
onCancel: () => void;
savePaymentMethod?: boolean;
}
const PaymentFormInner: React.FC<PaymentFormProps> = ({
amountCents,
onSuccess,
onCancel,
savePaymentMethod = false,
}) => {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const confirmPayment = useConfirmPayment();
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
setIsProcessing(true);
setErrorMessage(null);
try {
// Confirm the payment with Stripe
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.href,
},
redirect: 'if_required',
});
if (error) {
setErrorMessage(error.message || 'Payment failed. Please try again.');
setIsProcessing(false);
return;
}
if (paymentIntent && paymentIntent.status === 'succeeded') {
// Confirm the payment on the backend
await confirmPayment.mutateAsync({
payment_intent_id: paymentIntent.id,
save_payment_method: savePaymentMethod,
});
setIsComplete(true);
setTimeout(() => {
onSuccess();
}, 1500);
}
} catch (err: any) {
setErrorMessage(err.message || 'An unexpected error occurred.');
setIsProcessing(false);
}
};
if (isComplete) {
return (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Payment Successful!
</h3>
<p className="text-gray-600 dark:text-gray-400">
{formatCurrency(amountCents)} has been added to your credits.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4">
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Amount</span>
<span className="text-xl font-bold text-gray-900 dark:text-white">
{formatCurrency(amountCents)}
</span>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="flex-1 py-2.5 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-700 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={!stripe || isProcessing}
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<CreditCard className="w-4 h-4" />
Pay {formatCurrency(amountCents)}
</>
)}
</button>
</div>
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
Your payment is securely processed by Stripe
</p>
</form>
);
};
interface CreditPaymentModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
amountCents: number;
onAmountChange: (cents: number) => void;
savePaymentMethod?: boolean;
}
export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
isOpen,
onClose,
onSuccess,
amountCents,
onAmountChange,
savePaymentMethod = false,
}) => {
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [isLoadingIntent, setIsLoadingIntent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPaymentForm, setShowPaymentForm] = useState(false);
const createPaymentIntent = useCreatePaymentIntent();
const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;
useEffect(() => {
if (!isOpen) {
setClientSecret(null);
setShowPaymentForm(false);
setError(null);
}
}, [isOpen]);
const handleContinueToPayment = async () => {
setIsLoadingIntent(true);
setError(null);
try {
const result = await createPaymentIntent.mutateAsync(amountCents);
setClientSecret(result.client_secret);
setShowPaymentForm(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to initialize payment. Please try again.');
} finally {
setIsLoadingIntent(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Add Credits
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
{!showPaymentForm ? (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
{[1000, 2500, 5000].map((amount) => (
<button
key={amount}
onClick={() => onAmountChange(amount)}
className={`py-3 px-4 rounded-lg border-2 transition-colors ${
amountCents === amount
? 'border-brand-600 bg-brand-50 dark:bg-brand-900/30 text-brand-600'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
}`}
>
<span className="font-semibold">{formatCurrency(amount)}</span>
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom amount (whole dollars only)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-900 dark:text-white font-medium">$</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={amountCents / 100}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '');
onAmountChange(Math.max(5, parseInt(val) || 5) * 100);
}}
onKeyDown={(e) => {
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.preventDefault();
}
}}
className="w-full pl-8 pr-12 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500">.00</span>
</div>
</div>
{error && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-3 pt-4">
<button
onClick={onClose}
className="flex-1 py-2 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-700"
>
Cancel
</button>
<button
onClick={handleContinueToPayment}
disabled={isLoadingIntent}
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoadingIntent ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
<>
<CreditCard className="w-4 h-4" />
Continue to Payment
</>
)}
</button>
</div>
</div>
) : clientSecret ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#2563eb',
colorBackground: '#ffffff',
colorText: '#1e293b',
colorDanger: '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '4px',
borderRadius: '8px',
},
},
}}
>
<PaymentFormInner
amountCents={amountCents}
onSuccess={onSuccess}
onCancel={() => {
setShowPaymentForm(false);
setClientSecret(null);
}}
savePaymentMethod={savePaymentMethod}
/>
</Elements>
) : null}
</div>
</div>
);
};
export default CreditPaymentModal;

View File

@@ -97,6 +97,9 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
// Store token in cookie (use 'access_token' to match what client.ts expects)
setCookie('access_token', response.data.token, 7);
// Clear any existing masquerade stack - this is a fresh login
localStorage.removeItem('masquerade_stack');
// Fetch user data to determine redirect
const userResponse = await apiClient.get('/auth/me/');
const userData = userResponse.data;

View File

@@ -11,10 +11,13 @@ import {
Smartphone,
Plus,
AlertTriangle,
ChevronDown
ChevronDown,
Sparkles,
Check
} from 'lucide-react';
import api from '../api/client';
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
import EmailTemplatePresetSelector from './EmailTemplatePresetSelector';
interface EmailTemplateFormProps {
template?: EmailTemplate | null;
@@ -44,6 +47,15 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop');
const [showPreview, setShowPreview] = useState(false);
const [showVariables, setShowVariables] = useState(false);
const [showPresetSelector, setShowPresetSelector] = useState(false);
const [showTwoVersionsWarning, setShowTwoVersionsWarning] = useState(() => {
// Check localStorage to see if user has dismissed the warning
try {
return localStorage.getItem('emailTemplates_twoVersionsWarning_dismissed') !== 'true';
} catch {
return true;
}
});
// Fetch available variables
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
@@ -105,6 +117,24 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
}
};
const handlePresetSelect = (preset: any) => {
setName(preset.name);
setDescription(preset.description);
setSubject(preset.subject);
setHtmlContent(preset.html_content);
setTextContent(preset.text_content);
setShowPresetSelector(false);
};
const handleDismissTwoVersionsWarning = () => {
setShowTwoVersionsWarning(false);
try {
localStorage.setItem('emailTemplates_twoVersionsWarning_dismissed', 'true');
} catch {
// Ignore localStorage errors
}
};
const categories: { value: EmailTemplateCategory; label: string }[] = [
{ value: 'APPOINTMENT', label: t('emailTemplates.categoryAppointment', 'Appointment') },
{ value: 'REMINDER', label: t('emailTemplates.categoryReminder', 'Reminder') },
@@ -137,6 +167,23 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
{/* Modal Body */}
<div className="flex-1 overflow-y-auto p-6">
{/* Choose from Preset Button */}
{!isEditing && (
<div className="mb-6">
<button
type="button"
onClick={() => setShowPresetSelector(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition-all shadow-md hover:shadow-lg font-medium"
>
<Sparkles className="h-5 w-5" />
{t('emailTemplates.chooseFromPreset', 'Choose from Pre-designed Templates')}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
{t('emailTemplates.presetHint', 'Start with a professionally designed template and customize it to your needs')}
</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Form */}
<div className="space-y-4">
@@ -245,12 +292,37 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
{/* Content Tabs */}
<div>
{/* Info callout about HTML and Text versions */}
{showTwoVersionsWarning && (
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
{t('emailTemplates.twoVersionsRequired', 'Please edit both email versions')}
</h4>
<p className="text-xs text-blue-800 dark:text-blue-300 leading-relaxed mb-3">
{t('emailTemplates.twoVersionsExplanation', 'Your customers will receive one of two versions of this email depending on their email client. Edit both the HTML version (rich formatting) and the Plain Text version (simple text) below. Make sure both versions include the same information so all your customers get the complete message.')}
</p>
<button
type="button"
onClick={handleDismissTwoVersionsWarning}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 dark:bg-blue-500 text-white text-xs font-medium rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors"
>
<Check className="h-3.5 w-3.5" />
{t('emailTemplates.iUnderstand', 'I Understand')}
</button>
</div>
</div>
</div>
)}
<div className="flex items-center gap-4 mb-2">
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
<button
type="button"
onClick={() => setActiveTab('html')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
activeTab === 'html'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
@@ -258,11 +330,14 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
>
<Code className="h-4 w-4" />
HTML
{!htmlContent.trim() && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
</button>
<button
type="button"
onClick={() => setActiveTab('text')}
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
activeTab === 'text'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
@@ -270,6 +345,9 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
>
<FileText className="h-4 w-4" />
Text
{!textContent.trim() && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
</button>
</div>
@@ -448,6 +526,15 @@ const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
)}
</button>
</div>
{/* Preset Selector Modal */}
{showPresetSelector && (
<EmailTemplatePresetSelector
category={category}
onSelect={handlePresetSelect}
onClose={() => setShowPresetSelector(false)}
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
X,
Search,
Eye,
Check,
Sparkles,
Smile,
Minus,
ChevronRight
} from 'lucide-react';
import api from '../api/client';
import { EmailTemplateCategory } from '../types';
interface TemplatePreset {
name: string;
description: string;
style: string;
subject: string;
html_content: string;
text_content: string;
}
interface PresetsResponse {
presets: Record<EmailTemplateCategory, TemplatePreset[]>;
}
interface EmailTemplatePresetSelectorProps {
category: EmailTemplateCategory;
onSelect: (preset: TemplatePreset) => void;
onClose: () => void;
}
const styleIcons: Record<string, React.ReactNode> = {
professional: <Sparkles className="h-4 w-4" />,
friendly: <Smile className="h-4 w-4" />,
minimalist: <Minus className="h-4 w-4" />,
};
const styleColors: Record<string, string> = {
professional: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
friendly: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
minimalist: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
};
const EmailTemplatePresetSelector: React.FC<EmailTemplatePresetSelectorProps> = ({
category,
onSelect,
onClose,
}) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [selectedPreview, setSelectedPreview] = useState<TemplatePreset | null>(null);
const [selectedStyle, setSelectedStyle] = useState<string>('all');
// Fetch presets
const { data: presetsData, isLoading } = useQuery<PresetsResponse>({
queryKey: ['email-template-presets'],
queryFn: async () => {
const { data } = await api.get('/email-templates/presets/');
return data;
},
});
const presets = presetsData?.presets[category] || [];
// Filter presets
const filteredPresets = presets.filter(preset => {
const matchesSearch = searchQuery.trim() === '' ||
preset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
preset.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStyle = selectedStyle === 'all' || preset.style === selectedStyle;
return matchesSearch && matchesStyle;
});
// Get unique styles from presets
const availableStyles = Array.from(new Set(presets.map(p => p.style)));
const handleSelectPreset = (preset: TemplatePreset) => {
onSelect(preset);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('emailTemplates.selectPreset', 'Choose a Template')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('emailTemplates.presetDescription', 'Select a pre-designed template to customize')}
</p>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Search and Filters */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('emailTemplates.searchPresets', 'Search templates...')}
className="w-full pl-9 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Style Filter */}
<div className="flex gap-2">
<button
onClick={() => setSelectedStyle('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedStyle === 'all'
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
All Styles
</button>
{availableStyles.map(style => (
<button
key={style}
onClick={() => setSelectedStyle(style)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedStyle === style
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
{styleIcons[style]}
<span className="capitalize">{style}</span>
</button>
))}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : filteredPresets.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{t('emailTemplates.noPresets', 'No templates found matching your criteria')}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredPresets.map((preset, index) => (
<div
key={index}
className="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group"
>
{/* Preview Image Placeholder */}
<div className="h-40 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-600 dark:to-gray-700 relative overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<iframe
srcDoc={preset.html_content}
className="w-full h-full pointer-events-none transform scale-50 origin-top-left"
style={{ width: '200%', height: '200%' }}
title={preset.name}
sandbox="allow-same-origin"
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-4">
<button
onClick={() => setSelectedPreview(preset)}
className="flex items-center gap-2 px-3 py-1.5 bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white rounded-lg text-sm font-medium"
>
<Eye className="h-4 w-4" />
Preview
</button>
</div>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white line-clamp-1">
{preset.name}
</h4>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${styleColors[preset.style] || styleColors.professional}`}>
{styleIcons[preset.style]}
<span className="capitalize">{preset.style}</span>
</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
{preset.description}
</p>
<button
onClick={() => handleSelectPreset(preset)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
>
<Check className="h-4 w-4" />
Use This Template
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Preview Modal */}
{selectedPreview && (
<div className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedPreview.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{selectedPreview.description}
</p>
</div>
<button
onClick={() => setSelectedPreview(null)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject
</label>
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm">
{selectedPreview.subject}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Preview
</label>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<iframe
srcDoc={selectedPreview.html_content}
className="w-full h-96 bg-white"
title="Template Preview"
sandbox="allow-same-origin"
/>
</div>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => setSelectedPreview(null)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
Close
</button>
<button
onClick={() => handleSelectPreset(selectedPreview)}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
<Check className="h-4 w-4" />
Use This Template
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default EmailTemplatePresetSelector;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import {
@@ -13,21 +13,19 @@ import {
Briefcase,
Ticket,
HelpCircle,
Code,
ChevronDown,
BookOpen,
FileQuestion,
LifeBuoy,
Zap,
Plug,
Package,
Clock,
Store,
Mail
Mail,
Plug,
BookOpen,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import {
SidebarSection,
SidebarItem,
SidebarDivider,
} from './navigation/SidebarComponents';
interface SidebarProps {
business: Business;
@@ -38,41 +36,14 @@ interface SidebarProps {
const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCollapse }) => {
const { t } = useTranslation();
const location = useLocation();
const { role } = user;
const logoutMutation = useLogout();
const [isHelpOpen, setIsHelpOpen] = useState(location.pathname.startsWith('/help') || location.pathname === '/support');
const [isPluginsOpen, setIsPluginsOpen] = useState(location.pathname.startsWith('/plugins') || location.pathname === '/plugins/marketplace');
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
const isActive = exact
? location.pathname === path
: location.pathname.startsWith(path);
const baseClasses = `flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors`;
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
const activeClasses = 'bg-white/10 text-white';
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
const disabledClasses = 'text-white/30 cursor-not-allowed';
if (disabled) {
return `${baseClasses} ${collapsedClasses} ${disabledClasses}`;
}
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
};
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewSettings = role === 'owner';
// Tickets: owners/managers always, staff only with permission
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
const getDashboardLink = () => {
if (role === 'resource') return '/';
return '/';
};
const handleSignOut = () => {
logoutMutation.mutate();
};
@@ -84,23 +55,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
background: `linear-gradient(to bottom right, ${business.primaryColor}, ${business.secondaryColor || business.primaryColor})`
}}
>
{/* Header / Logo */}
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
className={`flex items-center gap-3 w-full text-left px-6 py-6 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{/* Logo-only mode: full width */}
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
<div className="flex items-center justify-center w-full">
<img
src={business.logoUrl}
alt={business.name}
className="max-w-full max-h-16 object-contain"
className="max-w-full max-h-12 object-contain"
/>
</div>
) : (
<>
{/* Logo/Icon display */}
{business.logoUrl && business.logoDisplayMode !== 'text-only' ? (
<div className="flex items-center justify-center w-10 h-10 shrink-0">
<img
@@ -110,12 +80,13 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
/>
</div>
) : business.logoDisplayMode !== 'logo-only' && (
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
<div
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg font-bold text-xl shrink-0"
style={{ color: business.primaryColor }}
>
{business.name.substring(0, 2).toUpperCase()}
</div>
)}
{/* Text display */}
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
@@ -126,219 +97,156 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
</button>
<nav className="flex-1 px-4 space-y-1 overflow-y-auto">
<Link to={getDashboardLink()} className={getNavClass('/', true)} title={t('nav.dashboard')}>
<LayoutDashboard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
</Link>
<Link to="/scheduler" className={getNavClass('/scheduler')} title={t('nav.scheduler')}>
<CalendarDays size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.scheduler')}</span>}
</Link>
<Link to="/tasks" className={getNavClass('/tasks')} title={t('nav.tasks', 'Tasks')}>
<Clock size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.tasks', 'Tasks')}</span>}
</Link>
{/* Navigation */}
<nav className="flex-1 px-3 space-y-6 overflow-y-auto pb-4">
{/* Core Features - Always visible */}
<SidebarSection isCollapsed={isCollapsed}>
<SidebarItem
to="/"
icon={LayoutDashboard}
label={t('nav.dashboard')}
isCollapsed={isCollapsed}
exact
/>
<SidebarItem
to="/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
/>
</SidebarSection>
{/* Manage Section - Staff+ */}
{canViewManagementPages && (
<>
<Link to="/customers" className={getNavClass('/customers')} title={t('nav.customers')}>
<Users size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.customers')}</span>}
</Link>
<Link to="/services" className={getNavClass('/services')} title={t('nav.services', 'Services')}>
<Briefcase size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.services', 'Services')}</span>}
</Link>
<Link to="/resources" className={getNavClass('/resources')} title={t('nav.resources')}>
<ClipboardList size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.resources')}</span>}
</Link>
</>
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
<SidebarItem
to="/customers"
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/services"
icon={Briefcase}
label={t('nav.services', 'Services')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/resources"
icon={ClipboardList}
label={t('nav.resources')}
isCollapsed={isCollapsed}
/>
{canViewAdminPages && (
<SidebarItem
to="/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
/>
)}
</SidebarSection>
)}
{canViewTickets && (
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
<Ticket size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.tickets')}</span>}
</Link>
{/* Communicate Section - Tickets + Messages */}
{(canViewTickets || canViewAdminPages) && (
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
{canViewAdminPages && (
<SidebarItem
to="/messages"
icon={MessageSquare}
label={t('nav.messages')}
isCollapsed={isCollapsed}
/>
)}
{canViewTickets && (
<SidebarItem
to="/tickets"
icon={Ticket}
label={t('nav.tickets')}
isCollapsed={isCollapsed}
/>
)}
</SidebarSection>
)}
{/* Money Section - Payments */}
{canViewAdminPages && (
<>
{/* Payments link: always visible for owners, only visible for others if enabled */}
{(role === 'owner' || business.paymentsEnabled) && (
business.paymentsEnabled ? (
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</Link>
) : (
<div
className={getNavClass('/payments', false, true)}
title={t('nav.paymentsDisabledTooltip')}
>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</div>
)
)}
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
<MessageSquare size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.messages')}</span>}
</Link>
<Link to="/staff" className={getNavClass('/staff')} title={t('nav.staff')}>
<Users size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
{/* Plugins Dropdown */}
<div>
<button
onClick={() => setIsPluginsOpen(!isPluginsOpen)}
className={`flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors w-full ${isCollapsed ? 'px-3 justify-center' : 'px-4'} ${location.pathname.startsWith('/plugins') ? 'bg-white/10 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
title={t('nav.plugins', 'Plugins')}
>
<Plug size={20} className="shrink-0" />
{!isCollapsed && (
<>
<span className="flex-1 text-left">{t('nav.plugins', 'Plugins')}</span>
<ChevronDown size={16} className={`shrink-0 transition-transform ${isPluginsOpen ? 'rotate-180' : ''}`} />
</>
)}
</button>
{isPluginsOpen && !isCollapsed && (
<div className="ml-4 mt-1 space-y-1 border-l border-white/20 pl-4">
<Link
to="/plugins/marketplace"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/plugins/marketplace' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.marketplace', 'Marketplace')}
>
<Store size={16} className="shrink-0" />
<span>{t('nav.marketplace', 'Marketplace')}</span>
</Link>
<Link
to="/plugins/my-plugins"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/plugins/my-plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.myPlugins', 'My Plugins')}
>
<Package size={16} className="shrink-0" />
<span>{t('nav.myPlugins', 'My Plugins')}</span>
</Link>
<Link
to="/email-templates"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/email-templates' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.emailTemplates', 'Email Templates')}
>
<Mail size={16} className="shrink-0" />
<span>{t('nav.emailTemplates', 'Email Templates')}</span>
</Link>
<Link
to="/help/plugins"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.pluginDocs', 'Plugin Documentation')}
>
<Zap size={16} className="shrink-0" />
<span>{t('nav.pluginDocs', 'Plugin Docs')}</span>
</Link>
</div>
)}
</div>
{/* Help Dropdown */}
<div>
<button
onClick={() => setIsHelpOpen(!isHelpOpen)}
className={`flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors w-full ${isCollapsed ? 'px-3 justify-center' : 'px-4'} ${location.pathname.startsWith('/help') || location.pathname === '/support' ? 'bg-white/10 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
title={t('nav.help', 'Help')}
>
<HelpCircle size={20} className="shrink-0" />
{!isCollapsed && (
<>
<span className="flex-1 text-left">{t('nav.help', 'Help')}</span>
<ChevronDown size={16} className={`shrink-0 transition-transform ${isHelpOpen ? 'rotate-180' : ''}`} />
</>
)}
</button>
{isHelpOpen && !isCollapsed && (
<div className="ml-4 mt-1 space-y-1 border-l border-white/20 pl-4">
<Link
to="/help/guide"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/guide' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.platformGuide', 'Platform Guide')}
>
<BookOpen size={16} className="shrink-0" />
<span>{t('nav.platformGuide', 'Platform Guide')}</span>
</Link>
<Link
to="/help/ticketing"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/ticketing' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.ticketingHelp', 'Ticketing System')}
>
<FileQuestion size={16} className="shrink-0" />
<span>{t('nav.ticketingHelp', 'Ticketing System')}</span>
</Link>
{role === 'owner' && (
<Link
to="/help/api"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/api' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.apiDocs', 'API Documentation')}
>
<Code size={16} className="shrink-0" />
<span>{t('nav.apiDocs', 'API Docs')}</span>
</Link>
)}
<div className="pt-2 mt-2 border-t border-white/10">
<Link
to="/support"
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/support' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
title={t('nav.support', 'Support')}
>
<MessageSquare size={16} className="shrink-0" />
<span>{t('nav.support', 'Support')}</span>
</Link>
</div>
</div>
)}
</div>
</>
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
<SidebarItem
to="/payments"
icon={CreditCard}
label={t('nav.payments')}
isCollapsed={isCollapsed}
disabled={!business.paymentsEnabled && role !== 'owner'}
/>
</SidebarSection>
)}
{canViewSettings && (
<div className="pt-8 mt-8 border-t border-white/10">
{canViewSettings && (
<Link to="/settings" className={getNavClass('/settings', true)} title={t('nav.businessSettings')}>
<Settings size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.businessSettings')}</span>}
</Link>
)}
</div>
{/* Extend Section - Plugins & Templates */}
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/email-templates"
icon={Mail}
label={t('nav.emailTemplates', 'Email Templates')}
isCollapsed={isCollapsed}
/>
</SidebarSection>
)}
{/* Footer Section - Settings & Help */}
<SidebarDivider isCollapsed={isCollapsed} />
<SidebarSection isCollapsed={isCollapsed}>
{canViewSettings && (
<SidebarItem
to="/settings"
icon={Settings}
label={t('nav.businessSettings')}
isCollapsed={isCollapsed}
/>
)}
<SidebarItem
to="/help"
icon={HelpCircle}
label={t('nav.helpDocs', 'Help & Docs')}
isCollapsed={isCollapsed}
/>
</SidebarSection>
</nav>
{/* User Section */}
<div className="p-4 border-t border-white/10">
<a
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 text-xs text-white/60 mb-4 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
className={`flex items-center gap-2 text-xs text-white/60 mb-3 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
>
<SmoothScheduleLogo className="w-6 h-6 text-white" />
<SmoothScheduleLogo className="w-5 h-5 text-white" />
{!isCollapsed && (
<div>
<span className="block">{t('common.poweredBy')}</span>
<span className="font-semibold text-white/80">Smooth Schedule</span>
</div>
<span className="text-white/60">Smooth Schedule</span>
)}
</a>
<button
onClick={handleSignOut}
disabled={logoutMutation.isPending}
className={`flex items-center gap-3 px-4 py-2 text-sm font-medium text-white/70 hover:text-white w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/5 w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
>
<LogOut size={20} className="shrink-0" />
<LogOut size={18} className="shrink-0" />
{!isCollapsed && <span>{t('auth.signOut')}</span>}
</button>
</div>

View File

@@ -0,0 +1,281 @@
/**
* Shared Sidebar Navigation Components
*
* Reusable building blocks for main sidebar and settings sidebar navigation.
*/
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ChevronDown, LucideIcon } from 'lucide-react';
interface SidebarSectionProps {
title?: string;
children: React.ReactNode;
isCollapsed?: boolean;
className?: string;
}
/**
* Section wrapper with optional header
*/
export const SidebarSection: React.FC<SidebarSectionProps> = ({
title,
children,
isCollapsed = false,
className = '',
}) => {
return (
<div className={`space-y-1 ${className}`}>
{title && !isCollapsed && (
<h3 className="px-4 pt-1 pb-1.5 text-xs font-semibold uppercase tracking-wider text-white/40">
{title}
</h3>
)}
{title && isCollapsed && (
<div className="mx-auto w-8 border-t border-white/20 my-2" />
)}
{children}
</div>
);
};
interface SidebarItemProps {
to: string;
icon: LucideIcon;
label: string;
isCollapsed?: boolean;
exact?: boolean;
disabled?: boolean;
badge?: string | number;
variant?: 'default' | 'settings';
}
/**
* Navigation item with icon
*/
export const SidebarItem: React.FC<SidebarItemProps> = ({
to,
icon: Icon,
label,
isCollapsed = false,
exact = false,
disabled = false,
badge,
variant = 'default',
}) => {
const location = useLocation();
const isActive = exact
? location.pathname === to
: location.pathname.startsWith(to);
const baseClasses = 'flex items-center gap-3 py-2.5 text-sm font-medium rounded-lg transition-colors';
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
// Different color schemes for main nav vs settings nav
const colorClasses = variant === 'settings'
? isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
: isActive
? 'bg-white/10 text-white'
: 'text-white/70 hover:text-white hover:bg-white/5';
const disabledClasses = variant === 'settings'
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-white/30 cursor-not-allowed';
const className = `${baseClasses} ${collapsedClasses} ${disabled ? disabledClasses : colorClasses}`;
if (disabled) {
return (
<div className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && <span className="flex-1">{label}</span>}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
)}
</div>
);
}
return (
<Link to={to} className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && <span className="flex-1">{label}</span>}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
{badge}
</span>
)}
</Link>
);
};
interface SidebarDropdownProps {
icon: LucideIcon;
label: string;
children: React.ReactNode;
isCollapsed?: boolean;
defaultOpen?: boolean;
isActiveWhen?: string[];
}
/**
* Collapsible dropdown section
*/
export const SidebarDropdown: React.FC<SidebarDropdownProps> = ({
icon: Icon,
label,
children,
isCollapsed = false,
defaultOpen = false,
isActiveWhen = [],
}) => {
const location = useLocation();
const [isOpen, setIsOpen] = React.useState(
defaultOpen || isActiveWhen.some(path => location.pathname.startsWith(path))
);
const isActive = isActiveWhen.some(path => location.pathname.startsWith(path));
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-3 py-2.5 text-sm font-medium rounded-lg transition-colors w-full ${
isCollapsed ? 'px-3 justify-center' : 'px-4'
} ${
isActive
? 'bg-white/10 text-white'
: 'text-white/70 hover:text-white hover:bg-white/5'
}`}
title={label}
>
<Icon size={20} className="shrink-0" />
{!isCollapsed && (
<>
<span className="flex-1 text-left">{label}</span>
<ChevronDown
size={16}
className={`shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</>
)}
</button>
{isOpen && !isCollapsed && (
<div className="ml-4 mt-1 space-y-0.5 border-l border-white/20 pl-4">
{children}
</div>
)}
</div>
);
};
interface SidebarSubItemProps {
to: string;
icon: LucideIcon;
label: string;
}
/**
* Sub-item for dropdown menus
*/
export const SidebarSubItem: React.FC<SidebarSubItemProps> = ({
to,
icon: Icon,
label,
}) => {
const location = useLocation();
const isActive = location.pathname === to;
return (
<Link
to={to}
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${
isActive
? 'bg-white/10 text-white'
: 'text-white/60 hover:text-white hover:bg-white/5'
}`}
title={label}
>
<Icon size={16} className="shrink-0" />
<span>{label}</span>
</Link>
);
};
interface SidebarDividerProps {
isCollapsed?: boolean;
}
/**
* Visual divider between sections
*/
export const SidebarDivider: React.FC<SidebarDividerProps> = ({ isCollapsed }) => {
return (
<div className={`my-4 ${isCollapsed ? 'mx-3' : 'mx-4'} border-t border-white/10`} />
);
};
interface SettingsSidebarSectionProps {
title: string;
children: React.ReactNode;
}
/**
* Section for settings sidebar (different styling)
*/
export const SettingsSidebarSection: React.FC<SettingsSidebarSectionProps> = ({
title,
children,
}) => {
return (
<div className="space-y-0.5">
<h3 className="px-4 pt-0.5 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{title}
</h3>
{children}
</div>
);
};
interface SettingsSidebarItemProps {
to: string;
icon: LucideIcon;
label: string;
description?: string;
}
/**
* Settings navigation item with optional description
*/
export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
to,
icon: Icon,
label,
description,
}) => {
const location = useLocation();
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
return (
<Link
to={to}
className={`flex items-start gap-2.5 px-4 py-1.5 text-sm rounded-lg transition-colors ${
isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800'
}`}
>
<Icon size={16} className="shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="font-medium">{label}</span>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
{description}
</p>
)}
</div>
</Link>
);
};