- 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>
2883 lines
140 KiB
TypeScript
2883 lines
140 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useOutletContext } from 'react-router-dom';
|
||
import { Business, User, CustomDomain } from '../types';
|
||
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil, Upload, Image as ImageIcon, Mail, Phone, MessageSquare, CreditCard, Loader2, DollarSign, ArrowUpRight, ArrowDownRight, Clock } from 'lucide-react';
|
||
import DomainPurchase from '../components/DomainPurchase';
|
||
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
|
||
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
|
||
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
|
||
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
||
import { useCommunicationCredits, useCreditTransactions, useUpdateCreditsSettings, useAddCredits } from '../hooks/useCommunicationCredits';
|
||
import { CreditPaymentModal } from '../components/CreditPaymentForm';
|
||
import OnboardingWizard from '../components/OnboardingWizard';
|
||
import ApiTokensSection from '../components/ApiTokensSection';
|
||
import TicketEmailAddressManager from '../components/TicketEmailAddressManager';
|
||
|
||
// Curated color palettes with complementary primary and secondary colors
|
||
const colorPalettes = [
|
||
{
|
||
name: 'Ocean Blue',
|
||
description: 'Professional & trustworthy',
|
||
primary: '#2563eb',
|
||
secondary: '#0ea5e9',
|
||
preview: 'bg-gradient-to-br from-blue-600 to-sky-500',
|
||
},
|
||
{
|
||
name: 'Sky Blue',
|
||
description: 'Light & airy',
|
||
primary: '#0ea5e9',
|
||
secondary: '#38bdf8',
|
||
preview: 'bg-gradient-to-br from-sky-500 to-sky-400',
|
||
},
|
||
{
|
||
name: 'Cyan Splash',
|
||
description: 'Modern & vibrant',
|
||
primary: '#06b6d4',
|
||
secondary: '#22d3ee',
|
||
preview: 'bg-gradient-to-br from-cyan-500 to-cyan-400',
|
||
},
|
||
{
|
||
name: 'Aqua Fresh',
|
||
description: 'Clean & refreshing',
|
||
primary: '#14b8a6',
|
||
secondary: '#2dd4bf',
|
||
preview: 'bg-gradient-to-br from-teal-500 to-teal-400',
|
||
},
|
||
{
|
||
name: 'Mint Green',
|
||
description: 'Soft & welcoming',
|
||
primary: '#10b981',
|
||
secondary: '#34d399',
|
||
preview: 'bg-gradient-to-br from-emerald-500 to-emerald-400',
|
||
},
|
||
{
|
||
name: 'Coral Reef',
|
||
description: 'Warm & inviting',
|
||
primary: '#f97316',
|
||
secondary: '#fb923c',
|
||
preview: 'bg-gradient-to-br from-orange-500 to-orange-400',
|
||
},
|
||
{
|
||
name: 'Lavender Dream',
|
||
description: 'Gentle & elegant',
|
||
primary: '#a78bfa',
|
||
secondary: '#c4b5fd',
|
||
preview: 'bg-gradient-to-br from-violet-400 to-violet-300',
|
||
},
|
||
{
|
||
name: 'Rose Pink',
|
||
description: 'Friendly & modern',
|
||
primary: '#ec4899',
|
||
secondary: '#f472b6',
|
||
preview: 'bg-gradient-to-br from-pink-500 to-pink-400',
|
||
},
|
||
{
|
||
name: 'Forest Green',
|
||
description: 'Natural & calming',
|
||
primary: '#059669',
|
||
secondary: '#10b981',
|
||
preview: 'bg-gradient-to-br from-emerald-600 to-emerald-400',
|
||
},
|
||
{
|
||
name: 'Royal Purple',
|
||
description: 'Creative & luxurious',
|
||
primary: '#7c3aed',
|
||
secondary: '#a78bfa',
|
||
preview: 'bg-gradient-to-br from-violet-600 to-purple-400',
|
||
},
|
||
{
|
||
name: 'Slate Gray',
|
||
description: 'Minimal & sophisticated',
|
||
primary: '#475569',
|
||
secondary: '#64748b',
|
||
preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
|
||
},
|
||
{
|
||
name: 'Crimson Red',
|
||
description: 'Bold & dynamic',
|
||
primary: '#dc2626',
|
||
secondary: '#ef4444',
|
||
preview: 'bg-gradient-to-br from-red-600 to-red-400',
|
||
},
|
||
];
|
||
|
||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens' | 'email-addresses' | 'communication';
|
||
|
||
// Resource Types Management Section Component
|
||
const ResourceTypesSection: React.FC = () => {
|
||
const { t } = useTranslation();
|
||
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 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');
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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 flex items-center gap-2">
|
||
<Layers size={20} className="text-indigo-500" />
|
||
{t('settings.resourceTypes', 'Resource Types')}
|
||
</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
{t('settings.resourceTypesDescription', 'Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment)')}
|
||
</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>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
// Communication Credits Section Component
|
||
interface CommunicationSectionProps {
|
||
business: Business;
|
||
}
|
||
|
||
const CommunicationSection: React.FC<CommunicationSectionProps> = ({ business }) => {
|
||
const { t } = useTranslation();
|
||
const { data: credits, isLoading: creditsLoading } = useCommunicationCredits();
|
||
const { data: transactions } = useCreditTransactions(1, 10);
|
||
const updateSettings = useUpdateCreditsSettings();
|
||
const addCredits = useAddCredits();
|
||
|
||
// Wizard state
|
||
const [showWizard, setShowWizard] = useState(false);
|
||
const [wizardStep, setWizardStep] = useState(1);
|
||
const [wizardData, setWizardData] = useState({
|
||
appointmentsPerMonth: 100,
|
||
smsRemindersEnabled: true,
|
||
smsPerAppointment: 2, // reminder + confirmation
|
||
maskedCallingEnabled: false,
|
||
avgCallMinutes: 3,
|
||
callsPerMonth: 20,
|
||
dedicatedNumberNeeded: false,
|
||
// Phone number recommendation fields
|
||
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); // $25 default
|
||
|
||
// Update settings form when credits data loads
|
||
React.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]);
|
||
|
||
// Calculate recommended phone numbers based on calling pattern
|
||
const getRecommendedPhoneNumbers = () => {
|
||
if (!wizardData.maskedCallingEnabled || !wizardData.dedicatedNumberNeeded) {
|
||
return 0;
|
||
}
|
||
|
||
if (wizardData.callingPattern === 'sequential') {
|
||
// Sequential: Only one call window open at a time per staff member
|
||
// They can share numbers, so just need 1-2 for the whole business
|
||
return Math.max(1, Math.ceil(wizardData.staffCount / 3)); // 1 number per 3 staff
|
||
} else {
|
||
// Concurrent: Staff can call any customer at any time
|
||
// Need enough numbers to handle the busiest staff member's daily load
|
||
// Each active appointment needs its own masked session
|
||
return wizardData.maxDailyAppointmentsPerStaff;
|
||
}
|
||
};
|
||
|
||
// Calculate estimated monthly cost based on wizard data
|
||
const calculateEstimate = () => {
|
||
let totalCents = 0;
|
||
|
||
// SMS costs ($0.03 per message)
|
||
if (wizardData.smsRemindersEnabled) {
|
||
const smsCount = wizardData.appointmentsPerMonth * wizardData.smsPerAppointment;
|
||
totalCents += smsCount * 3; // 3 cents per SMS
|
||
}
|
||
|
||
// Masked calling costs ($0.05 per minute)
|
||
if (wizardData.maskedCallingEnabled) {
|
||
const callMinutes = wizardData.callsPerMonth * wizardData.avgCallMinutes;
|
||
totalCents += callMinutes * 5; // 5 cents per minute
|
||
}
|
||
|
||
// Dedicated phone number ($2/month per number)
|
||
if (wizardData.dedicatedNumberNeeded) {
|
||
const recommendedNumbers = getRecommendedPhoneNumbers();
|
||
totalCents += recommendedNumbers * 200; // $2 per number
|
||
}
|
||
|
||
return totalCents;
|
||
};
|
||
|
||
// Calculate recommended starting balance
|
||
const getRecommendedBalance = () => {
|
||
const monthlyEstimate = calculateEstimate();
|
||
// Recommend 2 months of estimated usage plus a buffer
|
||
return Math.max(2500, Math.ceil((monthlyEstimate * 2.5) / 500) * 500);
|
||
};
|
||
|
||
const handleSaveSettings = async () => {
|
||
await updateSettings.mutateAsync(settingsForm);
|
||
};
|
||
|
||
// Check if needs setup (used for wizard display)
|
||
const needsSetup = !credits || (credits.balance_cents === 0 && credits.total_loaded_cents === 0);
|
||
|
||
const handlePaymentSuccess = async () => {
|
||
// Save auto-reload settings if this is from the wizard
|
||
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 (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>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<Phone className="w-5 h-5" />
|
||
SMS & Calling Credits
|
||
</h3>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
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="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">
|
||
Dedicated Phone Numbers
|
||
</span>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
Get dedicated business numbers for masked calls ($2/month each)
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={wizardData.dedicatedNumberNeeded}
|
||
onChange={(e) =>
|
||
setWizardData({ ...wizardData, dedicatedNumberNeeded: e.target.checked })
|
||
}
|
||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500 w-5 h-5"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{wizardData.dedicatedNumberNeeded && (
|
||
<div className="space-y-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-2">
|
||
When can staff call customers?
|
||
</label>
|
||
<div className="space-y-3">
|
||
<label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||
<input
|
||
type="radio"
|
||
name="callingPattern"
|
||
value="sequential"
|
||
checked={wizardData.callingPattern === 'sequential'}
|
||
onChange={() => setWizardData({ ...wizardData, callingPattern: 'sequential' })}
|
||
className="mt-1 text-brand-600 focus:ring-brand-500"
|
||
/>
|
||
<div>
|
||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||
One at a time (after previous appointment ends)
|
||
</span>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Staff can only call the next customer after finishing with the current one.
|
||
<span className="text-green-600 dark:text-green-400 font-medium"> Requires fewer phone numbers.</span>
|
||
</p>
|
||
</div>
|
||
</label>
|
||
<label className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||
<input
|
||
type="radio"
|
||
name="callingPattern"
|
||
value="concurrent"
|
||
checked={wizardData.callingPattern === 'concurrent'}
|
||
onChange={() => setWizardData({ ...wizardData, callingPattern: 'concurrent' })}
|
||
className="mt-1 text-brand-600 focus:ring-brand-500"
|
||
/>
|
||
<div>
|
||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||
Any time during the day
|
||
</span>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Staff can call any customer on their schedule at any time.
|
||
<span className="text-amber-600 dark:text-amber-400 font-medium"> Requires more phone numbers.</span>
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{wizardData.callingPattern === 'sequential' ? (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
How many staff members handle appointments?
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={wizardData.staffCount}
|
||
onChange={(e) =>
|
||
setWizardData({ ...wizardData, staffCount: 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>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Maximum daily appointments for your busiest staff member
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={wizardData.maxDailyAppointmentsPerStaff}
|
||
onChange={(e) =>
|
||
setWizardData({ ...wizardData, maxDailyAppointmentsPerStaff: 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"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Each appointment needs its own masked number when calls can happen concurrently
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Phone Number Recommendation */}
|
||
<div className="bg-brand-50 dark:bg-brand-900/20 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<Phone className="w-5 h-5 text-brand-600 mt-0.5" />
|
||
<div>
|
||
<p className="text-sm font-medium text-brand-900 dark:text-brand-100">
|
||
Recommended: {getRecommendedPhoneNumbers()} phone number{getRecommendedPhoneNumbers() !== 1 ? 's' : ''}
|
||
</p>
|
||
<p className="text-xs text-brand-700 dark:text-brand-300 mt-1">
|
||
{wizardData.callingPattern === 'sequential' ? (
|
||
<>
|
||
With sequential calling, numbers can be shared across staff.
|
||
We recommend 1 number per 3 staff members for smooth operation.
|
||
</>
|
||
) : (
|
||
<>
|
||
With concurrent calling, you need {wizardData.maxDailyAppointmentsPerStaff} numbers
|
||
so your busiest staff member can reach any customer at any time.
|
||
</>
|
||
)}
|
||
</p>
|
||
<p className="text-sm font-semibold text-brand-600 mt-2">
|
||
Monthly cost: {formatCurrency(getRecommendedPhoneNumbers() * 200)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<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>
|
||
)}
|
||
|
||
{wizardData.dedicatedNumberNeeded && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
Phone Numbers ({getRecommendedPhoneNumbers()})
|
||
<span className="text-xs ml-1">
|
||
({wizardData.callingPattern === 'sequential' ? 'sequential' : 'concurrent'})
|
||
</span>
|
||
</span>
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{formatCurrency(getRecommendedPhoneNumbers() * 200)}
|
||
</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 className="mt-3">
|
||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||
Or enter a custom amount (whole dollars only)
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-900 dark:text-white font-medium">$</span>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={topUpAmount / 100}
|
||
onChange={(e) => {
|
||
const val = e.target.value.replace(/[^0-9]/g, '');
|
||
setTopUpAmount(Math.max(5, parseInt(val) || 5) * 100);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
className="w-full pl-8 pr-12 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||
/>
|
||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500">.00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto-Reload Setup */}
|
||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||
<label className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||
<RefreshCw className="w-4 h-4" />
|
||
Enable Auto-Reload
|
||
</span>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Automatically add credits when your balance runs low
|
||
</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 pt-3 border-t border-gray-200 dark:border-gray-600">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Reload when balance falls below
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={settingsForm.auto_reload_threshold_cents / 100}
|
||
onChange={(e) => {
|
||
const val = e.target.value.replace(/[^0-9]/g, '');
|
||
setSettingsForm({
|
||
...settingsForm,
|
||
auto_reload_threshold_cents: Math.max(1, parseInt(val) || 1) * 100,
|
||
});
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
className="w-full pl-8 pr-12 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||
/>
|
||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 text-sm">.00</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
Amount to reload
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={settingsForm.auto_reload_amount_cents / 100}
|
||
onChange={(e) => {
|
||
const val = e.target.value.replace(/[^0-9]/g, '');
|
||
setSettingsForm({
|
||
...settingsForm,
|
||
auto_reload_amount_cents: Math.max(5, parseInt(val) || 5) * 100,
|
||
});
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
className="w-full pl-8 pr-12 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||
/>
|
||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 text-sm">.00</span>
|
||
</div>
|
||
</div>
|
||
</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) => (
|
||
<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-center text-gray-500 dark:text-gray-400 py-8">
|
||
No transactions yet
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Top-up Modal with Stripe Payment */}
|
||
<CreditPaymentModal
|
||
isOpen={showTopUp}
|
||
onClose={() => setShowTopUp(false)}
|
||
onSuccess={handlePaymentSuccess}
|
||
amountCents={topUpAmount}
|
||
onAmountChange={setTopUpAmount}
|
||
savePaymentMethod={settingsForm.auto_reload_enabled}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SettingsPage: React.FC = () => {
|
||
const { t } = useTranslation();
|
||
const { business, updateBusiness, user } = useOutletContext<{ business: Business; updateBusiness: (updates: Partial<Business>) => void; user: User}>();
|
||
const [formState, setFormState] = useState(business);
|
||
const isOwner = user.role === 'owner';
|
||
const [showCustomColors, setShowCustomColors] = useState(false);
|
||
const [showToast, setShowToast] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||
|
||
// OAuth settings
|
||
const { data: oauthData, isLoading: oauthLoading } = useBusinessOAuthSettings();
|
||
const updateOAuthMutation = useUpdateBusinessOAuthSettings();
|
||
const [oauthSettings, setOAuthSettings] = useState({
|
||
enabledProviders: [] as string[],
|
||
allowRegistration: false,
|
||
autoLinkByEmail: true,
|
||
useCustomCredentials: false,
|
||
});
|
||
|
||
// Custom Domains
|
||
const { data: customDomains = [], isLoading: domainsLoading } = useCustomDomains();
|
||
const addDomainMutation = useAddCustomDomain();
|
||
const deleteDomainMutation = useDeleteCustomDomain();
|
||
const verifyDomainMutation = useVerifyCustomDomain();
|
||
const setPrimaryMutation = useSetPrimaryDomain();
|
||
const [newDomain, setNewDomain] = useState('');
|
||
const [verifyingDomainId, setVerifyingDomainId] = useState<number | null>(null);
|
||
const [verifyError, setVerifyError] = useState<string | null>(null);
|
||
|
||
// OAuth Credentials
|
||
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 [showOnboarding, setShowOnboarding] = useState(false);
|
||
|
||
// Drag and drop state for logo uploads
|
||
const [isDraggingLogo, setIsDraggingLogo] = useState(false);
|
||
const [isDraggingEmailLogo, setIsDraggingEmailLogo] = useState(false);
|
||
|
||
// Lightbox state for viewing logos
|
||
const [lightboxImage, setLightboxImage] = useState<{ url: string; title: string } | null>(null);
|
||
|
||
// Email preview modal state
|
||
const [showEmailPreview, setShowEmailPreview] = useState(false);
|
||
|
||
// 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);
|
||
// Map credentials from the response to local state
|
||
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 after 3 seconds
|
||
useEffect(() => {
|
||
if (showToast) {
|
||
const timer = setTimeout(() => setShowToast(false), 3000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [showToast]);
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const { name, value, type, checked } = e.target;
|
||
if (type === 'checkbox') {
|
||
setFormState(prev => ({...prev, [name]: checked }));
|
||
} else {
|
||
setFormState(prev => ({ ...prev, [name]: value }));
|
||
}
|
||
};
|
||
|
||
// Drag and drop handlers for logo upload
|
||
const handleLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDraggingLogo(true);
|
||
};
|
||
|
||
const handleLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDraggingLogo(false);
|
||
};
|
||
|
||
const handleLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDraggingLogo(false);
|
||
|
||
const files = e.dataTransfer.files;
|
||
if (files && files.length > 0) {
|
||
const file = files[0];
|
||
if (file.type.startsWith('image/')) {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Drag and drop handlers for email logo upload
|
||
const handleEmailLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDraggingEmailLogo(true);
|
||
};
|
||
|
||
const handleEmailLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDraggingEmailLogo(false);
|
||
};
|
||
|
||
const handleEmailLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDraggingEmailLogo(false);
|
||
|
||
const files = e.dataTransfer.files;
|
||
if (files && files.length > 0) {
|
||
const file = files[0];
|
||
if (file.type.startsWith('image/')) {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSave = () => {
|
||
updateBusiness(formState);
|
||
setShowToast(true);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setFormState(business);
|
||
};
|
||
|
||
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],
|
||
};
|
||
});
|
||
};
|
||
|
||
// Custom Domain handlers
|
||
const handleAddDomain = () => {
|
||
if (!newDomain.trim()) return;
|
||
|
||
addDomainMutation.mutate(newDomain, {
|
||
onSuccess: () => {
|
||
setNewDomain('');
|
||
setShowToast(true);
|
||
},
|
||
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);
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleVerifyDomain = (domainId: number) => {
|
||
setVerifyingDomainId(domainId);
|
||
setVerifyError(null);
|
||
|
||
verifyDomainMutation.mutate(domainId, {
|
||
onSuccess: (data) => {
|
||
setVerifyingDomainId(null);
|
||
if (data.verified) {
|
||
setShowToast(true);
|
||
} 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);
|
||
},
|
||
onError: (error: any) => {
|
||
alert(error.response?.data?.error || 'Failed to set primary domain');
|
||
},
|
||
});
|
||
};
|
||
|
||
// OAuth Credentials handlers
|
||
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] }));
|
||
};
|
||
|
||
// 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: '🎮' },
|
||
};
|
||
|
||
// Tab configuration
|
||
const tabs = [
|
||
{ id: 'general' as const, label: 'General', icon: Building2 },
|
||
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
|
||
{ id: 'domains' as const, label: 'Domains', icon: Globe },
|
||
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
||
{ id: 'api-tokens' as const, label: 'API Tokens', icon: Key },
|
||
{ id: 'email-addresses' as const, label: 'Email Addresses', icon: Mail },
|
||
{ id: 'communication' as const, label: 'SMS & Calling', icon: Phone },
|
||
];
|
||
|
||
return (
|
||
<div className="p-8 max-w-4xl mx-auto pb-24">
|
||
{/* Header */}
|
||
<div className="mb-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
|
||
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
|
||
<nav className="flex gap-1" aria-label="Settings tabs">
|
||
{tabs.map((tab) => {
|
||
const Icon = tab.icon;
|
||
const isActive = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||
isActive
|
||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||
}`}
|
||
>
|
||
<Icon size={18} />
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<div className="space-y-6">
|
||
{/* GENERAL TAB */}
|
||
{activeTab === 'general' && (
|
||
<>
|
||
{/* Business Identity */}
|
||
{isOwner && (
|
||
<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">
|
||
<Building2 size={20} className="text-brand-500"/> 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')}</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')}</label>
|
||
<div className="flex">
|
||
<input type="text" name="subdomain" value={formState.subdomain} onChange={handleChange} 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>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Branding */}
|
||
<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-6 flex items-center gap-2">
|
||
<Palette size={20} className="text-purple-500"/> {t('settings.branding')}
|
||
</h3>
|
||
|
||
{/* Logo Upload */}
|
||
<div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||
<h4 className="font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<ImageIcon size={16} className="text-blue-500" />
|
||
Brand Logos
|
||
</h4>
|
||
<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 Upload/Display */}
|
||
<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-3">Website Logo</h5>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||
Used in sidebar and customer-facing pages
|
||
</p>
|
||
<div
|
||
onDragOver={handleLogoDragOver}
|
||
onDragLeave={handleLogoDragLeave}
|
||
onDrop={handleLogoDrop}
|
||
className={`transition-all ${isDraggingLogo ? 'scale-105' : ''}`}
|
||
>
|
||
{formState.logoUrl ? (
|
||
<div className="space-y-3">
|
||
<div className="relative inline-block group">
|
||
<img
|
||
src={formState.logoUrl}
|
||
alt="Business logo"
|
||
onClick={() => setLightboxImage({ url: formState.logoUrl!, title: 'Website Logo' })}
|
||
className="w-32 h-32 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setFormState(prev => ({
|
||
...prev,
|
||
logoUrl: undefined,
|
||
logoDisplayMode: 'logo-and-text' // Reset to show icon with text
|
||
}));
|
||
}}
|
||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
|
||
title="Remove logo"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
Click to view full size • Click × to remove • Drag and drop to replace
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className={`w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
|
||
isDraggingLogo
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
|
||
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
||
}`}>
|
||
<div className="text-center">
|
||
<ImageIcon size={32} className="mx-auto mb-2" />
|
||
<p className="text-xs">Drop image here</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<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) {
|
||
// TODO: Upload to backend
|
||
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 transition-colors text-sm font-medium"
|
||
>
|
||
<Upload size={16} />
|
||
{formState.logoUrl ? 'Change Logo' : 'Upload Logo'}
|
||
</label>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
PNG, JPG, or SVG. Recommended: 500x500px
|
||
</p>
|
||
</div>
|
||
|
||
{/* Logo 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
|
||
name="logoDisplayMode"
|
||
value={formState.logoDisplayMode || 'text-only'}
|
||
onChange={handleChange}
|
||
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>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
How your branding appears in the sidebar
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Email Logo Upload/Display */}
|
||
<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-3">Email Logo</h5>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||
Used in email notifications and receipts
|
||
</p>
|
||
<div
|
||
onDragOver={handleEmailLogoDragOver}
|
||
onDragLeave={handleEmailLogoDragLeave}
|
||
onDrop={handleEmailLogoDrop}
|
||
className={`transition-all ${isDraggingEmailLogo ? 'scale-105' : ''}`}
|
||
>
|
||
{formState.emailLogoUrl ? (
|
||
<div className="space-y-3">
|
||
<div className="relative inline-block group">
|
||
<img
|
||
src={formState.emailLogoUrl}
|
||
alt="Email logo"
|
||
onClick={() => setLightboxImage({ url: formState.emailLogoUrl!, title: 'Email Logo' })}
|
||
className="w-48 h-16 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
|
||
/>
|
||
<button
|
||
type="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.5 shadow-lg transition-colors z-10"
|
||
title="Remove email logo"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
Click to view full size • Click × to remove • Drag and drop to replace
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className={`w-48 h-16 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
|
||
isDraggingEmailLogo
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
|
||
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
||
}`}>
|
||
<div className="text-center">
|
||
<ImageIcon size={24} className="mx-auto mb-1" />
|
||
<p className="text-xs">Drop image here</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<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) {
|
||
// TODO: Upload to backend
|
||
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 transition-colors text-sm font-medium"
|
||
>
|
||
<Upload size={16} />
|
||
{formState.emailLogoUrl ? 'Change Email Logo' : 'Upload Email Logo'}
|
||
</label>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
PNG with transparent background. Recommended: 600x200px
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEmailPreview(true)}
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium mt-3"
|
||
>
|
||
<Eye size={16} />
|
||
Preview Email
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sidebar Preview */}
|
||
<div className="mt-6">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Sidebar Preview
|
||
</label>
|
||
<div
|
||
className="w-full max-w-xs p-6 rounded-xl"
|
||
style={{ background: `linear-gradient(to bottom right, ${formState.primaryColor}, ${formState.secondaryColor || formState.primaryColor})` }}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{/* Logo-only mode: full width */}
|
||
{formState.logoDisplayMode === 'logo-only' && formState.logoUrl ? (
|
||
<div className="flex items-center justify-center w-full">
|
||
<img
|
||
src={formState.logoUrl}
|
||
alt={formState.name}
|
||
className="max-w-full max-h-16 object-contain"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Logo/Icon display - only show if NOT text-only mode */}
|
||
{formState.logoDisplayMode !== 'text-only' && (
|
||
formState.logoUrl ? (
|
||
<div className="flex items-center justify-center w-10 h-10 shrink-0">
|
||
<img
|
||
src={formState.logoUrl}
|
||
alt={formState.name}
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0"
|
||
style={{ color: formState.primaryColor }}
|
||
>
|
||
{formState.name.substring(0, 2).toUpperCase()}
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* Text display - only show if NOT logo-only mode */}
|
||
{formState.logoDisplayMode !== 'logo-only' && (
|
||
<div className="overflow-hidden">
|
||
<h1 className="font-bold leading-tight truncate text-white">{formState.name}</h1>
|
||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
This is how your branding will appear in the navigation sidebar
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Color Palette Selection */}
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||
<Sparkles size={16} className="text-amber-500" />
|
||
Recommended Palettes
|
||
</h4>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">Choose a professionally designed color scheme</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
{colorPalettes.map((palette) => {
|
||
const isSelected = formState.primaryColor.toLowerCase() === palette.primary.toLowerCase() &&
|
||
formState.secondaryColor.toLowerCase() === palette.secondary.toLowerCase();
|
||
return (
|
||
<button
|
||
key={palette.name}
|
||
type="button"
|
||
onClick={() => {
|
||
setFormState(prev => ({
|
||
...prev,
|
||
primaryColor: palette.primary,
|
||
secondaryColor: palette.secondary,
|
||
}));
|
||
setShowCustomColors(false);
|
||
}}
|
||
className={`relative p-3 rounded-xl border-2 transition-all duration-200 text-left group hover:scale-[1.02] ${
|
||
isSelected
|
||
? 'border-brand-500 ring-2 ring-brand-500/20 bg-brand-50 dark:bg-brand-900/20'
|
||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||
}`}
|
||
>
|
||
{isSelected && (
|
||
<div className="absolute -top-2 -right-2 w-5 h-5 bg-brand-500 rounded-full flex items-center justify-center shadow-sm">
|
||
<Check size={12} className="text-white" />
|
||
</div>
|
||
)}
|
||
<div className={`h-12 rounded-lg mb-2 ${palette.preview}`} />
|
||
<div className="flex gap-1.5 mb-2">
|
||
<div
|
||
className="w-5 h-5 rounded-full border-2 border-white shadow-sm"
|
||
style={{ backgroundColor: palette.primary }}
|
||
title="Primary"
|
||
/>
|
||
<div
|
||
className="w-5 h-5 rounded-full border-2 border-white shadow-sm"
|
||
style={{ backgroundColor: palette.secondary }}
|
||
title="Secondary"
|
||
/>
|
||
</div>
|
||
<p className="text-xs font-semibold text-gray-900 dark:text-white truncate">{palette.name}</p>
|
||
<p className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{palette.description}</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Custom Colors Toggle */}
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCustomColors(!showCustomColors)}
|
||
className="flex items-center gap-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||
>
|
||
<Palette size={16} />
|
||
{showCustomColors ? 'Hide custom colors' : 'Use custom colors'}
|
||
</button>
|
||
|
||
{showCustomColors && (
|
||
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl space-y-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Primary Color</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
name="primaryColor"
|
||
value={formState.primaryColor}
|
||
onChange={handleChange}
|
||
className="h-10 w-10 p-1 rounded-lg cursor-pointer border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700"
|
||
/>
|
||
<input
|
||
type="text"
|
||
name="primaryColor"
|
||
value={formState.primaryColor}
|
||
onChange={handleChange}
|
||
className="flex-1 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 font-mono text-sm uppercase"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Secondary Color</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
name="secondaryColor"
|
||
value={formState.secondaryColor}
|
||
onChange={handleChange}
|
||
className="h-10 w-10 p-1 rounded-lg cursor-pointer border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700"
|
||
/>
|
||
<input
|
||
type="text"
|
||
name="secondaryColor"
|
||
value={formState.secondaryColor}
|
||
onChange={handleChange}
|
||
className="flex-1 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 font-mono text-sm uppercase"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Live Preview */}
|
||
<div className="mt-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||
<h4 className="font-medium text-gray-900 dark:text-white mb-3 text-sm">Live Preview</h4>
|
||
<div className="bg-gray-100 dark:bg-gray-900 rounded-xl p-3 overflow-hidden">
|
||
<div
|
||
className="rounded-t-lg p-2.5 flex items-center justify-between"
|
||
style={{ background: `linear-gradient(to bottom right, ${formState.primaryColor}, ${formState.secondaryColor || formState.primaryColor})` }}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-5 h-5 bg-white rounded font-bold text-[10px] flex items-center justify-center" style={{ color: formState.primaryColor }}>
|
||
{formState.name.charAt(0)}
|
||
</div>
|
||
<span className="text-white text-xs font-semibold">{formState.name}</span>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white dark:bg-gray-800 rounded-b-lg p-3 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
className="px-2 py-1 rounded text-[10px] text-white font-medium"
|
||
style={{ backgroundColor: formState.primaryColor }}
|
||
>
|
||
Primary
|
||
</button>
|
||
<button
|
||
className="px-2 py-1 rounded text-[10px] font-medium border"
|
||
style={{ color: formState.secondaryColor, borderColor: formState.secondaryColor }}
|
||
>
|
||
Secondary
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Payments */}
|
||
{isOwner && (
|
||
<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">
|
||
<Wallet size={20} className="text-emerald-500"/> {t('settings.payments')}
|
||
</h3>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h4 className="font-medium text-gray-900 dark:text-white">{t('settings.acceptPayments')}</h4>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('settings.acceptPaymentsDescription')}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`${formState.paymentsEnabled ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-6 w-11 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={() => {
|
||
const newEnabled = !formState.paymentsEnabled;
|
||
setFormState(prev => ({ ...prev, paymentsEnabled: newEnabled }));
|
||
|
||
// If enabling payments and Stripe isn't connected, show onboarding wizard
|
||
if (newEnabled && !business.stripeConnectAccountId) {
|
||
setShowOnboarding(true);
|
||
}
|
||
}}
|
||
>
|
||
<span className={`${formState.paymentsEnabled ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}/>
|
||
</button>
|
||
</div>
|
||
{formState.paymentsEnabled && !business.stripeConnectAccountId && (
|
||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||
<div className="flex items-start gap-2">
|
||
<AlertCircle size={16} className="text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||
<div className="text-sm text-amber-700 dark:text-amber-400">
|
||
<p className="font-medium">{t('settings.stripeSetupRequired')}</p>
|
||
<p className="text-xs mt-1">{t('settings.stripeSetupDescription')}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Booking Policy */}
|
||
<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">
|
||
<BookKey size={20} className="text-green-500"/> {t('settings.bookingPolicy')}
|
||
</h3>
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h4 className="font-medium text-gray-900 dark:text-white">Require Payment to Book</h4>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">Customers must have a card on file to book.</p>
|
||
</div>
|
||
<button type="button" className={`${formState.requirePaymentMethodToBook ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-6 w-11 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={() => setFormState(prev => ({ ...prev, requirePaymentMethodToBook: !prev.requirePaymentMethodToBook }))} >
|
||
<span className={`${formState.requirePaymentMethodToBook ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}/>
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cancellation Window (hours)</label>
|
||
<input type="number" name="cancellationWindowHours" value={formState.cancellationWindowHours} 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" placeholder="e.g., 24"/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Late Fee (%)</label>
|
||
<input type="number" name="lateCancellationFeePercent" value={formState.lateCancellationFeePercent} onChange={handleChange} min="0" max="100" 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" placeholder="e.g., 50"/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{/* RESOURCES TAB */}
|
||
{activeTab === 'resources' && isOwner && (
|
||
<ResourceTypesSection />
|
||
)}
|
||
|
||
{/* DOMAINS TAB */}
|
||
{activeTab === 'domains' && (
|
||
<>
|
||
{/* Quick Domain Setup */}
|
||
{isOwner && (
|
||
<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">
|
||
{formState.subdomain}.smoothschedule.com
|
||
</code>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(`${formState.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 */}
|
||
{isOwner && 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) => (
|
||
<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 */}
|
||
{isOwner && 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">
|
||
<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 */}
|
||
{isOwner && 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">
|
||
<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>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* AUTHENTICATION TAB */}
|
||
{activeTab === 'authentication' && isOwner && (
|
||
<>
|
||
{/* 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) => {
|
||
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">
|
||
{/* Provider credentials - collapsible */}
|
||
{(['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 === '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 <span className="text-gray-400">(optional)</span></label>
|
||
<input
|
||
type="text"
|
||
value={providerCreds.tenant_id || ''}
|
||
onChange={(e) => updateCredential(provider, 'tenant_id', e.target.value)}
|
||
placeholder="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>
|
||
)}
|
||
|
||
{!useCustomCredentials && (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<div className="flex items-start gap-2">
|
||
<ShieldCheck size={16} className="text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||
<p className="text-xs text-blue-700 dark:text-blue-400">
|
||
Using platform credentials. Enable custom credentials above to use your own OAuth apps with your branding.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Toast Notification */}
|
||
<div
|
||
className={`fixed bottom-6 right-6 transition-all duration-300 ease-in-out ${
|
||
showToast
|
||
? 'opacity-100 translate-y-0'
|
||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3 px-4 py-3 bg-green-600 text-white rounded-lg shadow-lg">
|
||
<CheckCircle size={20} />
|
||
<span className="font-medium">{t('settings.savedSuccessfully')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Onboarding Wizard for Stripe Connect */}
|
||
{showOnboarding && (
|
||
<OnboardingWizard
|
||
business={business}
|
||
onComplete={() => {
|
||
setShowOnboarding(false);
|
||
updateBusiness({ initialSetupComplete: true });
|
||
}}
|
||
onSkip={() => {
|
||
setShowOnboarding(false);
|
||
// If they skip, disable payments
|
||
setFormState(prev => ({ ...prev, paymentsEnabled: false }));
|
||
updateBusiness({ paymentsEnabled: false });
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Lightbox Modal for Logo Preview */}
|
||
{lightboxImage && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||
onClick={() => setLightboxImage(null)}
|
||
>
|
||
<div className="relative max-w-4xl max-h-[90vh] flex flex-col">
|
||
<div className="flex items-center justify-between mb-4 text-white">
|
||
<h3 className="text-lg font-semibold">{lightboxImage.title}</h3>
|
||
<button
|
||
onClick={() => setLightboxImage(null)}
|
||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||
title="Close"
|
||
>
|
||
<X size={24} />
|
||
</button>
|
||
</div>
|
||
<div
|
||
className="bg-white dark:bg-gray-800 rounded-lg p-8 overflow-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<img
|
||
src={lightboxImage.url}
|
||
alt={lightboxImage.title}
|
||
className="max-w-full max-h-[70vh] object-contain mx-auto"
|
||
/>
|
||
</div>
|
||
<p className="text-white text-sm mt-4 text-center">
|
||
Click anywhere outside to close
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Email Preview Modal */}
|
||
{showEmailPreview && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||
onClick={() => setShowEmailPreview(false)}
|
||
>
|
||
<div
|
||
className="relative max-w-2xl w-full bg-white dark:bg-gray-800 rounded-lg shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Email Preview</h3>
|
||
<button
|
||
onClick={() => setShowEmailPreview(false)}
|
||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||
title="Close"
|
||
>
|
||
<X size={24} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 overflow-auto max-h-[70vh]">
|
||
{/* Email Template Preview */}
|
||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-8" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||
{/* Email Header with Logo */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-t-lg p-6 text-center border-b-4" style={{ borderBottomColor: formState.primaryColor }}>
|
||
{formState.emailLogoUrl ? (
|
||
<img
|
||
src={formState.emailLogoUrl}
|
||
alt={formState.name}
|
||
className="mx-auto max-h-20 object-contain"
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-center">
|
||
<div
|
||
className="inline-flex items-center justify-center w-16 h-16 rounded-full text-white font-bold text-2xl"
|
||
style={{ backgroundColor: formState.primaryColor }}
|
||
>
|
||
{formState.name.substring(0, 2).toUpperCase()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Email Body */}
|
||
<div className="bg-white dark:bg-gray-800 p-8">
|
||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||
Appointment Confirmation
|
||
</h2>
|
||
|
||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||
Hi John Doe,
|
||
</p>
|
||
|
||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||
Your appointment has been confirmed. Here are the details:
|
||
</p>
|
||
|
||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 mb-6">
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<p className="text-gray-500 dark:text-gray-400 font-medium">Service</p>
|
||
<p className="text-gray-900 dark:text-white">Haircut & Style</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500 dark:text-gray-400 font-medium">Date & Time</p>
|
||
<p className="text-gray-900 dark:text-white">Dec 15, 2025 at 2:00 PM</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500 dark:text-gray-400 font-medium">Duration</p>
|
||
<p className="text-gray-900 dark:text-white">60 minutes</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500 dark:text-gray-400 font-medium">Price</p>
|
||
<p className="text-gray-900 dark:text-white">$45.00</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="w-full text-white font-semibold py-3 px-6 rounded-lg transition-colors"
|
||
style={{ backgroundColor: formState.primaryColor }}
|
||
>
|
||
View Appointment Details
|
||
</button>
|
||
|
||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6">
|
||
Need to make changes? You can reschedule or cancel up to 24 hours before your appointment.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Email Footer */}
|
||
<div className="bg-gray-100 dark:bg-gray-900 rounded-b-lg p-6 text-center">
|
||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-2">
|
||
{formState.name}
|
||
</p>
|
||
<p className="text-gray-500 dark:text-gray-500 text-xs">
|
||
{business.subdomain}.smoothschedule.com
|
||
</p>
|
||
<p className="text-gray-400 dark:text-gray-600 text-xs mt-4">
|
||
© 2025 {formState.name}. All rights reserved.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-lg">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||
This is a preview of how your appointment confirmation emails will appear to customers.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* API TOKENS TAB */}
|
||
{activeTab === 'api-tokens' && isOwner && (
|
||
<ApiTokensSection />
|
||
)}
|
||
|
||
{/* EMAIL ADDRESSES TAB */}
|
||
{activeTab === 'email-addresses' && isOwner && (
|
||
<TicketEmailAddressManager />
|
||
)}
|
||
|
||
{/* COMMUNICATION TAB */}
|
||
{activeTab === 'communication' && isOwner && (
|
||
<CommunicationSection business={business} />
|
||
)}
|
||
|
||
{/* Floating Action Buttons */}
|
||
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
|
||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
{showToast && (
|
||
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||
<CheckCircle size={16} />
|
||
Changes saved successfully
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={handleCancel}
|
||
className="flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium border"
|
||
style={{ color: formState.secondaryColor, borderColor: formState.secondaryColor }}
|
||
>
|
||
<X size={18} />
|
||
Cancel Changes
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
className="flex items-center gap-2 px-6 py-3 text-white rounded-lg transition-colors shadow-md font-medium hover:opacity-90"
|
||
style={{ backgroundColor: formState.primaryColor }}
|
||
>
|
||
<Save size={18} />
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|