feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens
Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
211
frontend/src/pages/pricing/PricingPage.tsx
Normal file
211
frontend/src/pages/pricing/PricingPage.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
|
||||
Button, List, ThemeIcon, TextInput, Center, Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
description: 'For small communities getting started',
|
||||
icon: IconRocket,
|
||||
color: 'blue',
|
||||
features: [
|
||||
{ text: 'Up to 50 units', included: true },
|
||||
{ text: 'Chart of Accounts', included: true },
|
||||
{ text: 'Assessment Tracking', included: true },
|
||||
{ text: 'Basic Reports', included: true },
|
||||
{ text: 'Board Planning', included: false },
|
||||
{ text: 'AI Investment Advisor', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
name: 'Professional',
|
||||
price: '$79',
|
||||
period: '/month',
|
||||
description: 'For growing HOAs that need full features',
|
||||
icon: IconStar,
|
||||
color: 'violet',
|
||||
popular: true,
|
||||
features: [
|
||||
{ text: 'Up to 200 units', included: true },
|
||||
{ text: 'Everything in Starter', included: true },
|
||||
{ text: 'Board Planning & Scenarios', included: true },
|
||||
{ text: 'AI Investment Advisor', included: true },
|
||||
{ text: 'Advanced Reports', included: true },
|
||||
{ text: 'Priority Support', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: '$199',
|
||||
period: '/month',
|
||||
description: 'For large communities and management firms',
|
||||
icon: IconCrown,
|
||||
color: 'orange',
|
||||
features: [
|
||||
{ text: 'Unlimited units', included: true },
|
||||
{ text: 'Everything in Professional', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Custom Integrations', included: true },
|
||||
{ text: 'Dedicated Account Manager', included: true },
|
||||
{ text: 'SLA Guarantee', included: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function PricingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [businessName, setBusinessName] = useState('');
|
||||
|
||||
const handleSelectPlan = async (planId: string) => {
|
||||
setLoading(planId);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.post('/billing/create-checkout-session', {
|
||||
planId,
|
||||
email: email || undefined,
|
||||
businessName: businessName || undefined,
|
||||
});
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
setError('Unable to create checkout session');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to start checkout');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py={60}>
|
||||
<Stack align="center" mb={40}>
|
||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
||||
<Title order={1} ta="center">
|
||||
Simple, transparent pricing
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed" ta="center" maw={500}>
|
||||
Choose the plan that fits your community. All plans include a 14-day free trial.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Optional pre-capture fields */}
|
||||
<Center mb="xl">
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="HOA / Business name"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.currentTarget.value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Group>
|
||||
</Center>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="lg" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
withBorder
|
||||
shadow={plan.popular ? 'lg' : 'sm'}
|
||||
radius="md"
|
||||
p="xl"
|
||||
style={plan.popular ? {
|
||||
border: '2px solid var(--mantine-color-violet-5)',
|
||||
position: 'relative',
|
||||
} : undefined}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="violet"
|
||||
variant="filled"
|
||||
style={{ position: 'absolute', top: -10, right: 20 }}
|
||||
>
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
|
||||
<plan.icon size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} size="lg">{plan.name}</Text>
|
||||
<Text size="xs" c="dimmed">{plan.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group align="baseline" gap={4}>
|
||||
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
|
||||
{plan.price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">{plan.period}</Text>
|
||||
</Group>
|
||||
|
||||
<List spacing="xs" size="sm" center>
|
||||
{plan.features.map((f, i) => (
|
||||
<List.Item
|
||||
key={i}
|
||||
icon={
|
||||
<ThemeIcon
|
||||
size={20}
|
||||
radius="xl"
|
||||
color={f.included ? 'teal' : 'gray'}
|
||||
variant={f.included ? 'filled' : 'light'}
|
||||
>
|
||||
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
color={plan.color}
|
||||
variant={plan.popular ? 'filled' : 'light'}
|
||||
loading={loading === plan.id}
|
||||
onClick={() => handleSelectPlan(plan.id)}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed" mt="xl">
|
||||
All plans include a 14-day free trial. No credit card required to start.
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user