Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
140
frontend/src/pages/marketing/AboutPage.tsx
Normal file
140
frontend/src/pages/marketing/AboutPage.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Lightbulb, Shield, Eye, HeartHandshake } from 'lucide-react';
|
||||
import CTASection from '../../components/marketing/CTASection';
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: Lightbulb,
|
||||
titleKey: 'marketing.about.values.simplicity.title',
|
||||
descriptionKey: 'marketing.about.values.simplicity.description',
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
titleKey: 'marketing.about.values.reliability.title',
|
||||
descriptionKey: 'marketing.about.values.reliability.description',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
titleKey: 'marketing.about.values.transparency.title',
|
||||
descriptionKey: 'marketing.about.values.transparency.description',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: HeartHandshake,
|
||||
titleKey: 'marketing.about.values.support.title',
|
||||
descriptionKey: 'marketing.about.values.support.description',
|
||||
color: 'orange',
|
||||
},
|
||||
];
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
brand: 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400',
|
||||
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Section */}
|
||||
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.about.title')}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.about.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Story Section */}
|
||||
<section className="py-20 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.about.story.title')}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-6">
|
||||
{t('marketing.about.story.content')}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{t('marketing.about.story.content2')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-brand-500 to-brand-600 rounded-2xl p-8 text-white">
|
||||
<div className="text-6xl font-bold mb-2">2017</div>
|
||||
<div className="text-brand-100">{t('marketing.about.story.founded')}</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-brand-200 rounded-full" />
|
||||
<span className="text-brand-100">8+ years building scheduling solutions</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-brand-200 rounded-full" />
|
||||
<span className="text-brand-100">Battle-tested with real businesses</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-brand-200 rounded-full" />
|
||||
<span className="text-brand-100">Features born from customer feedback</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-brand-200 rounded-full" />
|
||||
<span className="text-brand-100">Now available to everyone</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<section className="py-20 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.about.mission.title')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400 leading-relaxed max-w-3xl mx-auto">
|
||||
{t('marketing.about.mission.content')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
<section className="py-20 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-12 text-center">
|
||||
{t('marketing.about.values.title')}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{values.map((value) => (
|
||||
<div key={value.titleKey} className="text-center">
|
||||
<div className={`inline-flex p-4 rounded-2xl ${colorClasses[value.color]} mb-4`}>
|
||||
<value.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t(value.titleKey)}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t(value.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<CTASection variant="minimal" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
261
frontend/src/pages/marketing/ContactPage.tsx
Normal file
261
frontend/src/pages/marketing/ContactPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mail, Phone, MapPin, Send, MessageSquare } from 'lucide-react';
|
||||
|
||||
const ContactPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [formState, setFormState] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate form submission
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
setIsSubmitting(false);
|
||||
setSubmitted(true);
|
||||
setFormState({ name: '', email: '', subject: '', message: '' });
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
label: 'Email',
|
||||
value: t('marketing.contact.info.email'),
|
||||
href: `mailto:${t('marketing.contact.info.email')}`,
|
||||
},
|
||||
{
|
||||
icon: Phone,
|
||||
label: 'Phone',
|
||||
value: t('marketing.contact.info.phone'),
|
||||
href: `tel:${t('marketing.contact.info.phone').replace(/[^0-9+]/g, '')}`,
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
label: 'Address',
|
||||
value: t('marketing.contact.info.address'),
|
||||
href: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Section */}
|
||||
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.contact.title')}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.contact.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Content */}
|
||||
<section className="py-20 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16">
|
||||
{/* Contact Form */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
Send us a message
|
||||
</h2>
|
||||
|
||||
{submitted ? (
|
||||
<div className="p-8 bg-green-50 dark:bg-green-900/20 rounded-2xl border border-green-200 dark:border-green-800 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Send className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">
|
||||
Message Sent!
|
||||
</h3>
|
||||
<p className="text-green-600 dark:text-green-400">
|
||||
{t('marketing.contact.form.success')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSubmitted(false)}
|
||||
className="mt-4 text-sm text-green-700 dark:text-green-300 underline"
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.contact.form.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formState.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder={t('marketing.contact.form.namePlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.contact.form.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder={t('marketing.contact.form.emailPlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.contact.form.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
value={formState.subject}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder={t('marketing.contact.form.subjectPlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.contact.form.message')}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formState.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={5}
|
||||
placeholder={t('marketing.contact.form.messagePlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3.5 px-6 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('marketing.contact.form.sending')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-5 w-5" />
|
||||
{t('marketing.contact.form.submit')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
Get in touch
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-12">
|
||||
{contactInfo.map((item) => (
|
||||
<div key={item.label} className="flex items-start gap-4">
|
||||
<div className="p-3 bg-brand-50 dark:bg-brand-900/30 rounded-xl">
|
||||
<item.icon className="h-6 w-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
{item.label}
|
||||
</div>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
className="text-gray-900 dark:text-white hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-900 dark:text-white">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sales CTA */}
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-xl">
|
||||
<MessageSquare className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('marketing.contact.sales.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('marketing.contact.sales.description')}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:sales@smoothschedule.com"
|
||||
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
|
||||
>
|
||||
Schedule a call
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactPage;
|
||||
173
frontend/src/pages/marketing/FeaturesPage.tsx
Normal file
173
frontend/src/pages/marketing/FeaturesPage.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
CreditCard,
|
||||
Building2,
|
||||
Palette,
|
||||
BarChart3,
|
||||
Plug,
|
||||
UserCircle,
|
||||
Bell,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import FeatureCard from '../../components/marketing/FeatureCard';
|
||||
import CTASection from '../../components/marketing/CTASection';
|
||||
|
||||
const FeaturesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const featureCategories = [
|
||||
{
|
||||
title: 'Scheduling & Calendar',
|
||||
features: [
|
||||
{
|
||||
icon: Calendar,
|
||||
titleKey: 'marketing.features.scheduling.title',
|
||||
descriptionKey: 'marketing.features.scheduling.description',
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Real-Time Availability',
|
||||
description: 'Customers see only available time slots. No double bookings, ever.',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: 'Automated Reminders',
|
||||
description: 'Reduce no-shows with email and SMS reminders sent automatically.',
|
||||
color: 'purple',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Resource Management',
|
||||
features: [
|
||||
{
|
||||
icon: Users,
|
||||
titleKey: 'marketing.features.resources.title',
|
||||
descriptionKey: 'marketing.features.resources.description',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
icon: Smartphone,
|
||||
title: 'Staff Mobile App',
|
||||
description: 'Your team can view schedules and manage appointments on the go.',
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Role-Based Access',
|
||||
description: 'Control what each team member can see and do with granular permissions.',
|
||||
color: 'cyan',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Customer Experience',
|
||||
features: [
|
||||
{
|
||||
icon: UserCircle,
|
||||
titleKey: 'marketing.features.customers.title',
|
||||
descriptionKey: 'marketing.features.customers.description',
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
titleKey: 'marketing.features.payments.title',
|
||||
descriptionKey: 'marketing.features.payments.description',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
titleKey: 'marketing.features.whiteLabel.title',
|
||||
descriptionKey: 'marketing.features.whiteLabel.description',
|
||||
color: 'purple',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Business Growth',
|
||||
features: [
|
||||
{
|
||||
icon: Building2,
|
||||
titleKey: 'marketing.features.multiTenant.title',
|
||||
descriptionKey: 'marketing.features.multiTenant.description',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
titleKey: 'marketing.features.analytics.title',
|
||||
descriptionKey: 'marketing.features.analytics.description',
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
icon: Plug,
|
||||
titleKey: 'marketing.features.integrations.title',
|
||||
descriptionKey: 'marketing.features.integrations.description',
|
||||
color: 'cyan',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Section */}
|
||||
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.features.title')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{t('marketing.features.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature Categories */}
|
||||
{featureCategories.map((category, categoryIndex) => (
|
||||
<section
|
||||
key={categoryIndex}
|
||||
className={`py-20 ${
|
||||
categoryIndex % 2 === 0
|
||||
? 'bg-white dark:bg-gray-900'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mb-10 text-center">
|
||||
{category.title}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
{category.features.map((feature, featureIndex) => (
|
||||
<FeatureCard
|
||||
key={featureIndex}
|
||||
icon={feature.icon}
|
||||
title={feature.titleKey ? t(feature.titleKey) : feature.title || ''}
|
||||
description={
|
||||
feature.descriptionKey
|
||||
? t(feature.descriptionKey)
|
||||
: feature.description || ''
|
||||
}
|
||||
iconColor={feature.color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* CTA Section */}
|
||||
<CTASection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesPage;
|
||||
258
frontend/src/pages/marketing/HomePage.tsx
Normal file
258
frontend/src/pages/marketing/HomePage.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
CreditCard,
|
||||
Building2,
|
||||
Palette,
|
||||
BarChart3,
|
||||
Plug,
|
||||
UserCircle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import Hero from '../../components/marketing/Hero';
|
||||
import FeatureCard from '../../components/marketing/FeatureCard';
|
||||
import HowItWorks from '../../components/marketing/HowItWorks';
|
||||
import StatsSection from '../../components/marketing/StatsSection';
|
||||
import TestimonialCard from '../../components/marketing/TestimonialCard';
|
||||
import CTASection from '../../components/marketing/CTASection';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Calendar,
|
||||
titleKey: 'marketing.features.scheduling.title',
|
||||
descriptionKey: 'marketing.features.scheduling.description',
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
titleKey: 'marketing.features.resources.title',
|
||||
descriptionKey: 'marketing.features.resources.description',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: UserCircle,
|
||||
titleKey: 'marketing.features.customers.title',
|
||||
descriptionKey: 'marketing.features.customers.description',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
titleKey: 'marketing.features.payments.title',
|
||||
descriptionKey: 'marketing.features.payments.description',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
icon: Building2,
|
||||
titleKey: 'marketing.features.multiTenant.title',
|
||||
descriptionKey: 'marketing.features.multiTenant.description',
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
titleKey: 'marketing.features.whiteLabel.title',
|
||||
descriptionKey: 'marketing.features.whiteLabel.description',
|
||||
color: 'cyan',
|
||||
},
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote: "SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.",
|
||||
author: "Sarah Johnson",
|
||||
role: "Owner",
|
||||
company: "Luxe Salon",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
quote: "The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.",
|
||||
author: "Michael Chen",
|
||||
role: "CEO",
|
||||
company: "FitLife Studios",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
quote: "Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.",
|
||||
author: "Emily Rodriguez",
|
||||
role: "Manager",
|
||||
company: "Peak Performance Therapy",
|
||||
rating: 5,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Section */}
|
||||
<Hero />
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 lg:py-28 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.features.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{t('marketing.features.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.titleKey}
|
||||
icon={feature.icon}
|
||||
title={t(feature.titleKey)}
|
||||
description={t(feature.descriptionKey)}
|
||||
iconColor={feature.color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View All Features Link */}
|
||||
<div className="text-center mt-12">
|
||||
<Link
|
||||
to="/features"
|
||||
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
|
||||
>
|
||||
{t('common.viewAll')} {t('marketing.nav.features').toLowerCase()}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<HowItWorks />
|
||||
|
||||
{/* Stats Section */}
|
||||
<StatsSection />
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.testimonials.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.testimonials.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Testimonials Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<TestimonialCard key={index} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Preview Section */}
|
||||
<section className="py-20 lg:py-28 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.pricing.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.pricing.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Cards Preview */}
|
||||
<div className="grid md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto">
|
||||
{/* Free */}
|
||||
<div className="p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('marketing.pricing.tiers.free.name')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('marketing.pricing.tiers.free.description')}
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold text-gray-900 dark:text-white">$0</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('marketing.pricing.perMonth')}</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="block w-full py-3 px-4 text-center text-sm font-medium text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.getStarted')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Professional - Highlighted */}
|
||||
<div className="relative p-6 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20">
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-brand-500 text-white text-xs font-semibold rounded-full">
|
||||
{t('marketing.pricing.mostPopular')}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{t('marketing.pricing.tiers.professional.name')}
|
||||
</h3>
|
||||
<p className="text-sm text-brand-100 mb-4">
|
||||
{t('marketing.pricing.tiers.professional.description')}
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold text-white">$29</span>
|
||||
<span className="text-brand-200">{t('marketing.pricing.perMonth')}</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="block w-full py-3 px-4 text-center text-sm font-medium text-brand-600 bg-white rounded-lg hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.getStarted')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Business */}
|
||||
<div className="p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('marketing.pricing.tiers.business.name')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('marketing.pricing.tiers.business.description')}
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold text-gray-900 dark:text-white">$79</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('marketing.pricing.perMonth')}</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="block w-full py-3 px-4 text-center text-sm font-medium text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-lg hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.getStarted')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Full Pricing Link */}
|
||||
<div className="text-center mt-10">
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
|
||||
>
|
||||
View full pricing details
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<CTASection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
124
frontend/src/pages/marketing/PricingPage.tsx
Normal file
124
frontend/src/pages/marketing/PricingPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PricingCard from '../../components/marketing/PricingCard';
|
||||
import FAQAccordion from '../../components/marketing/FAQAccordion';
|
||||
import CTASection from '../../components/marketing/CTASection';
|
||||
|
||||
const PricingPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: t('marketing.faq.questions.freePlan.question'),
|
||||
answer: t('marketing.faq.questions.freePlan.answer'),
|
||||
},
|
||||
{
|
||||
question: t('marketing.faq.questions.cancel.question'),
|
||||
answer: t('marketing.faq.questions.cancel.answer'),
|
||||
},
|
||||
{
|
||||
question: t('marketing.faq.questions.payment.question'),
|
||||
answer: t('marketing.faq.questions.payment.answer'),
|
||||
},
|
||||
{
|
||||
question: t('marketing.faq.questions.migrate.question'),
|
||||
answer: t('marketing.faq.questions.migrate.answer'),
|
||||
},
|
||||
{
|
||||
question: t('marketing.faq.questions.support.question'),
|
||||
answer: t('marketing.faq.questions.support.answer'),
|
||||
},
|
||||
{
|
||||
question: t('marketing.faq.questions.customDomain.question'),
|
||||
answer: t('marketing.faq.questions.customDomain.answer'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Section */}
|
||||
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.pricing.title')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{t('marketing.pricing.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mb-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
billingPeriod === 'monthly'
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t('marketing.pricing.monthly')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setBillingPeriod(billingPeriod === 'monthly' ? 'annual' : 'monthly')}
|
||||
className="relative flex-shrink-0 w-12 h-6 bg-brand-600 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label="Toggle billing period"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${
|
||||
billingPeriod === 'annual' ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
billingPeriod === 'annual'
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t('marketing.pricing.annual')}
|
||||
</span>
|
||||
</div>
|
||||
{billingPeriod === 'annual' && (
|
||||
<span className="px-2 py-1 text-xs font-semibold text-brand-700 bg-brand-100 dark:bg-brand-900/30 dark:text-brand-300 rounded-full whitespace-nowrap">
|
||||
{t('marketing.pricing.annualSave')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
<PricingCard tier="free" billingPeriod={billingPeriod} />
|
||||
<PricingCard tier="professional" billingPeriod={billingPeriod} highlighted />
|
||||
<PricingCard tier="business" billingPeriod={billingPeriod} />
|
||||
<PricingCard tier="enterprise" billingPeriod={billingPeriod} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.faq.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.faq.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FAQAccordion items={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<CTASection variant="minimal" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
970
frontend/src/pages/marketing/SignupPage.tsx
Normal file
970
frontend/src/pages/marketing/SignupPage.tsx
Normal file
@@ -0,0 +1,970 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Building2,
|
||||
User,
|
||||
CreditCard,
|
||||
CheckCircle,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
interface SignupFormData {
|
||||
// Step 1: Business info
|
||||
businessName: string;
|
||||
subdomain: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
// Step 2: User info
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
// Step 3: Plan selection
|
||||
plan: 'free' | 'professional' | 'business' | 'enterprise';
|
||||
}
|
||||
|
||||
const SignupPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const firstNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus on firstName when entering step 2
|
||||
useEffect(() => {
|
||||
if (currentStep === 2 && firstNameRef.current) {
|
||||
firstNameRef.current.focus();
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const [formData, setFormData] = useState<SignupFormData>({
|
||||
businessName: '',
|
||||
subdomain: '',
|
||||
addressLine1: '',
|
||||
addressLine2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: 'US',
|
||||
phone: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
plan: (searchParams.get('plan') as SignupFormData['plan']) || 'professional',
|
||||
});
|
||||
|
||||
// Total steps: Business Info, User Info, Plan Selection, Confirmation
|
||||
const totalSteps = 4;
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof SignupFormData, string>>>({});
|
||||
const [subdomainAvailable, setSubdomainAvailable] = useState<boolean | null>(null);
|
||||
const [checkingSubdomain, setCheckingSubdomain] = useState(false);
|
||||
const [subdomainManuallyEdited, setSubdomainManuallyEdited] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [signupComplete, setSignupComplete] = useState(false);
|
||||
|
||||
// Signup steps
|
||||
const steps = [
|
||||
{ number: 1, title: t('marketing.signup.steps.business'), icon: Building2 },
|
||||
{ number: 2, title: t('marketing.signup.steps.account'), icon: User },
|
||||
{ number: 3, title: t('marketing.signup.steps.plan'), icon: CreditCard },
|
||||
{ number: 4, title: t('marketing.signup.steps.confirm'), icon: CheckCircle },
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free' as const,
|
||||
name: t('marketing.pricing.tiers.free.name'),
|
||||
price: '$0',
|
||||
period: t('marketing.pricing.period'),
|
||||
features: [
|
||||
t('marketing.pricing.tiers.free.features.0'),
|
||||
t('marketing.pricing.tiers.free.features.1'),
|
||||
t('marketing.pricing.tiers.free.features.2'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'professional' as const,
|
||||
name: t('marketing.pricing.tiers.professional.name'),
|
||||
price: '$29',
|
||||
period: t('marketing.pricing.period'),
|
||||
popular: true,
|
||||
features: [
|
||||
t('marketing.pricing.tiers.professional.features.0'),
|
||||
t('marketing.pricing.tiers.professional.features.1'),
|
||||
t('marketing.pricing.tiers.professional.features.2'),
|
||||
t('marketing.pricing.tiers.professional.features.3'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'business' as const,
|
||||
name: t('marketing.pricing.tiers.business.name'),
|
||||
price: '$79',
|
||||
period: t('marketing.pricing.period'),
|
||||
features: [
|
||||
t('marketing.pricing.tiers.business.features.0'),
|
||||
t('marketing.pricing.tiers.business.features.1'),
|
||||
t('marketing.pricing.tiers.business.features.2'),
|
||||
t('marketing.pricing.tiers.business.features.3'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise' as const,
|
||||
name: t('marketing.pricing.tiers.enterprise.name'),
|
||||
price: t('marketing.pricing.tiers.enterprise.price'),
|
||||
period: '',
|
||||
features: [
|
||||
t('marketing.pricing.tiers.enterprise.features.0'),
|
||||
t('marketing.pricing.tiers.enterprise.features.1'),
|
||||
t('marketing.pricing.tiers.enterprise.features.2'),
|
||||
t('marketing.pricing.tiers.enterprise.features.3'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Generate subdomain from business name (only if user hasn't manually edited it)
|
||||
useEffect(() => {
|
||||
if (formData.businessName && !subdomainManuallyEdited) {
|
||||
const generated = formData.businessName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 30);
|
||||
setFormData((prev) => ({ ...prev, subdomain: generated }));
|
||||
}
|
||||
}, [formData.businessName, subdomainManuallyEdited]);
|
||||
|
||||
// Check subdomain availability with debounce
|
||||
useEffect(() => {
|
||||
if (!formData.subdomain || formData.subdomain.length < 3) {
|
||||
setSubdomainAvailable(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setCheckingSubdomain(true);
|
||||
try {
|
||||
const response = await apiClient.post('/api/auth/signup/check-subdomain/', {
|
||||
subdomain: formData.subdomain,
|
||||
});
|
||||
setSubdomainAvailable(response.data.available);
|
||||
} catch {
|
||||
setSubdomainAvailable(null);
|
||||
} finally {
|
||||
setCheckingSubdomain(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [formData.subdomain]);
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
setErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubdomainChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
setFormData((prev) => ({ ...prev, subdomain: value }));
|
||||
setErrors((prev) => ({ ...prev, subdomain: undefined }));
|
||||
setSubdomainAvailable(null);
|
||||
setSubdomainManuallyEdited(true);
|
||||
};
|
||||
|
||||
const validateStep = (step: number): boolean => {
|
||||
const newErrors: Partial<Record<keyof SignupFormData, string>> = {};
|
||||
|
||||
if (step === 1) {
|
||||
if (!formData.businessName.trim()) {
|
||||
newErrors.businessName = t('marketing.signup.errors.businessNameRequired');
|
||||
}
|
||||
if (!formData.subdomain.trim()) {
|
||||
newErrors.subdomain = t('marketing.signup.errors.subdomainRequired');
|
||||
} else if (formData.subdomain.length < 3) {
|
||||
newErrors.subdomain = t('marketing.signup.errors.subdomainTooShort');
|
||||
} else if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(formData.subdomain) && formData.subdomain.length > 2) {
|
||||
newErrors.subdomain = t('marketing.signup.errors.subdomainInvalid');
|
||||
} else if (subdomainAvailable === false) {
|
||||
newErrors.subdomain = t('marketing.signup.errors.subdomainTaken');
|
||||
}
|
||||
// Address validation
|
||||
if (!formData.addressLine1.trim()) {
|
||||
newErrors.addressLine1 = t('marketing.signup.errors.addressRequired');
|
||||
}
|
||||
if (!formData.city.trim()) {
|
||||
newErrors.city = t('marketing.signup.errors.cityRequired');
|
||||
}
|
||||
if (!formData.state.trim()) {
|
||||
newErrors.state = t('marketing.signup.errors.stateRequired');
|
||||
}
|
||||
if (!formData.postalCode.trim()) {
|
||||
newErrors.postalCode = t('marketing.signup.errors.postalCodeRequired');
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
if (!formData.firstName.trim()) {
|
||||
newErrors.firstName = t('marketing.signup.errors.firstNameRequired');
|
||||
}
|
||||
if (!formData.lastName.trim()) {
|
||||
newErrors.lastName = t('marketing.signup.errors.lastNameRequired');
|
||||
}
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = t('marketing.signup.errors.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = t('marketing.signup.errors.emailInvalid');
|
||||
}
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('marketing.signup.errors.passwordRequired');
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = t('marketing.signup.errors.passwordTooShort');
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = t('marketing.signup.errors.passwordMismatch');
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (validateStep(currentStep)) {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, totalSteps));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// Determine if current step is the confirmation step (last step)
|
||||
const isConfirmationStep = currentStep === totalSteps;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateStep(currentStep)) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/auth/signup/', {
|
||||
business_name: formData.businessName,
|
||||
subdomain: formData.subdomain,
|
||||
address_line1: formData.addressLine1,
|
||||
address_line2: formData.addressLine2,
|
||||
city: formData.city,
|
||||
state: formData.state,
|
||||
postal_code: formData.postalCode,
|
||||
country: formData.country,
|
||||
phone: formData.phone,
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
tier: formData.plan.toUpperCase(),
|
||||
payments_enabled: false,
|
||||
});
|
||||
|
||||
setSignupComplete(true);
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
t('marketing.signup.errors.generic');
|
||||
setSubmitError(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (signupComplete) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-20">
|
||||
<div className="max-w-lg mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.signup.success.title')}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{t('marketing.signup.success.message')}
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('marketing.signup.success.yourUrl')}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-brand-600 dark:text-brand-400">
|
||||
{formData.subdomain}.smoothschedule.com
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t('marketing.signup.success.checkEmail')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${formData.subdomain}.lvh.me${port}/login`;
|
||||
}}
|
||||
className="w-full py-3 px-6 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('marketing.signup.success.goToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 lg:py-20">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t('marketing.signup.title')}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.signup.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.number}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
||||
currentStep > step.number
|
||||
? 'bg-green-500 text-white'
|
||||
: currentStep === step.number
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{currentStep > step.number ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : (
|
||||
<step.icon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-2 text-xs font-medium hidden sm:block ${
|
||||
currentStep >= step.number
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded ${
|
||||
currentStep > step.number
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 sm:p-8">
|
||||
{/* Step 1: Business Info */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.signup.businessInfo.title')}
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="businessName"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="businessName"
|
||||
name="businessName"
|
||||
value={formData.businessName}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="organization"
|
||||
placeholder={t('marketing.signup.businessInfo.namePlaceholder')}
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.businessName
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.businessName && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.businessName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subdomain"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.subdomain')}
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
id="subdomain"
|
||||
name="subdomain"
|
||||
value={formData.subdomain}
|
||||
onChange={handleSubdomainChange}
|
||||
autoComplete="off"
|
||||
placeholder="your-business"
|
||||
className={`flex-1 px-4 py-3 rounded-l-xl border-y border-l ${
|
||||
errors.subdomain
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: subdomainAvailable === true
|
||||
? 'border-green-500 focus:ring-green-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
<span className="px-4 py-3 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-r-xl text-gray-500 dark:text-gray-400 text-sm">
|
||||
.smoothschedule.com
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{checkingSubdomain && (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('marketing.signup.businessInfo.checking')}
|
||||
</span>
|
||||
)}
|
||||
{!checkingSubdomain && subdomainAvailable === true && (
|
||||
<span className="text-sm text-green-500 flex items-center gap-1">
|
||||
<Check className="w-4 h-4" />
|
||||
{t('marketing.signup.businessInfo.available')}
|
||||
</span>
|
||||
)}
|
||||
{!checkingSubdomain && subdomainAvailable === false && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{t('marketing.signup.businessInfo.taken')}
|
||||
</span>
|
||||
)}
|
||||
{errors.subdomain && !subdomainAvailable && (
|
||||
<span className="text-sm text-red-500">{errors.subdomain}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('marketing.signup.businessInfo.subdomainNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Business Address */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.signup.businessInfo.address')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="addressLine1"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.addressLine1')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addressLine1"
|
||||
name="addressLine1"
|
||||
value={formData.addressLine1}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="address-line1"
|
||||
placeholder={t('marketing.signup.businessInfo.addressLine1Placeholder')}
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.addressLine1
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.addressLine1 && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.addressLine1}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="addressLine2"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.addressLine2')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addressLine2"
|
||||
name="addressLine2"
|
||||
value={formData.addressLine2}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="address-line2"
|
||||
placeholder={t('marketing.signup.businessInfo.addressLine2Placeholder')}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="city"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.city')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="address-level2"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.city
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.city && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.city}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="state"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.state')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
value={formData.state}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="address-level1"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.state
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.state && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.state}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="postalCode"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.postalCode')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
value={formData.postalCode}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="postal-code"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.postalCode
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.postalCode && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.postalCode}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.businessInfo.phone')}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="tel"
|
||||
placeholder={t('marketing.signup.businessInfo.phonePlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 focus:ring-brand-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: User Info */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.signup.accountInfo.title')}
|
||||
</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="firstName"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.accountInfo.firstName')}
|
||||
</label>
|
||||
<input
|
||||
ref={firstNameRef}
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="given-name"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.firstName
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="lastName"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.accountInfo.lastName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="family-name"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.lastName
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.accountInfo.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.email
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.accountInfo.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.password
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{t('marketing.signup.accountInfo.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
autoComplete="new-password"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${
|
||||
errors.confirmPassword
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
|
||||
} bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Plan Selection */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.signup.planSelection.title')}
|
||||
</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{plans.map((plan) => (
|
||||
<button
|
||||
key={plan.id}
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, plan: plan.id }))}
|
||||
className={`relative text-left p-4 rounded-xl border-2 transition-all ${
|
||||
formData.plan === plan.id
|
||||
? 'border-brand-600 bg-brand-50 dark:bg-gray-800 dark:border-brand-500'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<span className="absolute -top-3 left-4 px-2 py-0.5 text-xs font-semibold text-white bg-brand-600 rounded-full">
|
||||
{t('marketing.pricing.popular')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{plan.price}
|
||||
{plan.period && (
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
/{plan.period}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
formData.plan === plan.id
|
||||
? 'border-brand-600 bg-brand-600'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{formData.plan === plan.id && (
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{plan.features.slice(0, 3).map((feature, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
|
||||
>
|
||||
<Check className="w-3 h-3 text-green-500 flex-shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Step (last step) */}
|
||||
{isConfirmationStep && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('marketing.signup.confirm.title')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('marketing.signup.confirm.business')}
|
||||
</h3>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{formData.businessName}
|
||||
</p>
|
||||
<p className="text-sm text-brand-600 dark:text-brand-400">
|
||||
{formData.subdomain}.smoothschedule.com
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('marketing.signup.confirm.account')}
|
||||
</h3>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{formData.firstName} {formData.lastName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formData.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('marketing.signup.confirm.plan')}
|
||||
</h3>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{plans.find((p) => p.id === formData.plan)?.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{plans.find((p) => p.id === formData.plan)?.price}
|
||||
{plans.find((p) => p.id === formData.plan)?.period &&
|
||||
`/${plans.find((p) => p.id === formData.plan)?.period}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
{t('marketing.signup.confirm.terms')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
{currentStep > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('marketing.signup.back')}
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{!isConfirmationStep ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
{t('marketing.signup.next')}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('marketing.signup.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{t('marketing.signup.createAccount')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Link */}
|
||||
<p className="text-center mt-6 text-gray-600 dark:text-gray-400">
|
||||
{t('marketing.signup.haveAccount')}{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline font-medium"
|
||||
>
|
||||
{t('marketing.signup.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupPage;
|
||||
Reference in New Issue
Block a user