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:
@@ -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="/" />} />
|
||||
|
||||
339
frontend/src/components/CreditPaymentForm.tsx
Normal file
339
frontend/src/components/CreditPaymentForm.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
292
frontend/src/components/EmailTemplatePresetSelector.tsx
Normal file
292
frontend/src/components/EmailTemplatePresetSelector.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
281
frontend/src/components/navigation/SidebarComponents.tsx
Normal file
281
frontend/src/components/navigation/SidebarComponents.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
195
frontend/src/hooks/useCommunicationCredits.ts
Normal file
195
frontend/src/hooks/useCommunicationCredits.ts
Normal 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
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
154
frontend/src/layouts/SettingsLayout.tsx
Normal file
154
frontend/src/layouts/SettingsLayout.tsx
Normal 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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
52
frontend/src/pages/settings/ApiSettings.tsx
Normal file
52
frontend/src/pages/settings/ApiSettings.tsx
Normal 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;
|
||||
427
frontend/src/pages/settings/AuthenticationSettings.tsx
Normal file
427
frontend/src/pages/settings/AuthenticationSettings.tsx
Normal 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;
|
||||
288
frontend/src/pages/settings/BillingSettings.tsx
Normal file
288
frontend/src/pages/settings/BillingSettings.tsx
Normal 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;
|
||||
321
frontend/src/pages/settings/BrandingSettings.tsx
Normal file
321
frontend/src/pages/settings/BrandingSettings.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Branding Settings Page
|
||||
*
|
||||
* Logo uploads, colors, and display preferences.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { Business, User } from '../../types';
|
||||
|
||||
// Color palette options
|
||||
const colorPalettes = [
|
||||
{ name: 'Ocean Blue', primary: '#2563eb', secondary: '#0ea5e9' },
|
||||
{ name: 'Sky Blue', primary: '#0ea5e9', secondary: '#38bdf8' },
|
||||
{ name: 'Mint Green', primary: '#10b981', secondary: '#34d399' },
|
||||
{ name: 'Coral Reef', primary: '#f97316', secondary: '#fb923c' },
|
||||
{ name: 'Lavender', primary: '#a78bfa', secondary: '#c4b5fd' },
|
||||
{ name: 'Rose Pink', primary: '#ec4899', secondary: '#f472b6' },
|
||||
{ name: 'Forest Green', primary: '#059669', secondary: '#10b981' },
|
||||
{ name: 'Royal Purple', primary: '#7c3aed', secondary: '#a78bfa' },
|
||||
{ name: 'Slate Gray', primary: '#475569', secondary: '#64748b' },
|
||||
{ name: 'Crimson Red', primary: '#dc2626', secondary: '#ef4444' },
|
||||
];
|
||||
|
||||
const BrandingSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, updateBusiness, user } = useOutletContext<{
|
||||
business: Business;
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
logoUrl: business.logoUrl,
|
||||
emailLogoUrl: business.emailLogoUrl,
|
||||
logoDisplayMode: business.logoDisplayMode || 'text-only',
|
||||
primaryColor: business.primaryColor,
|
||||
secondaryColor: business.secondaryColor || business.primaryColor,
|
||||
});
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateBusiness(formState);
|
||||
setShowToast(true);
|
||||
setTimeout(() => setShowToast(false), 3000);
|
||||
};
|
||||
|
||||
const selectPalette = (primary: string, secondary: string) => {
|
||||
setFormState(prev => ({ ...prev, primaryColor: primary, secondaryColor: secondary }));
|
||||
};
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only the business owner can access these settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Palette className="text-purple-500" />
|
||||
{t('settings.branding.title', 'Branding')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Customize your business appearance with logos and colors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logo Section */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Brand Logos
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Upload your logos for different purposes. PNG with transparent background recommended.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Website Logo */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Website Logo</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Used in sidebar and customer-facing pages. Recommended: 500x500px
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{formState.logoUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formState.logoUrl}
|
||||
alt="Logo"
|
||||
className="w-20 h-20 object-contain border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormState(prev => ({ ...prev, logoUrl: undefined }))}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-gray-400">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id="logo-upload"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="logo-upload"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{formState.logoUrl ? 'Change' : 'Upload'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Mode */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Display Mode
|
||||
</label>
|
||||
<select
|
||||
value={formState.logoDisplayMode}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, logoDisplayMode: e.target.value as any }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
||||
>
|
||||
<option value="text-only">Text Only</option>
|
||||
<option value="logo-only">Logo Only</option>
|
||||
<option value="logo-and-text">Logo and Text</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Logo */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Email Logo</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Used in email notifications. Recommended: 600x200px wide
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{formState.emailLogoUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formState.emailLogoUrl}
|
||||
alt="Email Logo"
|
||||
className="w-32 h-12 object-contain border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormState(prev => ({ ...prev, emailLogoUrl: undefined }))}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center text-gray-400">
|
||||
<ImageIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id="email-logo-upload"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="email-logo-upload"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{formState.emailLogoUrl ? 'Change' : 'Upload'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Colors Section */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Brand Colors
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Choose a color palette or customize your own colors.
|
||||
</p>
|
||||
|
||||
{/* Palette Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
|
||||
{colorPalettes.map((palette) => (
|
||||
<button
|
||||
key={palette.name}
|
||||
onClick={() => selectPalette(palette.primary, palette.secondary)}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
formState.primaryColor === palette.primary && formState.secondaryColor === palette.secondary
|
||||
? 'border-gray-900 dark:border-white ring-2 ring-offset-2 ring-gray-900 dark:ring-white'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-8 rounded-md mb-2"
|
||||
style={{ background: `linear-gradient(to right, ${palette.primary}, ${palette.secondary})` }}
|
||||
/>
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 text-center truncate">
|
||||
{palette.name}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Primary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formState.primaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, primaryColor: e.target.value }))}
|
||||
className="w-10 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.primaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, primaryColor: e.target.value }))}
|
||||
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Secondary Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formState.secondaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, secondaryColor: e.target.value }))}
|
||||
className="w-10 h-10 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.secondaryColor}
|
||||
onChange={(e) => setFormState(prev => ({ ...prev, secondaryColor: e.target.value }))}
|
||||
className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preview
|
||||
</label>
|
||||
<div
|
||||
className="h-10 rounded-lg"
|
||||
style={{ background: `linear-gradient(to right, ${formState.primaryColor}, ${formState.secondaryColor})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
|
||||
<Check size={18} />
|
||||
Changes saved successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandingSettings;
|
||||
727
frontend/src/pages/settings/CommunicationSettings.tsx
Normal file
727
frontend/src/pages/settings/CommunicationSettings.tsx
Normal 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;
|
||||
333
frontend/src/pages/settings/DomainsSettings.tsx
Normal file
333
frontend/src/pages/settings/DomainsSettings.tsx
Normal 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;
|
||||
52
frontend/src/pages/settings/EmailSettings.tsx
Normal file
52
frontend/src/pages/settings/EmailSettings.tsx
Normal 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;
|
||||
163
frontend/src/pages/settings/GeneralSettings.tsx
Normal file
163
frontend/src/pages/settings/GeneralSettings.tsx
Normal 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;
|
||||
292
frontend/src/pages/settings/ResourceTypesSettings.tsx
Normal file
292
frontend/src/pages/settings/ResourceTypesSettings.tsx
Normal 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;
|
||||
24
frontend/src/pages/settings/index.tsx
Normal file
24
frontend/src/pages/settings/index.tsx
Normal 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';
|
||||
Reference in New Issue
Block a user