Files
waggle-os/apps/www/app/_components/Pricing.tsx
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

254 lines
7.7 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import DownloadCTA from './DownloadCTA';
import { emit, events } from '../_lib/event-taxonomy';
import styles from './Pricing.module.css';
type BillingPeriod = 'monthly' | 'annual';
type TierId = 'SOLO' | 'TEAMS';
interface TierDef {
readonly id: TierId;
readonly nsKey: 'solo' | 'teams';
readonly highlighted: boolean;
readonly bulletKeys: readonly string[];
readonly ctaType: 'download' | 'stripe';
}
/**
* Tier content mirrors `packages/shared/src/tiers.ts` (the canonical tier
* system): SOLO is free forever with full memory + Harvest, unlimited
* workspaces, marketplace, all connectors, and BYO cloud models; TEAMS adds
* shared workspaces, WaggleDance, and governance. No bullets beyond what
* tiers.ts encodes. The 15-day Team trial lives in the section subhead —
* it applies to every install, so listing it as a Solo feature misreads.
*/
const TIER_DEFS: readonly TierDef[] = [
{
id: 'SOLO',
nsKey: 'solo',
highlighted: false,
bulletKeys: [
'bullet_memory',
'bullet_workspaces',
'bullet_marketplace',
'bullet_models',
'bullet_skills',
],
ctaType: 'download',
},
{
id: 'TEAMS',
nsKey: 'teams',
highlighted: true,
bulletKeys: [
'bullet_everything_solo',
'bullet_shared',
'bullet_dance',
'bullet_governance',
],
ctaType: 'stripe',
},
];
const STRIPE_ENDPOINT =
(process.env.NEXT_PUBLIC_API_URL ?? '').replace(/\/$/, '') +
'/api/stripe/checkout';
const KVARK_URL = 'https://www.kvark.ai';
export default function Pricing() {
const t = useTranslations('landing.pricing');
const [billing, setBilling] = useState<BillingPeriod>('monthly');
const [checkoutCancelled, setCheckoutCancelled] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setCheckoutCancelled(params.get('checkout') === 'cancelled');
}, []);
const handleBillingChange = useCallback((mode: BillingPeriod) => {
setBilling(mode);
emit({ name: events.pricingBillingToggle, properties: { mode } });
}, []);
const handleStripeCtaClick = useCallback(
(tier: TierId) => {
emit({
name: events.ctaClick,
properties: { section: 'pricing', tier, billing },
});
},
[billing],
);
return (
<section id="pricing" className="section" aria-labelledby="pricing-heading">
<div className="container-wide">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="pricing-heading" className="section-headline">
{t('headline')}
</h2>
<p className="section-lead">{t('subhead')}</p>
</header>
<div
role="group"
aria-label={t('toggle.aria_group')}
className={styles.toggleRow}
>
<button
type="button"
onClick={() => handleBillingChange('monthly')}
className={[
styles.toggle,
billing === 'monthly' ? styles.toggleActive : '',
]
.filter(Boolean)
.join(' ')}
aria-pressed={billing === 'monthly'}
>
{t('toggle.monthly')}
</button>
<button
type="button"
onClick={() => handleBillingChange('annual')}
className={[
styles.toggle,
billing === 'annual' ? styles.toggleActive : '',
]
.filter(Boolean)
.join(' ')}
aria-pressed={billing === 'annual'}
>
{t('toggle.annual')}
<span className={styles.savePill}>{t('toggle.save_pill')}</span>
</button>
</div>
{checkoutCancelled ? (
<div role="status" className={styles.checkoutNotice}>
<span>{t('notices.cancelled')}</span>
<a
href={`${STRIPE_ENDPOINT}?tier=teams&billing=${billing}`}
onClick={() => handleStripeCtaClick('TEAMS')}
className={styles.noticeLink}
>
{t('notices.retry')}
</a>
</div>
) : null}
<div className={styles.grid}>
{TIER_DEFS.map((tier) => {
const priceKey =
billing === 'monthly' ? 'price_monthly' : 'price_annual';
const cardClass = [
styles.tierCard,
tier.highlighted ? styles.tierCardHighlighted : '',
]
.filter(Boolean)
.join(' ');
return (
<div key={tier.id} className={cardClass}>
{tier.highlighted ? (
<span className={styles.badge}>{t('popular_badge')}</span>
) : null}
<h3 className={styles.tierName}>
{t(`tiers.${tier.nsKey}.name`)}
</h3>
<p className={styles.tierTagline}>
{t(`tiers.${tier.nsKey}.tagline`)}
</p>
<p className={styles.price}>
{t(`tiers.${tier.nsKey}.${priceKey}`)}
</p>
<p className={styles.priceNote}>
{t(`tiers.${tier.nsKey}.note`)}
</p>
<ul className={styles.bullets}>
{tier.bulletKeys.map((bk) => (
<li key={bk} className={styles.bullet}>
<CheckIcon />
<span>{t(`tiers.${tier.nsKey}.${bk}`)}</span>
</li>
))}
</ul>
{tier.ctaType === 'download' ? (
<DownloadCTA
section="solo-tier"
variant={tier.highlighted ? 'primary' : 'ghost'}
style={{ width: '100%' }}
/>
) : (
<a
href={`${STRIPE_ENDPOINT}?tier=${tier.id.toLowerCase()}&billing=${billing}`}
onClick={() => handleStripeCtaClick(tier.id)}
className={[
'btn',
tier.highlighted ? 'btn-primary' : 'btn-ghost',
styles.tierCta,
].join(' ')}
>
{t(`tiers.${tier.nsKey}.cta`)}
</a>
)}
</div>
);
})}
{/* Enterprise = KVARK sovereign deployment. A quieter third card
(description, no checklist) so the grid fills its row without
inventing a tier — pricing stays consultative. */}
<div className={[styles.tierCard, styles.tierCardQuiet].join(' ')}>
<h3 className={styles.tierName}>{t('enterprise.name')}</h3>
<p className={styles.tierTagline}>{t('enterprise.tagline')}</p>
<p className={styles.price}>{t('enterprise.price')}</p>
<p className={styles.priceNote}>{t('enterprise.note')}</p>
<p className={styles.enterpriseBody}>{t('enterprise.text')}</p>
<a
href={KVARK_URL}
target="_blank"
rel="noopener noreferrer"
className={['btn', 'btn-ghost', styles.tierCta].join(' ')}
>
{t('enterprise.cta')}
</a>
</div>
</div>
</div>
</section>
);
}
function CheckIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className={styles.bulletIcon}
>
<path
d="M3 8.5 L6.5 12 L13 4.5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}