feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
201
frontend/src/pages/ForgotPassword.tsx
Normal file
201
frontend/src/pages/ForgotPassword.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Forgot Password Page Component
|
||||
* Allows users to request a password reset email
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForgotPassword } from '../hooks/useAuth';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
|
||||
import LanguageSelector from '../components/LanguageSelector';
|
||||
import { AlertCircle, Loader2, Mail, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
|
||||
const ForgotPassword: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const forgotPasswordMutation = useForgotPassword();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Basic email validation
|
||||
if (!email) {
|
||||
setError(t('auth.emailRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setError(t('auth.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
forgotPasswordMutation.mutate(
|
||||
{ email },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || t('auth.forgotPasswordError'));
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
{/* Left Side - Image & Branding (Hidden on mobile) */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative bg-gray-900 text-white overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1497215728101-856f4ea42174?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1950&q=80')] bg-cover bg-center opacity-40"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent to-gray-900/50"></div>
|
||||
|
||||
<div className="relative z-10 flex flex-col justify-between w-full p-12">
|
||||
<div>
|
||||
<Link to="/" className="flex items-center gap-3 text-white/90 hover:text-white transition-colors">
|
||||
<SmoothScheduleLogo className="w-8 h-8 text-brand-500" />
|
||||
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 max-w-md">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
||||
{t('auth.forgotPasswordTitle')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-300">
|
||||
{t('auth.forgotPasswordDescription')}
|
||||
</p>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<div className="h-1 w-12 bg-brand-500 rounded-full"></div>
|
||||
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
|
||||
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
© {new Date().getFullYear()} {t('marketing.copyright')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Forgot Password Form */}
|
||||
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="mx-auto w-full max-w-sm lg:max-w-md">
|
||||
<div className="text-center lg:text-left mb-10">
|
||||
<Link to="/" className="lg:hidden flex justify-center mb-6">
|
||||
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
|
||||
</Link>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
||||
{t('auth.forgotPasswordHeading')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('auth.forgotPasswordSubheading')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="mb-6 rounded-lg bg-green-50 dark:bg-green-900/20 p-6 border border-green-100 dark:border-green-800/50 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 dark:text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{t('auth.forgotPasswordSuccessTitle')}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
<p>{t('auth.forgotPasswordSuccessMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{t('auth.validationError')}
|
||||
</h3>
|
||||
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
|
||||
placeholder={t('auth.enterEmail')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70 disabled:cursor-not-allowed transition-all duration-200 ease-in-out transform active:scale-[0.98]"
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{forgotPasswordMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin h-5 w-5" />
|
||||
{t('auth.sending')}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t('auth.sendResetLink')}</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-2 text-sm font-medium text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 transition-colors"
|
||||
data-testid="back-to-login"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="mt-8 flex justify-center">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -20,8 +20,12 @@ import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Settings
|
||||
Settings,
|
||||
Lock,
|
||||
Crown,
|
||||
ArrowUpRight
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
import { PluginInstallation, PluginCategory } from '../types';
|
||||
import EmailTemplateSelector from '../components/EmailTemplateSelector';
|
||||
@@ -65,6 +69,7 @@ const MyPlugins: React.FC = () => {
|
||||
// Check plan permissions
|
||||
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
|
||||
const hasPluginsFeature = canUse('plugins');
|
||||
const canCreatePlugins = canUse('can_create_plugins');
|
||||
const isLocked = !hasPluginsFeature;
|
||||
|
||||
// Fetch installed plugins
|
||||
@@ -299,8 +304,7 @@ const MyPlugins: React.FC = () => {
|
||||
{plugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
||||
onClick={() => handleEdit(plugin)}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -388,13 +392,19 @@ const MyPlugins: React.FC = () => {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* Configure button */}
|
||||
<button
|
||||
onClick={() => handleEdit(plugin)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
title={t('plugins.configure', 'Configure')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('plugins.configure', 'Configure')}
|
||||
</button>
|
||||
{/* Schedule button - only if not already scheduled */}
|
||||
{!plugin.scheduledTaskId && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/tasks');
|
||||
}}
|
||||
onClick={() => navigate('/tasks')}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
title={t('plugins.schedule', 'Schedule')}
|
||||
>
|
||||
@@ -411,10 +421,7 @@ const MyPlugins: React.FC = () => {
|
||||
)}
|
||||
{plugin.hasUpdate && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(plugin);
|
||||
}}
|
||||
onClick={() => handleUpdate(plugin)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||
title={t('plugins.update', 'Update')}
|
||||
@@ -428,10 +435,7 @@ const MyPlugins: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRating(plugin);
|
||||
}}
|
||||
onClick={() => handleRating(plugin)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium"
|
||||
title={plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
>
|
||||
@@ -439,10 +443,7 @@ const MyPlugins: React.FC = () => {
|
||||
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUninstall(plugin);
|
||||
}}
|
||||
onClick={() => handleUninstall(plugin)}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title={t('plugins.uninstall', 'Uninstall')}
|
||||
>
|
||||
@@ -457,25 +458,67 @@ const MyPlugins: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
|
||||
<div className={`rounded-xl p-6 border ${
|
||||
canCreatePlugins
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0 p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<Package className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
<div className={`shrink-0 p-2 rounded-lg ${
|
||||
canCreatePlugins
|
||||
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||
: 'bg-gradient-to-br from-amber-400 to-orange-500'
|
||||
}`}>
|
||||
{canCreatePlugins ? (
|
||||
<Package className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Crown className="h-6 w-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
{t('plugins.needCustomPlugin', 'Need a custom plugin?')}
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mb-4">
|
||||
{t('plugins.customPluginDescription', 'Create your own custom plugins to extend your business functionality with specific features tailored to your needs.')}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className={`text-lg font-semibold ${
|
||||
canCreatePlugins
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{t('plugins.needCustomPlugin', 'Need a custom plugin?')}
|
||||
</h3>
|
||||
{!canCreatePlugins && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t('common.upgradeRequired', 'Upgrade Required')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`mb-4 ${
|
||||
canCreatePlugins
|
||||
? 'text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{canCreatePlugins
|
||||
? t('plugins.customPluginDescription', 'Create your own custom plugins to extend your business functionality with specific features tailored to your needs.')
|
||||
: t('plugins.customPluginUpgradeDescription', 'Custom plugins allow you to create automated workflows tailored to your business needs. Upgrade your plan to unlock this feature.')
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/plugins/create')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('plugins.createCustomPlugin', 'Create Custom Plugin')}
|
||||
</button>
|
||||
{canCreatePlugins ? (
|
||||
<button
|
||||
onClick={() => navigate('/plugins/create')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('plugins.createCustomPlugin', 'Create Custom Plugin')}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
{t('common.upgradeYourPlan', 'Upgrade Your Plan')}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
84
frontend/src/pages/NotFound.tsx
Normal file
84
frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Home, ArrowLeft, FileQuestion } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* NotFound Component
|
||||
*
|
||||
* Displays a 404 error page when users navigate to a non-existent route.
|
||||
* Provides navigation options to return to the home page or go back.
|
||||
*/
|
||||
const NotFound: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4 py-16">
|
||||
<div className="max-w-md w-full text-center">
|
||||
{/* Illustration/Icon */}
|
||||
<div className="mb-8 flex justify-center">
|
||||
<div className="relative">
|
||||
<FileQuestion
|
||||
className="w-32 h-32 text-gray-300 dark:text-gray-700"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl font-bold text-gray-400 dark:text-gray-600">
|
||||
404
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('errors.pageNotFound', 'Page Not Found')}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t('errors.pageNotFoundDescription', 'The page you are looking for does not exist or has been moved.')}
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
{/* Go Home Button */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<Home className="w-5 h-5" aria-hidden="true" />
|
||||
{t('navigation.goHome', 'Go Home')}
|
||||
</Link>
|
||||
|
||||
{/* Go Back Button */}
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" aria-hidden="true" />
|
||||
{t('navigation.goBack', 'Go Back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Additional Help Text */}
|
||||
<p className="mt-8 text-sm text-gray-500 dark:text-gray-500">
|
||||
{t('errors.needHelp', 'Need help?')}{' '}
|
||||
<Link
|
||||
to="/support"
|
||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
{t('navigation.contactSupport', 'Contact Support')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
312
frontend/src/pages/ResetPassword.tsx
Normal file
312
frontend/src/pages/ResetPassword.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Reset Password Page Component
|
||||
* Allows users to reset their password using a token from email
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { useResetPassword } from '../hooks/useAuth';
|
||||
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
|
||||
import { AlertCircle, Loader2, Lock, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const ResetPassword: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const resetPasswordMutation = useResetPassword();
|
||||
|
||||
// Token validation - check if token exists
|
||||
const isTokenValid = !!token && token.length > 0;
|
||||
|
||||
const validatePasswords = (): string | null => {
|
||||
if (!password) {
|
||||
return t('auth.passwordRequired');
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return t('auth.passwordMinLength');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return t('auth.passwordsDoNotMatch');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validate token
|
||||
if (!isTokenValid) {
|
||||
setError(t('auth.invalidResetToken'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate passwords
|
||||
const validationError = validatePasswords();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
resetPasswordMutation.mutate(
|
||||
{ token: token!, password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
t('auth.resetPasswordError')
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Show error if token is missing
|
||||
if (!isTokenValid) {
|
||||
return (
|
||||
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="text-center mb-10">
|
||||
<Link to="/" className="flex justify-center mb-6">
|
||||
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
|
||||
</Link>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
||||
{t('auth.resetPassword')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{t('auth.invalidToken')}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
{t('auth.invalidTokenDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-brand-600 dark:text-brand-400 hover:text-brand-500 dark:hover:text-brand-300 font-medium"
|
||||
>
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="text-center mb-10">
|
||||
<Link to="/" className="flex justify-center mb-6">
|
||||
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
|
||||
</Link>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
||||
{t('auth.resetPassword')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 border border-green-100 dark:border-green-800/50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 dark:text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{t('auth.passwordResetSuccess')}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
{t('auth.passwordResetSuccessDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('auth.signIn')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="text-center mb-10">
|
||||
<Link to="/" className="flex justify-center mb-6">
|
||||
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
|
||||
</Link>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
||||
{t('auth.resetPassword')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('auth.enterNewPassword')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
{t('common.error')}
|
||||
</h3>
|
||||
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('auth.newPassword')}
|
||||
</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 pr-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
|
||||
placeholder={t('auth.enterNewPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('auth.passwordRequirements')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('auth.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 pr-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
|
||||
placeholder={t('auth.confirmNewPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
aria-label={showConfirmPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70 disabled:cursor-not-allowed transition-all duration-200 ease-in-out transform active:scale-[0.98]"
|
||||
>
|
||||
{resetPasswordMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin h-5 w-5" />
|
||||
{t('auth.resettingPassword')}
|
||||
</span>
|
||||
) : (
|
||||
t('auth.resetPassword')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-brand-600 dark:text-brand-400 hover:text-brand-500 dark:hover:text-brand-300 font-medium text-sm"
|
||||
>
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
|
||||
interface StaffDashboardProps {
|
||||
user: UserType;
|
||||
linkedResourceName?: string;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
@@ -59,6 +60,7 @@ interface Appointment {
|
||||
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
const userResourceId = user.linked_resource_id ?? null;
|
||||
const userResourceName = user.linked_resource_name ?? null;
|
||||
|
||||
// Fetch this week's appointments for statistics
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
@@ -291,6 +293,16 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.weekOverview', "Here's your week at a glance")}
|
||||
</p>
|
||||
{/* Resource Badge - Makes it clear which resource these stats are for */}
|
||||
{userResourceName && (
|
||||
<div className="mt-2 inline-flex items-center gap-2 px-3 py-1.5 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-lg text-sm">
|
||||
<User size={14} />
|
||||
<span>
|
||||
{t('staffDashboard.viewingAs', 'Viewing appointments for:')}
|
||||
</span>
|
||||
<span className="font-semibold">{userResourceName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current/Next Appointment Banner */}
|
||||
@@ -353,88 +365,88 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
{/* Stats Grid - Your Personal Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Today's Appointments */}
|
||||
{/* Your Appointments Today */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<Calendar size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.todayAppointments', 'Today')}
|
||||
{t('staffDashboard.yourToday', 'Your Today')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.todayCount}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.appointmentsLabel', 'appointments')}
|
||||
{t('staffDashboard.yourAppointments', 'your appointments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* This Week Total */}
|
||||
{/* Your Week Total */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
|
||||
<CalendarDays size={18} className="text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.thisWeek', 'This Week')}
|
||||
{t('staffDashboard.yourWeek', 'Your Week')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.weekTotal}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.totalAppointments', 'total appointments')}
|
||||
{t('staffDashboard.yourTotalAppointments', 'your total appointments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Completed */}
|
||||
{/* Your Completed */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg">
|
||||
<CheckCircle size={18} className="text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.completed', 'Completed')}
|
||||
{t('staffDashboard.yourCompleted', 'You Completed')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.completed}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.completionRate}% {t('staffDashboard.completionRate', 'completion rate')}
|
||||
{stats.completionRate}% {t('staffDashboard.yourCompletionRate', 'your completion rate')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hours Worked */}
|
||||
{/* Your Hours Worked */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-orange-100 dark:bg-orange-900/40 rounded-lg">
|
||||
<Clock size={18} className="text-orange-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.hoursWorked', 'Hours Worked')}
|
||||
{t('staffDashboard.yourHoursWorked', 'Your Hours')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.hoursWorked}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.thisWeekLabel', 'this week')}
|
||||
{t('staffDashboard.yourThisWeek', 'worked this week')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Upcoming Appointments */}
|
||||
{/* Your Upcoming Appointments */}
|
||||
<div className="lg:col-span-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.upcomingAppointments', 'Upcoming')}
|
||||
{t('staffDashboard.yourUpcoming', 'Your Upcoming Appointments')}
|
||||
</h2>
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
@@ -448,7 +460,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
<div className="text-center py-8">
|
||||
<Calendar size={40} className="mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.noUpcoming', 'No upcoming appointments')}
|
||||
{t('staffDashboard.noUpcomingForYou', 'You have no upcoming appointments')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -488,10 +500,10 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weekly Chart */}
|
||||
{/* Your Weekly Chart */}
|
||||
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('staffDashboard.weeklyOverview', 'This Week')}
|
||||
{t('staffDashboard.yourWeeklyOverview', 'Your Weekly Schedule')}
|
||||
</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
differenceInMinutes,
|
||||
addMinutes,
|
||||
isSameDay,
|
||||
isToday,
|
||||
parseISO,
|
||||
} from 'date-fns';
|
||||
import {
|
||||
@@ -48,19 +49,55 @@ interface Job {
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
const START_HOUR = 6; // 6 AM
|
||||
const END_HOUR = 22; // 10 PM
|
||||
const START_HOUR = 0; // 12:00 AM
|
||||
const END_HOUR = 24; // 11:59 PM
|
||||
|
||||
const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [draggedJob, setDraggedJob] = useState<Job | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledToCurrentTime = useRef(false);
|
||||
|
||||
const canEditSchedule = user.can_edit_schedule ?? false;
|
||||
|
||||
// Get the resource ID linked to this user (from the user object)
|
||||
// Update current time every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 60000); // Update every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Scroll to current time on initial load (only for today)
|
||||
useEffect(() => {
|
||||
if (
|
||||
timelineContainerRef.current &&
|
||||
isToday(currentDate) &&
|
||||
!hasScrolledToCurrentTime.current
|
||||
) {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours() + now.getMinutes() / 60;
|
||||
const scrollPosition = (currentHour - START_HOUR) * HOUR_HEIGHT;
|
||||
const containerHeight = timelineContainerRef.current.clientHeight;
|
||||
|
||||
// Center the current time in the viewport
|
||||
timelineContainerRef.current.scrollTop = scrollPosition - containerHeight / 2;
|
||||
hasScrolledToCurrentTime.current = true;
|
||||
}
|
||||
}, [currentDate]);
|
||||
|
||||
// Reset scroll flag when date changes
|
||||
useEffect(() => {
|
||||
hasScrolledToCurrentTime.current = false;
|
||||
}, [currentDate]);
|
||||
|
||||
// Get the resource ID and name linked to this user (from the user object)
|
||||
const userResourceId = user.linked_resource_id ?? null;
|
||||
const userResourceName = user.linked_resource_name ?? null;
|
||||
|
||||
// Fetch appointments for the current staff member's resource
|
||||
const { data: jobs = [], isLoading } = useQuery({
|
||||
@@ -125,10 +162,10 @@ const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
})
|
||||
);
|
||||
|
||||
// Generate time slots
|
||||
// Generate time slots (12 AM to 11 PM = hours 0-23)
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots = [];
|
||||
for (let hour = START_HOUR; hour <= END_HOUR; hour++) {
|
||||
for (let hour = START_HOUR; hour < END_HOUR; hour++) {
|
||||
slots.push({
|
||||
hour,
|
||||
label: format(new Date().setHours(hour, 0, 0, 0), 'h a'),
|
||||
@@ -161,6 +198,19 @@ const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
});
|
||||
}, [jobs, currentDate]);
|
||||
|
||||
// Calculate current time indicator position (only show for today)
|
||||
const currentTimeIndicator = useMemo(() => {
|
||||
if (!isToday(currentDate)) return null;
|
||||
|
||||
const hours = currentTime.getHours() + currentTime.getMinutes() / 60;
|
||||
const top = (hours - START_HOUR) * HOUR_HEIGHT;
|
||||
|
||||
return {
|
||||
top,
|
||||
label: format(currentTime, 'h:mm a'),
|
||||
};
|
||||
}, [currentDate, currentTime]);
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
const jobId = parseInt(event.active.id.toString().replace('job-', ''));
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
@@ -262,6 +312,14 @@ const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
? t('staff.dragToReschedule', 'Drag jobs to reschedule them')
|
||||
: t('staff.viewOnlySchedule', 'View your scheduled jobs for the day')}
|
||||
</p>
|
||||
{/* Resource Badge - Makes it clear which resource this schedule is for */}
|
||||
{userResourceName && (
|
||||
<div className="mt-2 inline-flex items-center gap-2 px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-lg text-sm">
|
||||
<User size={14} />
|
||||
<span>{t('staff.scheduleFor', 'Schedule for:')}</span>
|
||||
<span className="font-semibold">{userResourceName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -293,7 +351,7 @@ const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
</div>
|
||||
|
||||
{/* Timeline Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div ref={timelineContainerRef} className="flex-1 overflow-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
@@ -322,7 +380,7 @@ const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
{/* Events Column */}
|
||||
<div
|
||||
className="flex-1 relative"
|
||||
style={{ height: (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT }}
|
||||
style={{ height: (END_HOUR - START_HOUR) * HOUR_HEIGHT }}
|
||||
>
|
||||
{/* Hour Grid Lines */}
|
||||
{timeSlots.map((slot) => (
|
||||
@@ -333,19 +391,22 @@ const StaffSchedule: React.FC<StaffScheduleProps> = ({ user }) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Current Time Line */}
|
||||
{isSameDay(currentDate, new Date()) && (
|
||||
{/* Current Time Indicator - Real-time updates */}
|
||||
{currentTimeIndicator && (
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t-2 border-red-500 z-20"
|
||||
style={{
|
||||
top:
|
||||
(new Date().getHours() +
|
||||
new Date().getMinutes() / 60 -
|
||||
START_HOUR) *
|
||||
HOUR_HEIGHT,
|
||||
}}
|
||||
className="absolute left-0 right-0 z-20 pointer-events-none"
|
||||
style={{ top: currentTimeIndicator.top }}
|
||||
>
|
||||
<div className="absolute -left-1 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
|
||||
{/* Time Label */}
|
||||
<div className="absolute -left-20 -top-2.5 w-16 text-right pr-2">
|
||||
<span className="text-xs font-semibold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-1.5 py-0.5 rounded">
|
||||
{currentTimeIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* Line with circle */}
|
||||
<div className="border-t-2 border-red-500">
|
||||
<div className="absolute -left-1 -top-1.5 w-3 h-3 bg-red-500 rounded-full shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
859
frontend/src/pages/__tests__/LoginPage.test.tsx
Normal file
859
frontend/src/pages/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,859 @@
|
||||
/**
|
||||
* Comprehensive Unit Tests for LoginPage Component
|
||||
*
|
||||
* Test Coverage:
|
||||
* - Component rendering (form fields, buttons, links)
|
||||
* - Form validation
|
||||
* - Form submission and login flow
|
||||
* - Error handling and display
|
||||
* - MFA redirect flow
|
||||
* - Domain-based redirect logic (platform/business users)
|
||||
* - OAuth buttons integration
|
||||
* - Accessibility
|
||||
* - Internationalization
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import LoginPage from '../LoginPage';
|
||||
|
||||
// Create mock functions that will be used across tests
|
||||
const mockUseLogin = vi.fn();
|
||||
const mockUseNavigate = vi.fn();
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useLogin: mockUseLogin,
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: mockUseNavigate,
|
||||
Link: ({ children, to, ...props }: any) => <a href={to} {...props}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.enterEmail': 'Enter your email',
|
||||
'auth.welcomeBack': 'Welcome back',
|
||||
'auth.pleaseEnterDetails': 'Please enter your email and password to sign in.',
|
||||
'auth.signIn': 'Sign in',
|
||||
'auth.signingIn': 'Signing in...',
|
||||
'auth.authError': 'Authentication Error',
|
||||
'auth.invalidCredentials': 'Invalid credentials',
|
||||
'auth.orContinueWith': 'Or continue with',
|
||||
'marketing.tagline': 'Manage Your Business with Confidence',
|
||||
'marketing.description': 'Access your dashboard to manage appointments, customers, and grow your business.',
|
||||
'marketing.copyright': 'All rights reserved',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/SmoothScheduleLogo', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="logo">Logo</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/OAuthButtons', () => ({
|
||||
default: ({ disabled }: { disabled?: boolean }) => (
|
||||
<div data-testid="oauth-buttons">
|
||||
<button disabled={disabled}>Google</button>
|
||||
<button disabled={disabled}>Apple</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/LanguageSelector', () => ({
|
||||
default: () => <div data-testid="language-selector">Language Selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/DevQuickLogin', () => ({
|
||||
DevQuickLogin: ({ embedded }: { embedded?: boolean }) => (
|
||||
<div data-testid="dev-quick-login" data-embedded={embedded}>Dev Quick Login</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// 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('LoginPage', () => {
|
||||
let mockNavigate: ReturnType<typeof vi.fn>;
|
||||
let mockLoginMutate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup mocks
|
||||
mockNavigate = vi.fn();
|
||||
mockUseNavigate.mockReturnValue(mockNavigate);
|
||||
|
||||
mockLoginMutate = vi.fn();
|
||||
mockUseLogin.mockReturnValue({
|
||||
mutate: mockLoginMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
hostname: 'platform.lvh.me',
|
||||
port: '5173',
|
||||
protocol: 'http:',
|
||||
href: 'http://platform.lvh.me:5173/',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock sessionStorage
|
||||
global.sessionStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(),
|
||||
length: 0,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render login form', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: /welcome back/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your email and password to sign in.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render email input field', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(emailInput).toHaveAttribute('name', 'email');
|
||||
expect(emailInput).toHaveAttribute('required');
|
||||
expect(emailInput).toHaveAttribute('placeholder', 'Enter your email');
|
||||
});
|
||||
|
||||
it('should render password input field', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(passwordInput).toHaveAttribute('name', 'password');
|
||||
expect(passwordInput).toHaveAttribute('required');
|
||||
expect(passwordInput).toHaveAttribute('placeholder', '••••••••');
|
||||
});
|
||||
|
||||
it('should render submit button', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
it('should render email and password icons', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
// lucide-react renders SVG elements
|
||||
const form = screen.getByRole('heading', { name: /welcome back/i }).closest('div')?.parentElement;
|
||||
const svgs = form?.querySelectorAll('svg');
|
||||
|
||||
// Should have icons for email, password, and arrow in button
|
||||
expect(svgs).toBeDefined();
|
||||
expect(svgs!.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should render logo', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const logos = screen.getAllByTestId('logo');
|
||||
expect(logos.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render branding section on desktop', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Manage Your Business with Confidence')).toBeInTheDocument();
|
||||
expect(screen.getByText(/access your dashboard/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth Integration', () => {
|
||||
it('should render OAuth buttons', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByTestId('oauth-buttons')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display OAuth divider text', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Or continue with')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable OAuth buttons when login is pending', () => {
|
||||
mockUseLogin.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const oauthButtons = screen.getAllByRole('button', { name: /google|apple/i });
|
||||
oauthButtons.forEach(button => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional Components', () => {
|
||||
it('should render language selector', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render dev quick login component', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const devQuickLogin = screen.getByTestId('dev-quick-login');
|
||||
expect(devQuickLogin).toBeInTheDocument();
|
||||
expect(devQuickLogin).toHaveAttribute('data-embedded', 'true');
|
||||
});
|
||||
|
||||
it('should display copyright text', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
expect(screen.getByText(`© ${currentYear} All rights reserved`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Input Handling', () => {
|
||||
it('should update email field on input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement;
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
expect(emailInput.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should update password field on input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement;
|
||||
await user.type(passwordInput, 'password123');
|
||||
|
||||
expect(passwordInput.value).toBe('password123');
|
||||
});
|
||||
|
||||
it('should handle empty form submission', async () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// HTML5 validation should prevent submission
|
||||
expect(mockLoginMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call login mutation with email and password', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLoginMutate).toHaveBeenCalledWith(
|
||||
{ email: 'test@example.com', password: 'password123' },
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear error state on new submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
// First submission - simulate error
|
||||
await user.type(emailInput, 'wrong@example.com');
|
||||
await user.type(passwordInput, 'wrongpass');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Trigger error callback
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onError = callArgs[1].onError;
|
||||
onError({ response: { data: { error: 'Invalid credentials' } } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear inputs and try again
|
||||
await user.clear(emailInput);
|
||||
await user.clear(passwordInput);
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Error should be cleared before new submission
|
||||
expect(screen.queryByText('Invalid credentials')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable submit button when login is pending', () => {
|
||||
mockUseLogin.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /signing in/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show loading state in submit button', () => {
|
||||
const { useLogin } = require('../../hooks/useAuth');
|
||||
useLogin.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Signing in...')).toBeInTheDocument();
|
||||
|
||||
// Should have loading spinner (Loader2 icon)
|
||||
const button = screen.getByRole('button', { name: /signing in/i });
|
||||
const svg = button.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error message on login failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate error
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onError = callArgs[1].onError;
|
||||
onError({ response: { data: { error: 'Invalid credentials' } } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authentication Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display default error message when no specific error provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate error without message
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onError = callArgs[1].onError;
|
||||
onError({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error icon in error message', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate error
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onError = callArgs[1].onError;
|
||||
onError({ response: { data: { error: 'Invalid credentials' } } });
|
||||
|
||||
await waitFor(() => {
|
||||
const errorBox = screen.getByText('Invalid credentials').closest('div');
|
||||
const svg = errorBox?.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA Flow', () => {
|
||||
it('should redirect to MFA page when MFA is required', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate MFA required response
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
mfa_required: true,
|
||||
user_id: 123,
|
||||
mfa_methods: ['sms', 'totp'],
|
||||
phone_last_4: '1234',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
||||
'mfa_challenge',
|
||||
JSON.stringify({
|
||||
user_id: 123,
|
||||
mfa_methods: ['sms', 'totp'],
|
||||
phone_last_4: '1234',
|
||||
})
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/mfa-verify');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not navigate to dashboard when MFA is required', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate MFA required response
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
mfa_required: true,
|
||||
user_id: 123,
|
||||
mfa_methods: ['sms'],
|
||||
phone_last_4: '1234',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/mfa-verify');
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Domain-based Redirects', () => {
|
||||
it('should navigate to dashboard for platform user on platform domain', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'admin@platform.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate successful login for platform user
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'admin@platform.com',
|
||||
role: 'superuser',
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when platform user tries to login on business subdomain', async () => {
|
||||
// Mock business subdomain
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
hostname: 'demo.lvh.me',
|
||||
port: '5173',
|
||||
protocol: 'http:',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'admin@platform.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate platform user login on business subdomain
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'admin@platform.com',
|
||||
role: 'superuser',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect business user to their business subdomain', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'owner@demo.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate business user login from platform domain
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 2,
|
||||
email: 'owner@demo.com',
|
||||
role: 'owner',
|
||||
business_subdomain: 'demo',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toContain('demo.lvh.me');
|
||||
expect(window.location.href).toContain('access_token=access-token');
|
||||
expect(window.location.href).toContain('refresh_token=refresh-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when customer tries to login on root domain', async () => {
|
||||
// Mock root domain
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
hostname: 'lvh.me',
|
||||
port: '5173',
|
||||
protocol: 'http:',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'customer@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate customer login on root domain
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 3,
|
||||
email: 'customer@example.com',
|
||||
role: 'customer',
|
||||
business_subdomain: 'demo',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper form labels', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have required attributes on inputs', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toHaveAttribute('required');
|
||||
expect(screen.getByLabelText(/password/i)).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('should have proper input types', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toHaveAttribute('type', 'email');
|
||||
expect(screen.getByLabelText(/password/i)).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('should have autocomplete attributes', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toHaveAttribute('autoComplete', 'email');
|
||||
expect(screen.getByLabelText(/password/i)).toHaveAttribute('autoComplete', 'current-password');
|
||||
});
|
||||
|
||||
it('should have accessible logo links', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
const logoLinks = links.filter(link => link.getAttribute('href') === '/');
|
||||
|
||||
expect(logoLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translations for form labels', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for buttons', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for headings', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: /welcome back/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for placeholders', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for error messages', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate error
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onError = callArgs[1].onError;
|
||||
onError({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authentication Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual State', () => {
|
||||
it('should have proper styling classes on form elements', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toHaveClass('focus:ring-brand-500', 'focus:border-brand-500');
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
expect(passwordInput).toHaveClass('focus:ring-brand-500', 'focus:border-brand-500');
|
||||
});
|
||||
|
||||
it('should have proper button styling', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
expect(submitButton).toHaveClass('bg-brand-600', 'hover:bg-brand-700');
|
||||
});
|
||||
|
||||
it('should have error styling when error is displayed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Simulate error
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onError = callArgs[1].onError;
|
||||
onError({ response: { data: { error: 'Invalid credentials' } } });
|
||||
|
||||
await waitFor(() => {
|
||||
const errorBox = screen.getByText('Invalid credentials').closest('div');
|
||||
expect(errorBox).toHaveClass('bg-red-50', 'dark:bg-red-900/20');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should handle complete login flow successfully', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill in form
|
||||
await user.type(screen.getByLabelText(/email/i), 'owner@demo.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123');
|
||||
|
||||
// Submit form
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
// Verify mutation was called
|
||||
expect(mockLoginMutate).toHaveBeenCalledWith(
|
||||
{ email: 'owner@demo.com', password: 'password123' },
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Simulate successful response
|
||||
const callArgs = mockLoginMutate.mock.calls[0];
|
||||
const onSuccess = callArgs[1].onSuccess;
|
||||
onSuccess({
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'owner@demo.com',
|
||||
role: 'owner',
|
||||
business_subdomain: 'demo',
|
||||
},
|
||||
});
|
||||
|
||||
// Since we're on platform domain and user is business owner, should redirect
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toContain('demo');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all sections together', () => {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Form elements
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
|
||||
// Additional components
|
||||
expect(screen.getByTestId('oauth-buttons')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dev-quick-login')).toBeInTheDocument();
|
||||
|
||||
// Branding
|
||||
expect(screen.getAllByTestId('logo').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Manage Your Business with Confidence')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
536
frontend/src/pages/__tests__/NotFound.test.tsx
Normal file
536
frontend/src/pages/__tests__/NotFound.test.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Unit tests for NotFound component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering
|
||||
* - 404 message display
|
||||
* - Navigation links (Go Home, Go Back, Contact Support)
|
||||
* - Illustration/icon rendering
|
||||
* - Accessibility features
|
||||
* - Internationalization (i18n)
|
||||
* - Button interactions
|
||||
* - Responsive design elements
|
||||
* - Dark mode styling classes
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import NotFound from '../NotFound';
|
||||
|
||||
// Mock react-router-dom's useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'errors.pageNotFound': 'Page Not Found',
|
||||
'errors.pageNotFoundDescription': 'The page you are looking for does not exist or has been moved.',
|
||||
'navigation.goHome': 'Go Home',
|
||||
'navigation.goBack': 'Go Back',
|
||||
'errors.needHelp': 'Need help?',
|
||||
'navigation.contactSupport': 'Contact Support',
|
||||
};
|
||||
return translations[key] || fallback || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('NotFound', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the NotFound component', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Page Not Found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render within a centered container', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const mainDiv = container.querySelector('.min-h-screen');
|
||||
expect(mainDiv).toBeInTheDocument();
|
||||
expect(mainDiv).toHaveClass('flex', 'items-center', 'justify-center');
|
||||
});
|
||||
|
||||
it('should render all main sections', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
// Title
|
||||
expect(screen.getByText('Page Not Found')).toBeInTheDocument();
|
||||
|
||||
// Description
|
||||
expect(screen.getByText(/the page you are looking for does not exist/i)).toBeInTheDocument();
|
||||
|
||||
// Navigation buttons
|
||||
expect(screen.getByRole('link', { name: /go home/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
|
||||
// Support link
|
||||
expect(screen.getByRole('link', { name: /contact support/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 Message Display', () => {
|
||||
it('should display the 404 error code', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Page Not Found" heading', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveTextContent('Page Not Found');
|
||||
});
|
||||
|
||||
it('should display error description', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/the page you are looking for does not exist or has been moved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct heading styles', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('text-3xl', 'font-bold', 'text-gray-900', 'dark:text-white');
|
||||
});
|
||||
|
||||
it('should apply correct description styles', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/the page you are looking for does not exist or has been moved/i);
|
||||
expect(description).toHaveClass('text-lg', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
it('should render "Go Home" link with correct href', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
expect(homeLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('should render "Go Back" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Contact Support" link with correct href', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const supportLink = screen.getByRole('link', { name: /contact support/i });
|
||||
expect(supportLink).toHaveAttribute('href', '/support');
|
||||
});
|
||||
|
||||
it('should display Home icon in "Go Home" link', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
const icon = homeLink.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display ArrowLeft icon in "Go Back" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
const icon = backButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call navigate(-1) when "Go Back" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
||||
});
|
||||
|
||||
it('should call navigate(-1) only once per click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Illustration Rendering', () => {
|
||||
it('should render the FileQuestion illustration', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
// Check for FileQuestion icon (lucide-react renders as SVG)
|
||||
const illustrations = container.querySelectorAll('svg');
|
||||
expect(illustrations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display 404 text overlaid on illustration', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const errorCode = screen.getByText('404');
|
||||
expect(errorCode).toBeInTheDocument();
|
||||
expect(errorCode).toHaveClass('text-6xl', 'font-bold');
|
||||
});
|
||||
|
||||
it('should have correct illustration container classes', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const illustrationContainer = container.querySelector('.relative');
|
||||
expect(illustrationContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct styles to FileQuestion icon', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
// FileQuestion icon should have specific classes
|
||||
const iconContainer = container.querySelector('.text-gray-300.dark\\:text-gray-700');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Styling', () => {
|
||||
it('should apply primary button styles to "Go Home" link', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
expect(homeLink).toHaveClass(
|
||||
'bg-blue-600',
|
||||
'text-white',
|
||||
'hover:bg-blue-700',
|
||||
'rounded-lg'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply secondary button styles to "Go Back" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
expect(backButton).toHaveClass(
|
||||
'bg-gray-200',
|
||||
'text-gray-700',
|
||||
'hover:bg-gray-300',
|
||||
'rounded-lg'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply dark mode styles to "Go Back" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
expect(backButton).toHaveClass(
|
||||
'dark:bg-gray-700',
|
||||
'dark:text-gray-200',
|
||||
'dark:hover:bg-gray-600'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have transition classes on buttons', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
|
||||
expect(homeLink).toHaveClass('transition-colors');
|
||||
expect(backButton).toHaveClass('transition-colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-hidden on decorative icons', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const icons = container.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have focus styles on "Go Home" link', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
expect(homeLink).toHaveClass('focus:outline-none', 'focus:ring-2', 'focus:ring-blue-500');
|
||||
});
|
||||
|
||||
it('should have focus styles on "Go Back" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
expect(backButton).toHaveClass('focus:outline-none', 'focus:ring-2', 'focus:ring-gray-500');
|
||||
});
|
||||
|
||||
it('should be keyboard accessible', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
|
||||
// Tab to home link
|
||||
await user.tab();
|
||||
expect(homeLink).toHaveFocus();
|
||||
|
||||
// Tab to back button
|
||||
await user.tab();
|
||||
expect(backButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should support Enter key on "Go Back" button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
backButton.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
||||
});
|
||||
|
||||
it('should have accessible link text', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('link', { name: /go home/i })).toHaveAccessibleName();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toHaveAccessibleName();
|
||||
expect(screen.getByRole('link', { name: /contact support/i })).toHaveAccessibleName();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should have responsive flex classes on button container', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const buttonContainer = container.querySelector('.flex.flex-col.sm\\:flex-row');
|
||||
expect(buttonContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive padding on main container', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const mainContainer = container.querySelector('.px-4.py-16');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have max-width constraint on content', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const contentContainer = container.querySelector('.max-w-md.w-full');
|
||||
expect(contentContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should have dark mode background class', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const mainDiv = container.querySelector('.bg-gray-50.dark\\:bg-gray-900');
|
||||
expect(mainDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have dark mode text classes on heading', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should have dark mode text classes on description', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/the page you are looking for does not exist or has been moved/i);
|
||||
expect(description).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should have dark mode focus ring offset on buttons', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: /go home/i });
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
|
||||
expect(homeLink).toHaveClass('dark:focus:ring-offset-gray-900');
|
||||
expect(backButton).toHaveClass('dark:focus:ring-offset-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for page title', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Page Not Found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for error description', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('The page you are looking for does not exist or has been moved.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for "Go Home" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Go Home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for "Go Back" button', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Go Back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for help text', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Need help?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for support link', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Contact Support')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Support Section', () => {
|
||||
it('should render help text', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Need help?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact support link', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const supportLink = screen.getByRole('link', { name: /contact support/i });
|
||||
expect(supportLink).toBeInTheDocument();
|
||||
expect(supportLink).toHaveAttribute('href', '/support');
|
||||
});
|
||||
|
||||
it('should style support link correctly', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const supportLink = screen.getByRole('link', { name: /contact support/i });
|
||||
expect(supportLink).toHaveClass('text-blue-600', 'hover:text-blue-700', 'underline');
|
||||
});
|
||||
|
||||
it('should have dark mode styles on support link', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const supportLink = screen.getByRole('link', { name: /contact support/i });
|
||||
expect(supportLink).toHaveClass('dark:text-blue-400', 'dark:hover:text-blue-300');
|
||||
});
|
||||
|
||||
it('should display help text in smaller font', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const helpText = container.querySelector('.text-sm.text-gray-500');
|
||||
expect(helpText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete page structure correctly', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
// Check all main elements are present
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByText(/the page you are looking for does not exist/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /go home/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /contact support/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple user interactions', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
|
||||
// Click multiple times
|
||||
await user.click(backButton);
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(2);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
||||
});
|
||||
|
||||
it('should maintain proper layout structure', () => {
|
||||
const { container } = render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
// Check container structure
|
||||
const outerContainer = container.querySelector('.min-h-screen.flex.items-center.justify-center');
|
||||
expect(outerContainer).toBeInTheDocument();
|
||||
|
||||
const innerContainer = outerContainer?.querySelector('.max-w-md.w-full.text-center');
|
||||
expect(innerContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Edge Cases', () => {
|
||||
it('should render without crashing when navigation is unavailable', () => {
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Page Not Found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle rapid "Go Back" clicks gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||
|
||||
// Rapid clicks
|
||||
await user.click(backButton);
|
||||
await user.click(backButton);
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should render all buttons even if navigate function fails', () => {
|
||||
mockNavigate.mockImplementation(() => {
|
||||
throw new Error('Navigation failed');
|
||||
});
|
||||
|
||||
render(<NotFound />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('link', { name: /go home/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
687
frontend/src/pages/__tests__/Upgrade.test.tsx
Normal file
687
frontend/src/pages/__tests__/Upgrade.test.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* Unit tests for Upgrade page
|
||||
*
|
||||
* Tests cover:
|
||||
* - Pricing plans display (Professional, Business, Enterprise)
|
||||
* - Current plan highlighted
|
||||
* - Upgrade buttons work
|
||||
* - Feature comparison
|
||||
* - Payment flow initiation
|
||||
* - Billing period toggle (monthly/annual)
|
||||
* - Pricing calculations and savings display
|
||||
* - Enterprise contact flow
|
||||
* - Error handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import Upgrade from '../Upgrade';
|
||||
import { User, Business } from '../../types';
|
||||
|
||||
// Mock react-router-dom
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: vi.fn(),
|
||||
useOutletContext: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
// Handle translation keys with parameters
|
||||
if (key === 'upgrade.subtitle' && params?.businessName) {
|
||||
return `Choose the perfect plan for ${params.businessName}`;
|
||||
}
|
||||
if (key === 'upgrade.features.resources' && params?.count) {
|
||||
return `Up to ${params.count} resources`;
|
||||
}
|
||||
if (key === 'upgrade.billing.saveAmount' && params?.amount) {
|
||||
return `Save $${params.amount}/year`;
|
||||
}
|
||||
|
||||
// Simple key mapping for other translations
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.pricing.tiers.professional.name': 'Professional',
|
||||
'marketing.pricing.tiers.professional.description': 'For growing businesses',
|
||||
'marketing.pricing.tiers.business.name': 'Business',
|
||||
'marketing.pricing.tiers.business.description': 'For established businesses',
|
||||
'marketing.pricing.tiers.enterprise.name': 'Enterprise',
|
||||
'marketing.pricing.tiers.enterprise.description': 'For large organizations',
|
||||
'upgrade.title': 'Upgrade Your Plan',
|
||||
'upgrade.mostPopular': 'Most Popular',
|
||||
'upgrade.plan': 'Plan',
|
||||
'upgrade.selected': 'Selected',
|
||||
'upgrade.selectPlan': 'Select Plan',
|
||||
'upgrade.custom': 'Custom',
|
||||
'upgrade.month': 'month',
|
||||
'upgrade.year': 'year',
|
||||
'upgrade.billing.monthly': 'Monthly',
|
||||
'upgrade.billing.annual': 'Annual',
|
||||
'upgrade.billing.save20': 'Save 20%',
|
||||
'upgrade.features.unlimitedResources': 'Unlimited resources',
|
||||
'upgrade.features.customDomain': 'Custom domain',
|
||||
'upgrade.features.stripeConnect': 'Stripe Connect',
|
||||
'upgrade.features.whitelabel': 'White-label branding',
|
||||
'upgrade.features.emailReminders': 'Email reminders',
|
||||
'upgrade.features.prioritySupport': 'Priority email support',
|
||||
'upgrade.features.teamManagement': 'Team management',
|
||||
'upgrade.features.advancedAnalytics': 'Advanced analytics',
|
||||
'upgrade.features.apiAccess': 'API access',
|
||||
'upgrade.features.phoneSupport': 'Phone support',
|
||||
'upgrade.features.everything': 'Everything in Business',
|
||||
'upgrade.features.customIntegrations': 'Custom integrations',
|
||||
'upgrade.features.dedicatedManager': 'Dedicated success manager',
|
||||
'upgrade.features.sla': 'SLA guarantees',
|
||||
'upgrade.features.customContracts': 'Custom contracts',
|
||||
'upgrade.features.onPremise': 'On-premise option',
|
||||
'upgrade.orderSummary': 'Order Summary',
|
||||
'upgrade.billedMonthly': 'Billed monthly',
|
||||
'upgrade.billedAnnually': 'Billed annually',
|
||||
'upgrade.annualSavings': 'Annual Savings',
|
||||
'upgrade.trust.secure': 'Secure Checkout',
|
||||
'upgrade.trust.instant': 'Instant Access',
|
||||
'upgrade.trust.support': '24/7 Support',
|
||||
'upgrade.continueToPayment': 'Continue to Payment',
|
||||
'upgrade.contactSales': 'Contact Sales',
|
||||
'upgrade.processing': 'Processing...',
|
||||
'upgrade.secureCheckout': 'Secure checkout powered by Stripe',
|
||||
'upgrade.questions': 'Questions?',
|
||||
'upgrade.contactUs': 'Contact us',
|
||||
'upgrade.errors.processingFailed': 'Payment processing failed. Please try again.',
|
||||
'common.back': 'Back',
|
||||
};
|
||||
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'owner',
|
||||
};
|
||||
|
||||
const mockBusiness: Business = {
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#0066CC',
|
||||
secondaryColor: '#00AA66',
|
||||
whitelabelEnabled: false,
|
||||
plan: 'Professional',
|
||||
paymentsEnabled: true,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
};
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Upgrade Page', () => {
|
||||
let mockNavigate: ReturnType<typeof vi.fn>;
|
||||
let mockUseOutletContext: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate = vi.fn();
|
||||
mockUseOutletContext = vi.fn(() => ({ user: mockUser, business: mockBusiness }));
|
||||
|
||||
vi.mocked(useNavigate).mockReturnValue(mockNavigate);
|
||||
vi.mocked(useOutletContext).mockImplementation(mockUseOutletContext);
|
||||
|
||||
// Mock window.alert
|
||||
global.alert = vi.fn();
|
||||
|
||||
// Mock window.location.href
|
||||
delete (window as any).location;
|
||||
window.location = { href: '' } as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Page Rendering', () => {
|
||||
it('should render the page title', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Upgrade Your Plan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the subtitle with business name', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Choose the perfect plan for Test Business')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render back button', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate back when back button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing Plans Display', () => {
|
||||
it('should display all three pricing tiers', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display plan descriptions', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('For growing businesses')).toBeInTheDocument();
|
||||
expect(screen.getByText('For established businesses')).toBeInTheDocument();
|
||||
expect(screen.getByText('For large organizations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Most Popular" badge on Business plan', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display monthly prices by default', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
expect(screen.getByText('$79')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Custom" for Enterprise pricing', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display Professional plan as selected by default', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const selectedBadges = screen.getAllByText('Selected');
|
||||
expect(selectedBadges).toHaveLength(2); // One in card, one in summary
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Period Toggle', () => {
|
||||
it('should render monthly and annual billing options', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const monthlyButton = screen.getByRole('button', { name: /monthly/i });
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
|
||||
expect(monthlyButton).toBeInTheDocument();
|
||||
expect(annualButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Save 20%" badge on annual option', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Save 20%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch to annual pricing when annual button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
await user.click(annualButton);
|
||||
|
||||
// Annual prices
|
||||
expect(screen.getByText('$290')).toBeInTheDocument();
|
||||
expect(screen.getByText('$790')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display annual savings when annual billing is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
await user.click(annualButton);
|
||||
|
||||
// Professional: $29 * 12 - $290 = $58 savings
|
||||
expect(screen.getByText('Save $58/year')).toBeInTheDocument();
|
||||
// Business: $79 * 12 - $790 = $158 savings
|
||||
expect(screen.getByText('Save $158/year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch back to monthly pricing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
await user.click(annualButton);
|
||||
|
||||
expect(screen.getByText('$290')).toBeInTheDocument();
|
||||
|
||||
const monthlyButton = screen.getByRole('button', { name: /monthly/i });
|
||||
await user.click(monthlyButton);
|
||||
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan Selection', () => {
|
||||
it('should select Business plan when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
// Find the Business plan card
|
||||
const businessCard = screen.getByText('Business').closest('div[class*="cursor-pointer"]');
|
||||
expect(businessCard).toBeInTheDocument();
|
||||
|
||||
await user.click(businessCard!);
|
||||
|
||||
// Should update order summary
|
||||
expect(screen.getByText('Business Plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('$79')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select Enterprise plan when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const enterpriseCard = screen.getByText('Enterprise').closest('div[class*="cursor-pointer"]');
|
||||
await user.click(enterpriseCard!);
|
||||
|
||||
expect(screen.getByText('Enterprise Plan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show selected state on clicked plan', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const businessCard = screen.getByText('Business').closest('div[class*="cursor-pointer"]');
|
||||
await user.click(businessCard!);
|
||||
|
||||
// Find all "Selected" badges - should be 2 (one in card, one in summary)
|
||||
const selectedBadges = screen.getAllByText('Selected');
|
||||
expect(selectedBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Comparison', () => {
|
||||
it('should display Professional plan features', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Up to 10 resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom domain')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stripe Connect')).toBeInTheDocument();
|
||||
expect(screen.getByText('White-label branding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email reminders')).toBeInTheDocument();
|
||||
expect(screen.getByText('Priority email support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display Business plan features', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Unlimited resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Advanced analytics')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('API access')).toHaveLength(2); // Shown in both Business and Enterprise
|
||||
expect(screen.getByText('Phone support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display Enterprise plan features', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Everything in Business')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom integrations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dedicated success manager')).toBeInTheDocument();
|
||||
expect(screen.getByText('SLA guarantees')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom contracts')).toBeInTheDocument();
|
||||
expect(screen.getByText('On-premise option')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show features with checkmarks', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
// Check for SVG checkmark icons
|
||||
const checkIcons = screen.getAllByRole('img', { hidden: true });
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Order Summary', () => {
|
||||
it('should display order summary section', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Order Summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show selected plan in summary', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Professional Plan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show billing frequency in summary', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Billed monthly')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show price in summary', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
// Professional plan monthly price
|
||||
const summarySection = screen.getByText('Order Summary').closest('div');
|
||||
expect(within(summarySection!).getAllByText('$29')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should update summary when plan changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const businessCard = screen.getByText('Business').closest('div[class*="cursor-pointer"]');
|
||||
await user.click(businessCard!);
|
||||
|
||||
expect(screen.getByText('Business Plan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show annual savings in summary when annual billing selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
await user.click(annualButton);
|
||||
|
||||
expect(screen.getByText('Annual Savings')).toBeInTheDocument();
|
||||
expect(screen.getByText('-$58')).toBeInTheDocument(); // Professional plan savings
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trust Indicators', () => {
|
||||
it('should display trust indicators', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Secure Checkout')).toBeInTheDocument();
|
||||
expect(screen.getByText('Instant Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('24/7 Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display trust indicator icons', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
// Shield, Zap, and Users icons from lucide-react
|
||||
const trustSection = screen.getByText('Secure Checkout').closest('div')?.parentElement;
|
||||
expect(trustSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payment Flow Initiation', () => {
|
||||
it('should display upgrade button', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /continue to payment/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show processing state when upgrade button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /continue to payment/i });
|
||||
|
||||
// Click the button
|
||||
await user.click(upgradeButton);
|
||||
|
||||
// Should show processing state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Processing...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable button during processing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /continue to payment/i });
|
||||
await user.click(upgradeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upgradeButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show alert with upgrade details (placeholder for Stripe integration)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /continue to payment/i });
|
||||
await user.click(upgradeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.alert).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Upgrading to Professional')
|
||||
);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('should navigate after successful payment', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /continue to payment/i });
|
||||
await user.click(upgradeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('should change button text for Enterprise plan', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const enterpriseCard = screen.getByText('Enterprise').closest('div[class*="cursor-pointer"]');
|
||||
await user.click(enterpriseCard!);
|
||||
|
||||
expect(screen.getByRole('button', { name: /contact sales/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open email client for Enterprise plan', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const enterpriseCard = screen.getByText('Enterprise').closest('div[class*="cursor-pointer"]');
|
||||
await user.click(enterpriseCard!);
|
||||
|
||||
const contactButton = screen.getByRole('button', { name: /contact sales/i });
|
||||
await user.click(contactButton);
|
||||
|
||||
expect(window.location.href).toBe('mailto:sales@smoothschedule.com?subject=Enterprise Plan Inquiry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should not display error message initially', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.queryByText('Payment processing failed. Please try again.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display error message when payment fails', async () => {
|
||||
// Mock the upgrade process to fail
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// We need to mock the Promise.resolve to reject
|
||||
vi.spyOn(global, 'Promise').mockImplementationOnce((executor: any) => {
|
||||
return {
|
||||
then: (onSuccess: any, onError: any) => {
|
||||
onError(new Error('Payment failed'));
|
||||
return { catch: () => {} };
|
||||
},
|
||||
catch: (onError: any) => {
|
||||
onError(new Error('Payment failed'));
|
||||
return { finally: () => {} };
|
||||
},
|
||||
finally: (onFinally: any) => {
|
||||
onFinally();
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Behavior', () => {
|
||||
it('should have responsive grid for plan cards', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const planGrid = screen.getByText('Professional').closest('div')?.parentElement;
|
||||
expect(planGrid).toHaveClass('grid');
|
||||
expect(planGrid).toHaveClass('md:grid-cols-3');
|
||||
});
|
||||
|
||||
it('should center content in container', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const mainContainer = screen.getByText('Upgrade Your Plan').closest('div')?.parentElement;
|
||||
expect(mainContainer).toHaveClass('max-w-6xl');
|
||||
expect(mainContainer).toHaveClass('mx-auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1, name: /upgrade your plan/i });
|
||||
expect(h1).toBeInTheDocument();
|
||||
|
||||
const h2 = screen.getByRole('heading', { level: 2, name: /order summary/i });
|
||||
expect(h2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible plan tier headings', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const h3Headings = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(h3Headings.length).toBeGreaterThanOrEqual(3); // Professional, Business, Enterprise
|
||||
});
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /continue to payment/i });
|
||||
expect(upgradeButton).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('should have accessible links', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const contactLink = screen.getByRole('link', { name: /contact us/i });
|
||||
expect(contactLink).toBeInTheDocument();
|
||||
expect(contactLink).toHaveAttribute('href', 'mailto:support@smoothschedule.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should have dark mode classes', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const container = screen.getByText('Upgrade Your Plan').closest('div');
|
||||
expect(container).toHaveClass('dark:bg-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer Links', () => {
|
||||
it('should display questions section', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Questions?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display contact us link', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const contactLink = screen.getByRole('link', { name: /contact us/i });
|
||||
expect(contactLink).toBeInTheDocument();
|
||||
expect(contactLink).toHaveAttribute('href', 'mailto:support@smoothschedule.com');
|
||||
});
|
||||
|
||||
it('should display secure checkout message', () => {
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Secure checkout powered by Stripe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should maintain state across billing period changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
// Select Business plan
|
||||
const businessCard = screen.getByText('Business').closest('div[class*="cursor-pointer"]');
|
||||
await user.click(businessCard!);
|
||||
|
||||
// Switch to annual
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
await user.click(annualButton);
|
||||
|
||||
// Should still be Business plan
|
||||
expect(screen.getByText('Business Plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('$790')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update all prices when switching billing periods', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
// Switch to annual
|
||||
const annualButton = screen.getByRole('button', { name: /annual/i });
|
||||
await user.click(annualButton);
|
||||
|
||||
// Check summary updates
|
||||
expect(screen.getByText('Billed annually')).toBeInTheDocument();
|
||||
expect(screen.getByText('$290')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle rapid plan selections', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Upgrade />, { wrapper: createWrapper() });
|
||||
|
||||
const professionalCard = screen.getByText('Professional').closest('div[class*="cursor-pointer"]');
|
||||
const businessCard = screen.getByText('Business').closest('div[class*="cursor-pointer"]');
|
||||
const enterpriseCard = screen.getByText('Enterprise').closest('div[class*="cursor-pointer"]');
|
||||
|
||||
// Rapidly click different plans
|
||||
await user.click(businessCard!);
|
||||
await user.click(enterpriseCard!);
|
||||
await user.click(professionalCard!);
|
||||
|
||||
// Should end up with Professional selected
|
||||
expect(screen.getByText('Professional Plan')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
756
frontend/src/pages/__tests__/VerifyEmail.test.tsx
Normal file
756
frontend/src/pages/__tests__/VerifyEmail.test.tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* Unit tests for VerifyEmail component
|
||||
*
|
||||
* Tests all verification functionality including:
|
||||
* - Loading state while verifying
|
||||
* - Success message on verification
|
||||
* - Error state for invalid token
|
||||
* - Redirect after success
|
||||
* - Already verified state
|
||||
* - Missing token handling
|
||||
* - Navigation flows
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import VerifyEmail from '../VerifyEmail';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../api/client');
|
||||
|
||||
// Mock the cookies utility
|
||||
vi.mock('../../utils/cookies', () => ({
|
||||
deleteCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the domain utility
|
||||
vi.mock('../../utils/domain', () => ({
|
||||
getBaseDomain: vi.fn(() => 'lvh.me'),
|
||||
}));
|
||||
|
||||
// Helper to render component with router and search params
|
||||
const renderWithRouter = (searchParams: string = '') => {
|
||||
const TestComponent = () => (
|
||||
<MemoryRouter initialEntries={[`/verify-email${searchParams}`]}>
|
||||
<Routes>
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
return render(<TestComponent />);
|
||||
};
|
||||
|
||||
describe('VerifyEmail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset location mock
|
||||
delete (window as any).location;
|
||||
(window as any).location = {
|
||||
protocol: 'http:',
|
||||
hostname: 'platform.lvh.me',
|
||||
port: '5173',
|
||||
href: 'http://platform.lvh.me:5173/verify-email',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Missing Token Handling', () => {
|
||||
it('should show error when no token is provided', () => {
|
||||
renderWithRouter('');
|
||||
|
||||
expect(screen.getByText('Invalid Link')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No verification token was provided. Please check your email for the correct link.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render "Go to Login" button when token is missing', () => {
|
||||
renderWithRouter('');
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to login when button clicked with missing token', async () => {
|
||||
renderWithRouter('');
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Wait for navigation to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error icon for missing token', () => {
|
||||
const { container } = renderWithRouter('');
|
||||
|
||||
// Check for red error styling
|
||||
const errorIcon = container.querySelector('.bg-red-100');
|
||||
expect(errorIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pending State', () => {
|
||||
it('should show pending state with verify button when token is present', () => {
|
||||
renderWithRouter('?token=valid-token-123');
|
||||
|
||||
expect(screen.getByText('Verify Your Email')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Click the button below to confirm your email address.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /confirm verification/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show shield icon in pending state', () => {
|
||||
const { container } = renderWithRouter('?token=valid-token-123');
|
||||
|
||||
// Check for brand color styling
|
||||
const iconContainer = container.querySelector('.bg-brand-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state while verifying', async () => {
|
||||
// Mock API to never resolve (simulating slow response)
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verifying Your Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Please wait while we verify your email address...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading spinner during verification', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { container } = renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = container.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call API with correct token', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter('?token=test-token-456');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/email/verify/', { token: 'test-token-456' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success State', () => {
|
||||
it('should show success message after successful verification', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Your email address has been successfully verified. You can now sign in to your account.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show success icon after verification', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
const { container } = renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const successIcon = container.querySelector('.bg-green-100');
|
||||
expect(successIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear auth cookies after successful verification', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
const { deleteCookie } = await import('../../utils/cookies');
|
||||
|
||||
renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteCookie).toHaveBeenCalledWith('access_token');
|
||||
expect(deleteCookie).toHaveBeenCalledWith('refresh_token');
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to login page on success button click', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
|
||||
// Mock window.location.href to track the redirect
|
||||
let redirectUrl = '';
|
||||
Object.defineProperty((window as any).location, 'href', {
|
||||
set: (url: string) => {
|
||||
redirectUrl = url;
|
||||
},
|
||||
get: () => redirectUrl || 'http://platform.lvh.me:5173/verify-email',
|
||||
});
|
||||
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Verify the redirect URL was set correctly
|
||||
expect(redirectUrl).toBe('http://lvh.me:5173/login');
|
||||
});
|
||||
|
||||
it('should build correct redirect URL with base domain', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter('?token=valid-token-123');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
|
||||
// Mock window.location.href to track the redirect
|
||||
let redirectUrl = '';
|
||||
Object.defineProperty((window as any).location, 'href', {
|
||||
set: (url: string) => {
|
||||
redirectUrl = url;
|
||||
},
|
||||
get: () => redirectUrl || 'http://platform.lvh.me:5173/verify-email',
|
||||
});
|
||||
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Verify the redirect URL uses the base domain
|
||||
expect(redirectUrl).toContain('lvh.me');
|
||||
expect(redirectUrl).toContain('/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Already Verified State', () => {
|
||||
it('should show already verified message when email is already verified', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } });
|
||||
|
||||
renderWithRouter('?token=already-verified-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Already Verified')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This email address has already been verified. You can use it to sign in to your account.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show mail icon for already verified state', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } });
|
||||
|
||||
const { container } = renderWithRouter('?token=already-verified-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const iconContainer = container.querySelector('.bg-blue-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to login from already verified state', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } });
|
||||
|
||||
renderWithRouter('?token=already-verified-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Already Verified')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error State', () => {
|
||||
it('should show error message for invalid token', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Invalid verification token',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithRouter('?token=invalid-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verification Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Invalid verification token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show default error message when no error detail provided', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue({
|
||||
response: {
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithRouter('?token=invalid-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verification Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Failed to verify email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error icon for failed verification', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Token expired',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter('?token=expired-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorIcon = container.querySelector('.bg-red-100');
|
||||
expect(errorIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show helpful message about requesting new link', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Token expired',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithRouter('?token=expired-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'If you need a new verification link, please sign in and request one from your profile settings.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to login from error state', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Token expired',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithRouter('?token=expired-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verification Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verification Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Failed to verify email')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty token parameter', () => {
|
||||
renderWithRouter('?token=');
|
||||
|
||||
expect(screen.getByText('Invalid Link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple query parameters', () => {
|
||||
renderWithRouter('?token=test-token&utm_source=email&extra=param');
|
||||
|
||||
expect(screen.getByText('Verify Your Email')).toBeInTheDocument();
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
expect(verifyButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very long tokens', async () => {
|
||||
const longToken = 'a'.repeat(500);
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter(`?token=${longToken}`);
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/email/verify/', { token: longToken });
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle special characters in token', async () => {
|
||||
const specialToken = 'token-with-special_chars.123+abc=xyz';
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter(`?token=${encodeURIComponent(specialToken)}`);
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow multiple simultaneous verification attempts', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
let resolvePromise: (value: any) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockPost.mockReturnValue(pendingPromise as any);
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
|
||||
// Click multiple times
|
||||
fireEvent.click(verifyButton);
|
||||
fireEvent.click(verifyButton);
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verifying Your Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// API should only be called once
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({ data: { detail: 'Email verified successfully.' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redirect URL Construction', () => {
|
||||
it('should use correct protocol in redirect URL', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
(window as any).location.protocol = 'https:';
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Protocol should be used in redirect construction
|
||||
expect((window as any).location.protocol).toBe('https:');
|
||||
});
|
||||
|
||||
it('should include port in redirect URL when present', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
(window as any).location.port = '3000';
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect((window as any).location.port).toBe('3000');
|
||||
});
|
||||
|
||||
it('should handle empty port correctly', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
(window as any).location.port = '';
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect((window as any).location.port).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete User Flows', () => {
|
||||
it('should support complete successful verification flow', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
const { deleteCookie } = await import('../../utils/cookies');
|
||||
|
||||
renderWithRouter('?token=complete-flow-token');
|
||||
|
||||
// Initial state - show verify button
|
||||
expect(screen.getByText('Verify Your Email')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /confirm verification/i })).toBeInTheDocument();
|
||||
|
||||
// User clicks verify
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
// Loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verifying Your Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Success state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify cookies were cleared
|
||||
expect(deleteCookie).toHaveBeenCalledWith('access_token');
|
||||
expect(deleteCookie).toHaveBeenCalledWith('refresh_token');
|
||||
|
||||
// User can navigate to login
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support complete failed verification flow', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Verification token has expired',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithRouter('?token=expired-flow-token');
|
||||
|
||||
// Initial state
|
||||
expect(screen.getByText('Verify Your Email')).toBeInTheDocument();
|
||||
|
||||
// User clicks verify
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
// Loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verifying Your Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Error state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verification Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Verification token has expired')).toBeInTheDocument();
|
||||
|
||||
// User can navigate to login
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support already verified user flow', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } });
|
||||
|
||||
renderWithRouter('?token=already-verified-flow');
|
||||
|
||||
// User clicks verify
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
// Already verified state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Already Verified')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This email address has already been verified. You can use it to sign in to your account.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// User can navigate to login
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /verify your email/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.tagName).toBe('H1');
|
||||
});
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const button = screen.getByRole('button', { name: /confirm verification/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-brand-500');
|
||||
});
|
||||
|
||||
it('should maintain focus on interactive elements', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /go to login/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have visible text for all states', async () => {
|
||||
const mockPost = vi.mocked(apiClient.post);
|
||||
mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } });
|
||||
|
||||
renderWithRouter('?token=test-token');
|
||||
|
||||
// Pending state has text
|
||||
expect(screen.getByText('Verify Your Email')).toBeInTheDocument();
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /confirm verification/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
// Loading state has text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verifying Your Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Success state has text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email Verified!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
869
frontend/src/pages/customer/__tests__/BookingPage.test.tsx
Normal file
869
frontend/src/pages/customer/__tests__/BookingPage.test.tsx
Normal file
@@ -0,0 +1,869 @@
|
||||
/**
|
||||
* Unit tests for BookingPage component
|
||||
*
|
||||
* Tests all booking functionality including:
|
||||
* - Service selection and rendering
|
||||
* - Date/time picker interaction
|
||||
* - Multi-step booking flow
|
||||
* - Booking confirmation
|
||||
* - Loading states
|
||||
* - Error states
|
||||
* - Complete user flows
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import React, { type ReactNode } from 'react';
|
||||
import BookingPage from '../BookingPage';
|
||||
import { useServices } from '../../../hooks/useServices';
|
||||
import { User, Business, Service } from '../../../types';
|
||||
|
||||
// Mock the useServices hook
|
||||
vi.mock('../../../hooks/useServices', () => ({
|
||||
useServices: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons to avoid rendering issues in tests
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => <div data-testid="check-icon">Check</div>,
|
||||
ChevronLeft: () => <div data-testid="chevron-left-icon">ChevronLeft</div>,
|
||||
Calendar: () => <div data-testid="calendar-icon">Calendar</div>,
|
||||
Clock: () => <div data-testid="clock-icon">Clock</div>,
|
||||
AlertTriangle: () => <div data-testid="alert-icon">AlertTriangle</div>,
|
||||
CreditCard: () => <div data-testid="credit-card-icon">CreditCard</div>,
|
||||
Loader2: () => <div data-testid="loader-icon">Loader2</div>,
|
||||
}));
|
||||
|
||||
// Test data factories
|
||||
const createMockUser = (overrides?: Partial<User>): User => ({
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'customer',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#10B981',
|
||||
whitelabelEnabled: false,
|
||||
paymentsEnabled: true,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockService = (overrides?: Partial<Service>): Service => ({
|
||||
id: '1',
|
||||
name: 'Haircut',
|
||||
durationMinutes: 60,
|
||||
price: 50.0,
|
||||
description: 'Professional haircut service',
|
||||
displayOrder: 0,
|
||||
photos: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Test wrapper with all necessary providers
|
||||
const createWrapper = (queryClient: QueryClient, user: User, business: Business) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/book']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/book"
|
||||
element={
|
||||
<div>
|
||||
{React.cloneElement(children as React.ReactElement, {
|
||||
// Simulate useOutletContext
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom render function with context
|
||||
const renderBookingPage = (user: User, business: Business, queryClient: QueryClient) => {
|
||||
// Mock useOutletContext by wrapping the component
|
||||
const BookingPageWithContext = () => {
|
||||
// Simulate the outlet context
|
||||
const context = { user, business };
|
||||
|
||||
// Pass context through a wrapper component
|
||||
return React.createElement(BookingPage, { ...context } as any);
|
||||
};
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<BookingPageWithContext />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('BookingPage', () => {
|
||||
let queryClient: QueryClient;
|
||||
let mockUser: User;
|
||||
let mockBusiness: Business;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
mockUser = createMockUser();
|
||||
mockBusiness = createMockBusiness();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
describe('Service Selection (Step 1)', () => {
|
||||
it('should render loading state while fetching services', () => {
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByTestId('loader-icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render empty state when no services available', () => {
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('No services available for booking at this time.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render list of available services', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
createMockService({ id: '2', name: 'Hair Color', price: 120, durationMinutes: 120 }),
|
||||
createMockService({ id: '3', name: 'Styling', price: 40, durationMinutes: 45 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Haircut')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hair Color')).toBeInTheDocument();
|
||||
expect(screen.getByText('Styling')).toBeInTheDocument();
|
||||
expect(screen.getByText('$50.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('$120.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('$40.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display service details including duration and description', () => {
|
||||
const mockServices = [
|
||||
createMockService({
|
||||
id: '1',
|
||||
name: 'Deep Tissue Massage',
|
||||
price: 90,
|
||||
durationMinutes: 90,
|
||||
description: 'Relaxing full-body massage',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Deep Tissue Massage')).toBeInTheDocument();
|
||||
expect(screen.getByText(/90 min.*Relaxing full-body massage/)).toBeInTheDocument();
|
||||
expect(screen.getByText('$90.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should advance to step 2 when a service is selected', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const serviceButton = screen.getByRole('button', { name: /Haircut/i });
|
||||
fireEvent.click(serviceButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show correct subtitle for step 1', () => {
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Pick from our list of available services.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Selection (Step 2)', () => {
|
||||
beforeEach(() => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should display available time slots', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service first
|
||||
const serviceButton = screen.getByRole('button', { name: /Haircut/i });
|
||||
fireEvent.click(serviceButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that time slots are displayed
|
||||
const timeButtons = screen.getAllByRole('button');
|
||||
// Should have multiple time slot buttons
|
||||
expect(timeButtons.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should show subtitle with current date', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service first
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const todayDate = new Date().toLocaleDateString();
|
||||
expect(screen.getByText(new RegExp(`Available times for ${todayDate}`, 'i'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should advance to step 3 when a time is selected', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select first available time slot
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should show back button on step 2', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chevron-left-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should go back to step 1 when back button is clicked', async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Select a service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click back button
|
||||
const backButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
|
||||
if (backButton) {
|
||||
fireEvent.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Booking Confirmation (Step 3)', () => {
|
||||
beforeEach(() => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const navigateToStep3 = async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Step 1: Select service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Step 2: Select time
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('should display booking confirmation details', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
expect(screen.getByText('Confirm Your Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You are booking/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Haircut/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('calendar-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show confirm appointment button', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Confirm Appointment/i });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show subtitle with review instructions', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
expect(screen.getByText('Please review your appointment details below.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show back button on step 3', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
expect(screen.getByTestId('chevron-left-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should go back to step 2 when back button is clicked', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
// Click back button
|
||||
const backButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
|
||||
if (backButton) {
|
||||
fireEvent.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should advance to step 4 when confirm button is clicked', async () => {
|
||||
await navigateToStep3();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Confirm Appointment/i });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Booking Success (Step 4)', () => {
|
||||
beforeEach(() => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const navigateToStep4 = async () => {
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Step 1: Select service
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Step 2: Select time
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Step 3: Confirm
|
||||
fireEvent.click(screen.getByRole('button', { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('should display success message with check icon', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('check-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show booking confirmation details', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
expect(screen.getByText(/Your appointment for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Haircut/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is confirmed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show confirmation email message', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
expect(screen.getByText("We've sent a confirmation to your email.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Go to Dashboard" link', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: /Go to Dashboard/i });
|
||||
expect(dashboardLink).toBeInTheDocument();
|
||||
expect(dashboardLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('should show "Book Another" button', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const bookAnotherButton = screen.getByRole('button', { name: /Book Another/i });
|
||||
expect(bookAnotherButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset flow when "Book Another" is clicked', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const bookAnotherButton = screen.getByRole('button', { name: /Book Another/i });
|
||||
fireEvent.click(bookAnotherButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show back button on step 4', async () => {
|
||||
await navigateToStep4();
|
||||
|
||||
const backButtons = screen.queryAllByTestId('chevron-left-icon');
|
||||
expect(backButtons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete User Flow', () => {
|
||||
it('should complete entire booking flow from service selection to confirmation', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Massage Therapy', price: 80, durationMinutes: 90 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Step 1: User sees and selects service
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
expect(screen.getByText('Massage Therapy')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Massage Therapy/i }));
|
||||
|
||||
// Step 2: User sees and selects time
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
fireEvent.click(timeButtons[0]);
|
||||
|
||||
// Step 3: User confirms booking
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Massage Therapy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Confirm Appointment/i }));
|
||||
|
||||
// Step 4: User sees success message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Massage Therapy/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow user to navigate backward through steps', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Go to step 2
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go back to step 1
|
||||
const backButton1 = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
if (backButton1) fireEvent.click(backButton1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 1: Select a Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go to step 2 again
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go to step 3
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
if (timeButtons.length > 0) fireEvent.click(timeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 3: Confirm Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go back to step 2
|
||||
const backButton2 = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('[data-testid="chevron-left-icon"]')
|
||||
);
|
||||
if (backButton2) fireEvent.click(backButton2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Step 2: Choose a Time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle service with zero price', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Free Consultation', price: 0, durationMinutes: 30 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('$0.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle service with long name', () => {
|
||||
const mockServices = [
|
||||
createMockService({
|
||||
id: '1',
|
||||
name: 'Very Long Service Name That Could Potentially Break The Layout',
|
||||
price: 100,
|
||||
durationMinutes: 120,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText('Very Long Service Name That Could Potentially Break The Layout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle service with long description', () => {
|
||||
const longDescription = 'A'.repeat(200);
|
||||
const mockServices = [
|
||||
createMockService({
|
||||
id: '1',
|
||||
name: 'Service',
|
||||
price: 50,
|
||||
durationMinutes: 60,
|
||||
description: longDescription,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
expect(screen.getByText(new RegExp(longDescription))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple services with same price', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Service A', price: 50, durationMinutes: 60 }),
|
||||
createMockService({ id: '2', name: 'Service B', price: 50, durationMinutes: 45 }),
|
||||
createMockService({ id: '3', name: 'Service C', price: 50, durationMinutes: 30 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const priceElements = screen.getAllByText('$50.00');
|
||||
expect(priceElements.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle rapid step navigation', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Rapidly click through steps
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
if (timeButtons.length > 0) {
|
||||
fireEvent.click(timeButtons[0]);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.queryByRole('button', { name: /Confirm Appointment/i });
|
||||
if (confirmButton) {
|
||||
fireEvent.click(confirmButton);
|
||||
}
|
||||
});
|
||||
|
||||
// Should end up at success page
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Appointment Booked!')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const heading = screen.getByText('Step 1: Select a Service');
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have clickable service buttons', () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
const serviceButton = screen.getByRole('button', { name: /Haircut/i });
|
||||
expect(serviceButton).toBeInTheDocument();
|
||||
expect(serviceButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should have navigable link in success step', async () => {
|
||||
const mockServices = [
|
||||
createMockService({ id: '1', name: 'Haircut', price: 50, durationMinutes: 60 }),
|
||||
];
|
||||
|
||||
vi.mocked(useServices).mockReturnValue({
|
||||
data: mockServices,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderBookingPage(mockUser, mockBusiness, queryClient);
|
||||
|
||||
// Navigate to success page
|
||||
fireEvent.click(screen.getByRole('button', { name: /Haircut/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const timeButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent && /\d{1,2}:\d{2}/.test(btn.textContent)
|
||||
);
|
||||
if (timeButtons.length > 0) fireEvent.click(timeButtons[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.queryByRole('button', { name: /Confirm Appointment/i });
|
||||
if (confirmButton) fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const dashboardLink = screen.queryByRole('link', { name: /Go to Dashboard/i });
|
||||
expect(dashboardLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Staff Help Guide
|
||||
*
|
||||
* Simplified documentation for staff members.
|
||||
* Only covers features that staff have access to.
|
||||
* Comprehensive documentation for staff members.
|
||||
* Covers all features that staff have access to with detailed explanations.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
Clock,
|
||||
GripVertical,
|
||||
Ticket,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Lightbulb,
|
||||
MapPin,
|
||||
Phone,
|
||||
User as UserIcon,
|
||||
FileText,
|
||||
Bell,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { User } from '../../types';
|
||||
|
||||
@@ -63,12 +72,55 @@ const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('staffHelp.welcome', 'Welcome to SmoothSchedule')}
|
||||
</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.intro',
|
||||
'This guide covers everything you need to know as a staff member. You can view your schedule, manage your availability, and stay updated on your assignments.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
{t(
|
||||
'staffHelp.introDetails',
|
||||
'As a staff member, you have access to a focused set of tools designed to help you manage your work day efficiently. Your manager handles the broader scheduling and customer management, while you can concentrate on your assigned jobs and personal availability.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<LayoutDashboard size={20} className="text-blue-500" />
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.quickOverview.dashboard', 'Dashboard')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.quickOverview.dashboardDesc', 'Your daily summary at a glance')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Calendar size={20} className="text-green-500" />
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.quickOverview.schedule', 'My Schedule')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.quickOverview.scheduleDesc', 'View and manage your daily jobs')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<CalendarOff size={20} className="text-rose-500" />
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.quickOverview.availability', 'Availability')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.quickOverview.availabilityDesc', 'Block time when you\'re unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -89,16 +141,118 @@ const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||
"Your dashboard provides a quick overview of your day. Here you can see today's summary and any important updates."
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.dashboard.feature1', 'View daily summary and stats')}</span>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{t(
|
||||
'staffHelp.dashboard.descriptionDetail',
|
||||
"The dashboard is designed to give you the most important information at a glance when you first log in. This helps you plan your day and stay organized without having to dig through multiple pages."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.dashboard.whatYouSee', 'What You\'ll See')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar size={18} className="text-blue-600" />
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.dashboard.todaysJobs', "Today's Jobs")}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.dashboard.todaysJobsDesc',
|
||||
"A count of how many appointments you have scheduled for today. This helps you quickly understand how busy your day will be."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock size={18} className="text-green-600" />
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.dashboard.nextAppointment', 'Next Appointment')}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.dashboard.nextAppointmentDesc',
|
||||
"Shows your upcoming appointment with the customer name and time. Great for knowing at a glance what's coming up next."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RefreshCw size={18} className="text-purple-600" />
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.dashboard.weeklyStats', 'Weekly Overview')}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.dashboard.weeklyStatsDesc',
|
||||
"A summary of your appointments this week, including completed jobs and upcoming ones. Helps you track your workload."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bell size={18} className="text-orange-600" />
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.dashboard.recentUpdates', 'Recent Updates')}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.dashboard.recentUpdatesDesc',
|
||||
"Any recent changes to your schedule, such as new bookings, cancellations, or rescheduled appointments."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.dashboard.features', 'Key Features')}
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-600 dark:text-gray-300 mb-6">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.dashboard.feature1Title', 'Daily Summary:')}</span>{' '}
|
||||
<span>{t('staffHelp.dashboard.feature1Desc', 'See your total jobs for the day at a glance, so you know what to expect.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.dashboard.feature2', 'Quick access to your schedule')}</span>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.dashboard.feature2Title', 'Quick Navigation:')}</span>{' '}
|
||||
<span>{t('staffHelp.dashboard.feature2Desc', 'One-click access to your schedule and availability pages.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.dashboard.feature3Title', 'Real-Time Updates:')}</span>{' '}
|
||||
<span>{t('staffHelp.dashboard.feature3Desc', 'The dashboard refreshes automatically to show the latest information about your schedule.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.dashboard.tip', 'Pro Tip')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.dashboard.tipDesc',
|
||||
"Start each work day by checking your dashboard. It takes just a few seconds and ensures you're prepared for what's ahead. If you notice something unexpected, contact your manager early."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -119,63 +273,217 @@ const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||
'The My Schedule page shows a vertical timeline of all your jobs for the day. You can navigate between days to see past and future appointments.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{t(
|
||||
'staffHelp.schedule.descriptionDetail',
|
||||
'This is your primary view for understanding your work day. Each job is displayed as a card on the timeline, showing you exactly when and where you need to be. The timeline runs from morning to evening, making it easy to visualize your entire day.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.schedule.features', 'Features')}
|
||||
{t('staffHelp.schedule.understandingTimeline', 'Understanding the Timeline')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
<li className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-brand-500" />
|
||||
<span>
|
||||
{t('staffHelp.schedule.feature1', 'See all your jobs in a vertical timeline')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Clock size={16} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.schedule.timelineHours', 'Time Scale')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'staffHelp.schedule.timelineHoursDesc',
|
||||
'The left side shows hours of the day. Jobs appear at their scheduled time, with their height representing how long they last. A 1-hour job takes up more space than a 30-minute job.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<FileText size={16} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.schedule.jobCards', 'Job Cards')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'staffHelp.schedule.jobCardsDesc',
|
||||
'Each job appears as a colored card showing the customer name, service type, and time. Tap or click on a job to see more details like customer phone number and any special notes.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<div className="w-3 h-0.5 bg-red-500"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{t('staffHelp.schedule.currentTime', 'Current Time Indicator')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'staffHelp.schedule.currentTimeDesc',
|
||||
"When viewing today's schedule, a red line moves across the timeline showing the current time. This helps you see at a glance what's past, current, and upcoming."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.schedule.jobDetails', 'Job Information')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.schedule.jobDetailsIntro',
|
||||
'When you tap on a job, you can see all the important details you need:'
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<UserIcon size={18} className="text-brand-500" />
|
||||
<div>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
{t('staffHelp.schedule.customerInfo', 'Customer Name')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.schedule.customerInfoDesc', 'Who the appointment is with')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Phone size={18} className="text-brand-500" />
|
||||
<div>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
{t('staffHelp.schedule.phoneInfo', 'Phone Number')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.schedule.phoneInfoDesc', 'Tap to call if you need to reach them')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<MapPin size={18} className="text-brand-500" />
|
||||
<div>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
{t('staffHelp.schedule.locationInfo', 'Location/Address')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.schedule.locationInfoDesc', 'Where the service will take place')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<FileText size={18} className="text-brand-500" />
|
||||
<div>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
{t('staffHelp.schedule.notesInfo', 'Special Notes')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffHelp.schedule.notesInfoDesc', 'Any special instructions or requests')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.schedule.navigation', 'Navigating Between Days')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300 mb-6">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t(
|
||||
'staffHelp.schedule.feature2',
|
||||
'View customer name and appointment details'
|
||||
'staffHelp.schedule.navArrows',
|
||||
'Use the left and right arrows at the top to move between days'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t('staffHelp.schedule.feature3', 'Navigate between days using arrows')}
|
||||
{t(
|
||||
'staffHelp.schedule.navToday',
|
||||
'Tap the "Today" button to quickly jump back to the current day'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t('staffHelp.schedule.feature4', 'See current time indicator on today\'s view')}
|
||||
{t(
|
||||
'staffHelp.schedule.navCalendar',
|
||||
'Tap on the date to open a calendar picker for jumping to any specific day'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{canEditSchedule ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800 mb-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||
<GripVertical size={18} className="text-green-500" />
|
||||
{t('staffHelp.schedule.rescheduleTitle', 'Drag to Reschedule')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'staffHelp.schedule.rescheduleDesc',
|
||||
'You have permission to reschedule your jobs. Simply drag a job up or down on the timeline to move it to a different time slot. Changes will be saved automatically.'
|
||||
)}
|
||||
</p>
|
||||
<div className="bg-white dark:bg-gray-800 rounded p-3">
|
||||
<h5 className="font-medium text-sm text-gray-900 dark:text-white mb-2">
|
||||
{t('staffHelp.schedule.howToDrag', 'How to Reschedule:')}
|
||||
</h5>
|
||||
<ol className="text-xs text-gray-600 dark:text-gray-300 space-y-1 list-decimal list-inside">
|
||||
<li>{t('staffHelp.schedule.dragStep1', 'Press and hold on a job card')}</li>
|
||||
<li>{t('staffHelp.schedule.dragStep2', 'Drag it up or down to the new time')}</li>
|
||||
<li>{t('staffHelp.schedule.dragStep3', 'Release to drop it in place')}</li>
|
||||
<li>{t('staffHelp.schedule.dragStep4', 'The change saves automatically')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.schedule.viewOnly',
|
||||
'Your schedule is view-only. Contact a manager if you need to reschedule an appointment.'
|
||||
)}
|
||||
</p>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info size={18} className="text-gray-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.schedule.viewOnlyTitle', 'View-Only Access')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.schedule.viewOnly',
|
||||
'Your schedule is view-only. Contact a manager if you need to reschedule an appointment.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.schedule.importantNote', 'Important')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.schedule.importantNoteDesc',
|
||||
"If your schedule looks incorrect or you're missing appointments, try refreshing the page. If the problem persists, contact your manager as there may have been recent changes that haven't synced yet."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -196,26 +504,227 @@ const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||
'Use the My Availability page to set times when you are not available for bookings. This helps managers and the booking system know when not to schedule you.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{t(
|
||||
'staffHelp.availability.descriptionDetail',
|
||||
"Whether you have a doctor's appointment, a vacation planned, or regular commitments like picking up kids from school, time blocks let you communicate your unavailability in advance. This prevents scheduling conflicts and ensures your manager knows when you're not free."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.availability.whenToUse', 'When to Use Time Blocks')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
||||
<div className="p-3 bg-rose-50 dark:bg-rose-900/20 rounded-lg border border-rose-200 dark:border-rose-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.availability.vacationUse', 'Vacation & Time Off')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.availability.vacationUseDesc',
|
||||
'Block out entire days or weeks when you\'ll be away on vacation or taking personal time off.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-rose-50 dark:bg-rose-900/20 rounded-lg border border-rose-200 dark:border-rose-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.availability.appointmentsUse', 'Personal Appointments')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.availability.appointmentsUseDesc',
|
||||
'Doctor visits, car maintenance, or any one-time personal commitment during work hours.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-rose-50 dark:bg-rose-900/20 rounded-lg border border-rose-200 dark:border-rose-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.availability.recurringUse', 'Recurring Commitments')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.availability.recurringUseDesc',
|
||||
'Weekly therapy sessions, school pickups, or any regular obligation. Set it once and it repeats automatically.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-rose-50 dark:bg-rose-900/20 rounded-lg border border-rose-200 dark:border-rose-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.availability.emergencyUse', 'Last-Minute Needs')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.availability.emergencyUseDesc',
|
||||
'Unexpected situations where you need time blocked quickly. Your manager will be notified of new blocks.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.availability.howTo', 'How to Block Time')}
|
||||
</h3>
|
||||
<ol className="space-y-2 text-sm text-gray-600 dark:text-gray-300 list-decimal list-inside mb-4">
|
||||
<li>{t('staffHelp.availability.step1', 'Click "Add Time Block" button')}</li>
|
||||
<li>{t('staffHelp.availability.step2', 'Select the date and time range')}</li>
|
||||
<li>{t('staffHelp.availability.step3', 'Add an optional reason (e.g., "Vacation", "Doctor appointment")')}</li>
|
||||
<li>{t('staffHelp.availability.step4', 'Choose if it repeats (one-time, weekly, etc.)')}</li>
|
||||
<li>{t('staffHelp.availability.step5', 'Save your time block')}</li>
|
||||
</ol>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6">
|
||||
<ol className="space-y-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-rose-600 text-white text-xs flex items-center justify-center font-medium">1</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.availability.step1Title', 'Click "Add Time Block"')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'staffHelp.availability.step1Desc',
|
||||
'Find this button at the top of the My Availability page. It opens a form where you can specify your unavailable time.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-rose-600 text-white text-xs flex items-center justify-center font-medium">2</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.availability.step2Title', 'Select Date and Time')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'staffHelp.availability.step2Desc',
|
||||
'Choose the specific date, then set the start and end time. For all-day events, you can toggle "All Day" to block the entire day.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-rose-600 text-white text-xs flex items-center justify-center font-medium">3</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.availability.step3Title', 'Add a Reason (Optional but Recommended)')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'staffHelp.availability.step3Desc',
|
||||
'Enter a brief reason like "Doctor appointment" or "School pickup". This helps your manager understand why you\'re unavailable and is visible only to staff.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-rose-600 text-white text-xs flex items-center justify-center font-medium">4</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.availability.step4Title', 'Set Recurrence (If Needed)')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'staffHelp.availability.step4Desc',
|
||||
'For recurring commitments, choose how often it repeats: weekly, bi-weekly, or monthly. You can also set an end date for the recurrence.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-rose-600 text-white text-xs flex items-center justify-center font-medium">5</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('staffHelp.availability.step5Title', 'Save Your Time Block')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'staffHelp.availability.step5Desc',
|
||||
'Click Save to create your time block. It will immediately appear on your availability calendar and prevent new bookings during that time.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<strong>{t('staffHelp.availability.note', 'Note:')}</strong>{' '}
|
||||
{t(
|
||||
'staffHelp.availability.noteDesc',
|
||||
'Time blocks you create will prevent new bookings during those times. Existing appointments are not affected.'
|
||||
)}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.availability.managingBlocks', 'Managing Your Time Blocks')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300 mb-6">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.availability.viewBlocks', 'View All Blocks:')}</span>{' '}
|
||||
<span>{t('staffHelp.availability.viewBlocksDesc', 'See all your upcoming time blocks in a list or calendar view.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.availability.editBlocks', 'Edit Existing Blocks:')}</span>{' '}
|
||||
<span>{t('staffHelp.availability.editBlocksDesc', 'Click on any time block to change its date, time, or reason.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.availability.deleteBlocks', 'Delete Blocks:')}</span>{' '}
|
||||
<span>{t('staffHelp.availability.deleteBlocksDesc', 'If your plans change, you can delete a time block to become available again.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle size={16} className="text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{t('staffHelp.availability.editSeries', 'Edit Recurring Series:')}</span>{' '}
|
||||
<span>{t('staffHelp.availability.editSeriesDesc', 'For recurring blocks, you can edit just one occurrence or the entire series.')}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.availability.importantNote', 'Important to Know')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.availability.noteDesc',
|
||||
'Time blocks you create will prevent new bookings during those times. However, existing appointments that were already scheduled are not automatically cancelled or moved. If you have a conflict with an existing appointment, contact your manager to reschedule it.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.availability.bestPractices', 'Best Practices')}
|
||||
</h4>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.availability.tip1',
|
||||
'• Add time blocks as far in advance as possible so your manager can plan around them.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.availability.tip2',
|
||||
'• Always include a reason so your manager understands the context.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.availability.tip3',
|
||||
'• For recurring commitments, use the recurrence feature rather than creating multiple individual blocks.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.availability.tip4',
|
||||
'• Review your time blocks periodically to remove any that are no longer needed.'
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -238,43 +747,221 @@ const StaffHelp: React.FC<StaffHelpProps> = ({ user }) => {
|
||||
'You have access to the ticketing system. Use tickets to communicate with customers, report issues, or track requests.'
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.tickets.feature1', 'View and respond to tickets')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.tickets.feature2', 'Create new tickets for customer issues')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{t('staffHelp.tickets.feature3', 'Track ticket status and history')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{t(
|
||||
'staffHelp.tickets.descriptionDetail',
|
||||
'The ticketing system provides a structured way to handle customer inquiries, problems, and requests. Each ticket tracks the entire conversation history, making it easy to follow up and ensure nothing falls through the cracks.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.tickets.whatYouCanDo', 'What You Can Do')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.tickets.viewTickets', 'View Tickets')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.tickets.viewTicketsDesc',
|
||||
'See all tickets assigned to you or visible to staff. Filter by status, priority, or date.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.tickets.respondTickets', 'Respond to Tickets')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.tickets.respondTicketsDesc',
|
||||
'Add comments and replies to tickets. Customers receive notifications when you respond.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.tickets.createTickets', 'Create Tickets')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.tickets.createTicketsDesc',
|
||||
'Open new tickets on behalf of customers when they contact you directly or report issues in person.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.tickets.trackStatus', 'Track Status')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.tickets.trackStatusDesc',
|
||||
'See the full history of each ticket including all comments, status changes, and when it was created.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffHelp.tickets.ticketStatuses', 'Understanding Ticket Status')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div className="p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
|
||||
{t('staffHelp.tickets.statusOpen', 'Open')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffHelp.tickets.statusOpenDesc', 'Awaiting response')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{t('staffHelp.tickets.statusInProgress', 'In Progress')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffHelp.tickets.statusInProgressDesc', 'Being worked on')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">
|
||||
{t('staffHelp.tickets.statusWaiting', 'Waiting')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffHelp.tickets.statusWaitingDesc', 'Waiting on customer')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
{t('staffHelp.tickets.statusClosed', 'Closed')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffHelp.tickets.statusClosedDesc', 'Issue resolved')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb size={20} className="text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-1">
|
||||
{t('staffHelp.tickets.bestPractices', 'Tips for Good Ticket Handling')}
|
||||
</h4>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.tickets.tip1',
|
||||
'• Respond promptly - even if just to acknowledge you\'ve seen the ticket.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.tickets.tip2',
|
||||
'• Be clear and specific in your responses. Avoid jargon the customer might not understand.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.tickets.tip3',
|
||||
'• If you can\'t resolve an issue, escalate to your manager rather than leaving it open indefinitely.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'staffHelp.tickets.tip4',
|
||||
'• Always close tickets once the issue is fully resolved to keep your queue organized.'
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Help Footer */}
|
||||
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
|
||||
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('staffHelp.footer.title', 'Need More Help?')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t(
|
||||
'staffHelp.footer.description',
|
||||
"If you have questions or need assistance, please contact your manager or supervisor."
|
||||
)}
|
||||
</p>
|
||||
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center mb-6">
|
||||
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('staffHelp.footer.title', 'Need More Help?')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'staffHelp.footer.description',
|
||||
"If you have questions or need assistance, please contact your manager or supervisor."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-3">
|
||||
{t('staffHelp.footer.commonQuestions', 'Common Questions')}
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="font-medium text-brand-600 dark:text-brand-400">Q:</span>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t('staffHelp.footer.q1', "I can't see my schedule?")}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t(
|
||||
'staffHelp.footer.a1',
|
||||
'Try refreshing the page. If it still doesn\'t load, contact your manager to ensure you\'re assigned to a resource.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="font-medium text-brand-600 dark:text-brand-400">Q:</span>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t('staffHelp.footer.q2', 'How do I reschedule an appointment?')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t(
|
||||
'staffHelp.footer.a2',
|
||||
'If you have edit permissions, drag the job to a new time. Otherwise, contact your manager to reschedule.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="font-medium text-brand-600 dark:text-brand-400">Q:</span>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t('staffHelp.footer.q3', 'Why was my time block rejected?')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t(
|
||||
'staffHelp.footer.a3',
|
||||
'Your manager may have declined the request if it conflicts with existing commitments. They should reach out to discuss alternatives.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{canAccessTickets && (
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('staffHelp.footer.openTicket', 'Open a Ticket')}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t(
|
||||
'staffHelp.footer.ticketPrompt',
|
||||
"Can't find the answer you're looking for? Open a support ticket and we'll help you out."
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('staffHelp.footer.openTicket', 'Open a Ticket')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
687
frontend/src/pages/marketing/__tests__/AboutPage.test.tsx
Normal file
687
frontend/src/pages/marketing/__tests__/AboutPage.test.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* Unit tests for AboutPage component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with all sections
|
||||
* - Header section with title and subtitle
|
||||
* - Story section with timeline
|
||||
* - Mission section content
|
||||
* - Values section with all value cards
|
||||
* - CTA section integration
|
||||
* - Internationalization (i18n)
|
||||
* - Accessibility attributes
|
||||
* - Responsive design elements
|
||||
* - Dark mode support
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import AboutPage from '../AboutPage';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
// Return mock translations based on key
|
||||
const translations: Record<string, string> = {
|
||||
// Header section
|
||||
'marketing.about.title': 'About Smooth Schedule',
|
||||
'marketing.about.subtitle': "We're on a mission to simplify scheduling for businesses everywhere.",
|
||||
|
||||
// Story section
|
||||
'marketing.about.story.title': 'Our Story',
|
||||
'marketing.about.story.content': 'We started creating bespoke custom scheduling and payment solutions in 2017. Through that work, we became convinced that we had a better way of doing things than other scheduling services out there.',
|
||||
'marketing.about.story.content2': "Along the way, we discovered features and options that customers love, capabilities that nobody else offers. That's when we decided to change our model so we could help more businesses. SmoothSchedule was born from years of hands-on experience building what businesses actually need.",
|
||||
'marketing.about.story.founded': 'Building scheduling solutions',
|
||||
'marketing.about.story.timeline.experience': '8+ years building scheduling solutions',
|
||||
'marketing.about.story.timeline.battleTested': 'Battle-tested with real businesses',
|
||||
'marketing.about.story.timeline.feedback': 'Features born from customer feedback',
|
||||
'marketing.about.story.timeline.available': 'Now available to everyone',
|
||||
|
||||
// Mission section
|
||||
'marketing.about.mission.title': 'Our Mission',
|
||||
'marketing.about.mission.content': 'To empower service businesses with the tools they need to grow, while giving their customers a seamless booking experience.',
|
||||
|
||||
// Values section
|
||||
'marketing.about.values.title': 'Our Values',
|
||||
'marketing.about.values.simplicity.title': 'Simplicity',
|
||||
'marketing.about.values.simplicity.description': 'We believe powerful software can still be simple to use.',
|
||||
'marketing.about.values.reliability.title': 'Reliability',
|
||||
'marketing.about.values.reliability.description': 'Your business depends on us, so we never compromise on uptime.',
|
||||
'marketing.about.values.transparency.title': 'Transparency',
|
||||
'marketing.about.values.transparency.description': 'No hidden fees, no surprises. What you see is what you get.',
|
||||
'marketing.about.values.support.title': 'Support',
|
||||
'marketing.about.values.support.description': "We're here to help you succeed, every step of the way.",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CTASection component
|
||||
vi.mock('../../../components/marketing/CTASection', () => ({
|
||||
default: ({ variant }: { variant?: string }) => (
|
||||
<div data-testid="cta-section" data-variant={variant}>
|
||||
CTA Section
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AboutPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render the about page', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const title = screen.getByText(/About Smooth Schedule/i);
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render all major sections', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Header
|
||||
expect(screen.getByText(/About Smooth Schedule/i)).toBeInTheDocument();
|
||||
|
||||
// Story
|
||||
expect(screen.getByText(/Our Story/i)).toBeInTheDocument();
|
||||
|
||||
// Mission
|
||||
expect(screen.getByText(/Our Mission/i)).toBeInTheDocument();
|
||||
|
||||
// Values
|
||||
expect(screen.getByText(/Our Values/i)).toBeInTheDocument();
|
||||
|
||||
// CTA
|
||||
expect(screen.getByTestId('cta-section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header Section', () => {
|
||||
it('should render page title', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const title = screen.getByText(/About Smooth Schedule/i);
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render page subtitle', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText(/We're on a mission to simplify scheduling/i);
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title as h1 element', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveTextContent(/About Smooth Schedule/i);
|
||||
});
|
||||
|
||||
it('should apply proper styling to title', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('text-4xl');
|
||||
expect(heading).toHaveClass('sm:text-5xl');
|
||||
expect(heading).toHaveClass('font-bold');
|
||||
});
|
||||
|
||||
it('should apply proper styling to subtitle', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText(/We're on a mission to simplify scheduling/i);
|
||||
expect(subtitle).toHaveClass('text-xl');
|
||||
expect(subtitle.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('should have gradient background', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const gradientSection = container.querySelector('.bg-gradient-to-br');
|
||||
expect(gradientSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should center align header text', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const headerContainer = container.querySelector('.text-center');
|
||||
expect(headerContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Story Section', () => {
|
||||
it('should render story title', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2, name: /Our Story/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render first story paragraph', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const paragraph = screen.getByText(/We started creating bespoke custom scheduling/i);
|
||||
expect(paragraph).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render second story paragraph', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const paragraph = screen.getByText(/Along the way, we discovered features/i);
|
||||
expect(paragraph).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render founding year 2017', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const year = screen.getByText(/2017/i);
|
||||
expect(year).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render founding description', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Building scheduling solutions/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all timeline items', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/8\+ years building scheduling solutions/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Battle-tested with real businesses/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Features born from customer feedback/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Now available to everyone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have grid layout for story content', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const gridElement = container.querySelector('.grid.md\\:grid-cols-2');
|
||||
expect(gridElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should style founding year prominently', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const year = screen.getByText(/2017/i);
|
||||
expect(year).toHaveClass('text-6xl');
|
||||
expect(year).toHaveClass('font-bold');
|
||||
});
|
||||
|
||||
it('should have brand gradient background for timeline card', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const gradientCard = container.querySelector('.bg-gradient-to-br.from-brand-500');
|
||||
expect(gradientCard).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mission Section', () => {
|
||||
it('should render mission title', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2, name: /Our Mission/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render mission content', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const content = screen.getByText(/To empower service businesses with the tools they need to grow/i);
|
||||
expect(content).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply proper styling to mission title', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2, name: /Our Mission/i });
|
||||
expect(heading).toHaveClass('text-3xl');
|
||||
expect(heading).toHaveClass('font-bold');
|
||||
});
|
||||
|
||||
it('should apply proper styling to mission content', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const content = screen.getByText(/To empower service businesses/i);
|
||||
expect(content).toHaveClass('text-xl');
|
||||
expect(content.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('should center align mission section', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const missionSection = screen.getByText(/Our Mission/i).closest('div')?.parentElement;
|
||||
expect(missionSection).toHaveClass('text-center');
|
||||
});
|
||||
|
||||
it('should have gray background', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const missionSection = container.querySelector('.bg-gray-50');
|
||||
expect(missionSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Values Section', () => {
|
||||
it('should render values title', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2, name: /Our Values/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all four value cards', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Simplicity/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reliability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Transparency/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Support/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Simplicity value description', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/We believe powerful software can still be simple to use/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Reliability value description', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Your business depends on us, so we never compromise on uptime/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Transparency value description', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/No hidden fees, no surprises/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Support value description', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/We're here to help you succeed/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have grid layout for value cards', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const gridElement = container.querySelector('.grid.md\\:grid-cols-2.lg\\:grid-cols-4');
|
||||
expect(gridElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render value icons', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Each value card should have an icon
|
||||
const icons = container.querySelectorAll('svg');
|
||||
// Should have at least 4 icons (one for each value)
|
||||
expect(icons.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('should apply color-coded icon backgrounds', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Check for different colored backgrounds
|
||||
expect(container.querySelector('.bg-brand-100')).toBeInTheDocument();
|
||||
expect(container.querySelector('.bg-green-100')).toBeInTheDocument();
|
||||
expect(container.querySelector('.bg-purple-100')).toBeInTheDocument();
|
||||
expect(container.querySelector('.bg-orange-100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should center align value cards', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const valueCards = container.querySelectorAll('.text-center');
|
||||
expect(valueCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render value titles as h3 elements', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const simplicityHeading = screen.getByRole('heading', { level: 3, name: /Simplicity/i });
|
||||
expect(simplicityHeading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA Section', () => {
|
||||
it('should render CTA section', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaSection = screen.getByTestId('cta-section');
|
||||
expect(ctaSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CTA section with minimal variant', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaSection = screen.getByTestId('cta-section');
|
||||
expect(ctaSection).toHaveAttribute('data-variant', 'minimal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translations for header', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/About Smooth Schedule/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/We're on a mission to simplify scheduling/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for story section', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Our Story/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/We started creating bespoke custom scheduling/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for mission section', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Our Mission/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/To empower service businesses/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for values section', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Our Values/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Simplicity/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reliability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Transparency/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Support/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for timeline items', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/8\+ years building scheduling solutions/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Battle-tested with real businesses/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Features born from customer feedback/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible heading hierarchy', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
const h2Elements = screen.getAllByRole('heading', { level: 2 });
|
||||
const h3Elements = screen.getAllByRole('heading', { level: 3 });
|
||||
|
||||
expect(h1).toBeInTheDocument();
|
||||
expect(h2Elements.length).toBeGreaterThan(0);
|
||||
expect(h3Elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper heading structure (h1 -> h2 -> h3)', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Should have exactly one h1
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1).toBeInTheDocument();
|
||||
|
||||
// Should have multiple h2 sections
|
||||
const h2Elements = screen.getAllByRole('heading', { level: 2 });
|
||||
expect(h2Elements.length).toBe(3); // Story, Mission, Values
|
||||
|
||||
// Should have h3 for value titles
|
||||
const h3Elements = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(h3Elements.length).toBe(4); // Four values
|
||||
});
|
||||
|
||||
it('should use semantic HTML elements', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible text contrast', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Light mode text should use gray-900
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('text-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should have responsive heading sizes', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1).toHaveClass('text-4xl');
|
||||
expect(h1).toHaveClass('sm:text-5xl');
|
||||
});
|
||||
|
||||
it('should have responsive grid for story section', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const storyGrid = container.querySelector('.md\\:grid-cols-2');
|
||||
expect(storyGrid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive grid for values section', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const valuesGrid = container.querySelector('.md\\:grid-cols-2.lg\\:grid-cols-4');
|
||||
expect(valuesGrid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive padding', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const sections = container.querySelectorAll('.py-20');
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have responsive container width', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const maxWidthContainers = container.querySelectorAll('[class*="max-w-"]');
|
||||
expect(maxWidthContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should have dark mode classes for headings', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should have dark mode classes for text elements', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText(/We're on a mission to simplify scheduling/i);
|
||||
expect(subtitle).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should have dark mode background classes', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const darkBg = container.querySelector('.dark\\:bg-gray-900');
|
||||
expect(darkBg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have dark mode gradient classes', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const darkGradient = container.querySelector('.dark\\:from-gray-900');
|
||||
expect(darkGradient).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have dark mode icon background classes', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const darkIconBg = container.querySelector('.dark\\:bg-brand-900\\/30');
|
||||
expect(darkIconBg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Spacing', () => {
|
||||
it('should have proper section padding', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const sections = container.querySelectorAll('.py-20');
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have responsive section padding', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const responsivePadding = container.querySelector('.lg\\:py-28');
|
||||
expect(responsivePadding).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should constrain content width', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(container.querySelector('.max-w-4xl')).toBeInTheDocument();
|
||||
expect(container.querySelector('.max-w-7xl')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper margins between elements', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('mb-6');
|
||||
});
|
||||
|
||||
it('should have gap between grid items', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const gridsWithGap = container.querySelectorAll('[class*="gap-"]');
|
||||
expect(gridsWithGap.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Elements', () => {
|
||||
it('should render timeline bullets', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const bullets = container.querySelectorAll('.rounded-full');
|
||||
// Should have bullet points for timeline items
|
||||
expect(bullets.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('should apply rounded corners to cards', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const roundedElements = container.querySelectorAll('.rounded-2xl');
|
||||
expect(roundedElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have icon containers with proper styling', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const iconContainer = container.querySelector('.inline-flex.p-4.rounded-2xl');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should render complete page structure', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
|
||||
// Story
|
||||
expect(screen.getByText(/2017/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/8\+ years building scheduling solutions/i)).toBeInTheDocument();
|
||||
|
||||
// Mission
|
||||
expect(screen.getByText(/To empower service businesses/i)).toBeInTheDocument();
|
||||
|
||||
// Values
|
||||
expect(screen.getByText(/Simplicity/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reliability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Transparency/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Support/i)).toBeInTheDocument();
|
||||
|
||||
// CTA
|
||||
expect(screen.getByTestId('cta-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have all sections in correct order', () => {
|
||||
const { container } = render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
expect(sections.length).toBe(5); // Header, Story, Mission, Values, CTA (in div)
|
||||
});
|
||||
|
||||
it('should maintain proper visual hierarchy', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
// h1 for main title
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1).toHaveTextContent(/About Smooth Schedule/i);
|
||||
|
||||
// h2 for section titles
|
||||
const h2Elements = screen.getAllByRole('heading', { level: 2 });
|
||||
expect(h2Elements).toHaveLength(3);
|
||||
|
||||
// h3 for value titles
|
||||
const h3Elements = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(h3Elements).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should render all timeline items', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const timelineItems = [
|
||||
/8\+ years building scheduling solutions/i,
|
||||
/Battle-tested with real businesses/i,
|
||||
/Features born from customer feedback/i,
|
||||
/Now available to everyone/i,
|
||||
];
|
||||
|
||||
timelineItems.forEach(item => {
|
||||
expect(screen.getByText(item)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all value cards with descriptions', () => {
|
||||
render(<AboutPage />, { wrapper: createWrapper() });
|
||||
|
||||
const values = [
|
||||
{ title: /Simplicity/i, description: /powerful software can still be simple/i },
|
||||
{ title: /Reliability/i, description: /never compromise on uptime/i },
|
||||
{ title: /Transparency/i, description: /No hidden fees/i },
|
||||
{ title: /Support/i, description: /help you succeed/i },
|
||||
];
|
||||
|
||||
values.forEach(value => {
|
||||
expect(screen.getByText(value.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(value.description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
655
frontend/src/pages/marketing/__tests__/HomePage.test.tsx
Normal file
655
frontend/src/pages/marketing/__tests__/HomePage.test.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Comprehensive unit tests for HomePage component
|
||||
*
|
||||
* Tests the marketing HomePage including:
|
||||
* - Hero section rendering
|
||||
* - Feature sections display
|
||||
* - CTA buttons presence
|
||||
* - Navigation links work
|
||||
* - Marketing components integration
|
||||
* - Translations
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import HomePage from '../HomePage';
|
||||
|
||||
// Mock child components to isolate HomePage testing
|
||||
vi.mock('../../../components/marketing/Hero', () => ({
|
||||
default: () => <div data-testid="hero-section">Hero Component</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/FeatureCard', () => ({
|
||||
default: ({ title, description, icon: Icon }: any) => (
|
||||
<div data-testid="feature-card">
|
||||
<Icon data-testid="feature-icon" />
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/PluginShowcase', () => ({
|
||||
default: () => <div data-testid="plugin-showcase">Plugin Showcase Component</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/BenefitsSection', () => ({
|
||||
default: () => <div data-testid="benefits-section">Benefits Section Component</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/TestimonialCard', () => ({
|
||||
default: ({ quote, author, role, company, rating }: any) => (
|
||||
<div data-testid="testimonial-card">
|
||||
<p>{quote}</p>
|
||||
<div>{author}</div>
|
||||
<div>{role}</div>
|
||||
<div>{company}</div>
|
||||
<div>{rating} stars</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/CTASection', () => ({
|
||||
default: () => <div data-testid="cta-section">CTA Section Component</div>,
|
||||
}));
|
||||
|
||||
// Mock useTranslation hook
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
// Features section
|
||||
'marketing.home.featuresSection.title': 'Powerful Features',
|
||||
'marketing.home.featuresSection.subtitle': 'Everything you need to manage your business',
|
||||
|
||||
// Features
|
||||
'marketing.home.features.intelligentScheduling.title': 'Intelligent Scheduling',
|
||||
'marketing.home.features.intelligentScheduling.description': 'Smart scheduling that works for you',
|
||||
'marketing.home.features.automationEngine.title': 'Automation Engine',
|
||||
'marketing.home.features.automationEngine.description': 'Automate repetitive tasks',
|
||||
'marketing.home.features.multiTenant.title': 'Multi-Tenant',
|
||||
'marketing.home.features.multiTenant.description': 'Manage multiple businesses',
|
||||
'marketing.home.features.integratedPayments.title': 'Integrated Payments',
|
||||
'marketing.home.features.integratedPayments.description': 'Accept payments easily',
|
||||
'marketing.home.features.customerManagement.title': 'Customer Management',
|
||||
'marketing.home.features.customerManagement.description': 'Manage your customers',
|
||||
'marketing.home.features.advancedAnalytics.title': 'Advanced Analytics',
|
||||
'marketing.home.features.advancedAnalytics.description': 'Track your performance',
|
||||
'marketing.home.features.digitalContracts.title': 'Digital Contracts',
|
||||
'marketing.home.features.digitalContracts.description': 'Sign contracts digitally',
|
||||
|
||||
// Testimonials section
|
||||
'marketing.home.testimonialsSection.title': 'What Our Customers Say',
|
||||
'marketing.home.testimonialsSection.subtitle': 'Join thousands of happy customers',
|
||||
|
||||
// Testimonials
|
||||
'marketing.home.testimonials.winBack.quote': 'SmoothSchedule helped us win back customers',
|
||||
'marketing.home.testimonials.winBack.author': 'John Doe',
|
||||
'marketing.home.testimonials.winBack.role': 'CEO',
|
||||
'marketing.home.testimonials.winBack.company': 'Acme Corp',
|
||||
'marketing.home.testimonials.resources.quote': 'Resource management is a breeze',
|
||||
'marketing.home.testimonials.resources.author': 'Jane Smith',
|
||||
'marketing.home.testimonials.resources.role': 'Operations Manager',
|
||||
'marketing.home.testimonials.resources.company': 'Tech Solutions',
|
||||
'marketing.home.testimonials.whiteLabel.quote': 'White label features are amazing',
|
||||
'marketing.home.testimonials.whiteLabel.author': 'Bob Johnson',
|
||||
'marketing.home.testimonials.whiteLabel.role': 'Founder',
|
||||
'marketing.home.testimonials.whiteLabel.company': 'StartupXYZ',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderHomePage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<HomePage />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('HomePage', () => {
|
||||
describe('Hero Section', () => {
|
||||
it('should render the hero section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const heroSection = screen.getByTestId('hero-section');
|
||||
expect(heroSection).toBeInTheDocument();
|
||||
expect(heroSection).toHaveTextContent('Hero Component');
|
||||
});
|
||||
|
||||
it('should render hero section as the first major section', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const heroSection = screen.getByTestId('hero-section');
|
||||
const allSections = container.querySelectorAll('[data-testid]');
|
||||
|
||||
// Hero should be one of the first sections
|
||||
expect(allSections[0]).toBe(heroSection);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Features Section', () => {
|
||||
it('should render features section heading', () => {
|
||||
renderHomePage();
|
||||
|
||||
const heading = screen.getByText('Powerful Features');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.tagName).toBe('H2');
|
||||
});
|
||||
|
||||
it('should render features section subtitle', () => {
|
||||
renderHomePage();
|
||||
|
||||
const subtitle = screen.getByText('Everything you need to manage your business');
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display all 7 feature cards', () => {
|
||||
renderHomePage();
|
||||
|
||||
const featureCards = screen.getAllByTestId('feature-card');
|
||||
expect(featureCards).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('should render intelligent scheduling feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Intelligent Scheduling')).toBeInTheDocument();
|
||||
expect(screen.getByText('Smart scheduling that works for you')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render automation engine feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Automation Engine')).toBeInTheDocument();
|
||||
expect(screen.getByText('Automate repetitive tasks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multi-tenant feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Multi-Tenant')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage multiple businesses')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render integrated payments feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Integrated Payments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accept payments easily')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render customer management feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Customer Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage your customers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render advanced analytics feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Advanced Analytics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Track your performance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render digital contracts feature', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Digital Contracts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sign contracts digitally')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all feature icons', () => {
|
||||
renderHomePage();
|
||||
|
||||
const featureIcons = screen.getAllByTestId('feature-icon');
|
||||
expect(featureIcons).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('should apply correct styling to features section', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
// Find the features section by looking for the section with feature cards
|
||||
const sections = container.querySelectorAll('section');
|
||||
const featuresSection = Array.from(sections).find(section =>
|
||||
section.querySelector('[data-testid="feature-card"]')
|
||||
);
|
||||
|
||||
expect(featuresSection).toBeInTheDocument();
|
||||
expect(featuresSection).toHaveClass('bg-white', 'dark:bg-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Showcase Section', () => {
|
||||
it('should render the plugin showcase section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const pluginShowcase = screen.getByTestId('plugin-showcase');
|
||||
expect(pluginShowcase).toBeInTheDocument();
|
||||
expect(pluginShowcase).toHaveTextContent('Plugin Showcase Component');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Benefits Section', () => {
|
||||
it('should render the benefits section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const benefitsSection = screen.getByTestId('benefits-section');
|
||||
expect(benefitsSection).toBeInTheDocument();
|
||||
expect(benefitsSection).toHaveTextContent('Benefits Section Component');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials Section', () => {
|
||||
it('should render testimonials section heading', () => {
|
||||
renderHomePage();
|
||||
|
||||
const heading = screen.getByText('What Our Customers Say');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.tagName).toBe('H2');
|
||||
});
|
||||
|
||||
it('should render testimonials section subtitle', () => {
|
||||
renderHomePage();
|
||||
|
||||
const subtitle = screen.getByText('Join thousands of happy customers');
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display all 3 testimonial cards', () => {
|
||||
renderHomePage();
|
||||
|
||||
const testimonialCards = screen.getAllByTestId('testimonial-card');
|
||||
expect(testimonialCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should render win-back testimonial', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('SmoothSchedule helped us win back customers')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('CEO')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render resources testimonial', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Resource management is a breeze')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Operations Manager')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tech Solutions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render white-label testimonial', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('White label features are amazing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
expect(screen.getByText('Founder')).toBeInTheDocument();
|
||||
expect(screen.getByText('StartupXYZ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all testimonials with 5-star ratings', () => {
|
||||
renderHomePage();
|
||||
|
||||
const ratingElements = screen.getAllByText('5 stars');
|
||||
expect(ratingElements).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply correct styling to testimonials section', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
// Find the testimonials section by looking for the section with testimonial cards
|
||||
const sections = container.querySelectorAll('section');
|
||||
const testimonialsSection = Array.from(sections).find(section =>
|
||||
section.querySelector('[data-testid="testimonial-card"]')
|
||||
);
|
||||
|
||||
expect(testimonialsSection).toBeInTheDocument();
|
||||
expect(testimonialsSection).toHaveClass('bg-gray-50', 'dark:bg-gray-800/50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA Section', () => {
|
||||
it('should render the CTA section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const ctaSection = screen.getByTestId('cta-section');
|
||||
expect(ctaSection).toBeInTheDocument();
|
||||
expect(ctaSection).toHaveTextContent('CTA Section Component');
|
||||
});
|
||||
|
||||
it('should render CTA section as the last section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const ctaSection = screen.getByTestId('cta-section');
|
||||
const allSections = screen.getAllByTestId(/section/);
|
||||
|
||||
// CTA should be the last section
|
||||
expect(allSections[allSections.length - 1]).toBe(ctaSection);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Page Structure', () => {
|
||||
it('should render all main sections in correct order', () => {
|
||||
renderHomePage();
|
||||
|
||||
const hero = screen.getByTestId('hero-section');
|
||||
const pluginShowcase = screen.getByTestId('plugin-showcase');
|
||||
const benefits = screen.getByTestId('benefits-section');
|
||||
const cta = screen.getByTestId('cta-section');
|
||||
|
||||
// All sections should be present
|
||||
expect(hero).toBeInTheDocument();
|
||||
expect(pluginShowcase).toBeInTheDocument();
|
||||
expect(benefits).toBeInTheDocument();
|
||||
expect(cta).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper semantic structure', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
// Should have multiple section elements
|
||||
const sections = container.querySelectorAll('section');
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have proper heading hierarchy
|
||||
const h2Headings = container.querySelectorAll('h2');
|
||||
expect(h2Headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render within a container div', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const rootDiv = container.firstChild;
|
||||
expect(rootDiv).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should apply responsive padding classes to features section', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
const featuresSection = Array.from(sections).find(section =>
|
||||
section.querySelector('[data-testid="feature-card"]')
|
||||
);
|
||||
|
||||
expect(featuresSection).toHaveClass('py-20', 'lg:py-28');
|
||||
});
|
||||
|
||||
it('should apply responsive grid layout to features', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
// Find the grid container
|
||||
const gridContainer = container.querySelector('.grid.md\\:grid-cols-2.lg\\:grid-cols-3');
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply responsive grid layout to testimonials', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
// Find all grid containers
|
||||
const gridContainers = container.querySelectorAll('.grid.md\\:grid-cols-2.lg\\:grid-cols-3');
|
||||
|
||||
// There should be at least 2 grid containers (features and testimonials)
|
||||
expect(gridContainers.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should have max-width container for content', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const maxWidthContainers = container.querySelectorAll('.max-w-7xl');
|
||||
expect(maxWidthContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply responsive padding to containers', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const paddedContainers = container.querySelectorAll('.px-4.sm\\:px-6.lg\\:px-8');
|
||||
expect(paddedContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for sections', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
// Features section should have dark mode background
|
||||
const sections = container.querySelectorAll('section');
|
||||
const hasFeatureSection = Array.from(sections).some(section =>
|
||||
section.classList.contains('dark:bg-gray-900')
|
||||
);
|
||||
expect(hasFeatureSection).toBe(true);
|
||||
});
|
||||
|
||||
it('should include dark mode classes for testimonials section', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
const hasTestimonialSection = Array.from(sections).some(section =>
|
||||
section.classList.contains('dark:bg-gray-800/50')
|
||||
);
|
||||
expect(hasTestimonialSection).toBe(true);
|
||||
});
|
||||
|
||||
it('should include dark mode classes for headings', () => {
|
||||
renderHomePage();
|
||||
|
||||
const heading = screen.getByText('Powerful Features');
|
||||
expect(heading).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for descriptions', () => {
|
||||
renderHomePage();
|
||||
|
||||
const subtitle = screen.getByText('Everything you need to manage your business');
|
||||
expect(subtitle).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic heading elements', () => {
|
||||
renderHomePage();
|
||||
|
||||
const h2Headings = screen.getAllByRole('heading', { level: 2 });
|
||||
expect(h2Headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper heading hierarchy for features section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const featuresHeading = screen.getByText('Powerful Features');
|
||||
expect(featuresHeading).toHaveClass('text-3xl', 'sm:text-4xl', 'font-bold');
|
||||
});
|
||||
|
||||
it('should have proper heading hierarchy for testimonials section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const testimonialsHeading = screen.getByText('What Our Customers Say');
|
||||
expect(testimonialsHeading).toHaveClass('text-3xl', 'sm:text-4xl', 'font-bold');
|
||||
});
|
||||
|
||||
it('should maintain readable text contrast', () => {
|
||||
renderHomePage();
|
||||
|
||||
const heading = screen.getByText('Powerful Features');
|
||||
expect(heading).toHaveClass('text-gray-900', 'dark:text-white');
|
||||
|
||||
const subtitle = screen.getByText('Everything you need to manage your business');
|
||||
expect(subtitle).toHaveClass('text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should use semantic section elements', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Translation Integration', () => {
|
||||
it('should use translations for features section title', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Powerful Features')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for features section subtitle', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Everything you need to manage your business')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for all feature titles', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('Intelligent Scheduling')).toBeInTheDocument();
|
||||
expect(screen.getByText('Automation Engine')).toBeInTheDocument();
|
||||
expect(screen.getByText('Multi-Tenant')).toBeInTheDocument();
|
||||
expect(screen.getByText('Integrated Payments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Customer Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Advanced Analytics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Digital Contracts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for testimonials section', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByText('What Our Customers Say')).toBeInTheDocument();
|
||||
expect(screen.getByText('Join thousands of happy customers')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('should integrate Hero component', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByTestId('hero-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should integrate FeatureCard components', () => {
|
||||
renderHomePage();
|
||||
|
||||
const featureCards = screen.getAllByTestId('feature-card');
|
||||
expect(featureCards).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('should integrate PluginShowcase component', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByTestId('plugin-showcase')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should integrate BenefitsSection component', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByTestId('benefits-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should integrate TestimonialCard components', () => {
|
||||
renderHomePage();
|
||||
|
||||
const testimonialCards = screen.getAllByTestId('testimonial-card');
|
||||
expect(testimonialCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should integrate CTASection component', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByTestId('cta-section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Sections', () => {
|
||||
it('should have distinct background colors for sections', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
|
||||
// Features section - white background
|
||||
const featuresSection = Array.from(sections).find(section =>
|
||||
section.classList.contains('bg-white')
|
||||
);
|
||||
expect(featuresSection).toBeInTheDocument();
|
||||
|
||||
// Testimonials section - gray background
|
||||
const testimonialsSection = Array.from(sections).find(section =>
|
||||
section.classList.contains('bg-gray-50')
|
||||
);
|
||||
expect(testimonialsSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should center content with max-width containers', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const maxWidthContainers = container.querySelectorAll('.max-w-7xl.mx-auto');
|
||||
expect(maxWidthContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply consistent spacing between sections', () => {
|
||||
const { container } = renderHomePage();
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
const hasPaddedSections = Array.from(sections).some(section =>
|
||||
section.classList.contains('py-20') || section.classList.contains('lg:py-28')
|
||||
);
|
||||
expect(hasPaddedSections).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Card Configuration', () => {
|
||||
it('should pass correct props to feature cards', () => {
|
||||
renderHomePage();
|
||||
|
||||
// Check that feature cards receive title and description
|
||||
const featureCards = screen.getAllByTestId('feature-card');
|
||||
|
||||
featureCards.forEach(card => {
|
||||
// Each card should have an h3 (title) and p (description)
|
||||
const title = within(card).getByRole('heading', { level: 3 });
|
||||
const description = within(card).getByText(/.+/);
|
||||
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonial Configuration', () => {
|
||||
it('should render testimonials with all required fields', () => {
|
||||
renderHomePage();
|
||||
|
||||
const testimonialCards = screen.getAllByTestId('testimonial-card');
|
||||
|
||||
testimonialCards.forEach(card => {
|
||||
// Each testimonial should have quote, author, role, company, and rating
|
||||
expect(card.textContent).toMatch(/.+/); // Has content
|
||||
});
|
||||
});
|
||||
|
||||
it('should render testimonials with proper author information', () => {
|
||||
renderHomePage();
|
||||
|
||||
// Check all authors are displayed
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render testimonials with proper company information', () => {
|
||||
renderHomePage();
|
||||
|
||||
// Check all companies are displayed
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tech Solutions')).toBeInTheDocument();
|
||||
expect(screen.getByText('StartupXYZ')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Unit tests for TermsOfServicePage component
|
||||
*
|
||||
* Tests the Terms of Service page including:
|
||||
* - Header rendering with title and last updated date
|
||||
* - All 16 sections are present with correct headings
|
||||
* - Section content rendering
|
||||
* - List items in sections with requirements/prohibitions/terms
|
||||
* - Contact information rendering
|
||||
* - Translation keys usage
|
||||
* - Semantic HTML structure
|
||||
* - Styling and CSS classes
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../../i18n';
|
||||
import TermsOfServicePage from '../TermsOfServicePage';
|
||||
|
||||
// Helper to render with i18n provider
|
||||
const renderWithI18n = (component: React.ReactElement) => {
|
||||
return render(<I18nextProvider i18n={i18n}>{component}</I18nextProvider>);
|
||||
};
|
||||
|
||||
describe('TermsOfServicePage', () => {
|
||||
describe('Header Section', () => {
|
||||
it('should render the main title', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 1, name: /terms of service/i });
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the last updated date', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(screen.getByText(/last updated/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct header styling', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const headerSection = container.querySelector('.py-20');
|
||||
expect(headerSection).toBeInTheDocument();
|
||||
expect(headerSection).toHaveClass('lg:py-28');
|
||||
expect(headerSection).toHaveClass('bg-gradient-to-br');
|
||||
});
|
||||
|
||||
it('should use semantic heading hierarchy', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1.textContent).toContain('Terms of Service');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Sections - All 16 Sections Present', () => {
|
||||
it('should render section 1: Acceptance of Terms', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /1\.\s*acceptance of terms/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/by accessing and using smoothschedule/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 2: Description of Service', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /2\.\s*description of service/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/smoothschedule is a scheduling platform/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 3: User Accounts', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /3\.\s*user accounts/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/to use the service, you must:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 4: Acceptable Use', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /4\.\s*acceptable use/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/you agree not to use the service to:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 5: Subscriptions and Payments', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /5\.\s*subscriptions and payments/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/subscription terms:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 6: Trial Period', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /6\.\s*trial period/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/we may offer a free trial period/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 7: Data and Privacy', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /7\.\s*data and privacy/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/your use of the service is also governed by our privacy policy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 8: Service Availability', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /8\.\s*service availability/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/while we strive for 99\.9% uptime/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 9: Intellectual Property', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /9\.\s*intellectual property/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/the service, including all software, designs/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 10: Termination', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /10\.\s*termination/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/we may terminate or suspend your account/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 11: Limitation of Liability', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /11\.\s*limitation of liability/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/to the maximum extent permitted by law/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 12: Warranty Disclaimer', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /12\.\s*warranty disclaimer/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/the service is provided "as is" and "as available"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 13: Indemnification', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /13\.\s*indemnification/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/you agree to indemnify and hold harmless/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 14: Changes to Terms', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /14\.\s*changes to terms/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/we reserve the right to modify these terms/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 15: Governing Law', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /15\.\s*governing law/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/these terms shall be governed by and construed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render section 16: Contact Us', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /16\.\s*contact us/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(screen.getByText(/if you have any questions about these terms/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Content - User Accounts Requirements', () => {
|
||||
it('should render all four user account requirements', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(screen.getByText(/create an account with accurate and complete information/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/maintain the security of your account credentials/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/notify us immediately of any unauthorized access/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/be responsible for all activities under your account/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render user accounts section with a list', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
const userAccountsList = Array.from(lists).find(list =>
|
||||
list.textContent?.includes('accurate and complete information')
|
||||
);
|
||||
|
||||
expect(userAccountsList).toBeInTheDocument();
|
||||
expect(userAccountsList?.querySelectorAll('li')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Content - Acceptable Use Prohibitions', () => {
|
||||
it('should render all five acceptable use prohibitions', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(screen.getByText(/violate any applicable laws or regulations/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/infringe on intellectual property rights/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/transmit malicious code or interfere with the service/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/attempt to gain unauthorized access/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/use the service for any fraudulent or illegal purpose/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render acceptable use section with a list', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
const acceptableUseList = Array.from(lists).find(list =>
|
||||
list.textContent?.includes('Violate any applicable laws')
|
||||
);
|
||||
|
||||
expect(acceptableUseList).toBeInTheDocument();
|
||||
expect(acceptableUseList?.querySelectorAll('li')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Content - Subscriptions and Payments Terms', () => {
|
||||
it('should render all five subscription payment terms', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(screen.getByText(/subscriptions are billed in advance on a recurring basis/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/you may cancel your subscription at any time/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no refunds are provided for partial subscription periods/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/we reserve the right to change pricing with 30 days notice/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed payments may result in service suspension/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render subscriptions and payments section with a list', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
const subscriptionsList = Array.from(lists).find(list =>
|
||||
list.textContent?.includes('billed in advance')
|
||||
);
|
||||
|
||||
expect(subscriptionsList).toBeInTheDocument();
|
||||
expect(subscriptionsList?.querySelectorAll('li')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contact Information Section', () => {
|
||||
it('should render contact email label and address', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(screen.getByText(/email:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/legal@smoothschedule\.com/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact website label and URL', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(screen.getByText(/website:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/https:\/\/smoothschedule\.com\/contact/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display contact information with bold labels', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const strongElements = container.querySelectorAll('strong');
|
||||
const emailLabel = Array.from(strongElements).find(el => el.textContent === 'Email:');
|
||||
const websiteLabel = Array.from(strongElements).find(el => el.textContent === 'Website:');
|
||||
|
||||
expect(emailLabel).toBeInTheDocument();
|
||||
expect(websiteLabel).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Semantic HTML Structure', () => {
|
||||
it('should use h1 for main title', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const h1Elements = screen.getAllByRole('heading', { level: 1 });
|
||||
expect(h1Elements).toHaveLength(1);
|
||||
expect(h1Elements[0].textContent).toContain('Terms of Service');
|
||||
});
|
||||
|
||||
it('should use h2 for all section headings', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const h2Elements = screen.getAllByRole('heading', { level: 2 });
|
||||
// Should have 16 section headings
|
||||
expect(h2Elements.length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('should use proper list elements for requirements and prohibitions', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const ulElements = container.querySelectorAll('ul');
|
||||
// Should have 3 lists: user accounts, acceptable use, subscriptions
|
||||
expect(ulElements.length).toBe(3);
|
||||
|
||||
ulElements.forEach(ul => {
|
||||
expect(ul).toHaveClass('list-disc');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use section elements for major content areas', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
// Should have at least header and content sections
|
||||
expect(sections.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should use paragraph elements for content text', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
// Should have many paragraphs for all the content
|
||||
expect(paragraphs.length).toBeGreaterThan(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and CSS Classes', () => {
|
||||
it('should apply gradient background to header section', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const headerSection = container.querySelector('section');
|
||||
expect(headerSection).toHaveClass('bg-gradient-to-br');
|
||||
expect(headerSection).toHaveClass('from-white');
|
||||
expect(headerSection).toHaveClass('via-brand-50/30');
|
||||
});
|
||||
|
||||
it('should apply dark mode classes to header section', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const headerSection = container.querySelector('section');
|
||||
expect(headerSection).toHaveClass('dark:from-gray-900');
|
||||
expect(headerSection).toHaveClass('dark:via-gray-800');
|
||||
expect(headerSection).toHaveClass('dark:to-gray-900');
|
||||
});
|
||||
|
||||
it('should apply content section background colors', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const sections = container.querySelectorAll('section');
|
||||
const contentSection = sections[1]; // Second section is content
|
||||
|
||||
expect(contentSection).toHaveClass('bg-white');
|
||||
expect(contentSection).toHaveClass('dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('should apply prose classes to content container', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const proseContainer = container.querySelector('.prose');
|
||||
expect(proseContainer).toBeInTheDocument();
|
||||
expect(proseContainer).toHaveClass('prose-lg');
|
||||
expect(proseContainer).toHaveClass('dark:prose-invert');
|
||||
expect(proseContainer).toHaveClass('max-w-none');
|
||||
});
|
||||
|
||||
it('should apply heading styling classes', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const h2Elements = container.querySelectorAll('h2');
|
||||
h2Elements.forEach(heading => {
|
||||
expect(heading).toHaveClass('text-2xl');
|
||||
expect(heading).toHaveClass('font-bold');
|
||||
expect(heading).toHaveClass('text-gray-900');
|
||||
expect(heading).toHaveClass('dark:text-white');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply paragraph text color classes', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const paragraphs = container.querySelectorAll('.text-gray-600');
|
||||
expect(paragraphs.length).toBeGreaterThan(0);
|
||||
|
||||
paragraphs.forEach(p => {
|
||||
expect(p).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply list styling classes', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
lists.forEach(ul => {
|
||||
expect(ul).toHaveClass('list-disc');
|
||||
expect(ul).toHaveClass('pl-6');
|
||||
expect(ul).toHaveClass('text-gray-600');
|
||||
expect(ul).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply spacing classes to sections', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const h2Elements = container.querySelectorAll('h2');
|
||||
h2Elements.forEach(heading => {
|
||||
expect(heading).toHaveClass('mt-8');
|
||||
expect(heading).toHaveClass('mb-4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for title', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 1 });
|
||||
expect(title).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for subtitle/date', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lastUpdated = container.querySelector('.text-xl');
|
||||
expect(lastUpdated).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for section headings', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const headings = container.querySelectorAll('h2');
|
||||
headings.forEach(heading => {
|
||||
expect(heading).toHaveClass('dark:text-white');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include dark mode classes for content text', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const contentParagraphs = container.querySelectorAll('.text-gray-600');
|
||||
contentParagraphs.forEach(p => {
|
||||
expect(p).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include dark mode classes for lists', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
lists.forEach(ul => {
|
||||
expect(ul).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should apply responsive padding to header section', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const headerSection = container.querySelector('section');
|
||||
expect(headerSection).toHaveClass('py-20');
|
||||
expect(headerSection).toHaveClass('lg:py-28');
|
||||
});
|
||||
|
||||
it('should apply responsive title sizing', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 1 });
|
||||
expect(title).toHaveClass('text-4xl');
|
||||
expect(title).toHaveClass('sm:text-5xl');
|
||||
});
|
||||
|
||||
it('should apply responsive max-width constraints', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const maxWidthContainers = container.querySelectorAll('.max-w-4xl');
|
||||
expect(maxWidthContainers.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should apply responsive padding to containers', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const paddedContainers = container.querySelectorAll('.px-4');
|
||||
expect(paddedContainers.length).toBeGreaterThan(0);
|
||||
|
||||
paddedContainers.forEach(div => {
|
||||
expect(div).toHaveClass('sm:px-6');
|
||||
expect(div).toHaveClass('lg:px-8');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Completeness', () => {
|
||||
it('should render all sections in correct order', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const headings = screen.getAllByRole('heading', { level: 2 });
|
||||
|
||||
// Verify the order by checking for section numbers
|
||||
expect(headings[0].textContent).toMatch(/1\./);
|
||||
expect(headings[1].textContent).toMatch(/2\./);
|
||||
expect(headings[2].textContent).toMatch(/3\./);
|
||||
expect(headings[3].textContent).toMatch(/4\./);
|
||||
expect(headings[4].textContent).toMatch(/5\./);
|
||||
});
|
||||
|
||||
it('should have substantial content in each section', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
// Check that there are multiple paragraphs with substantial text
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
const substantialParagraphs = Array.from(paragraphs).filter(
|
||||
p => (p.textContent?.length ?? 0) > 50
|
||||
);
|
||||
|
||||
expect(substantialParagraphs.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('should render page without errors', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.querySelector('section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
// Should have exactly one h1
|
||||
const h1Elements = screen.getAllByRole('heading', { level: 1 });
|
||||
expect(h1Elements).toHaveLength(1);
|
||||
|
||||
// Should have multiple h2 elements for sections
|
||||
const h2Elements = screen.getAllByRole('heading', { level: 2 });
|
||||
expect(h2Elements.length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('should maintain readable text contrast in light mode', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const title = container.querySelector('h1');
|
||||
expect(title).toHaveClass('text-gray-900');
|
||||
|
||||
const paragraphs = container.querySelectorAll('p.text-gray-600');
|
||||
expect(paragraphs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use semantic list markup', () => {
|
||||
const { container } = renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
lists.forEach(ul => {
|
||||
const listItems = ul.querySelectorAll('li');
|
||||
expect(listItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have any empty headings', () => {
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
const allHeadings = screen.getAllByRole('heading');
|
||||
allHeadings.forEach(heading => {
|
||||
expect(heading.textContent).toBeTruthy();
|
||||
expect(heading.textContent?.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Translation Integration', () => {
|
||||
it('should use translation keys for all content', () => {
|
||||
// This is verified by the fact that content renders correctly through i18n
|
||||
renderWithI18n(<TermsOfServicePage />);
|
||||
|
||||
// Main title should be translated
|
||||
expect(screen.getByRole('heading', { name: /terms of service/i })).toBeInTheDocument();
|
||||
|
||||
// All 16 sections should be present (implies translations are working)
|
||||
const h2Elements = screen.getAllByRole('heading', { level: 2 });
|
||||
expect(h2Elements.length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('should render without i18n errors', () => {
|
||||
// Should not throw when rendering with i18n
|
||||
expect(() => renderWithI18n(<TermsOfServicePage />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user