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

@@ -72,6 +72,19 @@ const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace'));
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
const GeneralSettings = React.lazy(() => import('./pages/settings/GeneralSettings'));
const BrandingSettings = React.lazy(() => import('./pages/settings/BrandingSettings'));
const ResourceTypesSettings = React.lazy(() => import('./pages/settings/ResourceTypesSettings'));
const DomainsSettings = React.lazy(() => import('./pages/settings/DomainsSettings'));
const ApiSettings = React.lazy(() => import('./pages/settings/ApiSettings'));
const AuthenticationSettings = React.lazy(() => import('./pages/settings/AuthenticationSettings'));
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
const queryClient = new QueryClient({
@@ -537,9 +550,10 @@ const AppContent: React.FC = () => {
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
{/* Trial-expired users can access billing settings to upgrade */}
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
path="/settings/*"
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
</Routes>
@@ -678,10 +692,23 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
/>
{/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? (
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="domains" element={<DomainsSettings />} />
<Route path="api" element={<ApiSettings />} />
<Route path="authentication" element={<AuthenticationSettings />} />
<Route path="email" element={<EmailSettings />} />
<Route path="sms-calling" element={<CommunicationSettings />} />
<Route path="billing" element={<BillingSettings />} />
</Route>
) : (
<Route path="/settings/*" element={<Navigate to="/" />} />
)}
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />

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>
);
};

View File

@@ -72,6 +72,9 @@ export const useLogin = () => {
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Clear any existing masquerade stack - this is a fresh login
localStorage.removeItem('masquerade_stack');
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
},
@@ -91,6 +94,9 @@ export const useLogout = () => {
deleteCookie('access_token');
deleteCookie('refresh_token');
// Clear masquerade stack
localStorage.removeItem('masquerade_stack');
// Clear user cache
queryClient.removeQueries({ queryKey: ['currentUser'] });
queryClient.clear();

View File

@@ -0,0 +1,195 @@
/**
* Communication Credits Hooks
* For managing business SMS/calling credits and auto-reload settings
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface CommunicationCredits {
id: number;
balance_cents: number;
auto_reload_enabled: boolean;
auto_reload_threshold_cents: number;
auto_reload_amount_cents: number;
low_balance_warning_cents: number;
low_balance_warning_sent: boolean;
stripe_payment_method_id: string;
last_twilio_sync_at: string | null;
total_loaded_cents: number;
total_spent_cents: number;
created_at: string;
updated_at: string;
}
export interface CreditTransaction {
id: number;
amount_cents: number;
balance_after_cents: number;
transaction_type: 'manual' | 'auto_reload' | 'usage' | 'refund' | 'adjustment' | 'promo';
description: string;
reference_type: string;
reference_id: string;
stripe_charge_id: string;
created_at: string;
}
export interface UpdateCreditsSettings {
auto_reload_enabled?: boolean;
auto_reload_threshold_cents?: number;
auto_reload_amount_cents?: number;
low_balance_warning_cents?: number;
stripe_payment_method_id?: string;
}
export interface AddCreditsRequest {
amount_cents: number;
payment_method_id?: string;
}
/**
* Hook to get communication credits for current business
*/
export const useCommunicationCredits = () => {
return useQuery<CommunicationCredits>({
queryKey: ['communicationCredits'],
queryFn: async () => {
const { data } = await apiClient.get('/communication-credits/');
return data;
},
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to get credit transaction history
*/
export const useCreditTransactions = (page = 1, limit = 20) => {
return useQuery<{ results: CreditTransaction[]; count: number; next: string | null; previous: string | null }>({
queryKey: ['creditTransactions', page, limit],
queryFn: async () => {
const { data } = await apiClient.get('/communication-credits/transactions/', {
params: { page, limit },
});
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to update credit settings (auto-reload, thresholds, etc.)
*/
export const useUpdateCreditsSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (settings: UpdateCreditsSettings) => {
const { data } = await apiClient.patch('/communication-credits/settings/', settings);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(['communicationCredits'], data);
},
});
};
/**
* Hook to add credits (manual top-up)
*/
export const useAddCredits = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (request: AddCreditsRequest) => {
const { data } = await apiClient.post('/communication-credits/add/', request);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
},
});
};
/**
* Hook to create a payment intent for credit purchase
*/
export const useCreatePaymentIntent = () => {
return useMutation({
mutationFn: async (amount_cents: number) => {
const { data } = await apiClient.post('/communication-credits/create-payment-intent/', {
amount_cents,
});
return data as { client_secret: string; payment_intent_id: string };
},
});
};
/**
* Hook to confirm a payment after client-side processing
*/
export const useConfirmPayment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: { payment_intent_id: string; save_payment_method?: boolean }) => {
const { data } = await apiClient.post('/communication-credits/confirm-payment/', params);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
queryClient.invalidateQueries({ queryKey: ['creditTransactions'] });
},
});
};
/**
* Hook to set up Stripe payment method for auto-reload
*/
export const useSetupPaymentMethod = () => {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/communication-credits/setup-payment-method/');
return data as { client_secret: string };
},
});
};
/**
* Hook to save a payment method after setup
*/
export const useSavePaymentMethod = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payment_method_id: string) => {
const { data } = await apiClient.post('/communication-credits/save-payment-method/', {
payment_method_id,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['communicationCredits'] });
},
});
};
/**
* Hook to get communication usage stats
*/
export const useCommunicationUsageStats = () => {
return useQuery<{
sms_sent_this_month: number;
voice_minutes_this_month: number;
proxy_numbers_active: number;
estimated_cost_cents: number;
}>({
queryKey: ['communicationUsageStats'],
queryFn: async () => {
const { data } = await apiClient.get('/communication-credits/usage-stats/');
return data;
},
staleTime: 60 * 1000, // 1 minute
});
};

View File

