Add TenantCustomTier system and fix BusinessEditModal feature loading

Backend:
- Add TenantCustomTier model for per-tenant feature overrides
- Update EntitlementService to check custom tier before plan features
- Add custom_tier action on TenantViewSet (GET/PUT/DELETE)
- Add Celery task for grace period management (30-day expiry)

Frontend:
- Add DynamicFeaturesEditor component for dynamic feature management
- Fix BusinessEditModal to load features from plan defaults when no custom tier
- Update limits (max_users, max_resources, etc.) to use featureValues
- Remove outdated canonical feature check from FeaturePicker (removes warning icons)
- Add useBillingPlans hook for accessing billing system data
- Add custom tier API functions to platform.ts

Features now follow consistent rules:
- Load from plan defaults when no custom tier exists
- Load from custom tier when one exists
- Reset to plan defaults when plan changes
- Save to custom tier on edit

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-12 21:00:54 -05:00
parent d25c578e59
commit b384d9912a
183 changed files with 47627 additions and 3955 deletions

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import PricingTable from '../../components/marketing/PricingTable';
import DynamicPricingCards from '../../components/marketing/DynamicPricingCards';
import FeatureComparisonTable from '../../components/marketing/FeatureComparisonTable';
import FAQAccordion from '../../components/marketing/FAQAccordion';
import CTASection from '../../components/marketing/CTASection';
@@ -19,9 +20,25 @@ const PricingPage: React.FC = () => {
</p>
</div>
{/* Pricing Table */}
{/* Dynamic Pricing Cards */}
<div className="pb-20">
<PricingTable />
<DynamicPricingCards />
</div>
{/* Feature Comparison Table */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<h2 className="text-3xl font-bold text-center text-gray-900 dark:text-white mb-4">
{t('marketing.pricing.featureComparison.title', 'Compare Plans')}
</h2>
<p className="text-center text-gray-600 dark:text-gray-400 mb-12 max-w-2xl mx-auto">
{t(
'marketing.pricing.featureComparison.subtitle',
'See exactly what you get with each plan'
)}
</p>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<FeatureComparisonTable />
</div>
</div>
{/* FAQ Section */}
@@ -29,24 +46,26 @@ const PricingPage: React.FC = () => {
<h2 className="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
{t('marketing.pricing.faq.title')}
</h2>
<FAQAccordion items={[
{
question: t('marketing.pricing.faq.needPython.question'),
answer: t('marketing.pricing.faq.needPython.answer')
},
{
question: t('marketing.pricing.faq.exceedLimits.question'),
answer: t('marketing.pricing.faq.exceedLimits.answer')
},
{
question: t('marketing.pricing.faq.customDomain.question'),
answer: t('marketing.pricing.faq.customDomain.answer')
},
{
question: t('marketing.pricing.faq.dataSafety.question'),
answer: t('marketing.pricing.faq.dataSafety.answer')
}
]} />
<FAQAccordion
items={[
{
question: t('marketing.pricing.faq.needPython.question'),
answer: t('marketing.pricing.faq.needPython.answer'),
},
{
question: t('marketing.pricing.faq.exceedLimits.question'),
answer: t('marketing.pricing.faq.exceedLimits.answer'),
},
{
question: t('marketing.pricing.faq.customDomain.question'),
answer: t('marketing.pricing.faq.customDomain.answer'),
},
{
question: t('marketing.pricing.faq.dataSafety.question'),
answer: t('marketing.pricing.faq.dataSafety.answer'),
},
]}
/>
</div>
{/* CTA */}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
@@ -11,7 +11,10 @@ import {
Check,
AlertCircle,
Loader2,
Lock,
} from 'lucide-react';
import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js';
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
import apiClient from '../../api/client';
import { getBaseDomain, buildSubdomainUrl } from '../../utils/domain';
@@ -33,9 +36,165 @@ interface SignupFormData {
password: string;
confirmPassword: string;
// Step 3: Plan selection
plan: 'free' | 'professional' | 'business' | 'enterprise';
plan: 'free' | 'starter' | 'growth' | 'pro' | 'enterprise';
// Stripe data (populated during payment step)
stripeCustomerId: string;
}
// Stripe promise - initialized lazily
let stripePromise: Promise<Stripe | null> | null = null;
const getStripePromise = (publishableKey: string) => {
if (!stripePromise) {
stripePromise = loadStripe(publishableKey);
}
return stripePromise;
};
// Card element styling for dark/light mode
const cardElementOptions = {
style: {
base: {
fontSize: '16px',
color: '#1f2937',
fontFamily: 'system-ui, -apple-system, sans-serif',
'::placeholder': {
color: '#9ca3af',
},
},
invalid: {
color: '#ef4444',
},
},
};
// Payment form component (must be inside Elements provider)
interface PaymentFormProps {
onPaymentMethodReady: (ready: boolean) => void;
clientSecret: string;
}
const PaymentForm: React.FC<PaymentFormProps> = ({ onPaymentMethodReady, clientSecret }) => {
const stripe = useStripe();
const elements = useElements();
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [cardComplete, setCardComplete] = useState(false);
const handleCardChange = (event: any) => {
setCardComplete(event.complete);
if (event.error) {
setError(event.error.message);
} else {
setError(null);
}
};
const handleConfirmSetup = async () => {
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
setIsProcessing(true);
setError(null);
try {
const { error: confirmError, setupIntent } = await stripe.confirmCardSetup(
clientSecret,
{
payment_method: {
card: cardElement,
},
}
);
if (confirmError) {
setError(confirmError.message || t('marketing.signup.payment.error', 'Payment setup failed'));
onPaymentMethodReady(false);
} else if (setupIntent?.status === 'succeeded') {
setIsComplete(true);
onPaymentMethodReady(true);
}
} catch (err: any) {
setError(err.message || t('marketing.signup.payment.error', 'Payment setup failed'));
onPaymentMethodReady(false);
} finally {
setIsProcessing(false);
}
};
// Auto-confirm when card is complete (for better UX)
useEffect(() => {
if (cardComplete && !isComplete && !isProcessing && stripe && elements) {
// Slight delay to allow user to review
const timer = setTimeout(() => {
handleConfirmSetup();
}, 500);
return () => clearTimeout(timer);
}
}, [cardComplete, isComplete, isProcessing, stripe, elements]);
if (isComplete) {
return (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<Check className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-green-800 dark:text-green-200">
{t('marketing.signup.payment.success', 'Payment method saved')}
</p>
<p className="text-sm text-green-600 dark:text-green-400">
{t('marketing.signup.payment.successNote', 'You can continue to the next step.')}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="border border-gray-300 dark:border-gray-600 rounded-xl p-4 bg-white dark:bg-gray-800">
<CardElement
options={cardElementOptions}
onChange={handleCardChange}
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-3">
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</p>
</div>
)}
{isProcessing && (
<div className="flex items-center justify-center py-2">
<Loader2 className="w-5 h-5 text-brand-600 animate-spin mr-2" />
<span className="text-sm text-gray-600 dark:text-gray-400">
{t('marketing.signup.payment.processing', 'Validating payment method...')}
</span>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
{t('marketing.signup.payment.stripeNote', 'Payments are securely processed by Stripe. We never store your card details.')}
</p>
</div>
);
};
const SignupPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -66,11 +225,14 @@ const SignupPage: React.FC = () => {
email: '',
password: '',
confirmPassword: '',
plan: (searchParams.get('plan') as SignupFormData['plan']) || 'professional',
plan: (searchParams.get('plan') as SignupFormData['plan']) || 'growth',
stripeCustomerId: '',
});
// Total steps: Business Info, User Info, Plan Selection, Confirmation
const totalSteps = 4;
// Total steps: Business Info, User Info, Plan Selection, Payment (paid plans), Confirmation
// For free plans, we skip the payment step
const isPaidPlan = formData.plan !== 'free';
const totalSteps = isPaidPlan ? 5 : 4;
const [errors, setErrors] = useState<Partial<Record<keyof SignupFormData, string>>>({});
const [subdomainAvailable, setSubdomainAvailable] = useState<boolean | null>(null);
@@ -80,13 +242,32 @@ const SignupPage: React.FC = () => {
const [submitError, setSubmitError] = useState<string | null>(null);
const [signupComplete, setSignupComplete] = useState(false);
// Signup steps
const steps = [
{ number: 1, title: t('marketing.signup.steps.business'), icon: Building2 },
{ number: 2, title: t('marketing.signup.steps.account'), icon: User },
{ number: 3, title: t('marketing.signup.steps.plan'), icon: CreditCard },
{ number: 4, title: t('marketing.signup.steps.confirm'), icon: CheckCircle },
];
// Stripe state
const [stripePublishableKey, setStripePublishableKey] = useState<string | null>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [isLoadingPayment, setIsLoadingPayment] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const [paymentMethodReady, setPaymentMethodReady] = useState(false);
// Signup steps - dynamically include payment step for paid plans
const steps = isPaidPlan
? [
{ number: 1, title: t('marketing.signup.steps.business'), icon: Building2 },
{ number: 2, title: t('marketing.signup.steps.account'), icon: User },
{ number: 3, title: t('marketing.signup.steps.plan'), icon: CreditCard },
{ number: 4, title: t('marketing.signup.steps.payment', 'Payment'), icon: Lock },
{ number: 5, title: t('marketing.signup.steps.confirm'), icon: CheckCircle },
]
: [
{ number: 1, title: t('marketing.signup.steps.business'), icon: Building2 },
{ number: 2, title: t('marketing.signup.steps.account'), icon: User },
{ number: 3, title: t('marketing.signup.steps.plan'), icon: CreditCard },
{ number: 4, title: t('marketing.signup.steps.confirm'), icon: CheckCircle },
];
// Helper to get the step number for confirmation (last step)
const confirmationStep = totalSteps;
const paymentStep = isPaidPlan ? 4 : -1; // -1 means no payment step
const plans = [
{
@@ -94,47 +275,73 @@ const SignupPage: React.FC = () => {
name: t('marketing.pricing.tiers.free.name'),
price: '$0',
period: t('marketing.pricing.period'),
description: t('marketing.pricing.tiers.free.description'),
features: [
t('marketing.pricing.tiers.free.features.0'),
t('marketing.pricing.tiers.free.features.1'),
t('marketing.pricing.tiers.free.features.2'),
'1 user',
'1 resource',
'50 appointments/month',
'Online booking',
'Email reminders',
],
},
{
id: 'professional' as const,
name: t('marketing.pricing.tiers.professional.name'),
price: '$29',
id: 'starter' as const,
name: t('marketing.pricing.tiers.starter.name'),
price: '$19',
period: t('marketing.pricing.period'),
description: t('marketing.pricing.tiers.starter.description'),
features: [
'3 users',
'5 resources',
'200 appointments/month',
'Payment processing',
'Mobile app access',
],
},
{
id: 'growth' as const,
name: t('marketing.pricing.tiers.growth.name'),
price: '$59',
period: t('marketing.pricing.period'),
description: t('marketing.pricing.tiers.growth.description'),
popular: true,
features: [
t('marketing.pricing.tiers.professional.features.0'),
t('marketing.pricing.tiers.professional.features.1'),
t('marketing.pricing.tiers.professional.features.2'),
t('marketing.pricing.tiers.professional.features.3'),
'10 users',
'15 resources',
'1,000 appointments/month',
'SMS reminders',
'Custom domain',
'Integrations',
],
},
{
id: 'business' as const,
name: t('marketing.pricing.tiers.business.name'),
price: '$79',
id: 'pro' as const,
name: t('marketing.pricing.tiers.pro.name'),
price: '$99',
period: t('marketing.pricing.period'),
description: t('marketing.pricing.tiers.pro.description'),
features: [
t('marketing.pricing.tiers.business.features.0'),
t('marketing.pricing.tiers.business.features.1'),
t('marketing.pricing.tiers.business.features.2'),
t('marketing.pricing.tiers.business.features.3'),
'25 users',
'50 resources',
'5,000 appointments/month',
'API access',
'Advanced reporting',
'Team permissions',
],
},
{
id: 'enterprise' as const,
name: t('marketing.pricing.tiers.enterprise.name'),
price: t('marketing.pricing.tiers.enterprise.price'),
period: '',
price: '$199',
period: t('marketing.pricing.period'),
description: t('marketing.pricing.tiers.enterprise.description'),
features: [
t('marketing.pricing.tiers.enterprise.features.0'),
t('marketing.pricing.tiers.enterprise.features.1'),
t('marketing.pricing.tiers.enterprise.features.2'),
t('marketing.pricing.tiers.enterprise.features.3'),
'Unlimited users',
'Unlimited resources',
'Unlimited appointments',
'Multi-location',
'White label',
'Priority support',
],
},
];
@@ -248,10 +455,47 @@ const SignupPage: React.FC = () => {
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep((prev) => Math.min(prev + 1, totalSteps));
// Initialize payment when entering payment step
const initializePayment = useCallback(async () => {
if (clientSecret) return; // Already initialized
setIsLoadingPayment(true);
setPaymentError(null);
try {
const response = await apiClient.post('/auth/signup/setup-intent/', {
email: formData.email,
name: formData.businessName,
plan: formData.plan,
});
setClientSecret(response.data.client_secret);
setStripePublishableKey(response.data.publishable_key);
setFormData((prev) => ({
...prev,
stripeCustomerId: response.data.customer_id,
}));
} catch (error: any) {
const errorMessage =
error.response?.data?.error ||
t('marketing.signup.errors.paymentInit', 'Unable to initialize payment. Please try again.');
setPaymentError(errorMessage);
} finally {
setIsLoadingPayment(false);
}
}, [clientSecret, formData.email, formData.businessName, formData.plan, t]);
const handleNext = async () => {
if (!validateStep(currentStep)) return;
const nextStep = currentStep + 1;
// If moving to payment step (for paid plans), initialize payment
if (nextStep === paymentStep && isPaidPlan) {
await initializePayment();
}
setCurrentStep(Math.min(nextStep, totalSteps));
};
const handleBack = () => {
@@ -259,11 +503,18 @@ const SignupPage: React.FC = () => {
};
// Determine if current step is the confirmation step (last step)
const isConfirmationStep = currentStep === totalSteps;
const isConfirmationStep = currentStep === confirmationStep;
const isPaymentStep = currentStep === paymentStep;
const handleSubmit = async () => {
if (!validateStep(currentStep)) return;
// For paid plans, ensure payment method is ready
if (isPaidPlan && !paymentMethodReady) {
setSubmitError(t('marketing.signup.errors.paymentRequired', 'Please complete payment setup'));
return;
}
setIsSubmitting(true);
setSubmitError(null);
@@ -284,6 +535,7 @@ const SignupPage: React.FC = () => {
password: formData.password,
tier: formData.plan.toUpperCase(),
payments_enabled: false,
stripe_customer_id: formData.stripeCustomerId || undefined,
});
setSignupComplete(true);
@@ -298,6 +550,42 @@ const SignupPage: React.FC = () => {
}
};
// Show full-screen loading overlay during signup
if (isSubmitting) {
return (
<div className="min-h-screen bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 flex items-center justify-center">
<div className="max-w-md mx-auto px-4 text-center">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<div className="w-16 h-16 mx-auto mb-6 relative">
<div className="absolute inset-0 border-4 border-brand-200 dark:border-brand-900 rounded-full"></div>
<div className="absolute inset-0 border-4 border-brand-600 border-t-transparent rounded-full animate-spin"></div>
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('marketing.signup.creating')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t('marketing.signup.creatingNote')}
</p>
<div className="space-y-2 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 bg-brand-600 rounded-full animate-pulse"></div>
<span>Creating your workspace</span>
</div>
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 bg-brand-400 rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
<span>Setting up database</span>
</div>
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 bg-brand-300 rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
<span>Configuring your account</span>
</div>
</div>
</div>
</div>
</div>
);
}
if (signupComplete) {
return (
<div className="min-h-screen bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-20">
@@ -771,7 +1059,7 @@ const SignupPage: React.FC = () => {
{t('marketing.signup.planSelection.title')}
</h2>
<div className="grid sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plans.map((plan) => (
<button
key={plan.id}
@@ -812,8 +1100,13 @@ const SignupPage: React.FC = () => {
)}
</div>
</div>
{'description' in plan && plan.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
{plan.description}
</p>
)}
<ul className="space-y-1">
{plan.features.slice(0, 3).map((feature, index) => (
{plan.features.slice(0, 4).map((feature, index) => (
<li
key={index}
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
@@ -829,6 +1122,86 @@ const SignupPage: React.FC = () => {
</div>
)}
{/* Step 4: Payment (for paid plans only) */}
{isPaymentStep && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('marketing.signup.payment.title', 'Payment Information')}
</h2>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6">
<div className="flex items-start gap-3">
<Lock className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-200">
{t('marketing.signup.payment.trialNote', 'Start your 14-day free trial. You won\'t be charged until your trial ends.')}
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
{t('marketing.signup.payment.secureNote', 'Your payment information is secured by Stripe.')}
</p>
</div>
</div>
</div>
{/* Plan summary */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-6">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('marketing.signup.payment.selectedPlan', 'Selected Plan')}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{plans.find((p) => p.id === formData.plan)?.name}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{plans.find((p) => p.id === formData.plan)?.price}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
/{plans.find((p) => p.id === formData.plan)?.period}
</p>
</div>
</div>
</div>
{isLoadingPayment ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-brand-600 animate-spin" />
<span className="ml-3 text-gray-600 dark:text-gray-400">
{t('marketing.signup.payment.loading', 'Loading payment form...')}
</span>
</div>
) : paymentError ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-600 dark:text-red-400">{paymentError}</p>
<button
onClick={() => {
setPaymentError(null);
setClientSecret(null);
initializePayment();
}}
className="mt-2 text-sm text-red-700 dark:text-red-300 underline hover:no-underline"
>
{t('marketing.signup.payment.retry', 'Try again')}
</button>
</div>
</div>
</div>
) : stripePublishableKey && clientSecret ? (
<Elements stripe={getStripePromise(stripePublishableKey)} options={{ clientSecret }}>
<PaymentForm
onPaymentMethodReady={(ready) => setPaymentMethodReady(ready)}
clientSecret={clientSecret}
/>
</Elements>
) : null}
</div>
)}
{/* Confirmation Step (last step) */}
{isConfirmationStep && (
<div className="space-y-6">

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import ContactPage from '../ContactPage';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const renderContactPage = () => {
return render(
<BrowserRouter>
<ContactPage />
</BrowserRouter>
);
};
describe('ContactPage', () => {
describe('rendering', () => {
it('renders the page title', () => {
renderContactPage();
expect(screen.getByText('marketing.contact.title')).toBeInTheDocument();
});
it('renders the page subtitle', () => {
renderContactPage();
expect(screen.getByText('marketing.contact.subtitle')).toBeInTheDocument();
});
it('renders the form heading', () => {
renderContactPage();
expect(screen.getByText('marketing.contact.formHeading')).toBeInTheDocument();
});
it('renders the sidebar heading', () => {
renderContactPage();
expect(screen.getByText('marketing.contact.sidebarHeading')).toBeInTheDocument();
});
it('renders contact info sections', () => {
renderContactPage();
expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.getByText('Phone')).toBeInTheDocument();
expect(screen.getByText('Address')).toBeInTheDocument();
});
it('renders sales CTA section', () => {
renderContactPage();
expect(screen.getByText('marketing.contact.sales.title')).toBeInTheDocument();
expect(screen.getByText('marketing.contact.sales.description')).toBeInTheDocument();
});
});
describe('form', () => {
it('renders name input field', () => {
renderContactPage();
expect(screen.getByLabelText('marketing.contact.form.name')).toBeInTheDocument();
});
it('renders email input field', () => {
renderContactPage();
expect(screen.getByLabelText('marketing.contact.form.email')).toBeInTheDocument();
});
it('renders subject input field', () => {
renderContactPage();
expect(screen.getByLabelText('marketing.contact.form.subject')).toBeInTheDocument();
});
it('renders message textarea', () => {
renderContactPage();
expect(screen.getByLabelText('marketing.contact.form.message')).toBeInTheDocument();
});
it('renders submit button', () => {
renderContactPage();
expect(screen.getByRole('button', { name: /marketing.contact.form.submit/i })).toBeInTheDocument();
});
it('updates name field on input', () => {
renderContactPage();
const nameInput = screen.getByLabelText('marketing.contact.form.name');
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
expect(nameInput).toHaveValue('John Doe');
});
it('updates email field on input', () => {
renderContactPage();
const emailInput = screen.getByLabelText('marketing.contact.form.email');
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
expect(emailInput).toHaveValue('john@example.com');
});
it('updates subject field on input', () => {
renderContactPage();
const subjectInput = screen.getByLabelText('marketing.contact.form.subject');
fireEvent.change(subjectInput, { target: { value: 'Test Subject' } });
expect(subjectInput).toHaveValue('Test Subject');
});
it('updates message field on input', () => {
renderContactPage();
const messageInput = screen.getByLabelText('marketing.contact.form.message');
fireEvent.change(messageInput, { target: { value: 'Test message' } });
expect(messageInput).toHaveValue('Test message');
});
});
describe('form submission', () => {
it('submit button is not disabled initially', () => {
renderContactPage();
const submitButton = screen.getByRole('button', { name: /marketing.contact.form.submit/i });
expect(submitButton).not.toBeDisabled();
});
it('shows submit button with correct text', () => {
renderContactPage();
expect(screen.getByRole('button', { name: /marketing.contact.form.submit/i })).toBeInTheDocument();
});
});
describe('contact links', () => {
it('renders email link', () => {
renderContactPage();
const emailLink = screen.getByText('marketing.contact.info.email');
expect(emailLink.closest('a')).toHaveAttribute('href', 'mailto:marketing.contact.info.email');
});
it('renders phone link', () => {
renderContactPage();
const phoneLink = screen.getByText('marketing.contact.info.phone');
expect(phoneLink.closest('a')).toHaveAttribute('href', expect.stringContaining('tel:'));
});
it('renders schedule call link', () => {
renderContactPage();
expect(screen.getByText('marketing.contact.scheduleCall')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import FeaturesPage from '../FeaturesPage';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock components
vi.mock('../../../components/marketing/CodeBlock', () => ({
default: ({ code, filename }: { code: string; filename: string }) => (
<div data-testid="code-block">
<div data-testid="code-filename">{filename}</div>
<pre>{code}</pre>
</div>
),
}));
vi.mock('../../../components/marketing/CTASection', () => ({
default: () => <div data-testid="cta-section">CTA Section</div>,
}));
const renderFeaturesPage = () => {
return render(
<BrowserRouter>
<FeaturesPage />
</BrowserRouter>
);
};
describe('FeaturesPage', () => {
it('renders the page title', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.pageTitle')).toBeInTheDocument();
});
it('renders the page subtitle', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.pageSubtitle')).toBeInTheDocument();
});
it('renders the automation engine section', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.automationEngine.title')).toBeInTheDocument();
expect(screen.getByText('marketing.features.automationEngine.badge')).toBeInTheDocument();
});
it('renders automation engine features list', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.automationEngine.features.recurringJobs')).toBeInTheDocument();
expect(screen.getByText('marketing.features.automationEngine.features.customLogic')).toBeInTheDocument();
expect(screen.getByText('marketing.features.automationEngine.features.fullContext')).toBeInTheDocument();
expect(screen.getByText('marketing.features.automationEngine.features.zeroInfrastructure')).toBeInTheDocument();
});
it('renders the code block example', () => {
renderFeaturesPage();
expect(screen.getByTestId('code-block')).toBeInTheDocument();
expect(screen.getByTestId('code-filename')).toHaveTextContent('webhook_plugin.py');
});
it('renders the multi-tenancy section', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.multiTenancy.title')).toBeInTheDocument();
expect(screen.getByText('marketing.features.multiTenancy.badge')).toBeInTheDocument();
});
it('renders multi-tenancy features', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.multiTenancy.customDomains.title')).toBeInTheDocument();
expect(screen.getByText('marketing.features.multiTenancy.whiteLabeling.title')).toBeInTheDocument();
});
it('renders the contracts section', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.contracts.title')).toBeInTheDocument();
expect(screen.getByText('marketing.features.contracts.badge')).toBeInTheDocument();
});
it('renders contracts features list', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.contracts.features.templates')).toBeInTheDocument();
expect(screen.getByText('marketing.features.contracts.features.eSignature')).toBeInTheDocument();
expect(screen.getByText('marketing.features.contracts.features.auditTrail')).toBeInTheDocument();
expect(screen.getByText('marketing.features.contracts.features.pdfGeneration')).toBeInTheDocument();
});
it('renders the CTA section', () => {
renderFeaturesPage();
expect(screen.getByTestId('cta-section')).toBeInTheDocument();
});
it('renders compliance and automation sections', () => {
renderFeaturesPage();
expect(screen.getByText('marketing.features.contracts.compliance.title')).toBeInTheDocument();
expect(screen.getByText('marketing.features.contracts.automation.title')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import PricingPage from '../PricingPage';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// Mock components
vi.mock('../../../components/marketing/PricingTable', () => ({
default: () => <div data-testid="pricing-table">Pricing Table</div>,
}));
vi.mock('../../../components/marketing/FAQAccordion', () => ({
default: ({ items }: { items: Array<{ question: string; answer: string }> }) => (
<div data-testid="faq-accordion">
{items.map((item, index) => (
<div key={index} data-testid={`faq-item-${index}`}>
<span>{item.question}</span>
</div>
))}
</div>
),
}));
vi.mock('../../../components/marketing/CTASection', () => ({
default: () => <div data-testid="cta-section">CTA Section</div>,
}));
const renderPricingPage = () => {
return render(
<BrowserRouter>
<PricingPage />
</BrowserRouter>
);
};
describe('PricingPage', () => {
it('renders the page title', () => {
renderPricingPage();
expect(screen.getByText('marketing.pricing.title')).toBeInTheDocument();
});
it('renders the page subtitle', () => {
renderPricingPage();
expect(screen.getByText('marketing.pricing.subtitle')).toBeInTheDocument();
});
it('renders the PricingTable component', () => {
renderPricingPage();
expect(screen.getByTestId('pricing-table')).toBeInTheDocument();
});
it('renders the FAQ section title', () => {
renderPricingPage();
expect(screen.getByText('marketing.pricing.faq.title')).toBeInTheDocument();
});
it('renders the FAQAccordion component', () => {
renderPricingPage();
expect(screen.getByTestId('faq-accordion')).toBeInTheDocument();
});
it('renders all FAQ items', () => {
renderPricingPage();
expect(screen.getByTestId('faq-item-0')).toBeInTheDocument();
expect(screen.getByTestId('faq-item-1')).toBeInTheDocument();
expect(screen.getByTestId('faq-item-2')).toBeInTheDocument();
expect(screen.getByTestId('faq-item-3')).toBeInTheDocument();
});
it('renders FAQ questions', () => {
renderPricingPage();
expect(screen.getByText('marketing.pricing.faq.needPython.question')).toBeInTheDocument();
expect(screen.getByText('marketing.pricing.faq.exceedLimits.question')).toBeInTheDocument();
expect(screen.getByText('marketing.pricing.faq.customDomain.question')).toBeInTheDocument();
expect(screen.getByText('marketing.pricing.faq.dataSafety.question')).toBeInTheDocument();
});
it('renders the CTASection component', () => {
renderPricingPage();
expect(screen.getByTestId('cta-section')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import PrivacyPolicyPage from '../PrivacyPolicyPage';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { returnObjects?: boolean }) => {
if (options?.returnObjects) {
return ['Item 1', 'Item 2', 'Item 3'];
}
return key;
},
}),
}));
const renderPrivacyPolicyPage = () => {
return render(
<BrowserRouter>
<PrivacyPolicyPage />
</BrowserRouter>
);
};
describe('PrivacyPolicyPage', () => {
it('renders the page title', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.title')).toBeInTheDocument();
});
it('renders the last updated date', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.lastUpdated')).toBeInTheDocument();
});
it('renders section 1', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section1.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section1.content')).toBeInTheDocument();
});
it('renders section 2', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section2.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section2.subsection1.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section2.subsection2.title')).toBeInTheDocument();
});
it('renders section 3', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section3.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section3.intro')).toBeInTheDocument();
});
it('renders section 4', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section4.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section4.subsection1.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section4.subsection2.title')).toBeInTheDocument();
});
it('renders section 5', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section5.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section5.intro')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section5.disclaimer')).toBeInTheDocument();
});
it('renders section 6', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section6.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section6.content')).toBeInTheDocument();
});
it('renders section 7', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section7.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section7.intro')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section7.contact')).toBeInTheDocument();
});
it('renders section 8', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section8.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section8.intro')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section8.disclaimer')).toBeInTheDocument();
});
it('renders sections 9-14', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section9.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section10.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section11.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section12.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section13.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section14.title')).toBeInTheDocument();
});
it('renders section 15 contact info', () => {
renderPrivacyPolicyPage();
expect(screen.getByText('marketing.privacyPolicy.section15.title')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section15.intro')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section15.emailLabel')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section15.dpoLabel')).toBeInTheDocument();
expect(screen.getByText('marketing.privacyPolicy.section15.websiteLabel')).toBeInTheDocument();
});
it('renders list items from translations', () => {
renderPrivacyPolicyPage();
// The mock returns 'Item 1', 'Item 2', 'Item 3' for array translations
const listItems = screen.getAllByText('Item 1');
expect(listItems.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,376 @@
/**
* Comprehensive Unit Tests for SignupPage Component
*
* Test Coverage:
* - Component rendering (all form fields, buttons, step indicators)
* - Form validation (business info, user info, plan selection)
* - Multi-step navigation (forward, backward, progress tracking)
* - Subdomain availability checking
* - Form submission and signup flow
* - Success state and redirect
* - Error handling and display
* - Loading states
* - Accessibility
* - Internationalization
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, useNavigate, useSearchParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import SignupPage from '../SignupPage';
import apiClient from '../../../api/client';
import { buildSubdomainUrl } from '../../../utils/domain';
// Mock dependencies
vi.mock('../../../api/client');
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: vi.fn(),
useSearchParams: vi.fn(),
};
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
// Steps
'marketing.signup.steps.business': 'Business',
'marketing.signup.steps.account': 'Account',
'marketing.signup.steps.plan': 'Plan',
'marketing.signup.steps.confirm': 'Confirm',
// Main
'marketing.signup.title': 'Create Your Account',
'marketing.signup.subtitle': 'Get started for free. No credit card required.',
// Business Info
'marketing.signup.businessInfo.title': 'Tell us about your business',
'marketing.signup.businessInfo.name': 'Business Name',
'marketing.signup.businessInfo.namePlaceholder': 'e.g., Acme Salon & Spa',
'marketing.signup.businessInfo.subdomain': 'Choose Your Subdomain',
'marketing.signup.businessInfo.subdomainNote': 'A subdomain is required even if you plan to use your own custom domain later.',
'marketing.signup.businessInfo.checking': 'Checking availability...',
'marketing.signup.businessInfo.available': 'Available!',
'marketing.signup.businessInfo.taken': 'Already taken',
'marketing.signup.businessInfo.address': 'Business Address',
'marketing.signup.businessInfo.addressLine1': 'Street Address',
'marketing.signup.businessInfo.addressLine1Placeholder': '123 Main Street',
'marketing.signup.businessInfo.addressLine2': 'Address Line 2',
'marketing.signup.businessInfo.addressLine2Placeholder': 'Suite 100 (optional)',
'marketing.signup.businessInfo.city': 'City',
'marketing.signup.businessInfo.state': 'State / Province',
'marketing.signup.businessInfo.postalCode': 'Postal Code',
'marketing.signup.businessInfo.phone': 'Phone Number',
'marketing.signup.businessInfo.phonePlaceholder': '(555) 123-4567',
// Account Info
'marketing.signup.accountInfo.title': 'Create your admin account',
'marketing.signup.accountInfo.firstName': 'First Name',
'marketing.signup.accountInfo.lastName': 'Last Name',
'marketing.signup.accountInfo.email': 'Email Address',
'marketing.signup.accountInfo.password': 'Password',
'marketing.signup.accountInfo.confirmPassword': 'Confirm Password',
// Plan Selection
'marketing.signup.planSelection.title': 'Choose Your Plan',
// Pricing
'marketing.pricing.tiers.free.name': 'Free',
'marketing.pricing.tiers.professional.name': 'Professional',
'marketing.pricing.tiers.business.name': 'Business',
'marketing.pricing.tiers.enterprise.name': 'Enterprise',
'marketing.pricing.tiers.enterprise.price': 'Custom',
'marketing.pricing.period': 'month',
'marketing.pricing.popular': 'Most Popular',
'marketing.pricing.tiers.free.features.0': 'Up to 10 appointments',
'marketing.pricing.tiers.free.features.1': 'Basic scheduling',
'marketing.pricing.tiers.free.features.2': 'Email notifications',
'marketing.pricing.tiers.professional.features.0': 'Unlimited appointments',
'marketing.pricing.tiers.professional.features.1': 'Advanced scheduling',
'marketing.pricing.tiers.professional.features.2': 'SMS notifications',
'marketing.pricing.tiers.professional.features.3': 'Custom branding',
'marketing.pricing.tiers.business.features.0': 'Everything in Professional',
'marketing.pricing.tiers.business.features.1': 'Multi-location support',
'marketing.pricing.tiers.business.features.2': 'Team management',
'marketing.pricing.tiers.business.features.3': 'API access',
'marketing.pricing.tiers.enterprise.features.0': 'Everything in Business',
'marketing.pricing.tiers.enterprise.features.1': 'Dedicated support',
'marketing.pricing.tiers.enterprise.features.2': 'Custom integrations',
'marketing.pricing.tiers.enterprise.features.3': 'SLA guarantees',
// Confirm
'marketing.signup.confirm.title': 'Review Your Details',
'marketing.signup.confirm.business': 'Business',
'marketing.signup.confirm.account': 'Account',
'marketing.signup.confirm.plan': 'Selected Plan',
'marketing.signup.confirm.terms': 'By creating your account, you agree to our Terms of Service and Privacy Policy.',
// Errors
'marketing.signup.errors.businessNameRequired': 'Business name is required',
'marketing.signup.errors.subdomainRequired': 'Subdomain is required',
'marketing.signup.errors.subdomainTooShort': 'Subdomain must be at least 3 characters',
'marketing.signup.errors.subdomainInvalid': 'Subdomain can only contain lowercase letters, numbers, and hyphens',
'marketing.signup.errors.subdomainTaken': 'This subdomain is already taken',
'marketing.signup.errors.addressRequired': 'Street address is required',
'marketing.signup.errors.cityRequired': 'City is required',
'marketing.signup.errors.stateRequired': 'State/province is required',
'marketing.signup.errors.postalCodeRequired': 'Postal code is required',
'marketing.signup.errors.firstNameRequired': 'First name is required',
'marketing.signup.errors.lastNameRequired': 'Last name is required',
'marketing.signup.errors.emailRequired': 'Email is required',
'marketing.signup.errors.emailInvalid': 'Please enter a valid email address',
'marketing.signup.errors.passwordRequired': 'Password is required',
'marketing.signup.errors.passwordTooShort': 'Password must be at least 8 characters',
'marketing.signup.errors.passwordMismatch': 'Passwords do not match',
'marketing.signup.errors.generic': 'Something went wrong. Please try again.',
// Success
'marketing.signup.success.title': 'Welcome to Smooth Schedule!',
'marketing.signup.success.message': 'Your account has been created successfully.',
'marketing.signup.success.yourUrl': 'Your booking URL',
'marketing.signup.success.checkEmail': "We've sent a verification email to your inbox. Please verify your email to activate all features.",
'marketing.signup.success.goToLogin': 'Go to Login',
// Actions
'marketing.signup.back': 'Back',
'marketing.signup.next': 'Next',
'marketing.signup.creating': 'Creating account...',
'marketing.signup.creatingNote': "We're setting up your database. This may take up to a minute.",
'marketing.signup.createAccount': 'Create Account',
'marketing.signup.haveAccount': 'Already have an account?',
'marketing.signup.signIn': 'Sign in',
};
return translations[key] || key;
},
}),
}));
vi.mock('../../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
buildSubdomainUrl: vi.fn((subdomain: string, path: string) => `http://${subdomain}.lvh.me:5173${path}`),
}));
// Test wrapper with Router and QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
};
describe('SignupPage', () => {
let mockNavigate: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
// Setup navigate mock
mockNavigate = vi.fn();
vi.mocked(useNavigate).mockReturnValue(mockNavigate);
// Setup searchParams mock (default: no query params)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), vi.fn()]);
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
hostname: 'lvh.me',
port: '5173',
protocol: 'http:',
href: 'http://lvh.me:5173/signup',
},
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Initial Rendering', () => {
it('should render signup page with title and subtitle', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.getByRole('heading', { name: /create your account/i })).toBeInTheDocument();
expect(screen.getByText('Get started for free. No credit card required.')).toBeInTheDocument();
});
it('should render all step indicators', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.getByText('Business')).toBeInTheDocument();
expect(screen.getByText('Account')).toBeInTheDocument();
expect(screen.getByText('Plan')).toBeInTheDocument();
expect(screen.getByText('Confirm')).toBeInTheDocument();
});
it('should start at step 1 (Business Info)', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.getByText('Tell us about your business')).toBeInTheDocument();
expect(screen.getByLabelText(/business name/i)).toBeInTheDocument();
});
it('should render login link', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.getByText('Already have an account?')).toBeInTheDocument();
expect(screen.getByText('Sign in')).toBeInTheDocument();
});
});
describe('Step 1: Business Information', () => {
it('should render all business info fields', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.getByLabelText(/business name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/choose your subdomain/i)).toBeInTheDocument();
expect(screen.getByLabelText(/street address/i)).toBeInTheDocument();
expect(screen.getByLabelText(/address line 2/i)).toBeInTheDocument();
expect(screen.getByLabelText(/city/i)).toBeInTheDocument();
expect(screen.getByLabelText(/state \/ province/i)).toBeInTheDocument();
expect(screen.getByLabelText(/postal code/i)).toBeInTheDocument();
expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
});
it('should allow entering business name', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const businessNameInput = screen.getByLabelText(/business name/i) as HTMLInputElement;
await user.type(businessNameInput, 'Test Business');
expect(businessNameInput.value).toBe('Test Business');
});
it('should sanitize subdomain input', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const subdomainInput = screen.getByLabelText(/choose your subdomain/i) as HTMLInputElement;
await user.type(subdomainInput, 'Test-SUBDOMAIN-123!@#');
expect(subdomainInput.value).toBe('test-subdomain-123');
});
it('should validate business name is required', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
expect(screen.getByText('Business name is required')).toBeInTheDocument();
});
it('should validate subdomain minimum length', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const businessNameInput = screen.getByLabelText(/business name/i);
await user.type(businessNameInput, 'A');
const subdomainInput = screen.getByLabelText(/choose your subdomain/i);
await user.clear(subdomainInput);
await user.type(subdomainInput, 'ab');
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
expect(screen.getByText('Subdomain must be at least 3 characters')).toBeInTheDocument();
});
it('should validate address is required', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const businessNameInput = screen.getByLabelText(/business name/i);
await user.type(businessNameInput, 'Test Business');
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
expect(screen.getByText('Street address is required')).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should not show back button on step 1', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument();
});
it('should navigate to login page when clicking sign in link', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const signInButton = screen.getByText('Sign in');
await user.click(signInButton);
expect(mockNavigate).toHaveBeenCalledWith('/login');
});
});
describe('Accessibility', () => {
it('should have proper form labels for all inputs', () => {
render(<SignupPage />, { wrapper: createWrapper() });
expect(screen.getByLabelText(/business name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/choose your subdomain/i)).toBeInTheDocument();
expect(screen.getByLabelText(/street address/i)).toBeInTheDocument();
});
it('should have proper heading hierarchy', () => {
render(<SignupPage />, { wrapper: createWrapper() });
const h1 = screen.getByRole('heading', { level: 1, name: /create your account/i });
expect(h1).toBeInTheDocument();
const h2 = screen.getByRole('heading', { level: 2, name: /tell us about your business/i });
expect(h2).toBeInTheDocument();
});
it('should have proper autocomplete attributes', () => {
render(<SignupPage />, { wrapper: createWrapper() });
const businessNameInput = screen.getByLabelText(/business name/i);
expect(businessNameInput).toHaveAttribute('autoComplete', 'organization');
const addressInput = screen.getByLabelText(/street address/i);
expect(addressInput).toHaveAttribute('autoComplete', 'address-line1');
});
it('should display error messages near their fields', async () => {
const user = userEvent.setup();
render(<SignupPage />, { wrapper: createWrapper() });
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
const businessNameInput = screen.getByLabelText(/business name/i);
const errorMessage = screen.getByText('Business name is required');
// Error should be near the input (checking it exists is enough for this test)
expect(errorMessage).toBeInTheDocument();
expect(businessNameInput).toBeInTheDocument();
});
});
});