- Add monthly/annual billing toggle with 25% annual discount on pricing page - Implement 14-day no-card free trial (server-side Stripe subscription creation) - Enable upgrade/downgrade via Stripe Customer Portal - Add admin-initiated ACH/invoice billing for enterprise customers - Add billing card to Settings page with plan info and Manage Billing button - Handle past_due status with read-only grace period access - Add trial ending and trial expired email templates - Add DB migration for billing_interval and collection_method columns - Update ONBOARDING-AND-AUTH.md documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
9.3 KiB
TypeScript
285 lines
9.3 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
|
|
Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box,
|
|
} 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';
|
|
|
|
type BillingInterval = 'month' | 'year';
|
|
|
|
const plans = [
|
|
{
|
|
id: 'starter',
|
|
name: 'Starter',
|
|
monthlyPrice: 29,
|
|
annualPrice: 261, // 29 * 12 * 0.75
|
|
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',
|
|
monthlyPrice: 79,
|
|
annualPrice: 711, // 79 * 12 * 0.75
|
|
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',
|
|
monthlyPrice: 0,
|
|
annualPrice: 0,
|
|
description: 'For large communities and management firms',
|
|
icon: IconCrown,
|
|
color: 'orange',
|
|
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
|
|
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 },
|
|
],
|
|
},
|
|
];
|
|
|
|
function formatPrice(plan: typeof plans[0], interval: BillingInterval) {
|
|
if (plan.externalUrl) return { display: 'Custom', sub: '' };
|
|
if (interval === 'year') {
|
|
const monthly = (plan.annualPrice / 12).toFixed(2);
|
|
return {
|
|
display: `$${monthly}`,
|
|
sub: `/mo billed annually ($${plan.annualPrice}/yr)`,
|
|
};
|
|
}
|
|
return { display: `$${plan.monthlyPrice}`, sub: '/month' };
|
|
}
|
|
|
|
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 [billingInterval, setBillingInterval] = useState<BillingInterval>('month');
|
|
|
|
const handleStartTrial = async (planId: string) => {
|
|
if (!email.trim()) {
|
|
setError('Email address is required to start a trial');
|
|
return;
|
|
}
|
|
if (!businessName.trim()) {
|
|
setError('HOA / Business name is required to start a trial');
|
|
return;
|
|
}
|
|
|
|
setLoading(planId);
|
|
setError('');
|
|
try {
|
|
const { data } = await api.post('/billing/start-trial', {
|
|
planId,
|
|
billingInterval,
|
|
email: email.trim(),
|
|
businessName: businessName.trim(),
|
|
});
|
|
if (data.subscriptionId) {
|
|
// Navigate to pending page with subscription ID for polling
|
|
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
|
|
} else {
|
|
setError('Unable to start trial');
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.message || 'Failed to start trial');
|
|
} 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>
|
|
|
|
{/* Monthly / Annual Toggle */}
|
|
<Center mb="xl">
|
|
<Box pos="relative">
|
|
<SegmentedControl
|
|
value={billingInterval}
|
|
onChange={(val) => setBillingInterval(val as BillingInterval)}
|
|
data={[
|
|
{ label: 'Monthly', value: 'month' },
|
|
{ label: 'Annual', value: 'year' },
|
|
]}
|
|
size="md"
|
|
radius="xl"
|
|
/>
|
|
{billingInterval === 'year' && (
|
|
<Badge
|
|
color="green"
|
|
variant="filled"
|
|
size="sm"
|
|
style={{ position: 'absolute', top: -10, right: -40 }}
|
|
>
|
|
Save 25%
|
|
</Badge>
|
|
)}
|
|
</Box>
|
|
</Center>
|
|
|
|
{/* Pre-capture fields (required for trial) */}
|
|
<Center mb="xl">
|
|
<Group>
|
|
<TextInput
|
|
placeholder="Email address *"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
|
style={{ width: 220 }}
|
|
required
|
|
/>
|
|
<TextInput
|
|
placeholder="HOA / Business name *"
|
|
value={businessName}
|
|
onChange={(e) => setBusinessName(e.currentTarget.value)}
|
|
style={{ width: 220 }}
|
|
required
|
|
/>
|
|
</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) => {
|
|
const price = formatPrice(plan, billingInterval);
|
|
return (
|
|
<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>
|
|
|
|
<div>
|
|
<Group align="baseline" gap={4}>
|
|
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
|
|
{plan.externalUrl ? 'Request Quote' : price.display}
|
|
</Text>
|
|
</Group>
|
|
{price.sub && (
|
|
<Text size="xs" c="dimmed" mt={2}>
|
|
{price.sub}
|
|
</Text>
|
|
)}
|
|
{!plan.externalUrl && billingInterval === 'year' && (
|
|
<Text size="xs" c="dimmed" td="line-through" mt={2}>
|
|
${plan.monthlyPrice}/mo without annual discount
|
|
</Text>
|
|
)}
|
|
</div>
|
|
|
|
<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={!plan.externalUrl ? loading === plan.id : false}
|
|
onClick={() =>
|
|
plan.externalUrl
|
|
? window.open(plan.externalUrl, '_blank', 'noopener')
|
|
: handleStartTrial(plan.id)
|
|
}
|
|
>
|
|
{plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
|
|
</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.
|
|
</Text>
|
|
</Container>
|
|
);
|
|
}
|