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>
This commit is contained in:
poduck
2025-12-03 01:35:59 -05:00
parent ef58e9fc94
commit 5cef01ad0d
25 changed files with 2220 additions and 330 deletions

View File

@@ -0,0 +1,145 @@
/**
* Booking Settings Page
*
* Manage booking URLs and customer redirect settings.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import {
Calendar, Link2, Copy, ExternalLink, Save, CheckCircle
} from 'lucide-react';
import { Business, User } from '../../types';
const BookingSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user, updateBusiness } = useOutletContext<{
business: Business;
user: User;
updateBusiness: (updates: Partial<Business>) => void;
}>();
// Local state
const [showToast, setShowToast] = useState(false);
const [returnUrl, setReturnUrl] = useState(business.bookingReturnUrl || '');
const [returnUrlSaving, setReturnUrlSaving] = useState(false);
const isOwner = user.role === 'owner';
const handleSaveReturnUrl = async () => {
setReturnUrlSaving(true);
try {
await updateBusiness({ bookingReturnUrl: returnUrl });
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
} catch (error) {
alert('Failed to save return URL');
} finally {
setReturnUrlSaving(false);
}
};
if (!isOwner) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Calendar className="text-brand-500" />
{t('settings.booking.title', 'Booking')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure your booking page URL and customer redirect settings.
</p>
</div>
{/* Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Share this URL with your customers so they can book appointments with you.
</p>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
{business.subdomain}.smoothschedule.com
</code>
<button
onClick={() => {
navigator.clipboard.writeText(`https://${business.subdomain}.smoothschedule.com`);
setShowToast(true);
setTimeout(() => setShowToast(false), 2000);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="Copy to clipboard"
>
<Copy size={16} />
</button>
<a
href={`https://${business.subdomain}.smoothschedule.com`}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-brand-500 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title="Open booking page"
>
<ExternalLink size={16} />
</a>
</div>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Want to use your own domain? Set up a <a href="/settings/custom-domains" className="text-brand-500 hover:underline">custom domain</a>.
</p>
</section>
{/* Return URL - Where to redirect customers after booking */}
<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-2 flex items-center gap-2">
<ExternalLink size={20} className="text-green-500" /> Return URL
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
After a customer completes a booking, redirect them to this URL (e.g., a thank you page on your website).
</p>
<div className="flex gap-2">
<input
type="url"
value={returnUrl}
onChange={(e) => setReturnUrl(e.target.value)}
placeholder="https://yourbusiness.com/thank-you"
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 text-sm"
/>
<button
onClick={handleSaveReturnUrl}
disabled={returnUrlSaving}
className="inline-flex items-center gap-2 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"
>
<Save size={16} />
{returnUrlSaving ? 'Saving...' : 'Save'}
</button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Leave empty to keep customers on the booking confirmation page.
</p>
</section>
{/* Toast */}
{showToast && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
<CheckCircle size={18} />
Copied to clipboard
</div>
)}
</div>
);
};
export default BookingSettings;

View File

