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:
@@ -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