Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/jetstream/src/app/components/billing/Billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,13 @@ export const Billing = () => {
priceSubtext={
isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].priceSubtext : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].priceSubtext
}
description={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].description}
description={
isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].description : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].description
}
Comment on lines 276 to +281
features={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].features}
pricingTiers={
isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].pricingTiers : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].pricingTiers
}
checked={
selectedPlan === (isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].key : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].key)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ANALYTICS_KEYS } from '@jetstream/shared/constants';
import { JetstreamPricesByLookupKey, StripeUserFacingCustomer } from '@jetstream/types';
import { JetstreamPricesByLookupKey, StripeUserFacingCustomer, StripeUserFacingSubscriptionItem } from '@jetstream/types';
import { useAmplitude } from '@jetstream/ui-core';
import { useState } from 'react';
import { EnhancedBillingCard } from './EnhancedBillingCard';
Expand All @@ -18,28 +18,118 @@ interface BillingExistingSubscriptionsProps {
hasManualBilling: boolean;
}

const formatUsd = (amountInCents: number) => `$${(amountInCents / 100).toFixed(2).replace(/\.00$/, '')}`;

const intervalLabel = (interval: StripeUserFacingSubscriptionItem['recurringInterval']) => {
switch (interval) {
case 'MONTH':
return 'month';
case 'YEAR':
return 'year';
case 'WEEK':
return 'week';
case 'DAY':
return 'day';
default:
return 'period';
}
};