@@ -2,13 +2,17 @@
* Branding Settings Page
*
* Logo uploads, colors, and display preferences.
* Features live preview of color changes that revert on navigation/reload if not saved.
*/
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react';
import { Business, User } from '../../types';
import { applyBrandColors } from '../../utils/colorUtils';
import { UpgradePrompt } from '../../components/UpgradePrompt';
import { FeatureKey } from '../../hooks/usePlanFeatures';
// Color palette options
const colorPalettes = [
@@ -26,10 +30,12 @@ const colorPalettes = [
const BrandingSettings: React.FC = () => {
const { t } = useTranslation();
const { business, updateBusiness, user } = useOutletContext<{
const { business, updateBusiness, user, isFeatureLocked, lockedFeature } = useOutletContext<{
business: Business;
updateBusiness: (updates: Partial<Business>) => void;
user: User;
isFeatureLocked?: boolean;
lockedFeature?: FeatureKey;
}>();
const [formState, setFormState] = useState({
@@ -41,8 +47,37 @@ const BrandingSettings: React.FC = () => {
});
const [showToast, setShowToast] = useState(false);
// Store the original saved colors to restore on unmount/navigation
const savedColorsRef = useRef({
primary: business.primaryColor,
secondary: business.secondaryColor || business.primaryColor,
});
// Live preview: Update CSS variables as user cycles through palettes
useEffect(() => {
applyBrandColors(formState.primaryColor, formState.secondaryColor);
// Cleanup: Restore saved colors when component unmounts (navigation away)
return () => {
applyBrandColors(savedColorsRef.current.primary, savedColorsRef.current.secondary);
};
}, [formState.primaryColor, formState.secondaryColor]);
// Update savedColorsRef when business data changes (after successful save)
useEffect(() => {
savedColorsRef.current = {
primary: business.primaryColor,
secondary: business.secondaryColor || business.primaryColor,
};
}, [business.primaryColor, business.secondaryColor]);
const handleSave = async () => {
await updateBusiness(formState);
// Update the saved reference so cleanup doesn't revert
savedColorsRef.current = {
primary: formState.primaryColor,
secondary: formState.secondaryColor,
};
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
@@ -63,6 +98,11 @@ const BrandingSettings: React.FC = () => {
);
}
// Show upgrade prompt if feature is locked
if (isFeatureLocked && lockedFeature) {
return <UpgradePrompt feature={lockedFeature} />;
}
return (
<div className="space-y-6">
{/* Header */}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
/**
* Domains Settings Page
* Custom Domains Settings Page
*
* Manage custom domains and booking URLs for the business.
* Manage custom domains - BYOD and domain purchase.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, Link } from 'react-router-dom';
import {
Globe, Link2, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
ShoppingCart, Crown
Globe, Copy, Star, Trash2, RefreshCw, CheckCircle, AlertCircle,
ShoppingCart, Lock, ArrowUpRight
} from 'lucide-react';
import { Business, User, CustomDomain } from '../../types';
import {
@@ -21,9 +21,8 @@ import {
} from '../../hooks/useCustomDomains';
import DomainPurchase from '../../components/DomainPurchase';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
import { LockedSection } from '../../components/UpgradePrompt';
const DomainsSettings: React.FC = () => {
const CustomDomainsSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
@@ -115,51 +114,45 @@ const DomainsSettings: React.FC = () => {
);
}
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.domains.title', 'Custom Domains')}
{t('settings.customDomains.title', 'Custom Domains')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure custom domains for your booking pages.
Use your own domains for your booking pages.
</p>
</div>
<LockedSection feature="custom_domain" isLocked={!canUse('custom_domain')}>
{/* Quick Domain Setup - Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
</h3>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
{business.subdomain}.smoothschedule.com
</code>
<button
onClick={() => navigator.clipboard.writeText(`${business.subdomain}.smoothschedule.com`)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Copy to clipboard"
>
<Copy size={16} />
</button>
</div>
</section>
{/* Custom Domains Management */}
{business.plan !== 'Free' ? (
<>
{/* 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" />
Custom Domains
Bring Your Own Domain
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Use your own domains for your booking pages
Connect a domain you already own
</p>
</div>
</div>
@@ -288,7 +281,7 @@ const DomainsSettings: React.FC = () => {
</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">
<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">
@@ -302,26 +295,8 @@ const DomainsSettings: React.FC = () => {
</div>
<DomainPurchase />
</section>
</>
) : (
/* Upgrade prompt for free plans */
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<Crown size={24} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Unlock Custom Domains</h4>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to use your own domain (e.g., <span className="font-mono">book.yourbusiness.com</span>) or purchase a new one.
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all">
<Crown size={16} /> View Plans
</button>
</div>
</div>
</section>
)}
</div>
</div>
{/* Toast */}
{showToast && (
@@ -330,9 +305,8 @@ const DomainsSettings: React.FC = () => {
Changes saved successfully
</div>
)}
</LockedSection>
</div>
);
};
export default DomainsSettings;
export default CustomDomainsSettings;

View File

@@ -7,7 +7,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Building2, Save, Check } from 'lucide-react';
import { Building2, Save, Check, Globe } from 'lucide-react';
import { Business, User } from '../../types';
const GeneralSettings: React.FC = () => {
@@ -23,14 +23,40 @@ const GeneralSettings: React.FC = () => {
subdomain: business.subdomain,
contactEmail: business.contactEmail || '',
phone: business.phone || '',
timezone: business.timezone || 'America/New_York',
timezoneDisplayMode: business.timezoneDisplayMode || 'business',
});
const [showToast, setShowToast] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormState(prev => ({ ...prev, [name]: value }));
};
// Common timezones grouped by region
const commonTimezones = [
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
{ value: 'America/Anchorage', label: 'Alaska Time' },
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Central European Time' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Asia/Tokyo', label: 'Japan Time' },
{ value: 'Asia/Shanghai', label: 'China Time' },
{ value: 'Asia/Singapore', label: 'Singapore Time' },
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
{ value: 'UTC', label: 'UTC' },
];
const handleSave = async () => {
await updateBusiness(formState);
setShowToast(true);
@@ -103,6 +129,59 @@ const GeneralSettings: React.FC = () => {
</div>
</section>
{/* Timezone Settings */}
<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">
<Globe size={20} className="text-brand-500" />
{t('settings.timezone.title', 'Timezone Settings')}
</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.timezone.businessTimezone', 'Business Timezone')}
</label>
<select
name="timezone"
value={formState.timezone}
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"
>
{commonTimezones.map(tz => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('settings.timezone.businessTimezoneHint', 'The timezone where your business operates.')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.timezone.displayMode', 'Time Display Mode')}
</label>
<select
name="timezoneDisplayMode"
value={formState.timezoneDisplayMode}
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"
>
<option value="business">
{t('settings.timezone.businessMode', 'Business Timezone')}
</option>
<option value="viewer">
{t('settings.timezone.viewerMode', "Viewer's Local Timezone")}
</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formState.timezoneDisplayMode === 'business'
? t('settings.timezone.businessModeHint', 'All appointment times are displayed in your business timezone.')
: t('settings.timezone.viewerModeHint', 'Appointment times adapt to each viewer\'s local timezone.')}
</p>
</div>
</div>
</section>
{/* Contact Information */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">