@@ -44,6 +44,17 @@ export interface SubscriptionPlan {
permissions: Record<string, boolean>;
transaction_fee_percent: string;
transaction_fee_fixed: string;
// Communication pricing
sms_enabled: boolean;
sms_price_per_message_cents: number;
masked_calling_enabled: boolean;
masked_calling_price_per_minute_cents: number;
proxy_number_enabled: boolean;
proxy_number_monthly_fee_cents: number;
// Default credit settings
default_auto_reload_enabled: boolean;
default_auto_reload_threshold_cents: number;
default_auto_reload_amount_cents: number;
is_active: boolean;
is_public: boolean;
is_most_popular: boolean;
@@ -64,6 +75,17 @@ export interface SubscriptionPlanCreate {
permissions?: Record<string, boolean>;
transaction_fee_percent?: number;
transaction_fee_fixed?: number;
// Communication pricing
sms_enabled?: boolean;
sms_price_per_message_cents?: number;
masked_calling_enabled?: boolean;
masked_calling_price_per_minute_cents?: number;
proxy_number_enabled?: boolean;
proxy_number_monthly_fee_cents?: number;
// Default credit settings
default_auto_reload_enabled?: boolean;
default_auto_reload_threshold_cents?: number;
default_auto_reload_amount_cents?: number;
is_active?: boolean;
is_public?: boolean;
is_most_popular?: boolean;

View File

@@ -0,0 +1,154 @@
/**
* Settings Layout
*
* Provides a sidebar navigation for settings pages with grouped sections.
* Used as a wrapper for all /settings/* routes.
*/
import React from 'react';
import { Outlet, Link, useLocation, useNavigate, useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Building2,
Palette,
Layers,
Globe,
Key,
Lock,
Mail,
Phone,
CreditCard,
Webhook,
} from 'lucide-react';
import {
SettingsSidebarSection,
SettingsSidebarItem,
} from '../components/navigation/SidebarComponents';
import { Business, User } from '../types';
interface ParentContext {
user: User;
business: Business;
updateBusiness: (updates: Partial<Business>) => void;
}
const SettingsLayout: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
// Get context from parent route (BusinessLayout)
const parentContext = useOutletContext<ParentContext>();
return (
<div className="flex h-full bg-gray-50 dark:bg-gray-900">
{/* Settings Sidebar */}
<aside className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col shrink-0">
{/* Back Button */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} />
<span>{t('settings.backToApp', 'Back to App')}</span>
</button>
</div>
{/* Settings Title */}
<div className="px-4 py-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.title', 'Settings')}
</h2>
</div>
{/* Navigation */}
<nav className="flex-1 px-2 pb-4 space-y-3 overflow-y-auto">
{/* Business Section */}
<SettingsSidebarSection title={t('settings.sections.business', 'Business')}>
<SettingsSidebarItem
to="/settings/general"
icon={Building2}
label={t('settings.general.title', 'General')}
description={t('settings.general.description', 'Name, timezone, contact')}
/>
<SettingsSidebarItem
to="/settings/branding"
icon={Palette}
label={t('settings.branding.title', 'Branding')}
description={t('settings.branding.description', 'Logo, colors, appearance')}
/>
<SettingsSidebarItem
to="/settings/resource-types"
icon={Layers}
label={t('settings.resourceTypes.title', 'Resource Types')}
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
/>
</SettingsSidebarSection>
{/* Integrations Section */}
<SettingsSidebarSection title={t('settings.sections.integrations', 'Integrations')}>
<SettingsSidebarItem
to="/settings/domains"
icon={Globe}
label={t('settings.domains.title', 'Domains')}
description={t('settings.domains.description', 'Custom domain setup')}
/>
<SettingsSidebarItem
to="/settings/api"
icon={Key}
label={t('settings.api.title', 'API & Webhooks')}
description={t('settings.api.description', 'API tokens, webhooks')}
/>
</SettingsSidebarSection>
{/* Access Section */}
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
<SettingsSidebarItem
to="/settings/authentication"
icon={Lock}
label={t('settings.authentication.title', 'Authentication')}
description={t('settings.authentication.description', 'OAuth, social login')}
/>
</SettingsSidebarSection>
{/* Communication Section */}
<SettingsSidebarSection title={t('settings.sections.communication', 'Communication')}>
<SettingsSidebarItem
to="/settings/email"
icon={Mail}
label={t('settings.email.title', 'Email Setup')}
description={t('settings.email.description', 'Email addresses for tickets')}
/>
<SettingsSidebarItem
to="/settings/sms-calling"
icon={Phone}
label={t('settings.smsCalling.title', 'SMS & Calling')}
description={t('settings.smsCalling.description', 'Credits, phone numbers')}
/>
</SettingsSidebarSection>
{/* Billing Section */}
<SettingsSidebarSection title={t('settings.sections.billing', 'Billing')}>
<SettingsSidebarItem
to="/settings/billing"
icon={CreditCard}
label={t('settings.billing.title', 'Plan & Billing')}
description={t('settings.billing.description', 'Subscription, invoices')}
/>
</SettingsSidebarSection>
</nav>
</aside>
{/* Content Area */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-8">
<Outlet context={parentContext} />
</div>
</main>
</div>
);
};
export default SettingsLayout;

View File

@@ -18,7 +18,15 @@ import {
FileText,
BarChart3,
Package,
AlertTriangle
AlertTriangle,
Sparkles,
Smile,
Minus,
Grid3x3,
List,
Check,
Square,
CheckSquare
} from 'lucide-react';
import api from '../api/client';
import { EmailTemplate, EmailTemplateCategory } from '../types';
@@ -37,26 +45,52 @@ const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
// Category colors
const categoryColors: Record<EmailTemplateCategory, string> = {
APPOINTMENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
REMINDER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
CONFIRMATION: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
MARKETING: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
NOTIFICATION: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
REPORT: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
APPOINTMENT: 'bg-indigo-50 text-indigo-700 ring-indigo-700/20 dark:bg-indigo-400/10 dark:text-indigo-400 ring-indigo-400/30',
REMINDER: 'bg-orange-50 text-orange-700 ring-orange-700/20 dark:bg-orange-400/10 dark:text-orange-400 ring-orange-400/30',
CONFIRMATION: 'bg-green-50 text-green-700 ring-green-700/20 dark:bg-green-400/10 dark:text-green-400 ring-green-400/30',
MARKETING: 'bg-purple-50 text-purple-700 ring-purple-700/20 dark:bg-purple-400/10 dark:text-purple-400 ring-purple-400/30',
NOTIFICATION: 'bg-sky-50 text-sky-700 ring-sky-700/20 dark:bg-sky-400/10 dark:text-sky-400 ring-sky-400/30',
REPORT: 'bg-rose-50 text-rose-700 ring-rose-700/20 dark:bg-rose-400/10 dark:text-rose-400 ring-rose-400/30',
OTHER: 'bg-gray-50 text-gray-700 ring-gray-700/20 dark:bg-gray-400/10 dark:text-gray-400 ring-gray-400/30',
};
interface TemplatePreset {
name: string;
description: string;
style: string;
subject: string;
html_content: string;
text_content: string;
}
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-purple-50 text-purple-700 ring-purple-700/20 dark:bg-purple-400/10 dark:text-purple-400 ring-purple-400/30',
friendly: 'bg-green-50 text-green-700 ring-green-700/20 dark:bg-green-400/10 dark:text-green-400 ring-green-400/30',
minimalist: 'bg-gray-50 text-gray-700 ring-gray-700/20 dark:bg-gray-400/10 dark:text-gray-400 ring-gray-400/30',
};
const EmailTemplates: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeView, setActiveView] = useState<'my-templates' | 'browse'>('browse');
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<EmailTemplateCategory | 'ALL'>('ALL');
const [selectedStyle, setSelectedStyle] = useState<string>('all');
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<EmailTemplate | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
const [previewPreset, setPreviewPreset] = useState<TemplatePreset | null>(null);
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(new Set());
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
// Fetch email templates
const { data: templates = [], isLoading, error } = useQuery<EmailTemplate[]>({
@@ -82,6 +116,15 @@ const EmailTemplates: React.FC = () => {
},
});
// Fetch template presets
const { data: presetsData, isLoading: presetsLoading } = useQuery<{ presets: Record<EmailTemplateCategory, TemplatePreset[]> }>({
queryKey: ['email-template-presets'],
queryFn: async () => {
const { data } = await api.get('/email-templates/presets/');
return data;
},
});
// Delete template mutation
const deleteMutation = useMutation({
mutationFn: async (templateId: string) => {
@@ -105,6 +148,19 @@ const EmailTemplates: React.FC = () => {
},
});
// Bulk delete mutation
const bulkDeleteMutation = useMutation({
mutationFn: async (templateIds: string[]) => {
// Delete templates one by one (backend may not support bulk delete)
await Promise.all(templateIds.map(id => api.delete(`/email-templates/${id}/`)));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
setSelectedTemplates(new Set());
setShowBulkDeleteModal(false);
},
});
// Filter templates
const filteredTemplates = useMemo(() => {
let result = templates;
@@ -127,6 +183,47 @@ const EmailTemplates: React.FC = () => {
return result;
}, [templates, selectedCategory, searchQuery]);
// Filter presets
const filteredPresets = useMemo(() => {
if (!presetsData?.presets) return [];
let allPresets: (TemplatePreset & { category: EmailTemplateCategory })[] = [];
// Flatten presets from all categories
Object.entries(presetsData.presets).forEach(([category, presets]) => {
allPresets.push(...presets.map(p => ({ ...p, category: category as EmailTemplateCategory })));
});
// Filter by category
if (selectedCategory !== 'ALL') {
allPresets = allPresets.filter(p => p.category === selectedCategory);
}
// Filter by style
if (selectedStyle !== 'all') {
allPresets = allPresets.filter(p => p.style === selectedStyle);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
allPresets = allPresets.filter(p =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.subject.toLowerCase().includes(query)
);
}
return allPresets;
}, [presetsData, selectedCategory, selectedStyle, searchQuery]);
// Get available styles from all presets
const availableStyles = useMemo(() => {
if (!presetsData?.presets) return [];
const allPresets = Object.values(presetsData.presets).flat();
return Array.from(new Set(allPresets.map(p => p.style)));
}, [presetsData]);
const handleEdit = (template: EmailTemplate) => {
setEditingTemplate(template);
setShowCreateModal(true);
@@ -156,6 +253,55 @@ const EmailTemplates: React.FC = () => {
handleFormClose();
};
// Selection handlers
const handleSelectTemplate = (templateId: string) => {
setSelectedTemplates(prev => {
const next = new Set(prev);
if (next.has(templateId)) {
next.delete(templateId);
} else {
next.add(templateId);
}
return next;
});
};
const handleSelectAll = () => {
if (selectedTemplates.size === filteredTemplates.length) {
// Deselect all
setSelectedTemplates(new Set());
} else {
// Select all filtered templates
setSelectedTemplates(new Set(filteredTemplates.map(t => t.id)));
}
};
const handleBulkDelete = () => {
setShowBulkDeleteModal(true);
};
const confirmBulkDelete = () => {
bulkDeleteMutation.mutate(Array.from(selectedTemplates));
};
const handleUsePreset = (preset: TemplatePreset & { category: EmailTemplateCategory }) => {
// Create a new template from the preset
setEditingTemplate({
id: '',
name: preset.name,
description: preset.description,
subject: preset.subject,
htmlContent: preset.html_content,
textContent: preset.text_content,
category: preset.category,
scope: 'BUSINESS',
isDefault: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as EmailTemplate);
setShowCreateModal(true);
};
if (isLoading) {
return (
<div className="p-8">
@@ -188,11 +334,14 @@ const EmailTemplates: React.FC = () => {
{t('emailTemplates.title', 'Email Templates')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('emailTemplates.description', 'Create and manage reusable email templates for your plugins')}
{t('emailTemplates.description', 'Browse professional templates or create your own')}
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
onClick={() => {
setEditingTemplate(null);
setShowCreateModal(true);
}}
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"
>
<Plus className="h-5 w-5" />
@@ -200,6 +349,34 @@ const EmailTemplates: React.FC = () => {
</button>
</div>
{/* View Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveView('browse')}
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'browse'
? 'border-brand-600 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<Grid3x3 className="h-5 w-5" />
{t('emailTemplates.browseTemplates', 'Browse Templates')}
</button>
<button
onClick={() => setActiveView('my-templates')}
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'my-templates'
? 'border-brand-600 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<List className="h-5 w-5" />
{t('emailTemplates.myTemplates', 'My Templates')} ({templates.length})
</button>
</nav>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div className="flex flex-col lg:flex-row gap-4">
@@ -233,31 +410,137 @@ const EmailTemplates: React.FC = () => {
<option value="OTHER">{t('emailTemplates.categoryOther', 'Other')}</option>
</select>
</div>
{/* Style Filter (Browse Mode Only) */}
{activeView === 'browse' && availableStyles.length > 0 && (
<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>
{/* Active Filters Summary */}
{(searchQuery || selectedCategory !== 'ALL') && (
{(searchQuery || selectedCategory !== 'ALL' || selectedStyle !== 'all') && (
<div className="flex items-center gap-2 mt-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
{t('emailTemplates.showing', 'Showing')} {filteredTemplates.length} {t('emailTemplates.results', 'results')}
{t('emailTemplates.showing', 'Showing')} {activeView === 'browse' ? filteredPresets.length : filteredTemplates.length} {t('emailTemplates.results', 'results')}
</span>
{(searchQuery || selectedCategory !== 'ALL') && (
<button
onClick={() => {
setSearchQuery('');
setSelectedCategory('ALL');
}}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.clearAll', 'Clear all')}
</button>
)}
<button
onClick={() => {
setSearchQuery('');
setSelectedCategory('ALL');
setSelectedStyle('all');
}}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.clearAll', 'Clear all')}
</button>
</div>
)}
</div>
{/* Templates List */}
{filteredTemplates.length === 0 ? (
{/* Browse Templates View */}
{activeView === 'browse' && (
presetsLoading ? (
<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 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<Sparkles className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-4">
{searchQuery || selectedCategory !== 'ALL' || selectedStyle !== 'all'
? t('emailTemplates.noPresets', 'No templates found matching your criteria')
: t('emailTemplates.noPresetsAvailable', 'No templates available')}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPresets.map((preset, index) => (
<div
key={index}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all cursor-pointer group"
>
{/* Preview */}
<div className="h-56 bg-gray-50 dark:bg-gray-700 relative overflow-hidden">
<div className="absolute inset-0">
<iframe
srcDoc={preset.html_content}
className="w-full h-full pointer-events-none"
title={preset.name}
sandbox="allow-same-origin"
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-6 gap-2">
<button
onClick={() => setPreviewPreset(preset)}
className="flex items-center gap-2 px-4 py-2 bg-white/95 dark:bg-gray-800/95 text-gray-900 dark:text-white rounded-lg text-sm font-medium hover:bg-white dark:hover:bg-gray-800"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => handleUsePreset(preset)}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700"
>
<Plus className="h-4 w-4" />
Use Template
</button>
</div>
</div>
{/* Info */}
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<h4 className="text-base font-semibold text-gray-900 dark:text-white line-clamp-1 flex-1">
{preset.name}
</h4>
</div>
<div className="flex items-center gap-2 mb-3">
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[preset.category]}`}>
{categoryIcons[preset.category]}
{preset.category}
</span>
<span className={`inline-flex items-center gap-1 px-2.5 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-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{preset.description}
</p>
</div>
</div>
))}
</div>
)
)}
{/* My Templates List */}
{activeView === 'my-templates' && (filteredTemplates.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<Mail className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-4">
@@ -277,15 +560,92 @@ const EmailTemplates: React.FC = () => {
</div>
) : (
<div className="space-y-4">
{/* Bulk Actions Bar */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Select All Checkbox */}
<button
onClick={handleSelectAll}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{selectedTemplates.size === filteredTemplates.length && filteredTemplates.length > 0 ? (
<CheckSquare className="h-5 w-5 text-brand-600" />
) : selectedTemplates.size > 0 ? (
<div className="relative">
<Square className="h-5 w-5" />
<Minus className="h-3 w-3 absolute top-1 left-1 text-brand-600" />
</div>
) : (
<Square className="h-5 w-5" />
)}
<span>
{selectedTemplates.size === 0
? t('emailTemplates.selectAll', 'Select All')
: selectedTemplates.size === filteredTemplates.length
? t('emailTemplates.deselectAll', 'Deselect All')
: t('emailTemplates.selectedCount', '{{count}} selected', { count: selectedTemplates.size })}
</span>
</button>
</div>
{/* Bulk Delete Button */}
{selectedTemplates.size > 0 && (
<button
onClick={handleBulkDelete}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
<Trash2 className="h-4 w-4" />
{t('emailTemplates.deleteSelected', 'Delete Selected')} ({selectedTemplates.size})
</button>
)}
</div>
{filteredTemplates.map((template) => (
<div
key={template.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
className={`bg-white dark:bg-gray-800 rounded-xl border shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
selectedTemplates.has(template.id)
? 'border-brand-500 ring-2 ring-brand-500/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="p-6">
<div className="flex items-start justify-between">
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="flex">
{/* Checkbox */}
<div className="flex items-center justify-center w-12 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
<button
onClick={() => handleSelectTemplate(template.id)}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{selectedTemplates.has(template.id) ? (
<CheckSquare className="h-5 w-5 text-brand-600" />
) : (
<Square className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{/* Preview */}
<div className="w-48 h-32 bg-gray-50 dark:bg-gray-700 relative overflow-hidden flex-shrink-0">
{template.htmlContent ? (
<div className="absolute inset-0">
<iframe
srcDoc={template.htmlContent}
className="w-full h-full pointer-events-none"
title={template.name}
sandbox="allow-same-origin"
/>
</div>
) : (
<div className="flex items-center justify-center w-full h-full text-gray-400 text-xs">
No HTML Content
</div>
)}
</div>
<div className="p-6 flex-1">
<div className="flex items-start justify-between">
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{template.name}
@@ -359,11 +719,80 @@ const EmailTemplates: React.FC = () => {
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div> {/* Closes flex items-start justify-between */}
</div> {/* Closes p-6 flex-1 */}
</div> {/* Closes flex */}
</div>
</div>
))}
</div>
))}
{/* Preset Preview Modal */}
{previewPreset && (
<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-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">
{previewPreset.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{previewPreset.description}
</p>
</div>
<button
onClick={() => setPreviewPreset(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">
{previewPreset.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={previewPreset.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={() => setPreviewPreset(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={() => {
handleUsePreset(previewPreset as TemplatePreset & { category: EmailTemplateCategory });
setPreviewPreset(null);
}}
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"
>
<Plus className="h-4 w-4" />
Use This Template
</button>
</div>
</div>
</div>
)}
@@ -528,6 +957,80 @@ const EmailTemplates: React.FC = () => {
</div>
</div>
)}
{/* Bulk Delete Confirmation Modal */}
{showBulkDeleteModal && (
<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-md w-full overflow-hidden">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('emailTemplates.confirmBulkDelete', 'Delete Templates')}
</h3>
</div>
<button
onClick={() => setShowBulkDeleteModal(false)}
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>
{/* Modal Body */}
<div className="p-6">
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t('emailTemplates.bulkDeleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{selectedTemplates.size} {t('emailTemplates.templates', 'templates')}</span>?
</p>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 max-h-32 overflow-y-auto">
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
{filteredTemplates
.filter(t => selectedTemplates.has(t.id))
.map(t => (
<li key={t.id} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full"></span>
{t.name}
</li>
))}
</ul>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using these templates may no longer work correctly.')}
</p>
</div>
{/* Modal Footer */}
<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={() => setShowBulkDeleteModal(false)}
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"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={confirmBulkDelete}
disabled={bulkDeleteMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{bulkDeleteMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.deleting', 'Deleting...')}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{t('emailTemplates.deleteAll', 'Delete All')} ({selectedTemplates.size})
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -783,6 +783,7 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
can_create_plugins: false,
can_white_label: false,
can_api_access: false,
can_use_masked_phone_numbers: false,
},
transaction_fee_percent: plan?.transaction_fee_percent
? parseFloat(plan.transaction_fee_percent)
@@ -790,6 +791,17 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
transaction_fee_fixed: plan?.transaction_fee_fixed
? parseFloat(plan.transaction_fee_fixed)
: 0,
// Communication pricing
sms_enabled: plan?.sms_enabled ?? false,
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
masked_calling_enabled: plan?.masked_calling_enabled ?? false,
masked_calling_price_per_minute_cents: plan?.masked_calling_price_per_minute_cents ?? 5,
proxy_number_enabled: plan?.proxy_number_enabled ?? false,
proxy_number_monthly_fee_cents: plan?.proxy_number_monthly_fee_cents ?? 200,
// Default credit settings
default_auto_reload_enabled: plan?.default_auto_reload_enabled ?? false,
default_auto_reload_threshold_cents: plan?.default_auto_reload_threshold_cents ?? 1000,
default_auto_reload_amount_cents: plan?.default_auto_reload_amount_cents ?? 2500,
is_active: plan?.is_active ?? true,
is_public: plan?.is_public ?? true,
is_most_popular: plan?.is_most_popular ?? false,
@@ -888,8 +900,8 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="base">Base Tier</option>
<option value="addon">Add-on</option>
<option value="base" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Base Tier</option>
<option value="addon" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Add-on</option>
</select>
</div>
</div>
@@ -955,14 +967,51 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
onChange={(e) => setFormData((prev) => ({ ...prev, business_tier: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">None</option>
<option value="Free">Free</option>
<option value="Professional">Professional</option>
<option value="Business">Business</option>
<option value="Enterprise">Enterprise</option>
<option value="" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">None (Add-on)</option>
<option value="Free" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Free</option>
<option value="Starter" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Starter</option>
<option value="Professional" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Professional</option>
<option value="Business" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Business</option>
<option value="Enterprise" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Enterprise</option>
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trial Days
</label>
<input
type="number"
min="0"
value={formData.limits?.trial_days ?? 0}
onChange={(e) => setFormData((prev) => ({
...prev,
limits: { ...prev.limits, trial_days: parseInt(e.target.value) || 0 }
}))}
placeholder="0"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Days of free trial</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Display Order
</label>
<input
type="number"
min="0"
value={formData.limits?.display_order ?? 0}
onChange={(e) => setFormData((prev) => ({
...prev,
limits: { ...prev.limits, display_order: parseInt(e.target.value) || 0 }
}))}
placeholder="0"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Order on pricing page</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -1001,20 +1050,220 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
</div>
</div>
{/* Communication Pricing */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Communication Pricing
</h3>
{/* SMS Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">SMS Reminders</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Allow businesses on this tier to send SMS reminders</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.sms_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, sms_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
{formData.sms_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Price per SMS (cents)
</label>
<input
type="number"
min="0"
step="1"
value={formData.sms_price_per_message_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
sms_price_per_message_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Current: ${((formData.sms_price_per_message_cents || 0) / 100).toFixed(2)} per message
</p>
</div>
)}
</div>
{/* Masked Calling Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Masked Calling</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Allow anonymous calls between customers and staff</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.masked_calling_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, masked_calling_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
{formData.masked_calling_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Price per minute (cents)
</label>
<input
type="number"
min="0"
step="1"
value={formData.masked_calling_price_per_minute_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
masked_calling_price_per_minute_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Current: ${((formData.masked_calling_price_per_minute_cents || 0) / 100).toFixed(2)} per minute
</p>
</div>
)}
</div>
{/* Proxy Phone Number Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Proxy Phone Numbers</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Dedicated phone numbers for masked communication</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.proxy_number_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, proxy_number_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
{formData.proxy_number_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly fee per number (cents)
</label>
<input
type="number"
min="0"
step="1"
value={formData.proxy_number_monthly_fee_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
proxy_number_monthly_fee_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Current: ${((formData.proxy_number_monthly_fee_cents || 0) / 100).toFixed(2)} per month
</p>
</div>
)}
</div>
{/* Default Credit Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
Default auto-reload settings for new businesses on this tier
</p>
<div className="space-y-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.default_auto_reload_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, default_auto_reload_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Auto-reload enabled by default</span>
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reload threshold (cents)
</label>
<input
type="number"
min="0"
step="100"
value={formData.default_auto_reload_threshold_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
default_auto_reload_threshold_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Reload when balance falls below ${((formData.default_auto_reload_threshold_cents || 0) / 100).toFixed(2)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reload amount (cents)
</label>
<input
type="number"
min="0"
step="100"
value={formData.default_auto_reload_amount_cents || 0}
onChange={(e) =>
setFormData((prev) => ({
...prev,
default_auto_reload_amount_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Add ${((formData.default_auto_reload_amount_cents || 0) / 100).toFixed(2)} to balance
</p>
</div>
</div>
</div>
</div>
</div>
{/* Limits Configuration */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Limits Configuration
</h3>
<div className="grid grid-cols-2 gap-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Use -1 for unlimited. These limits control what businesses on this plan can create.
</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users
</label>
<input
type="number"
min="1"
value={formData.limits?.max_users || 0}
min="-1"
value={formData.limits?.max_users ?? 0}
onChange={(e) => handleLimitChange('max_users', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
@@ -1025,12 +1274,24 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
</label>
<input
type="number"
min="1"
value={formData.limits?.max_resources || 0}
min="-1"
value={formData.limits?.max_resources ?? 0}
onChange={(e) => handleLimitChange('max_resources', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Services
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_services ?? 0}
onChange={(e) => handleLimitChange('max_services', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Appointments / Month
@@ -1038,19 +1299,31 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
<input
type="number"
min="-1"
value={formData.limits?.max_appointments || 0}
value={formData.limits?.max_appointments ?? 0}
onChange={(e) => handleLimitChange('max_appointments', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Email Templates
</label>
<input
type="number"
min="-1"
value={formData.limits?.max_email_templates ?? 0}
onChange={(e) => handleLimitChange('max_email_templates', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Automated Tasks
</label>
<input
type="number"
min="0"
value={formData.limits?.max_automated_tasks || 0}
min="-1"
value={formData.limits?.max_automated_tasks ?? 0}
onChange={(e) => handleLimitChange('max_automated_tasks', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
@@ -1063,79 +1336,205 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Features & Permissions
</h3>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.can_accept_payments || false}
onChange={(e) => handlePermissionChange('can_accept_payments', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Stripe Payments</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.sms_reminders || false}
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.advanced_reporting || false}
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Reporting</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.priority_support || false}
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Email Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.can_use_custom_domain || false}
onChange={(e) => handlePermissionChange('can_use_custom_domain', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.can_create_plugins || false}
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.can_white_label || false}
onChange={(e) => handlePermissionChange('can_white_label', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<input
type="checkbox"
checked={formData.permissions?.can_api_access || false}
onChange={(e) => handlePermissionChange('can_api_access', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available to businesses on this plan.
</p>
{/* Payments & Revenue */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_accept_payments || false}
onChange={(e) => handlePermissionChange('can_accept_payments', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_process_refunds || false}
onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_create_packages || false}
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
</label>
</div>
</div>
{/* Communication */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.sms_reminders || false}
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_masked_phone_numbers || false}
onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_email_templates || false}
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
</label>
</div>
</div>
{/* Customization */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_customize_booking_page || false}
onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_custom_domain || false}
onChange={(e) => handlePermissionChange('can_use_custom_domain', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_white_label || false}
onChange={(e) => handlePermissionChange('can_white_label', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
</label>
</div>
</div>
{/* Advanced Features */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.advanced_reporting || false}
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_api_access || false}
onChange={(e) => handlePermissionChange('can_api_access', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_create_plugins || false}
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_export_data || false}
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_webhooks || false}
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.calendar_sync || false}
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
</label>
</div>
</div>
{/* Support & Enterprise */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.priority_support || false}
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.dedicated_support || false}
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.sso_enabled || false}
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
</label>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
/**
* API Settings Page
*
* Manage API tokens and webhooks for third-party integrations.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Key } from 'lucide-react';
import { Business, User } from '../../types';
import ApiTokensSection from '../../components/ApiTokensSection';
const ApiSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
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">
<Key className="text-amber-500" />
{t('settings.api.title', 'API & Webhooks')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Manage API access tokens and configure webhooks for integrations.
</p>
</div>
{/* API Tokens Section */}
<ApiTokensSection />
</div>
);
};
export default ApiSettings;

View File

@@ -0,0 +1,427 @@
/**
* Authentication Settings Page
*
* Configure OAuth providers, social login, and custom credentials.
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Lock, Users, Key, Save, Check, AlertCircle, Eye, EyeOff } from 'lucide-react';
import { Business, User } from '../../types';
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../../hooks/useBusinessOAuth';
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../../hooks/useBusinessOAuthCredentials';
// Provider display names and icons
const providerInfo: Record<string, { name: string; icon: string }> = {
google: { name: 'Google', icon: '🔍' },
apple: { name: 'Apple', icon: '🍎' },
facebook: { name: 'Facebook', icon: '📘' },
linkedin: { name: 'LinkedIn', icon: '💼' },
microsoft: { name: 'Microsoft', icon: '🪟' },
twitter: { name: 'X (Twitter)', icon: '🐦' },
twitch: { name: 'Twitch', icon: '🎮' },
};
const AuthenticationSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
// OAuth Settings hooks
const { data: oauthData, isLoading: oauthLoading } = useBusinessOAuthSettings();
const updateOAuthMutation = useUpdateBusinessOAuthSettings();
const [oauthSettings, setOAuthSettings] = useState({
enabledProviders: [] as string[],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: false,
});
// OAuth Credentials hooks
const { data: oauthCredentials, isLoading: credentialsLoading } = useBusinessOAuthCredentials();
const updateCredentialsMutation = useUpdateBusinessOAuthCredentials();
const [useCustomCredentials, setUseCustomCredentials] = useState(false);
const [credentials, setCredentials] = useState<any>({
google: { client_id: '', client_secret: '' },
apple: { client_id: '', client_secret: '', team_id: '', key_id: '' },
facebook: { client_id: '', client_secret: '' },
linkedin: { client_id: '', client_secret: '' },
microsoft: { client_id: '', client_secret: '', tenant_id: '' },
twitter: { client_id: '', client_secret: '' },
twitch: { client_id: '', client_secret: '' },
});
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
// Update OAuth settings when data loads
useEffect(() => {
if (oauthData?.settings) {
setOAuthSettings(oauthData.settings);
}
}, [oauthData]);
// Update OAuth credentials when data loads
useEffect(() => {
if (oauthCredentials) {
setUseCustomCredentials(oauthCredentials.useCustomCredentials || false);
const creds = oauthCredentials.credentials || {};
setCredentials({
google: creds.google || { client_id: '', client_secret: '' },
apple: creds.apple || { client_id: '', client_secret: '', team_id: '', key_id: '' },
facebook: creds.facebook || { client_id: '', client_secret: '' },
linkedin: creds.linkedin || { client_id: '', client_secret: '' },
microsoft: creds.microsoft || { client_id: '', client_secret: '', tenant_id: '' },
twitter: creds.twitter || { client_id: '', client_secret: '' },
twitch: creds.twitch || { client_id: '', client_secret: '' },
});
}
}, [oauthCredentials]);
// Auto-hide toast
useEffect(() => {
if (showToast) {
const timer = setTimeout(() => setShowToast(false), 3000);
return () => clearTimeout(timer);
}
}, [showToast]);
const handleOAuthSave = () => {
updateOAuthMutation.mutate(oauthSettings, {
onSuccess: () => {
setShowToast(true);
},
});
};
const toggleProvider = (provider: string) => {
setOAuthSettings((prev) => {
const isEnabled = prev.enabledProviders.includes(provider);
return {
...prev,
enabledProviders: isEnabled
? prev.enabledProviders.filter((p) => p !== provider)
: [...prev.enabledProviders, provider],
};
});
};
const handleCredentialsSave = () => {
const updateData: any = {
use_custom_credentials: useCustomCredentials,
};
if (useCustomCredentials) {
Object.entries(credentials).forEach(([provider, creds]: [string, any]) => {
if (creds.client_id || creds.client_secret) {
updateData[provider] = creds;
}
});
}
updateCredentialsMutation.mutate(updateData, {
onSuccess: () => {
setShowToast(true);
},
});
};
const updateCredential = (provider: string, field: string, value: string) => {
setCredentials((prev: any) => ({
...prev,
[provider]: {
...prev[provider],
[field]: value,
},
}));
};
const toggleShowSecret = (key: string) => {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
};
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">
<Lock className="text-purple-500" />
{t('settings.authentication.title', 'Authentication')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure social login and OAuth providers for customer sign-in.
</p>
</div>
{/* OAuth & Social Login */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Users size={20} className="text-indigo-500" /> Social Login
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Choose which providers customers can use to sign in</p>
</div>
<button
onClick={handleOAuthSave}
disabled={oauthLoading || updateOAuthMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={16} />
{updateOAuthMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
{oauthLoading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : oauthData?.availableProviders && oauthData.availableProviders.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{oauthData.availableProviders.map((provider: any) => {
const isEnabled = oauthSettings.enabledProviders.includes(provider.id);
const info = providerInfo[provider.id] || { name: provider.name, icon: '🔐' };
return (
<button
key={provider.id}
type="button"
onClick={() => toggleProvider(provider.id)}
className={`relative p-3 rounded-lg border-2 transition-all text-left ${
isEnabled
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
{isEnabled && (
<div className="absolute top-1.5 right-1.5 w-4 h-4 bg-brand-500 rounded-full flex items-center justify-center">
<Check size={10} className="text-white" />
</div>
)}
<div className="flex items-center gap-2">
<span className="text-lg">{info.icon}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{info.name}</span>
</div>
</button>
);
})}
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Allow OAuth Registration</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">New customers can create accounts via OAuth</p>
</div>
<button
type="button"
className={`${oauthSettings.allowRegistration ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
role="switch"
onClick={() => setOAuthSettings((prev) => ({ ...prev, allowRegistration: !prev.allowRegistration }))}
>
<span className={`${oauthSettings.allowRegistration ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Auto-link by Email</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Link OAuth accounts to existing accounts by email</p>
</div>
<button
type="button"
className={`${oauthSettings.autoLinkByEmail ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
role="switch"
onClick={() => setOAuthSettings((prev) => ({ ...prev, autoLinkByEmail: !prev.autoLinkByEmail }))}
>
<span className={`${oauthSettings.autoLinkByEmail ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
</div>
</div>
) : (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-300 text-sm">No OAuth Providers Available</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1">
Contact your platform administrator to enable OAuth providers.
</p>
</div>
</div>
</div>
)}
</section>
{/* Custom OAuth Credentials - Only shown if platform has enabled this permission */}
{business.canManageOAuthCredentials && (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Key size={20} className="text-purple-500" />
Custom OAuth Credentials
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Use your own OAuth app credentials for complete branding control
</p>
</div>
<button
onClick={handleCredentialsSave}
disabled={credentialsLoading || updateCredentialsMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={16} />
{updateCredentialsMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
{credentialsLoading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : (
<div className="space-y-4">
{/* Toggle Custom Credentials */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Use Custom Credentials</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{useCustomCredentials ? 'Using your custom OAuth credentials' : 'Using platform shared credentials'}
</p>
</div>
<button
type="button"
className={`${useCustomCredentials ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
role="switch"
onClick={() => setUseCustomCredentials(!useCustomCredentials)}
>
<span className={`${useCustomCredentials ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
{useCustomCredentials && (
<div className="space-y-3">
{(['google', 'apple', 'facebook', 'linkedin', 'microsoft', 'twitter', 'twitch'] as const).map((provider) => {
const info = providerInfo[provider];
const providerCreds = credentials[provider];
const hasCredentials = providerCreds.client_id || providerCreds.client_secret;
return (
<details key={provider} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<summary className="flex items-center justify-between p-3 cursor-pointer bg-gray-50 dark:bg-gray-900/50 hover:bg-gray-100 dark:hover:bg-gray-900">
<div className="flex items-center gap-2">
<span className="text-lg">{info.icon}</span>
<span className="font-medium text-gray-900 dark:text-white text-sm">{info.name}</span>
{hasCredentials && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded">
Configured
</span>
)}
</div>
</summary>
<div className="p-3 space-y-2 border-t border-gray-200 dark:border-gray-700">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Client ID</label>
<input
type="text"
value={providerCreds.client_id}
onChange={(e) => updateCredential(provider, 'client_id', e.target.value)}
placeholder={`Enter ${info.name} Client ID`}
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Client Secret</label>
<div className="relative">
<input
type={showSecrets[`${provider}_secret`] ? 'text' : 'password'}
value={providerCreds.client_secret}
onChange={(e) => updateCredential(provider, 'client_secret', e.target.value)}
placeholder={`Enter ${info.name} Client Secret`}
className="w-full px-3 py-1.5 pr-8 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
/>
<button
type="button"
onClick={() => toggleShowSecret(`${provider}_secret`)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showSecrets[`${provider}_secret`] ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
{/* Provider-specific fields */}
{provider === 'apple' && (
<>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Team ID</label>
<input
type="text"
value={providerCreds.team_id || ''}
onChange={(e) => updateCredential(provider, 'team_id', e.target.value)}
placeholder="Enter Apple Team ID"
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Key ID</label>
<input
type="text"
value={providerCreds.key_id || ''}
onChange={(e) => updateCredential(provider, 'key_id', e.target.value)}
placeholder="Enter Apple Key ID"
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
/>
</div>
</>
)}
{provider === 'microsoft' && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant ID</label>
<input
type="text"
value={providerCreds.tenant_id || ''}
onChange={(e) => updateCredential(provider, 'tenant_id', e.target.value)}
placeholder="Enter Microsoft Tenant ID (or 'common')"
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
/>
</div>
)}
</div>
</details>
);
})}
</div>
)}
</div>
)}
</section>
)}
{/* 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 AuthenticationSettings;

View File

@@ -0,0 +1,288 @@
/**
* Billing Settings Page
*
* Manage subscription plan, payment methods, and view invoices.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
CreditCard, Crown, Plus, Trash2, Check, AlertCircle,
FileText, ExternalLink, Wallet, Star
} from 'lucide-react';
import { Business, User } from '../../types';
// Plan details for display
const planDetails: Record<string, { name: string; price: string; features: string[] }> = {
Free: {
name: 'Free',
price: '$0/month',
features: ['Up to 10 resources', 'Basic scheduling', 'Email support'],
},
Starter: {
name: 'Starter',
price: '$29/month',
features: ['Up to 50 resources', 'Custom branding', 'Priority email support', 'API access'],
},
Professional: {
name: 'Professional',
price: '$79/month',
features: ['Unlimited resources', 'Custom domains', 'Phone support', 'Advanced analytics', 'Team permissions'],
},
Enterprise: {
name: 'Enterprise',
price: 'Custom',
features: ['All Professional features', 'Dedicated account manager', 'Custom integrations', 'SLA guarantee'],
},
};
const BillingSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
const [showAddCard, setShowAddCard] = useState(false);
const isOwner = user.role === 'owner';
// Mock payment methods - in a real app, these would come from Stripe
const [paymentMethods] = useState([
{ id: 'pm_1', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2025, isDefault: true },
]);
const currentPlan = planDetails[business.plan || 'Free'] || planDetails.Free;
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">
<CreditCard className="text-emerald-500" />
{t('settings.billing.title', 'Plan & Billing')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Manage your subscription, payment methods, and billing history.
</p>
</div>
{/* Current Plan */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Crown size={20} className="text-amber-500" />
Current Plan
</h3>
<div className="mt-4">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{currentPlan.name}
</span>
<span className="text-gray-500 dark:text-gray-400">
{currentPlan.price}
</span>
</div>
<ul className="mt-4 space-y-2">
{currentPlan.features.map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500" />
{feature}
</li>
))}
</ul>
</div>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center gap-2">
<Crown size={16} />
Upgrade Plan
</button>
</div>
</section>
{/* Wallet / Credits Summary */}
<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 flex items-center gap-2 mb-4">
<Wallet size={20} className="text-blue-500" />
Wallet
</h3>
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Your communication credits are managed in the SMS & Calling settings.
</p>
<a
href="/settings/sms-calling"
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Manage Credits
<ExternalLink size={14} />
</a>
</div>
</section>
{/* Payment Methods */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<CreditCard size={20} className="text-purple-500" />
Payment Methods
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage your payment methods for subscriptions and credits
</p>
</div>
<button
onClick={() => setShowAddCard(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-600 border border-brand-600 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Plus size={16} />
Add Card
</button>
</div>
{paymentMethods.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<CreditCard size={40} className="mx-auto mb-2 opacity-30" />
<p>No payment methods added yet.</p>
<p className="text-sm mt-1">Add a card to enable auto-reload and subscriptions.</p>
</div>
) : (
<div className="space-y-3">
{paymentMethods.map((method) => (
<div
key={method.id}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-center gap-4">
<div className="w-12 h-8 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600 flex items-center justify-center">
{method.brand === 'visa' && (
<span className="text-blue-600 font-bold text-sm">VISA</span>
)}
{method.brand === 'mastercard' && (
<span className="text-orange-600 font-bold text-sm">MC</span>
)}
{method.brand === 'amex' && (
<span className="text-blue-800 font-bold text-sm">AMEX</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{method.last4}
</span>
{method.isDefault && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-300 rounded flex items-center gap-1">
<Star size={10} className="fill-current" /> Default
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Expires {method.expMonth}/{method.expYear}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!method.isDefault && (
<button
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
Set Default
</button>
)}
<button
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Remove"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</section>
{/* Billing History */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<FileText size={20} className="text-gray-500" />
Billing History
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
View and download your invoices
</p>
</div>
</div>
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<FileText size={40} className="mx-auto mb-2 opacity-30" />
<p>No invoices yet.</p>
<p className="text-sm mt-1">Your invoices will appear here after your first payment.</p>
</div>
</section>
{/* Notice for Free Plan */}
{business.plan === 'Free' && (
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<AlertCircle size={24} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">You're on the Free Plan</h4>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to unlock custom domains, advanced features, and priority support.
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
<Crown size={16} /> View Plans
</button>
</div>
</div>
</section>
)}
{/* Add Card Modal Placeholder */}
{showAddCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add Payment Method
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
This will open a secure Stripe checkout to add your card.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowAddCard(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
Continue to Stripe
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BillingSettings;

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;

View File

@@ -0,0 +1,727 @@
/**
* Communication Settings Page
*
* Manage SMS and calling credits, auto-reload settings, and view transaction history.
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
Phone, Wallet, RefreshCw, Check, CreditCard, Loader2,
ArrowUpRight, ArrowDownRight, Clock, Save, MessageSquare
} from 'lucide-react';
import { Business, User } from '../../types';
import {
useCommunicationCredits,
useCreditTransactions,
useUpdateCreditsSettings,
} from '../../hooks/useCommunicationCredits';
import { CreditPaymentModal } from '../../components/CreditPaymentForm';
const CommunicationSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
const { data: credits, isLoading: creditsLoading } = useCommunicationCredits();
const { data: transactions } = useCreditTransactions(1, 10);
const updateSettings = useUpdateCreditsSettings();
// Wizard state
const [showWizard, setShowWizard] = useState(false);
const [wizardStep, setWizardStep] = useState(1);
const [wizardData, setWizardData] = useState({
appointmentsPerMonth: 100,
smsRemindersEnabled: true,
smsPerAppointment: 2,
maskedCallingEnabled: false,
avgCallMinutes: 3,
callsPerMonth: 20,
dedicatedNumberNeeded: false,
callingPattern: 'sequential' as 'concurrent' | 'sequential',
staffCount: 1,
maxDailyAppointmentsPerStaff: 8,
});
// Settings form state
const [settingsForm, setSettingsForm] = useState({
auto_reload_enabled: credits?.auto_reload_enabled ?? false,
auto_reload_threshold_cents: credits?.auto_reload_threshold_cents ?? 1000,
auto_reload_amount_cents: credits?.auto_reload_amount_cents ?? 2500,
low_balance_warning_cents: credits?.low_balance_warning_cents ?? 500,
});
// Top-up modal state
const [showTopUp, setShowTopUp] = useState(false);
const [topUpAmount, setTopUpAmount] = useState(2500);
const isOwner = user.role === 'owner';
// Update settings form when credits data loads
useEffect(() => {
if (credits) {
setSettingsForm({
auto_reload_enabled: credits.auto_reload_enabled,
auto_reload_threshold_cents: credits.auto_reload_threshold_cents,
auto_reload_amount_cents: credits.auto_reload_amount_cents,
low_balance_warning_cents: credits.low_balance_warning_cents,
});
}
}, [credits]);
// Check if needs setup
const needsSetup = !credits || (credits.balance_cents === 0 && credits.total_loaded_cents === 0);
// Calculate recommended phone numbers based on calling pattern
const getRecommendedPhoneNumbers = () => {
if (!wizardData.maskedCallingEnabled || !wizardData.dedicatedNumberNeeded) {
return 0;
}
if (wizardData.callingPattern === 'sequential') {
return Math.max(1, Math.ceil(wizardData.staffCount / 3));
} else {
return wizardData.maxDailyAppointmentsPerStaff;
}
};
// Calculate estimated monthly cost
const calculateEstimate = () => {
let totalCents = 0;
if (wizardData.smsRemindersEnabled) {
const smsCount = wizardData.appointmentsPerMonth * wizardData.smsPerAppointment;
totalCents += smsCount * 3;
}
if (wizardData.maskedCallingEnabled) {
const callMinutes = wizardData.callsPerMonth * wizardData.avgCallMinutes;
totalCents += callMinutes * 5;
}
if (wizardData.dedicatedNumberNeeded) {
const recommendedNumbers = getRecommendedPhoneNumbers();
totalCents += recommendedNumbers * 200;
}
return totalCents;
};
// Calculate recommended starting balance
const getRecommendedBalance = () => {
const monthlyEstimate = calculateEstimate();
return Math.max(2500, Math.ceil((monthlyEstimate * 2.5) / 500) * 500);
};
const handleSaveSettings = async () => {
await updateSettings.mutateAsync(settingsForm);
};
const handlePaymentSuccess = async () => {
if (showWizard || needsSetup) {
await updateSettings.mutateAsync(settingsForm);
}
setShowTopUp(false);
setShowWizard(false);
};
const formatCurrency = (cents: number) => {
return `$${(cents / 100).toFixed(2)}`;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
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>
);
}
if (creditsLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Phone className="text-green-500" />
SMS & Calling Credits
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Manage your prepaid credits for SMS reminders and masked calling.
</p>
</div>
{!needsSetup && (
<button
onClick={() => setShowWizard(true)}
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Recalculate usage
</button>
)}
</div>
{/* Setup Wizard or Main Content */}
{needsSetup || showWizard ? (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="mb-6">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{needsSetup ? 'Set Up Communication Credits' : 'Estimate Your Usage'}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Answer a few questions to estimate your monthly communication costs
</p>
</div>
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-8">
{[1, 2, 3, 4].map((step) => (
<React.Fragment key={step}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
wizardStep >= step
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{wizardStep > step ? <Check className="w-4 h-4" /> : step}
</div>
{step < 4 && (
<div
className={`flex-1 h-1 ${
wizardStep > step ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
{/* Step 1: Appointment Volume */}
{wizardStep === 1 && (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
How many appointments do you handle per month?
</label>
<input
type="number"
min="0"
value={wizardData.appointmentsPerMonth}
onChange={(e) =>
setWizardData({ ...wizardData, appointmentsPerMonth: parseInt(e.target.value) || 0 })
}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-lg"
placeholder="100"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Include all appointments: new bookings, rescheduled, and recurring
</p>
</div>
<div className="flex justify-end">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
Continue
</button>
</div>
</div>
)}
{/* Step 2: SMS Settings */}
{wizardStep === 2 && (
<div className="space-y-6">
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<label className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
Enable SMS Reminders
</span>
<p className="text-xs text-gray-500 dark:text-gray-400">
Send text reminders to customers and staff
</p>
</div>
<input
type="checkbox"
checked={wizardData.smsRemindersEnabled}
onChange={(e) =>
setWizardData({ ...wizardData, smsRemindersEnabled: e.target.checked })
}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
/>
</label>
</div>
{wizardData.smsRemindersEnabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMS messages per appointment
</label>
<select
value={wizardData.smsPerAppointment}
onChange={(e) =>
setWizardData({ ...wizardData, smsPerAppointment: parseInt(e.target.value) })
}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="1">1 - Reminder only</option>
<option value="2">2 - Confirmation + Reminder</option>
<option value="3">3 - Confirmation + Reminder + Follow-up</option>
<option value="4">4 - All of above + Staff notifications</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Cost: $0.03 per SMS message
</p>
</div>
)}
<div className="flex justify-between">
<button
onClick={() => setWizardStep(1)}
className="px-6 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"
>
Back
</button>
<button
onClick={() => setWizardStep(3)}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
Continue
</button>
</div>
</div>
)}
{/* Step 3: Masked Calling */}
{wizardStep === 3 && (
<div className="space-y-6">
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<label className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
Enable Masked Calling
</span>
<p className="text-xs text-gray-500 dark:text-gray-400">
Allow customers and staff to call each other without revealing real numbers
</p>
</div>
<input
type="checkbox"
checked={wizardData.maskedCallingEnabled}
onChange={(e) =>
setWizardData({ ...wizardData, maskedCallingEnabled: e.target.checked })
}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
/>
</label>
</div>
{wizardData.maskedCallingEnabled && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Estimated calls per month
</label>
<input
type="number"
min="0"
value={wizardData.callsPerMonth}
onChange={(e) =>
setWizardData({ ...wizardData, callsPerMonth: parseInt(e.target.value) || 0 })
}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Average call duration (minutes)
</label>
<input
type="number"
min="1"
value={wizardData.avgCallMinutes}
onChange={(e) =>
setWizardData({ ...wizardData, avgCallMinutes: parseInt(e.target.value) || 1 })
}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Cost: $0.05 per minute of voice calling
</p>
</>
)}
<div className="flex justify-between">
<button
onClick={() => setWizardStep(2)}
className="px-6 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"
>
Back
</button>
<button
onClick={() => setWizardStep(4)}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
See Estimate
</button>
</div>
</div>
)}
{/* Step 4: Summary and Load Credits */}
{wizardStep === 4 && (
<div className="space-y-6">
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-6">
<h5 className="font-medium text-gray-900 dark:text-white mb-4">
Estimated Monthly Costs
</h5>
<div className="space-y-3">
{wizardData.smsRemindersEnabled && (
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
SMS Messages ({wizardData.appointmentsPerMonth * wizardData.smsPerAppointment}/mo)
</span>
<span className="font-medium text-gray-900 dark:text-white">
{formatCurrency(wizardData.appointmentsPerMonth * wizardData.smsPerAppointment * 3)}
</span>
</div>
)}
{wizardData.maskedCallingEnabled && (
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Voice Calling ({wizardData.callsPerMonth * wizardData.avgCallMinutes} min/mo)
</span>
<span className="font-medium text-gray-900 dark:text-white">
{formatCurrency(wizardData.callsPerMonth * wizardData.avgCallMinutes * 5)}
</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-3 mt-3">
<div className="flex justify-between">
<span className="font-medium text-gray-900 dark:text-white">Total Estimated</span>
<span className="text-xl font-bold text-brand-600">
{formatCurrency(calculateEstimate())}/month
</span>
</div>
</div>
</div>
</div>
<div className="bg-brand-50 dark:bg-brand-900/20 rounded-lg p-6">
<h5 className="font-medium text-brand-900 dark:text-brand-100 mb-2">
Recommended Starting Balance
</h5>
<p className="text-3xl font-bold text-brand-600 mb-2">
{formatCurrency(getRecommendedBalance())}
</p>
<p className="text-sm text-brand-700 dark:text-brand-300">
This covers approximately 2-3 months of estimated usage with a safety buffer
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Choose your starting amount
</label>
<div className="grid grid-cols-4 gap-3">
{[1000, 2500, 5000, 10000].map((amount) => (
<button
key={amount}
onClick={() => setTopUpAmount(amount)}
className={`py-3 px-4 rounded-lg border-2 transition-colors ${
topUpAmount === 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>
<div className="flex justify-between">
<button
onClick={() => setWizardStep(3)}
className="px-6 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"
>
Back
</button>
<div className="flex gap-3">
{!needsSetup && (
<button
onClick={() => setShowWizard(false)}
className="px-6 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={() => setShowTopUp(true)}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 flex items-center gap-2"
>
<CreditCard className="w-4 h-4" />
Load {formatCurrency(topUpAmount)}
</button>
</div>
</div>
</div>
)}
</div>
) : (
<>
{/* Current Balance Card */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Current Balance</span>
<Wallet className="w-5 h-5 text-gray-400" />
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{formatCurrency(credits?.balance_cents || 0)}
</p>
<button
onClick={() => setShowTopUp(true)}
className="mt-3 w-full py-2 text-sm font-medium text-brand-600 border border-brand-600 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20"
>
Add Credits
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Total Loaded</span>
<ArrowUpRight className="w-5 h-5 text-green-500" />
</div>
<p className="text-3xl font-bold text-green-600">
{formatCurrency(credits?.total_loaded_cents || 0)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">All time</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Total Spent</span>
<ArrowDownRight className="w-5 h-5 text-red-500" />
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{formatCurrency(credits?.total_spent_cents || 0)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">All time</p>
</div>
</div>
{/* Auto-Reload Settings */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
Auto-Reload Settings
</h4>
<div className="space-y-4">
<label className="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
Enable Auto-Reload
</span>
<p className="text-xs text-gray-500 dark:text-gray-400">
Automatically add credits when balance falls below threshold
</p>
</div>
<input
type="checkbox"
checked={settingsForm.auto_reload_enabled}
onChange={(e) =>
setSettingsForm({ ...settingsForm, auto_reload_enabled: e.target.checked })
}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
/>
</label>
{settingsForm.auto_reload_enabled && (
<div className="grid grid-cols-2 gap-4 pl-4 border-l-2 border-brand-200 dark:border-brand-800">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reload when balance below
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="1"
step="1"
value={(settingsForm.auto_reload_threshold_cents / 100).toFixed(0)}
onChange={(e) =>
setSettingsForm({
...settingsForm,
auto_reload_threshold_cents: (parseFloat(e.target.value) || 0) * 100,
})
}
className="w-full pl-8 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reload amount
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="5"
step="5"
value={(settingsForm.auto_reload_amount_cents / 100).toFixed(0)}
onChange={(e) =>
setSettingsForm({
...settingsForm,
auto_reload_amount_cents: (parseFloat(e.target.value) || 0) * 100,
})
}
className="w-full pl-8 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"
/>
</div>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Low balance warning at
</label>
<div className="relative w-1/2">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="1"
step="1"
value={(settingsForm.low_balance_warning_cents / 100).toFixed(0)}
onChange={(e) =>
setSettingsForm({
...settingsForm,
low_balance_warning_cents: (parseFloat(e.target.value) || 0) * 100,
})
}
className="w-full pl-8 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"
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
You'll receive an email when your balance drops below this amount
</p>
</div>
<div className="pt-4">
<button
onClick={handleSaveSettings}
disabled={updateSettings.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center gap-2"
>
{updateSettings.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Settings
</button>
</div>
</div>
</div>
{/* Transaction History */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Clock className="w-4 h-4" />
Recent Transactions
</h4>
{transactions?.results && transactions.results.length > 0 ? (
<div className="space-y-3">
{transactions.results.map((tx: any) => (
<div
key={tx.id}
className="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700 last:border-0"
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
tx.amount_cents > 0
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
: 'bg-red-100 dark:bg-red-900/30 text-red-600'
}`}
>
{tx.amount_cents > 0 ? (
<ArrowUpRight className="w-4 h-4" />
) : (
<ArrowDownRight className="w-4 h-4" />
)}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{tx.description || tx.transaction_type.replace('_', ' ')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatDate(tx.created_at)}
</p>
</div>
</div>
<div className="text-right">
<p
className={`font-medium ${
tx.amount_cents > 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{tx.amount_cents > 0 ? '+' : ''}
{formatCurrency(tx.amount_cents)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Balance: {formatCurrency(tx.balance_after_cents)}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No transactions yet
</p>
)}
</div>
</>
)}
{/* Credit Payment Modal */}
<CreditPaymentModal
isOpen={showTopUp}
onClose={() => setShowTopUp(false)}
defaultAmount={topUpAmount}
onSuccess={handlePaymentSuccess}
/>
</div>
);
};
export default CommunicationSettings;

View File

@@ -0,0 +1,333 @@
/**
* Domains Settings Page
*
* Manage custom domains and booking URLs for the business.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
Globe, Link2, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
ShoppingCart, Crown
} from 'lucide-react';
import { Business, User, CustomDomain } from '../../types';
import {
useCustomDomains,
useAddCustomDomain,
useDeleteCustomDomain,
useVerifyCustomDomain,
useSetPrimaryDomain
} from '../../hooks/useCustomDomains';
import DomainPurchase from '../../components/DomainPurchase';
const DomainsSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
// Hooks
const { data: customDomains = [], isLoading: domainsLoading } = useCustomDomains();
const addDomainMutation = useAddCustomDomain();
const deleteDomainMutation = useDeleteCustomDomain();
const verifyDomainMutation = useVerifyCustomDomain();
const setPrimaryMutation = useSetPrimaryDomain();
// Local state
const [newDomain, setNewDomain] = useState('');
const [verifyingDomainId, setVerifyingDomainId] = useState<number | null>(null);
const [verifyError, setVerifyError] = useState<string | null>(null);
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
const handleAddDomain = () => {
if (!newDomain.trim()) return;
addDomainMutation.mutate(newDomain, {
onSuccess: () => {
setNewDomain('');
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Failed to add domain');
},
});
};
const handleDeleteDomain = (domainId: number) => {
if (!confirm('Are you sure you want to delete this custom domain?')) return;
deleteDomainMutation.mutate(domainId, {
onSuccess: () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
});
};
const handleVerifyDomain = (domainId: number) => {
setVerifyingDomainId(domainId);
setVerifyError(null);
verifyDomainMutation.mutate(domainId, {
onSuccess: (data: any) => {
setVerifyingDomainId(null);
if (data.verified) {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
} else {
setVerifyError(data.message);
}
},
onError: (error: any) => {
setVerifyingDomainId(null);
setVerifyError(error.response?.data?.message || 'Verification failed');
},
});
};
const handleSetPrimary = (domainId: number) => {
setPrimaryMutation.mutate(domainId, {
onSuccess: () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Failed to set primary domain');
},
});
};
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">
<Globe className="text-indigo-500" />
{t('settings.domains.title', 'Custom Domains')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure custom domains for your booking pages.
</p>
</div>
{/* Quick Domain Setup - Booking URL */}
<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 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
</h3>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
{business.subdomain}.smoothschedule.com
</code>
<button
onClick={() => navigator.clipboard.writeText(`${business.subdomain}.smoothschedule.com`)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Copy to clipboard"
>
<Copy size={16} />
</button>
</div>
</section>
{/* Custom Domains Management */}
{business.plan !== 'Free' ? (
<>
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Globe size={20} className="text-indigo-500" />
Custom Domains
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Use your own domains for your booking pages
</p>
</div>
</div>
{/* Add New Domain Form */}
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="booking.yourdomain.com"
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddDomain();
}
}}
/>
<button
onClick={handleAddDomain}
disabled={addDomainMutation.isPending || !newDomain.trim()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium text-sm"
>
{addDomainMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</div>
{/* Custom Domains List */}
{domainsLoading ? (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
Loading domains...
</div>
) : customDomains.length === 0 ? (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
<Globe size={40} className="mx-auto mb-2 opacity-30" />
<p className="text-sm">No custom domains yet. Add one above.</p>
</div>
) : (
<div className="space-y-3">
{customDomains.map((domain: CustomDomain) => (
<div
key={domain.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{domain.domain}
</h4>
{domain.is_primary && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300 rounded">
<Star size={10} className="fill-current" /> Primary
</span>
)}
{domain.is_verified ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded">
<CheckCircle size={10} /> Verified
</span>
) : (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded">
<AlertCircle size={10} /> Pending
</span>
)}
</div>
{!domain.is_verified && (
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-1">Add DNS TXT record:</p>
<div className="space-y-1">
<div className="flex items-center gap-1">
<span className="text-amber-700 dark:text-amber-400">Name:</span>
<code className="px-1 bg-white dark:bg-gray-800 rounded text-gray-900 dark:text-white">{domain.dns_txt_record_name}</code>
<button onClick={() => navigator.clipboard.writeText(domain.dns_txt_record_name || '')} className="text-amber-600 hover:text-amber-700"><Copy size={12} /></button>
</div>
<div className="flex items-center gap-1">
<span className="text-amber-700 dark:text-amber-400">Value:</span>
<code className="px-1 bg-white dark:bg-gray-800 rounded text-gray-900 dark:text-white truncate max-w-[200px]">{domain.dns_txt_record}</code>
<button onClick={() => navigator.clipboard.writeText(domain.dns_txt_record || '')} className="text-amber-600 hover:text-amber-700"><Copy size={12} /></button>
</div>
</div>
{verifyError && verifyingDomainId === domain.id && (
<p className="mt-1 text-red-600 dark:text-red-400">{verifyError}</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-3">
{!domain.is_verified && (
<button
onClick={() => handleVerifyDomain(domain.id)}
disabled={verifyingDomainId === domain.id}
className="p-1.5 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded transition-colors disabled:opacity-50"
title="Verify"
>
<RefreshCw size={16} className={verifyingDomainId === domain.id ? 'animate-spin' : ''} />
</button>
)}
{domain.is_verified && !domain.is_primary && (
<button
onClick={() => handleSetPrimary(domain.id)}
disabled={setPrimaryMutation.isPending}
className="p-1.5 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
title="Set as Primary"
>
<Star size={16} />
</button>
)}
<button
onClick={() => handleDeleteDomain(domain.id)}
disabled={deleteDomainMutation.isPending}
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Domain Purchase */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<ShoppingCart size={20} className="text-green-500" />
Purchase a Domain
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Search and register a new domain name
</p>
</div>
</div>
<DomainPurchase />
</section>
</>
) : (
/* Upgrade prompt for free plans */
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<Crown size={24} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Unlock Custom Domains</h4>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to use your own domain (e.g., <span className="font-mono">book.yourbusiness.com</span>) or purchase a new one.
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
<Crown size={16} /> View Plans
</button>
</div>
</div>
</section>
)}
{/* 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">
<CheckCircle size={18} />
Changes saved successfully
</div>
)}
</div>
);
};
export default DomainsSettings;

View File

@@ -0,0 +1,52 @@
/**
* Email Settings Page
*
* Manage email addresses for ticket system and customer communication.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Mail } from 'lucide-react';
import { Business, User } from '../../types';
import TicketEmailAddressManager from '../../components/TicketEmailAddressManager';
const EmailSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
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">
<Mail className="text-blue-500" />
{t('settings.email.title', 'Email Setup')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure email addresses for your ticketing system and customer communication.
</p>
</div>
{/* Email Address Manager */}
<TicketEmailAddressManager />
</div>
);
};
export default EmailSettings;

View File

@@ -0,0 +1,163 @@
/**
* General Settings Page
*
* Business identity settings: name, subdomain, timezone, contact info.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Building2, Save, Check } from 'lucide-react';
import { Business, User } from '../../types';
const GeneralSettings: React.FC = () => {
const { t } = useTranslation();
const { business, updateBusiness, user } = useOutletContext<{
business: Business;
updateBusiness: (updates: Partial<Business>) => void;
user: User;
}>();
const [formState, setFormState] = useState({
name: business.name,
subdomain: business.subdomain,
contactEmail: business.contactEmail || '',
phone: business.phone || '',
});
const [showToast, setShowToast] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormState(prev => ({ ...prev, [name]: value }));
};
const handleSave = async () => {
await updateBusiness(formState);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
const isOwner = user.role === 'owner';
if (!isOwner) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{t('settings.ownerOnly', '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">
<Building2 className="text-brand-500" />
{t('settings.general.title', 'General Settings')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('settings.general.subtitle', 'Manage your business identity and contact information.')}
</p>
</div>
{/* Business Identity */}
<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">
{t('settings.businessIdentity', 'Business Identity')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.businessName', 'Business Name')}
</label>
<input
type="text"
name="name"
value={formState.name}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.subdomain', 'Subdomain')}
</label>
<div className="flex">
<input
type="text"
name="subdomain"
value={formState.subdomain}
className="flex-1 min-w-0 px-4 py-2 border border-r-0 border-gray-300 dark:border-gray-600 rounded-l-lg bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed"
readOnly
/>
<span className="inline-flex items-center px-4 py-2 border border-l-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-sm rounded-r-lg">
.smoothschedule.com
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('settings.subdomainHint', 'Contact support to change your subdomain.')}
</p>
</div>
</div>
</section>
{/* Contact Information */}
<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">
{t('settings.contactInfo', 'Contact Information')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.contactEmail', 'Contact Email')}
</label>
<input
type="email"
name="contactEmail"
value={formState.contactEmail}
onChange={handleChange}
placeholder="contact@yourbusiness.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.phone', 'Phone Number')}
</label>
<input
type="tel"
name="phone"
value={formState.phone}
onChange={handleChange}
placeholder="+1 (555) 123-4567"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</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} />
{t('common.saveChanges', '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 animate-fade-in">
<Check size={18} />
{t('common.saved', 'Changes saved successfully')}
</div>
)}
</div>
);
};
export default GeneralSettings;

View File

@@ -0,0 +1,292 @@
/**
* Resource Types Settings Page
*
* Define and manage custom resource types (e.g., Stylist, Treatment Room, Equipment).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Layers, Plus, X, Pencil, Trash2, Users } from 'lucide-react';
import { Business, User } from '../../types';
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../../hooks/useResourceTypes';
const ResourceTypesSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
const { data: resourceTypes = [], isLoading } = useResourceTypes();
const createResourceType = useCreateResourceType();
const updateResourceType = useUpdateResourceType();
const deleteResourceType = useDeleteResourceType();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingType, setEditingType] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'OTHER' as 'STAFF' | 'OTHER',
iconName: '',
});
const isOwner = user.role === 'owner';
const openCreateModal = () => {
setEditingType(null);
setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
setIsModalOpen(true);
};
const openEditModal = (type: any) => {
setEditingType(type);
setFormData({
name: type.name,
description: type.description || '',
category: type.category,
iconName: type.icon_name || type.iconName || '',
});
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingType(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingType) {
await updateResourceType.mutateAsync({
id: editingType.id,
updates: formData,
});
} else {
await createResourceType.mutateAsync(formData);
}
closeModal();
} catch (error) {
console.error('Failed to save resource type:', error);
}
};
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
try {
await deleteResourceType.mutateAsync(id);
} catch (error: any) {
alert(error.response?.data?.error || 'Failed to delete resource type');
}
}
};
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">
<Layers className="text-indigo-500" />
{t('settings.resourceTypes.title', 'Resource Types')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment).
</p>
</div>
{/* Resource Types List */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.resourceTypes.list', 'Your Resource Types')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('settings.resourceTypes.listDescription', 'Create categories to organize your resources.')}
</p>
</div>
<button
onClick={openCreateModal}
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 text-sm"
>
<Plus size={16} />
{t('settings.addResourceType', 'Add Type')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : resourceTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Layers size={40} className="mx-auto mb-2 opacity-30" />
<p>{t('settings.noResourceTypes', 'No custom resource types yet.')}</p>
<p className="text-sm mt-1">{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}</p>
</div>
) : (
<div className="space-y-3">
{resourceTypes.map((type: any) => {
const isDefault = type.is_default || type.isDefault;
return (
<div
key={type.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
type.category === 'STAFF' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{type.category === 'STAFF' ? <Users size={20} /> : <Layers size={20} />}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
{type.name}
{isDefault && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
Default
</span>
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
</p>
{type.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
{type.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<button
onClick={() => openEditModal(type)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
{!isDefault && (
<button
onClick={() => handleDelete(type.id, type.name)}
disabled={deleteResourceType.isPending}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
title={t('common.delete', 'Delete')}
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</section>
{/* Modal for Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingType
? t('settings.editResourceType', 'Edit Resource Type')
: t('settings.addResourceType', 'Add Resource Type')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeName', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('settings.resourceTypeNamePlaceholder', 'e.g., Stylist, Treatment Room, Camera')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
placeholder={t('settings.resourceTypeDescriptionPlaceholder', 'Describe this type of resource...')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeCategory', 'Category')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as 'STAFF' | 'OTHER' })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="STAFF">{t('settings.categoryStaff', 'Staff (requires staff assignment)')}</option>
<option value="OTHER">{t('settings.categoryOther', 'Other (general resource)')}</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formData.category === 'STAFF'
? t('settings.staffCategoryHint', 'Staff resources must be assigned to a team member')
: t('settings.otherCategoryHint', 'General resources like rooms, equipment, or vehicles')}
</p>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createResourceType.isPending || updateResourceType.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{editingType ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ResourceTypesSettings;

View File

@@ -0,0 +1,24 @@
/**
* Settings Pages Index
*
* Exports all settings sub-pages for routing.
*/
// Business Settings
export { default as GeneralSettings } from './GeneralSettings';
export { default as BrandingSettings } from './BrandingSettings';
export { default as ResourceTypesSettings } from './ResourceTypesSettings';
// Integrations
export { default as DomainsSettings } from './DomainsSettings';
export { default as ApiSettings } from './ApiSettings';
// Access
export { default as AuthenticationSettings } from './AuthenticationSettings';
// Communication
export { default as EmailSettings } from './EmailSettings';
export { default as CommunicationSettings } from './CommunicationSettings';
// Billing
export { default as BillingSettings } from './BillingSettings';