Files
smoothschedule/frontend/src/pages/settings/CustomDomainsSettings.tsx
poduck 5cef01ad0d feat: Reorganize settings sidebar and add plan-based feature locking
- Add locked state to Plugins sidebar item with plan feature check
- Create Branding section in settings with Appearance, Email Templates, Custom Domains
- Split Domains page into Booking (URLs, redirects) and Custom Domains (BYOD, purchase)
- Add booking_return_url field to Tenant model for customer redirects
- Update SidebarItem component to support locked prop with lock icon
- Move Email Templates from main sidebar to Settings > Branding
- Add communication credits hooks and payment form updates
- Add timezone fields migration and various UI improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 01:35:59 -05:00

313 lines
14 KiB
TypeScript

/**
* Custom Domains Settings Page
*
* Manage custom domains - BYOD and domain purchase.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext, Link } from 'react-router-dom';
import {
Globe, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
ShoppingCart, Lock, ArrowUpRight
} from 'lucide-react';
import { Business, User, CustomDomain } from '../../types';
import {
useCustomDomains,
useAddCustomDomain,
useDeleteCustomDomain,
useVerifyCustomDomain,
useSetPrimaryDomain
} from '../../hooks/useCustomDomains';
import DomainPurchase from '../../components/DomainPurchase';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
const CustomDomainsSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
// Hooks
const { data: customDomains = [], isLoading: domainsLoading } = useCustomDomains();
const addDomainMutation = useAddCustomDomain();
const deleteDomainMutation = useDeleteCustomDomain();
const verifyDomainMutation = useVerifyCustomDomain();
const setPrimaryMutation = useSetPrimaryDomain();
// Local state
const [newDomain, setNewDomain] = useState('');
const [verifyingDomainId, setVerifyingDomainId] = useState<number | null>(null);
const [verifyError, setVerifyError] = useState<string | null>(null);
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
const { canUse } = usePlanFeatures();
const handleAddDomain = () => {
if (!newDomain.trim()) return;
addDomainMutation.mutate(newDomain, {
onSuccess: () => {
setNewDomain('');
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Failed to add domain');
},
});
};
const handleDeleteDomain = (domainId: number) => {
if (!confirm('Are you sure you want to delete this custom domain?')) return;
deleteDomainMutation.mutate(domainId, {
onSuccess: () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
});
};
const handleVerifyDomain = (domainId: number) => {
setVerifyingDomainId(domainId);
setVerifyError(null);
verifyDomainMutation.mutate(domainId, {
onSuccess: (data: any) => {
setVerifyingDomainId(null);
if (data.verified) {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
} else {
setVerifyError(data.message);
}
},
onError: (error: any) => {
setVerifyingDomainId(null);
setVerifyError(error.response?.data?.message || 'Verification failed');
},
});
};
const handleSetPrimary = (domainId: number) => {
setPrimaryMutation.mutate(domainId, {
onSuccess: () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Failed to set primary domain');
},
});
};
if (!isOwner) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings.
</p>
</div>
);
}
const isCustomDomainLocked = !canUse('custom_domain');
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Globe className="text-indigo-500" />
{t('settings.customDomains.title', 'Custom Domains')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Use your own domains for your booking pages.
</p>
</div>
{/* Custom Domains Management - with overlay when locked */}
<div className="relative">
{isCustomDomainLocked && (
<div className="absolute inset-0 z-10 bg-white/70 dark:bg-gray-900/70 backdrop-blur-[2px] rounded-xl flex items-center justify-center">
<Link
to="/settings/billing"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-orange-500 text-white font-semibold rounded-lg shadow-lg hover:from-amber-600 hover:to-orange-600 transition-all"
>
<Lock size={18} />
Upgrade to Enable Custom Domains
<ArrowUpRight size={18} />
</Link>
</div>
)}
<div className={isCustomDomainLocked ? 'pointer-events-none select-none' : ''}>
<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" />
Bring Your Own Domain
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Connect a domain you already own
</p>
</div>
</div>
{/* Add New Domain Form */}
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="booking.yourdomain.com"
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddDomain();
}
}}
/>
<button
onClick={handleAddDomain}
disabled={addDomainMutation.isPending || !newDomain.trim()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium text-sm"
>
{addDomainMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</div>
{/* Custom Domains List */}
{domainsLoading ? (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
Loading domains...
</div>
) : customDomains.length === 0 ? (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
<Globe size={40} className="mx-auto mb-2 opacity-30" />
<p className="text-sm">No custom domains yet. Add one above.</p>
</div>
) : (
<div className="space-y-3">
{customDomains.map((domain: CustomDomain) => (
<div
key={domain.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{domain.domain}
</h4>
{domain.is_primary && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300 rounded">
<Star size={10} className="fill-current" /> Primary
</span>
)}
{domain.is_verified ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded">
<CheckCircle size={10} /> Verified
</span>
) : (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded">
<AlertCircle size={10} /> Pending
</span>
)}
</div>
{!domain.is_verified && (
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-1">Add DNS TXT record:</p>
<div className="space-y-1">
<div className="flex items-center gap-1">
<span className="text-amber-700 dark:text-amber-400">Name:</span>
<code className="px-1 bg-white dark:bg-gray-800 rounded text-gray-900 dark:text-white">{domain.dns_txt_record_name}</code>
<button onClick={() => navigator.clipboard.writeText(domain.dns_txt_record_name || '')} className="text-amber-600 hover:text-amber-700"><Copy size={12} /></button>
</div>
<div className="flex items-center gap-1">
<span className="text-amber-700 dark:text-amber-400">Value:</span>
<code className="px-1 bg-white dark:bg-gray-800 rounded text-gray-900 dark:text-white truncate max-w-[200px]">{domain.dns_txt_record}</code>
<button onClick={() => navigator.clipboard.writeText(domain.dns_txt_record || '')} className="text-amber-600 hover:text-amber-700"><Copy size={12} /></button>
</div>
</div>
{verifyError && verifyingDomainId === domain.id && (
<p className="mt-1 text-red-600 dark:text-red-400">{verifyError}</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-3">
{!domain.is_verified && (
<button
onClick={() => handleVerifyDomain(domain.id)}
disabled={verifyingDomainId === domain.id}
className="p-1.5 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded transition-colors disabled:opacity-50"
title="Verify"
>
<RefreshCw size={16} className={verifyingDomainId === domain.id ? 'animate-spin' : ''} />
</button>
)}
{domain.is_verified && !domain.is_primary && (
<button
onClick={() => handleSetPrimary(domain.id)}
disabled={setPrimaryMutation.isPending}
className="p-1.5 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
title="Set as Primary"
>
<Star size={16} />
</button>
)}
<button
onClick={() => handleDeleteDomain(domain.id)}
disabled={deleteDomainMutation.isPending}
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Domain Purchase */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
<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>
</div>
</div>
{/* Toast */}
{showToast && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
<CheckCircle size={18} />
Changes saved successfully
</div>
)}
</div>
);
};
export default CustomDomainsSettings;