- 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>
313 lines
14 KiB
TypeScript
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;
|