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:
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
143
frontend/src/pages/marketing/__tests__/ContactPage.test.tsx
Normal file
143
frontend/src/pages/marketing/__tests__/ContactPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
102
frontend/src/pages/marketing/__tests__/FeaturesPage.test.tsx
Normal file
102
frontend/src/pages/marketing/__tests__/FeaturesPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
88
frontend/src/pages/marketing/__tests__/PricingPage.test.tsx
Normal file
88
frontend/src/pages/marketing/__tests__/PricingPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
376
frontend/src/pages/marketing/__tests__/SignupPage.test.tsx
Normal file
376
frontend/src/pages/marketing/__tests__/SignupPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user