This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1111 lines
59 KiB
TypeScript
1111 lines
59 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 } 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 OnboardingWizard from '../components/OnboardingWizard';
|
|
|
|
// 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: '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: 'Sunset Orange',
|
|
description: 'Energetic & warm',
|
|
primary: '#ea580c',
|
|
secondary: '#f97316',
|
|
preview: 'bg-gradient-to-br from-orange-600 to-amber-500',
|
|
},
|
|
{
|
|
name: 'Rose Pink',
|
|
description: 'Friendly & modern',
|
|
primary: '#db2777',
|
|
secondary: '#f472b6',
|
|
preview: 'bg-gradient-to-br from-pink-600 to-pink-400',
|
|
},
|
|
{
|
|
name: 'Slate Gray',
|
|
description: 'Minimal & sophisticated',
|
|
primary: '#475569',
|
|
secondary: '#64748b',
|
|
preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
|
|
},
|
|
{
|
|
name: 'Teal Wave',
|
|
description: 'Fresh & balanced',
|
|
primary: '#0d9488',
|
|
secondary: '#14b8a6',
|
|
preview: 'bg-gradient-to-br from-teal-600 to-teal-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';
|
|
|
|
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,
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Update OAuth settings when data loads
|
|
useEffect(() => {
|
|
if (oauthData?.businessSettings) {
|
|
setOAuthSettings(oauthData.businessSettings);
|
|
}
|
|
}, [oauthData]);
|
|
|
|
// Update OAuth credentials when data loads
|
|
useEffect(() => {
|
|
if (oauthCredentials) {
|
|
setUseCustomCredentials(oauthCredentials.use_custom_credentials || false);
|
|
if (oauthCredentials.google || oauthCredentials.apple || oauthCredentials.facebook ||
|
|
oauthCredentials.linkedin || oauthCredentials.microsoft || oauthCredentials.twitter ||
|
|
oauthCredentials.twitch) {
|
|
setCredentials({
|
|
google: oauthCredentials.google || { client_id: '', client_secret: '' },
|
|
apple: oauthCredentials.apple || { client_id: '', client_secret: '', team_id: '', key_id: '' },
|
|
facebook: oauthCredentials.facebook || { client_id: '', client_secret: '' },
|
|
linkedin: oauthCredentials.linkedin || { client_id: '', client_secret: '' },
|
|
microsoft: oauthCredentials.microsoft || { client_id: '', client_secret: '', tenant_id: '' },
|
|
twitter: oauthCredentials.twitter || { client_id: '', client_secret: '' },
|
|
twitch: oauthCredentials.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 }));
|
|
}
|
|
};
|
|
|
|
const handleSave = () => {
|
|
updateBusiness(formState);
|
|
setShowToast(true);
|
|
};
|
|
|
|
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: 'domains' as const, label: 'Domains', icon: Globe },
|
|
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
|
];
|
|
|
|
return (
|
|
<div className="p-8 max-w-4xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<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>
|
|
<button
|
|
onClick={handleSave}
|
|
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
|
|
>
|
|
<Save size={18} />
|
|
{t('common.saveChanges')}
|
|
</button>
|
|
</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>
|
|
|
|
{/* 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={{ backgroundColor: 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>
|
|
</>
|
|
)}
|
|
|
|
{/* 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);
|
|
const info = providerInfo[provider] || { name: provider, icon: '🔐' };
|
|
return (
|
|
<button
|
|
key={provider}
|
|
type="button"
|
|
onClick={() => toggleProvider(provider)}
|
|
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 */}
|
|
<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
|
|
{business.plan === 'Free' && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">
|
|
<Crown size={12} /> Pro
|
|
</span>
|
|
)}
|
|
</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>
|
|
{business.plan !== 'Free' && (
|
|
<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>
|
|
|
|
{business.plan === 'Free' ? (
|
|
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
|
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
|
Upgrade to use your own OAuth credentials for custom branding and higher rate limits.
|
|
</p>
|
|
<button className="inline-flex items-center gap-2 px-3 py-1.5 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={14} /> View Plans
|
|
</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 });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SettingsPage;
|