[Website] Self-host billing migration and some responsiveness fixes. (#19894)

Closes the following issues.

https://github.com/twentyhq/core-team-issues/issues/2371
https://github.com/twentyhq/core-team-issues/issues/2379
https://github.com/twentyhq/core-team-issues/issues/2383
This commit is contained in:
Abdullah. 2026-04-21 00:23:54 +05:00 committed by GitHub
parent 755f1c92d1
commit 83bc6d1a1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1229 additions and 229 deletions

View file

@ -1 +1,16 @@
PARTNER_APPLICATION_WEBHOOK_URL=
# Public site URL (used for Stripe checkout success URL and billing portal return)
NEXT_PUBLIC_WEBSITE_URL=
# Stripe — self-hosted enterprise checkout & subscription APIs
STRIPE_SECRET_KEY=
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=
# RS256 key pair used to sign enterprise license JWTs (use literal \n in PEM for env)
ENTERPRISE_JWT_PRIVATE_KEY=
ENTERPRISE_JWT_PUBLIC_KEY=
# Optional: short-lived validity token length in days (default 30)
# ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS=

View file

@ -29,6 +29,7 @@
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"stripe": "^20.3.1",
"three": "^0.183.2",
"zod": "^4.1.11"
},

View file

@ -0,0 +1,85 @@
import { signEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const sessionId = url.searchParams.get('session_id');
if (!sessionId) {
return NextResponse.json(
{ error: 'Missing session_id parameter' },
{ status: 400 },
);
}
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['subscription', 'customer'],
});
// Subscriptions that begin in a free trial complete with
// `payment_status: 'no_payment_required'`, so accept both successful states.
const SUCCESSFUL_PAYMENT_STATUSES: Array<typeof session.payment_status> = [
'paid',
'no_payment_required',
];
if (
session.status !== 'complete' ||
!SUCCESSFUL_PAYMENT_STATUSES.includes(session.payment_status)
) {
return NextResponse.json(
{ error: 'Checkout session is not completed' },
{ status: 402 },
);
}
const subscription = session.subscription;
if (!subscription || typeof subscription === 'string') {
return NextResponse.json(
{ error: 'Subscription not found' },
{ status: 400 },
);
}
const ACTIVATABLE_SUBSCRIPTION_STATUSES: Array<typeof subscription.status> =
['active', 'trialing'];
if (!ACTIVATABLE_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
return NextResponse.json(
{
error: 'Subscription is not active',
status: subscription.status,
},
{ status: 402 },
);
}
const customer = session.customer;
const licensee =
customer && typeof customer !== 'string' && !customer.deleted
? (customer.name ?? customer.email ?? 'Unknown')
: 'Unknown';
const enterpriseKey = signEnterpriseKey(subscription.id, licensee);
return NextResponse.json({
enterpriseKey,
licensee,
subscriptionId: subscription.id,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Activation error: ${message}` },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,59 @@
import {
getEnterprisePriceId,
getStripeClient,
} from '@/shared/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const stripe = getStripeClient();
const body = await request.json();
const billingInterval =
body.billingInterval === 'yearly' ? 'yearly' : 'monthly';
const priceId = getEnterprisePriceId(billingInterval);
const websiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL;
const defaultSuccessUrl = websiteUrl
? `${websiteUrl}/enterprise/activate?session_id={CHECKOUT_SESSION_ID}`
: undefined;
const successUrl = body.successUrl ?? defaultSuccessUrl;
if (!successUrl || typeof successUrl !== 'string') {
return NextResponse.json(
{
error:
'Missing successUrl or NEXT_PUBLIC_WEBSITE_URL for checkout redirect',
},
{ status: 500 },
);
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: body.seatCount ?? 1,
},
],
success_url: successUrl,
subscription_data: {
trial_period_days: 30,
metadata: {
source: 'enterprise-self-hosted',
},
},
});
return NextResponse.json({ url: session.url });
} catch (error: unknown) {
console.error(error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Checkout error: ${message}` },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,67 @@
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const { enterpriseKey, returnUrl } = body;
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
return NextResponse.json(
{ error: 'Missing enterpriseKey' },
{
status: 400,
},
);
}
const payload = verifyEnterpriseKey(enterpriseKey);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid enterprise key' },
{
status: 403,
},
);
}
const stripe = getStripeClient();
const subscription = await stripe.subscriptions.retrieve(payload.sub);
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const frontendUrl = process.env.NEXT_PUBLIC_WEBSITE_URL;
if (!frontendUrl) {
return NextResponse.json(
{ error: 'NEXT_PUBLIC_WEBSITE_URL is not configured' },
{ status: 500 },
);
}
const fullReturnUrl = returnUrl
? `${frontendUrl}${returnUrl}`
: frontendUrl;
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: fullReturnUrl,
});
return NextResponse.json({ url: session.url });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Portal error: ${message}` },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,92 @@
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const { enterpriseKey, seatCount } = body;
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
return NextResponse.json(
{ error: 'Missing enterpriseKey' },
{
status: 400,
},
);
}
if (typeof seatCount !== 'number' || seatCount < 1) {
return NextResponse.json(
{ error: 'Invalid seatCount' },
{
status: 400,
},
);
}
const payload = verifyEnterpriseKey(enterpriseKey);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid enterprise key' },
{
status: 403,
},
);
}
const stripe = getStripeClient();
const subscription = await stripe.subscriptions.retrieve(payload.sub);
const NON_UPDATABLE_STATUSES = ['canceled', 'incomplete_expired'];
if (
NON_UPDATABLE_STATUSES.includes(subscription.status) ||
subscription.cancel_at_period_end
) {
return NextResponse.json({
success: false,
reason: 'Subscription is canceled or scheduled for cancellation',
seatCount: subscription.items.data[0]?.quantity ?? 0,
subscriptionId: payload.sub,
});
}
if (!subscription.items.data[0]) {
return NextResponse.json(
{ error: 'No subscription item found' },
{ status: 400 },
);
}
const subscriptionItemId = subscription.items.data[0].id;
await stripe.subscriptions.update(payload.sub, {
items: [
{
id: subscriptionItemId,
quantity: seatCount,
},
],
proration_behavior: 'create_prorations',
});
return NextResponse.json({
success: true,
seatCount,
subscriptionId: payload.sub,
});
} catch (error: unknown) {
console.error(error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Seat update error: ${message}` },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,64 @@
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { getSubscriptionCurrentPeriodEnd } from '@/shared/enterprise/stripe-subscription-helpers';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const { enterpriseKey } = body;
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
return NextResponse.json(
{ error: 'Missing enterpriseKey' },
{
status: 400,
},
);
}
const payload = verifyEnterpriseKey(enterpriseKey);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid enterprise key' },
{
status: 403,
},
);
}
const stripe = getStripeClient();
const subscription = await stripe.subscriptions.retrieve(payload.sub);
const rawCancelAt = subscription.cancel_at;
const rawCancelAtPeriodEnd = subscription.cancel_at_period_end;
const rawCurrentPeriodEnd = getSubscriptionCurrentPeriodEnd(subscription);
const effectiveCancelAt =
rawCancelAt ??
(rawCancelAtPeriodEnd && rawCurrentPeriodEnd
? rawCurrentPeriodEnd
: null);
const isCancellationScheduled =
subscription.status !== 'canceled' && effectiveCancelAt !== null;
return NextResponse.json({
subscriptionId: subscription.id,
status: subscription.status,
cancelAt: effectiveCancelAt,
currentPeriodEnd: rawCurrentPeriodEnd,
isCancellationScheduled,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Status error: ${message}` },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,79 @@
import {
signValidityToken,
verifyEnterpriseKey,
} from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { getSubscriptionCurrentPeriodEnd } from '@/shared/enterprise/stripe-subscription-helpers';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const { enterpriseKey } = body;
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
return NextResponse.json(
{ error: 'Missing enterpriseKey' },
{
status: 400,
},
);
}
const payload = verifyEnterpriseKey(enterpriseKey);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid enterprise key' },
{
status: 403,
},
);
}
const stripe = getStripeClient();
const subscription = await stripe.subscriptions.retrieve(payload.sub);
const activeStatuses = ['active', 'trialing'];
if (!activeStatuses.includes(subscription.status)) {
return NextResponse.json(
{
error: 'Subscription is not active',
status: subscription.status,
},
{ status: 403 },
);
}
const rawCancelAt = subscription.cancel_at;
const rawCancelAtPeriodEnd = subscription.cancel_at_period_end;
const rawCurrentPeriodEnd = getSubscriptionCurrentPeriodEnd(subscription);
const effectiveCancelAt =
rawCancelAt ??
(rawCancelAtPeriodEnd && rawCurrentPeriodEnd
? rawCurrentPeriodEnd
: null);
const validityToken = signValidityToken(payload.sub, {
subscriptionCancelAt: effectiveCancelAt,
});
return NextResponse.json({
validityToken,
licensee: payload.licensee,
subscriptionId: payload.sub,
subscriptionStatus: subscription.status,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `Validation error: ${message}` },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,211 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
type ActivationResult = {
enterpriseKey: string;
licensee: string;
subscriptionId: string;
};
const PageWrap = styled.div`
box-sizing: border-box;
margin-left: auto;
margin-right: auto;
margin-top: ${theme.spacing(12)};
max-width: 700px;
min-height: 60vh;
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
`;
const Title = styled.h1`
font-size: ${theme.font.size(8)};
font-weight: 600;
margin-bottom: ${theme.spacing(4)};
`;
const ErrorBox = styled.div`
background-color: ${theme.colors.primary.border[5]};
border: 1px solid ${theme.colors.accent.pink[70]};
border-radius: ${theme.radius(2)};
color: ${theme.colors.accent.pink[100]};
padding: ${theme.spacing(4)};
`;
const SuccessLead = styled.p`
color: ${theme.colors.accent.green[100]};
margin-bottom: ${theme.spacing(4)};
`;
const LicenseeRow = styled.div`
margin-bottom: ${theme.spacing(6)};
`;
const KeyLabel = styled.div`
font-weight: 600;
margin-bottom: ${theme.spacing(2)};
`;
const KeyHint = styled.p`
color: ${theme.colors.primary.text[60]};
font-size: ${theme.font.size(3)};
margin-bottom: ${theme.spacing(2)};
`;
const KeyBlock = styled.div`
background-color: ${theme.colors.primary.border[5]};
border: 1px solid ${theme.colors.primary.border[20]};
border-radius: ${theme.radius(2)};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(2)};
line-height: 1.5;
padding: ${theme.spacing(4)};
padding-right: ${theme.spacing(16)};
position: relative;
word-break: break-all;
`;
const CopyButton = styled.button<{ $copied: boolean }>`
background-color: ${({ $copied }) =>
$copied ? theme.colors.accent.green[100] : theme.colors.primary.text[100]};
border: none;
border-radius: ${theme.radius(1)};
color: ${theme.colors.primary.background[100]};
cursor: pointer;
font-size: ${theme.font.size(2)};
padding: ${theme.spacing(2)} ${theme.spacing(3)};
position: absolute;
right: ${theme.spacing(2)};
top: ${theme.spacing(2)};
`;
const NextStepsBox = styled.div`
background-color: ${theme.colors.primary.border[5]};
border: 1px solid ${theme.colors.accent.blue[70]};
border-radius: ${theme.radius(2)};
margin-top: ${theme.spacing(8)};
padding: ${theme.spacing(4)};
`;
const NextStepsList = styled.ol`
line-height: 1.75;
margin-top: ${theme.spacing(2)};
padding-left: ${theme.spacing(5)};
`;
export function EnterpriseActivateClient() {
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
const [result, setResult] = useState<ActivationResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!sessionId) {
setError('No session ID provided. Please complete the checkout first.');
setLoading(false);
return;
}
const activate = async () => {
try {
const response = await fetch(
`/api/enterprise/activate?session_id=${encodeURIComponent(sessionId)}`,
);
const data: { error?: string } & Partial<ActivationResult> =
await response.json();
if (!response.ok) {
setError(data.error ?? 'Activation failed');
return;
}
if (data.enterpriseKey && data.licensee && data.subscriptionId) {
setResult({
enterpriseKey: data.enterpriseKey,
licensee: data.licensee,
subscriptionId: data.subscriptionId,
});
} else {
setError('Activation response was incomplete.');
}
} catch {
setError('Failed to activate enterprise key. Please try again.');
} finally {
setLoading(false);
}
};
void activate();
}, [sessionId]);
const handleCopy = async () => {
if (!result) {
return;
}
await navigator.clipboard.writeText(result.enterpriseKey);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<PageWrap>
<Title>{'Enterprise activation'}</Title>
{loading && <p>{'Activating your enterprise license…'}</p>}
{error !== null && <ErrorBox>{error}</ErrorBox>}
{result !== null && (
<div>
<SuccessLead>
{'Your enterprise license has been activated successfully.'}
</SuccessLead>
<LicenseeRow>
<strong>{'Licensee:'}</strong> {result.licensee}
</LicenseeRow>
<KeyLabel>{'Your enterprise key'}</KeyLabel>
<KeyHint>
{
'Copy this key and paste it into your Twenty self-hosted instance settings.'
}
</KeyHint>
<KeyBlock>
{result.enterpriseKey}
<CopyButton
$copied={copied}
onClick={() => void handleCopy()}
type="button"
>
{copied ? 'Copied!' : 'Copy'}
</CopyButton>
</KeyBlock>
<NextStepsBox>
<strong>{'Next steps'}</strong>
<NextStepsList>
<li>{'Copy the enterprise key above.'}</li>
<li>
{'Open your Twenty self-hosted instance Settings → Enterprise.'}
</li>
<li>{'Paste the key and click Activate.'}</li>
</NextStepsList>
</NextStepsBox>
</div>
)}
</PageWrap>
);
}

View file

@ -0,0 +1,37 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { EnterpriseActivateClient } from './EnterpriseActivateClient';
const activateFallbackClassName = css`
box-sizing: border-box;
color: ${theme.colors.primary.text[60]};
margin-left: auto;
margin-right: auto;
margin-top: ${theme.spacing(12)};
max-width: 700px;
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
`;
export const metadata: Metadata = {
title: 'Enterprise activation | Twenty',
description:
'Complete activation for your Twenty self-hosted enterprise license.',
};
function EnterpriseActivateFallback() {
return (
<div className={activateFallbackClassName}>{'Loading activation…'}</div>
);
}
export default function EnterpriseActivatePage() {
return (
<Suspense fallback={<EnterpriseActivateFallback />}>
<EnterpriseActivateClient />
</Suspense>
);
}

View file

@ -13,12 +13,6 @@ const GLB_URL = '/illustrations/why-twenty/stepper/logo.glb';
const VisualColumn = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
}
`;
const VisualContainer = styled.div`
@ -31,8 +25,10 @@ const VisualContainer = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
height: 705px;
max-width: 672px;
aspect-ratio: 672 / 705;
height: auto;
max-height: 705px;
min-height: 0;
}
`;

View file

@ -12,13 +12,23 @@ import { Card } from '../Card/Card';
const GUIDE_INTERSECTION_TOP = '176px';
const helpedHeadingClassName = css`
&[data-size='xl'] {
font-size: clamp(${theme.font.size(8)}, 9.5vw, ${theme.font.size(15)});
line-height: 1.1;
}
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 760px;
white-space: pre-line;
}
[data-family='sans'] {
white-space: nowrap;
&[data-size='xl'] {
font-size: ${theme.font.size(20)};
line-height: ${theme.lineHeight(21.5)};
}
[data-family='sans'] {
white-space: nowrap;
}
}
`;
@ -96,7 +106,11 @@ export function Scene({ data }: SceneProps) {
sectionRef={sectionRef}
/>
<StickyInner ref={innerRef}>
<GuideCrosshair crossX="50%" crossY={GUIDE_INTERSECTION_TOP} zIndex={0} />
<GuideCrosshair
crossX="50%"
crossY={GUIDE_INTERSECTION_TOP}
zIndex={0}
/>
<HeadlineBlock>
<EyebrowExitTarget data-helped-exit-target>
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />

View file

@ -11,10 +11,22 @@ const StyledRightColumn = styled.div`
}
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
align-self: start;
max-width: 672px;
display: flex;
height: calc(100vh - 4.5rem);
justify-content: center;
position: sticky;
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
top: 4.5rem;
}
`;
const VisualFrame = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
}
`;
@ -23,5 +35,9 @@ type RightColumnProps = {
};
export function RightColumn({ children }: RightColumnProps) {
return <StyledRightColumn>{children}</StyledRightColumn>;
return (
<StyledRightColumn>
<VisualFrame>{children}</VisualFrame>
</StyledRightColumn>
);
}

View file

@ -24,7 +24,7 @@ const StyledContainer = styled(Container)`
@media (min-width: ${theme.breakpoints.md}px) {
align-items: start;
column-gap: ${theme.spacing(10)};
grid-template-columns: minmax(0, 1fr) minmax(0, 672px);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
padding-bottom: ${theme.spacing(20)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};

View file

@ -117,7 +117,7 @@ export function StepperVisualFrame({
alt=""
className={patternImageClassName}
fill
sizes="(min-width: 921px) 672px, 100vw"
sizes="(min-width: 921px) 50vw, 100vw"
src={backgroundSrc}
/>
</PatternBackdrop>
@ -132,7 +132,7 @@ export function StepperVisualFrame({
className={shapeImageClassName}
fill
priority={false}
sizes="(min-width: 921px) 672px, 100vw"
sizes="(min-width: 921px) 50vw, 100vw"
src={shapeSrc}
/>
</ShapeOverlay>

View file

@ -26,7 +26,7 @@ const Grid = styled(Container)`
@media (min-width: ${theme.breakpoints.md}px) {
align-items: start;
column-gap: ${theme.spacing(10)};
grid-template-columns: minmax(0, 1fr) minmax(0, 672px);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 100%;
padding-bottom: ${theme.spacing(20)};
padding-left: ${theme.spacing(10)};

View file

@ -61,7 +61,7 @@ export function StepperVisualFrame({
className={shapeImageClassName}
fill
priority={false}
sizes="(min-width: 921px) 672px, 100vw"
sizes="(min-width: 921px) 50vw, 100vw"
src={shapeSrc}
/>
</ShapeOverlay>
@ -70,7 +70,7 @@ export function StepperVisualFrame({
alt=""
className={patternImageClassName}
fill
sizes="(min-width: 921px) 672px, 100vw"
sizes="(min-width: 921px) 50vw, 100vw"
src={backgroundSrc}
/>
</PatternBackdrop>

View file

@ -17,9 +17,22 @@ const VisualColumn = styled.div`
}
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
align-items: center;
align-self: start;
display: flex;
height: calc(100vh - 4.5rem);
justify-content: center;
position: sticky;
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
top: 4.5rem;
}
`;
const VisualFrame = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
}
`;
@ -41,26 +54,28 @@ export function Visual({ activeStepIndex, images }: ProductStepperVisualProps) {
return (
<VisualColumn>
<StepperVisualFrame
backgroundSrc={PRODUCT_STEPPER_BACKGROUND}
shapeSrc={PRODUCT_STEPPER_SHAPE}
>
{images.map((image, index) => {
if (!image) return null;
<VisualFrame>
<StepperVisualFrame
backgroundSrc={PRODUCT_STEPPER_BACKGROUND}
shapeSrc={PRODUCT_STEPPER_SHAPE}
>
{images.map((image, index) => {
if (!image) return null;
return (
<NextImage
key={`${image.src}-${index}`}
alt={image.alt}
className={slideImageClassName}
data-active={String(index === activeStepIndex)}
fill
sizes="(min-width: 921px) 672px, 100vw"
src={image.src}
/>
);
})}
</StepperVisualFrame>
return (
<NextImage
key={`${image.src}-${index}`}
alt={image.alt}
className={slideImageClassName}
data-active={String(index === activeStepIndex)}
fill
sizes="(min-width: 921px) 50vw, 100vw"
src={image.src}
/>
);
})}
</StepperVisualFrame>
</VisualFrame>
</VisualColumn>
);
}

View file

@ -7,15 +7,6 @@ import {
} from '@/lib/shared-asset-paths';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
import {
IconBuildingSkyscraper,
IconCalendarEvent,
@ -28,14 +19,22 @@ import {
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
const TABLER_STROKE = 1.6;
const SCENE_WIDTH = 411;
const SCENE_HEIGHT = 508;
const SCENE_SCALE = 1.025;
const SCENE_SCALE_MD = SCENE_SCALE * 0.86;
const SCENE_SCALE_SM = SCENE_SCALE * 0.74;
const SCENE_BASE_SCALE = 1.025;
const FAMILIAR_INTERFACE_SCENE_VIEWPORT_TRANSFORM = `translateX(-50%) scale(calc(${SCENE_BASE_SCALE} * min(100cqw / ${SCENE_WIDTH}px, 100cqh / ${SCENE_HEIGHT}px)))`;
const FIGMA_CARD_WIDTH = 174.301;
const FIGMA_FIELD_HEIGHT = 22.063;
const FIGMA_FIELD_GAP = 3.677;
@ -299,17 +298,9 @@ const SceneViewport = styled.div`
left: 50%;
position: absolute;
top: 0;
transform: translateX(-50%) scale(${SCENE_SCALE});
transform: ${FAMILIAR_INTERFACE_SCENE_VIEWPORT_TRANSFORM};
transform-origin: top center;
width: ${SCENE_WIDTH}px;
@media (max-width: ${theme.breakpoints.md - 1}px) {
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
}
@media (max-width: 640px) {
transform: translateX(-50%) scale(${SCENE_SCALE_SM});
}
`;
const SceneFrame = styled.div`

View file

@ -28,6 +28,9 @@ import { FastPathGradientBackdrop } from './FastPathGradientBackdrop';
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
const FAST_PATH_NOISE_BACKGROUND =
'url("/images/home/three-cards-feature/fast-path-background-noise.webp")';
const SCENE_DESIGN_WIDTH = 411;
const SCENE_DESIGN_HEIGHT = 524;
const FAST_PATH_SCALED_SCENE_TRANSFORM = `scale(min(100cqw / ${SCENE_DESIGN_WIDTH}px, 100cqh / ${SCENE_DESIGN_HEIGHT}px))`;
const TOOLBAR_VERTICAL_PADDING = 16;
const ACTION_BUTTON_HEIGHT = 24;
const TOOLBAR_TOTAL_HEIGHT =
@ -109,6 +112,16 @@ const ConfettiBurstLayer = styled.div`
position: absolute;
`;
const ScaledScene = styled.div`
height: 100%;
left: 0;
position: absolute;
top: 0;
transform: ${FAST_PATH_SCALED_SCENE_TRANSFORM};
transform-origin: bottom right;
width: 100%;
`;
const ConfettiParticle = styled.div<{
$color: string;
$delay: number;
@ -885,157 +898,162 @@ export function FastPathVisual({
</ConfettiBurstLayer>
))}
</SceneBackdrop>
<PreviewSurface $active={active} ref={previewSurfaceRef}>
<ToolbarRow>
<ActionButton>
<ActionIcon>
<IconPlus size={14} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
<ActionLabel>New Record</ActionLabel>
</ActionButton>
<ActionButton $iconOnly>
<ActionIcon>
<IconChevronUp size={16} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
</ActionButton>
<ActionButton $iconOnly>
<ActionIcon>
<IconChevronDown size={16} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
</ActionButton>
<ActionButton>
<ActionIcon>
<IconDotsVertical size={14} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
<ShortcutDivider />
<ActionLabel $muted>K</ActionLabel>
</ActionButton>
</ToolbarRow>
<ScaledScene>
<PreviewSurface $active={active} ref={previewSurfaceRef}>
<ToolbarRow>
<ActionButton>
<ActionIcon>
<IconPlus size={14} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
<ActionLabel>New Record</ActionLabel>
</ActionButton>
<ActionButton $iconOnly>
<ActionIcon>
<IconChevronUp size={16} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
</ActionButton>
<ActionButton $iconOnly>
<ActionIcon>
<IconChevronDown size={16} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
</ActionButton>
<ActionButton>
<ActionIcon>
<IconDotsVertical size={14} stroke={TOOLBAR_TABLER_STROKE} />
</ActionIcon>
<ShortcutDivider />
<ActionLabel $muted>K</ActionLabel>
</ActionButton>
</ToolbarRow>
<CommandPalette>
<SearchRow>
<IconChevronLeft
color={COLORS.mutedStrong}
size={16}
stroke={TOOLBAR_TABLER_STROKE}
/>
<SearchPlaceholder>Type anything...</SearchPlaceholder>
<SearchSparkles>
<IconSparkles
<CommandPalette>
<SearchRow>
<IconChevronLeft
color={COLORS.mutedStrong}
size={14}
size={16}
stroke={TOOLBAR_TABLER_STROKE}
/>
</SearchSparkles>
</SearchRow>
<SearchPlaceholder>Type anything...</SearchPlaceholder>
<SearchSparkles>
<IconSparkles
color={COLORS.mutedStrong}
size={14}
stroke={TOOLBAR_TABLER_STROKE}
/>
</SearchSparkles>
</SearchRow>
<PaletteBody>
<SectionLabel>Record Selection</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconMail size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
</MenuIconBox>
<MenuItemLabel>Send email</MenuItemLabel>
</MenuItem>
<MenuItem
$active
data-preview-active="true"
onClick={handleCommandClick}
>
<PreviewCursor data-preview-cursor="true">
<PreviewCursorIcon />
</PreviewCursor>
<MenuIconBox>
<IconDatabaseExport
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Export selection as CSV</MenuItemLabel>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconTrash size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
</MenuIconBox>
<MenuItemLabel>Delete 8 records</MenuItemLabel>
</MenuItem>
<PaletteBody>
<SectionLabel>Record Selection</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconMail size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
</MenuIconBox>
<MenuItemLabel>Send email</MenuItemLabel>
</MenuItem>
<MenuItem
$active
data-preview-active="true"
onClick={handleCommandClick}
>
<PreviewCursor data-preview-cursor="true">
<PreviewCursorIcon />
</PreviewCursor>
<MenuIconBox>
<IconDatabaseExport
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Export selection as CSV</MenuItemLabel>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconTrash
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Delete 8 records</MenuItemLabel>
</MenuItem>
<SectionLabel>&quot;Companies&quot; object</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconDatabaseImport
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Import data</MenuItemLabel>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconBuildingSkyscraper
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Create company</MenuItemLabel>
</MenuItem>
<SectionLabel>&quot;Companies&quot; object</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconDatabaseImport
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Import data</MenuItemLabel>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconBuildingSkyscraper
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Create company</MenuItemLabel>
</MenuItem>
<SectionLabel>Navigate</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconArrowUpRight
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Go to People</MenuItemLabel>
<ShortcutHint>
<ShortcutKey>G</ShortcutKey>
then
<ShortcutKey>P</ShortcutKey>
</ShortcutHint>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconArrowUpRight
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Go to Opportunities</MenuItemLabel>
<ShortcutHint>
<ShortcutKey>G</ShortcutKey>
then
<ShortcutKey>O</ShortcutKey>
</ShortcutHint>
</MenuItem>
<SectionLabel>Navigate</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconArrowUpRight
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Go to People</MenuItemLabel>
<ShortcutHint>
<ShortcutKey>G</ShortcutKey>
then
<ShortcutKey>P</ShortcutKey>
</ShortcutHint>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconArrowUpRight
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Go to Opportunities</MenuItemLabel>
<ShortcutHint>
<ShortcutKey>G</ShortcutKey>
then
<ShortcutKey>O</ShortcutKey>
</ShortcutHint>
</MenuItem>
<SectionLabel>Settings</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconArrowUpRight
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Go to settings</MenuItemLabel>
<ShortcutHint>
<ShortcutKey>G</ShortcutKey>
then
<ShortcutKey>S</ShortcutKey>
</ShortcutHint>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconMoon size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
</MenuIconBox>
<MenuItemLabel>Switch to dark mode</MenuItemLabel>
</MenuItem>
<SectionSpacer />
<SectionSpacer />
</PaletteBody>
</CommandPalette>
</PreviewSurface>
<SectionLabel>Settings</SectionLabel>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconArrowUpRight
size={MENU_ICON_SIZE}
stroke={MENU_TABLER_STROKE}
/>
</MenuIconBox>
<MenuItemLabel>Go to settings</MenuItemLabel>
<ShortcutHint>
<ShortcutKey>G</ShortcutKey>
then
<ShortcutKey>S</ShortcutKey>
</ShortcutHint>
</MenuItem>
<MenuItem onClick={handleCommandClick}>
<MenuIconBox>
<IconMoon size={MENU_ICON_SIZE} stroke={MENU_TABLER_STROKE} />
</MenuIconBox>
<MenuItemLabel>Switch to dark mode</MenuItemLabel>
</MenuItem>
<SectionSpacer />
<SectionSpacer />
</PaletteBody>
</CommandPalette>
</PreviewSurface>
</ScaledScene>
</VisualRoot>
);
}

View file

@ -34,6 +34,7 @@ const CardImage = styled.div`
const CardImageFrame = styled.div`
background-color: ${theme.colors.primary.border[10]};
border-radius: 2px;
container-type: size;
height: 100%;
isolation: isolate;
overflow: hidden;

View file

@ -17,9 +17,7 @@ import { LiveDataHeroTable } from './LiveDataHeroTable';
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
const SCENE_HEIGHT = 508;
const SCENE_WIDTH = 411;
const SCENE_SCALE = 1;
const SCENE_SCALE_MD = 0.86;
const SCENE_SCALE_SM = 0.74;
const LIVE_DATA_SCENE_VIEWPORT_TRANSFORM = `translateX(-50%) scale(min(100cqw / ${SCENE_WIDTH}px, 100cqh / ${SCENE_HEIGHT}px))`;
const TABLE_PANEL_HOVER_SCALE = 1.012;
const TABLER_STROKE = 1.65;
const FILTER_ICON_STROKE = 1.33;
@ -128,17 +126,9 @@ const SceneViewport = styled.div`
left: 50%;
position: absolute;
top: 0;
transform: translateX(-50%) scale(${SCENE_SCALE});
transform: ${LIVE_DATA_SCENE_VIEWPORT_TRANSFORM};
transform-origin: top center;
width: 101%;
@media (max-width: ${theme.breakpoints.md - 1}px) {
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
}
@media (max-width: 640px) {
transform: translateX(-50%) scale(${SCENE_SCALE_SM});
}
`;
const SceneFrame = styled.div`
@ -181,9 +171,7 @@ const TablePanel = styled.div<{ $active?: boolean }>`
position: absolute;
right: 0px;
transform: ${({ $active }) =>
`translate3d(0, 0, 0) scale(${
$active ? TABLE_PANEL_HOVER_SCALE : 1
})`};
`translate3d(0, 0, 0) scale(${$active ? TABLE_PANEL_HOVER_SCALE : 1})`};
transform-origin: bottom right;
transition:
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
@ -895,10 +883,7 @@ export function LiveDataVisual({
$bottom={bobCursor.bottom}
$right={bobCursor.right}
>
<MarkerCursorSlot
$pressed={phase === 'remove-filter'}
$visible
>
<MarkerCursorSlot $pressed={phase === 'remove-filter'} $visible>
<MarkerCursor
color={COLORS.bobCursor}
rotation={bobCursor.rotation}

View file

@ -19,10 +19,22 @@ const IllustrationColumn = styled.div`
}
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
align-self: start;
max-width: 672px;
display: flex;
height: calc(100vh - 4.5rem);
justify-content: center;
position: sticky;
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
top: 4.5rem;
}
`;
const IllustrationFrame = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
}
`;
@ -73,7 +85,9 @@ export function Flow({ body, heading, illustration }: FlowProps) {
onMobileStepIndexChange={setMobileStepIndex}
/>
<IllustrationColumn>
<IllustrationMount illustration={illustration} />
<IllustrationFrame>
<IllustrationMount illustration={illustration} />
</IllustrationFrame>
</IllustrationColumn>
</Root>
);

View file

@ -26,7 +26,7 @@ const Grid = styled(Container)`
@media (min-width: ${theme.breakpoints.md}px) {
align-items: start;
column-gap: ${theme.spacing(10)};
grid-template-columns: minmax(0, 1fr) minmax(0, 672px);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 100%;
padding-bottom: ${theme.spacing(20)};
padding-left: ${theme.spacing(10)};

View file

@ -0,0 +1,188 @@
import * as crypto from 'crypto';
export type EnterpriseKeyPayload = {
sub: string;
licensee: string;
iat: number;
};
export type EnterpriseValidityPayload = {
sub: string;
status: 'valid';
iat: number;
exp: number;
};
const ALGORITHM = 'RS256';
const DEFAULT_VALIDITY_TOKEN_DURATION_DAYS = 30;
const getValidityTokenDurationDays = (): number => {
const value = process.env.ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS;
if (value === undefined || value === '') {
return DEFAULT_VALIDITY_TOKEN_DURATION_DAYS;
}
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 1) {
return DEFAULT_VALIDITY_TOKEN_DURATION_DAYS;
}
return parsed;
};
export type SignValidityTokenOptions = {
subscriptionCancelAt: number | null;
};
const computeValidityExp = (
nowSeconds: number,
durationDays: number,
subscriptionCancelAt: number | null,
): number => {
const defaultExp = nowSeconds + durationDays * 24 * 60 * 60;
if (subscriptionCancelAt === null || subscriptionCancelAt <= 0) {
return defaultExp;
}
return Math.min(defaultExp, subscriptionCancelAt);
};
const getPrivateKey = (): string => {
const key = process.env.ENTERPRISE_JWT_PRIVATE_KEY;
if (!key) {
throw new Error('ENTERPRISE_JWT_PRIVATE_KEY is not configured');
}
return key.replace(/\\n/g, '\n');
};
const base64UrlEncode = (data: string): string => {
return Buffer.from(data)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
const base64UrlDecode = (data: string): string => {
const padded = data + '='.repeat((4 - (data.length % 4)) % 4);
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(base64, 'base64').toString('utf-8');
};
const signJwt = (
payload: Record<string, unknown>,
privateKey: string,
): string => {
const header = { alg: ALGORITHM, typ: 'JWT' };
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = crypto
.sign('sha256', Buffer.from(signingInput), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
})
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `${signingInput}.${signature}`;
};
export const verifyJwt = <T extends Record<string, unknown>>(
token: string,
publicKey: string,
): T | null => {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const [encodedHeader, encodedPayload, signature] = parts;
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signatureBuffer = Buffer.from(
signature.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - (signature.length % 4)) % 4),
'base64',
);
const isValid = crypto.verify(
'sha256',
Buffer.from(signingInput),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
},
signatureBuffer,
);
if (!isValid) {
return null;
}
return JSON.parse(base64UrlDecode(encodedPayload)) as T;
} catch {
return null;
}
};
export const signEnterpriseKey = (
subscriptionId: string,
licensee: string,
): string => {
const payload: EnterpriseKeyPayload = {
sub: subscriptionId,
licensee,
iat: Math.floor(Date.now() / 1000),
};
return signJwt(payload, getPrivateKey());
};
export const signValidityToken = (
subscriptionId: string,
options?: SignValidityTokenOptions,
): string => {
const now = Math.floor(Date.now() / 1000);
const durationDays = getValidityTokenDurationDays();
const subscriptionCancelAt = options?.subscriptionCancelAt ?? null;
const exp = computeValidityExp(now, durationDays, subscriptionCancelAt);
const payload: EnterpriseValidityPayload = {
sub: subscriptionId,
status: 'valid',
iat: now,
exp,
};
return signJwt(payload, getPrivateKey());
};
export const verifyEnterpriseKey = (
token: string,
): EnterpriseKeyPayload | null => {
const publicKey = getPublicKey();
return verifyJwt<EnterpriseKeyPayload>(token, publicKey);
};
const getPublicKey = (): string => {
const key = process.env.ENTERPRISE_JWT_PUBLIC_KEY;
if (!key) {
throw new Error('ENTERPRISE_JWT_PUBLIC_KEY is not configured');
}
return key.replace(/\\n/g, '\n');
};

View file

@ -0,0 +1,34 @@
import Stripe from 'stripe';
let stripeInstance: Stripe | null = null;
export const getStripeClient = (): Stripe => {
if (!stripeInstance) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('STRIPE_SECRET_KEY is not configured');
}
stripeInstance = new Stripe(secretKey, {});
}
return stripeInstance;
};
export const getEnterprisePriceId = (
billingInterval: 'monthly' | 'yearly' = 'monthly',
): string => {
const envKey =
billingInterval === 'yearly'
? 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID'
: 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID';
const priceId = process.env[envKey];
if (!priceId) {
throw new Error(`${envKey} is not configured`);
}
return priceId;
};

View file

@ -0,0 +1,17 @@
import type Stripe from 'stripe';
// Stripe's generated `Subscription` type can lag the REST shape; the API still
// exposes period boundaries used for license validity.
type SubscriptionWithPeriodBounds = Stripe.Subscription & {
current_period_end?: number;
};
export const getSubscriptionCurrentPeriodEnd = (
subscription: Stripe.Response<Stripe.Subscription>,
): number | null => {
const extended =
subscription as Stripe.Response<SubscriptionWithPeriodBounds>;
const end = extended.current_period_end;
return typeof end === 'number' ? end : null;
};

View file

@ -60945,6 +60945,7 @@ __metadata:
react-markdown: "npm:^10.1.0"
remark-gfm: "npm:^4.0.1"
sharp: "npm:^0.33.5"
stripe: "npm:^20.3.1"
three: "npm:^0.183.2"
zod: "npm:^4.1.11"
languageName: unknown