export const BillingExistingSubscriptions = ({
customerWithSubscriptions,
pricesByLookupKey,
hasManualBilling,
}: BillingExistingSubscriptionsProps) => {
const { trackEvent } = useAmplitude();

const activeSubscription = customerWithSubscriptions.subscriptions.find(({ status }) => ACTIVE_SUBSCRIPTION_STATUSES.has(status));
const activeItem = activeSubscription?.items[0];

const teamProductId = pricesByLookupKey?.TEAM_MONTHLY?.product?.id;
const proProductId = pricesByLookupKey?.PRO_MONTHLY?.product?.id;

const isTeamSubscription = !!activeItem && (activeItem.lookupKey?.startsWith('TEAM_') || activeItem.product === teamProductId);
const isProSubscription = !!activeItem && (activeItem.lookupKey?.startsWith('PRO_') || activeItem.product === proProductId);

const matchesCurrentPrice =
!!activeItem && !!pricesByLookupKey && Object.values(pricesByLookupKey).some((price) => price.id === activeItem.priceId);
const isLegacyPlan = !!activeItem && !hasManualBilling && !matchesCurrentPrice && (isTeamSubscription || isProSubscription);

const [selectedPlan, setSelectedPlan] = useState<string | null>(() => {
const priceId =
customerWithSubscriptions.subscriptions.find(({ status }) => ACTIVE_SUBSCRIPTION_STATUSES.has(status))?.items[0].priceId || null;
if (priceId) {
return Object.values(pricesByLookupKey || {}).find((price) => price.id === priceId)?.lookupKey || null;
if (!activeItem) {
return null;
}
return null;
return Object.values(pricesByLookupKey || {}).find((price) => price.id === activeItem.priceId)?.lookupKey || null;
});

const handleEnterpriseContact = () => {
trackEvent(ANALYTICS_KEYS.billing_session, { action: 'enterprise_contact' });
window.open('mailto:support@getjetstream.app?subject=Enterprise Plan Inquiry', '_blank');
};

const renderCurrentPlanSummary = () => {
if (!activeItem) {
return null;
}

let planLabel = 'Plan';
if (isTeamSubscription) {
planLabel = 'Team';
} else if (isProSubscription) {
planLabel = 'Professional';
}

let badge: string | null = null;
if (hasManualBilling) {
badge = 'Custom plan';
} else if (isLegacyPlan) {
badge = 'Legacy plan';
}

const perSeat = formatUsd(activeItem.unitAmount);
const total = formatUsd(activeItem.unitAmount * activeItem.quantity);
const interval = intervalLabel(activeItem.recurringInterval);

return (
<div className="slds-box slds-box_x-small slds-m-bottom_medium slds-text-align_center">
<div className="slds-text-heading_small">
Your current plan: <strong>{planLabel}</strong>
{badge && (
<span
className="slds-badge slds-m-left_x-small"
style={{ backgroundColor: hasManualBilling ? '#0176d3' : '#706e6b', color: 'white' }}
>
{badge}
</span>
)}
</div>
{isTeamSubscription ? (
<p className="slds-text-body_small slds-m-top_x-small">
{activeItem.quantity} {activeItem.quantity === 1 ? 'seat' : 'seats'} × {perSeat}/seat/{interval} ={' '}
<strong>
{total}/{interval}
</strong>
</p>
) : (
<p className="slds-text-body_small slds-m-top_x-small">
<strong>
{perSeat}/{interval}
</strong>
</p>
)}
{hasManualBilling && (
<p className="slds-text-body_small slds-text-color_weak slds-m-top_x-small">
You have a custom billing arrangement. Contact support for plan changes.
</p>
)}
</div>
);
};

return (
<div>
{renderCurrentPlanSummary()}

{!hasManualBilling && (
<div className="slds-text-align_center slds-m-bottom_medium">
<p className="slds-text-color_weak">Visit the billing portal to make changes to your plan</p>
Expand Down Expand Up @@ -84,6 +174,7 @@ export const BillingExistingSubscriptions = ({
description={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].description}
features={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].features}
comingSoonFeatures={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].comingSoonFeatures}
pricingTiers={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].pricingTiers}
checked={!hasManualBilling && selectedPlan === TEAM_MONTHLY_KEY}
disabled={hasManualBilling || selectedPlan !== TEAM_MONTHLY_KEY}
value={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].key}
Expand All @@ -96,6 +187,7 @@ export const BillingExistingSubscriptions = ({
description={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].description}
features={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].features}
comingSoonFeatures={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].comingSoonFeatures}
pricingTiers={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].pricingTiers}
checked={!hasManualBilling && selectedPlan === TEAM_ANNUAL_KEY}
disabled={hasManualBilling || selectedPlan !== TEAM_ANNUAL_KEY}
value={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].key}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ export const BillingPeriodToggle = ({ isAnnual, onChange }: BillingPeriodToggleP
return (
<div css={toggleStyles}>
<div className="billing-toggle-wrapper">
<div className={`savings-badge ${isAnnual ? 'visible' : ''}`}>Get two months free</div>
<div className="billing-toggle-container">
<div className="toggle-wrapper" onClick={() => onChange(!isAnnual)}>
<div className={`toggle-slider ${isAnnual ? 'annual' : ''}`} />
Expand Down
50 changes: 48 additions & 2 deletions apps/jetstream/src/app/components/billing/EnhancedBillingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ import { Icon } from '@jetstream/ui';
import classNames from 'classnames';
import { useId } from 'react';

interface PricingTier {
seats: string;
perUser: string;
}

interface EnhancedBillingCardProps {
planName: string;
price: string;
priceSubtext?: string;
description?: string;
features: string[];
comingSoonFeatures?: string[];
features: readonly string[];
comingSoonFeatures?: readonly string[];
pricingTiers?: readonly PricingTier[];
isEnterprise?: boolean;
disabled?: boolean;
disabledReason?: string;
Expand Down Expand Up @@ -199,6 +205,35 @@ const cardStyles = css`
font-style: italic;
position: relative;
}

.pricing-tiers {
margin: 12px auto 0;
max-width: 260px;
border: 1px solid #e5e5e5;
border-radius: 6px;
overflow: hidden;
}

.pricing-tier-row {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 6px 12px;
background: #fafafa;

&:not(:last-child) {
border-bottom: 1px solid #ececec;
}
}

.pricing-tier-seats {
color: #706e6b;
font-weight: 600;
}

.pricing-tier-amount {
color: #16325c;
}
`;

export const EnhancedBillingCard = ({
Expand All @@ -208,6 +243,7 @@ export const EnhancedBillingCard = ({
description,
features,
comingSoonFeatures,
pricingTiers,
isEnterprise = false,
disabled = false,
disabledReason,
Expand Down Expand Up @@ -256,6 +292,16 @@ export const EnhancedBillingCard = ({
<div className="price">{price}</div>
{priceSubtext && <div className="price-subtext">{priceSubtext}</div>}
{description && <div className="description">{description}</div>}
{pricingTiers && pricingTiers.length > 0 && (
<div className="pricing-tiers">
{pricingTiers.map((tier) => (
<div key={tier.seats} className="pricing-tier-row">
<span className="pricing-tier-seats">{tier.seats}</span>
<span className="pricing-tier-amount">{tier.perUser}</span>
</div>
))}
</div>
)}
</div>

<div className="features-section">
Expand Down
31 changes: 20 additions & 11 deletions apps/jetstream/src/app/components/billing/billing.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,18 @@ export const professionalFeatures = [
export const teamFeatures = [
'Everything in Professional',
'Manage team members',
'Up to 20 team members',
'Unlimited team members',
'SSO via OIDC and SAML',
'View & Manage team member session activity',
'Role-based access control',
];

export const teamFeaturesComingSoon = ['SOC 2 (in progress)', 'Share orgs between team members', 'Audit logs'];
export const teamFeaturesComingSoon = ['Share orgs between team members'];

export const enterpriseFeatures = [
'Everything in Team',
'Unlimited team members',
'SOC 2 Type II compliance',
'Audit logs',
'Single Sign-On (SSO)',
Comment on lines 38 to 42
'Custom agreements and terms',
'Dedicated account manager',
Expand All @@ -55,24 +56,32 @@ export const PLAN_DESCRIPTIONS = {
},
[PRO_ANNUAL_KEY]: {
key: 'PRO_ANNUAL',
price: '$250',
priceSubtext: '/year',
price: '$21',
priceSubtext: '/month, billed annually',
description: 'Save 2 months with annual billing',
features: professionalFeatures,
},
[TEAM_MONTHLY_KEY]: {
key: 'TEAM_MONTHLY',
price: '$125',
priceSubtext: '/month (includes 5 users)',
description: '$25/user/month with 5-user minimum',
price: '$30',
priceSubtext: '/user/month',
description: 'Per-user pricing — save with 6+ seats',
pricingTiers: [
{ seats: '1–5', perUser: '$30/user/month' },
{ seats: '6+', perUser: '$25/user/month' },
],
features: teamFeatures,
comingSoonFeatures: teamFeaturesComingSoon,
},
[TEAM_ANNUAL_KEY]: {
key: 'TEAM_ANNUAL',
price: '$1,100',
priceSubtext: '/year (includes 5 users)',
description: '$220/user/year with 5-user minimum',
price: '$25',
priceSubtext: '/user/month, billed annually',
description: 'Per-user pricing — save with 6+ seats',
pricingTiers: [
{ seats: '1–5', perUser: '$25/user/month' },
{ seats: '6+', perUser: '$21/user/month' },
],
features: teamFeatures,
comingSoonFeatures: teamFeaturesComingSoon,
},
Expand Down
47 changes: 40 additions & 7 deletions apps/landing/pages/pricing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ const tiers = [
name: 'Professional',
id: 'tier-professional',
href: '#',
price: { monthly: { price: '$25', suffix: 'month' }, annually: { price: '$250', suffix: 'year' } },
price: {
monthly: { price: '$25', suffix: '/month' },
annually: { price: '$21', suffix: '/month', secondaryLabel: 'billed annually' },
},
description: 'Perfect for individual users',
features: [
'Everything in Free plan',
Expand All @@ -58,28 +61,42 @@ const tiers = [
name: 'Team',
id: 'tier-team',
href: '#',
price: { monthly: { price: '$125', suffix: 'month' }, annually: { price: '$1,100', suffix: 'year' } },
description: { monthly: `Includes 5 users ($25/user/month)`, annually: `Includes 5 users ($22/user/month)` },
price: {
monthly: { price: '$30', suffix: '/user/month' },
annually: { price: '$25', suffix: '/user/month', secondaryLabel: 'billed annually' },
},
description: 'Per-user pricing — save with 6+ seats',
pricingTiers: {
monthly: [
{ seats: '1–5', perUser: '$30/user/month' },
{ seats: '6+', perUser: '$25/user/month' },
],
annually: [
{ seats: '1–5', perUser: '$25/user/month' },
{ seats: '6+', perUser: '$21/user/month' },
],
},
features: [
'Everything in Professional',
'Manage team members',
'Up to 20 team members',
'Unlimited team members',
'SSO via OIDC and SAML',
'View & Manage team member session activity',
'Role-based access control',
],
comingSoonFeatures: ['SOC 2 compliance (in-progress)', 'Share orgs between team members', 'Audit logs'],
comingSoonFeatures: ['Share orgs between team members'],
mostPopular: false,
},
{
name: 'Enterprise',
id: 'tier-enterprise',
href: 'mailto:sales@getjetstream.app?subject=Enterprise Plan Inquiry',
price: { monthly: { price: 'Custom', suffix: null }, annually: { price: 'Custom', suffix: null } },
description: 'Advanced features for large teams',
description: 'SOC 2 compliance, custom terms, and dedicated support',
features: [
'Everything in Team',
'Unlimited team members',
'SOC 2 Type II compliance',
'Audit logs',
'Custom agreements and terms',
'Dedicated account manager',
'White-glove onboarding',
Expand Down Expand Up @@ -145,6 +162,22 @@ export default function Page() {
<span className="text-sm/6 font-semibold text-gray-300">{tier.price[frequency.value].suffix}</span>
)}
</p>
{tier.price[frequency.value]?.secondaryLabel && (
<p className="mt-1 text-xs/5 text-gray-400">{tier.price[frequency.value].secondaryLabel}</p>
)}
{tier.pricingTiers && (
<div className="mt-4 overflow-hidden rounded-md ring-1 ring-white/10">
{tier.pricingTiers[frequency.value].map((row: { seats: string; perUser: string }) => (
<div
key={row.seats}
className="flex justify-between bg-white/5 px-3 py-1.5 text-xs/5 text-gray-200 not-last:border-b not-last:border-white/10"
>
<span className="font-semibold">{row.seats} seats</span>
<span>{row.perUser}</span>
</div>
))}
</div>
)}
<div className="mt-6 min-h-10">
<Link
href={
Expand Down
Loading