diff --git a/packages/twenty-website-new/.env.example b/packages/twenty-website-new/.env.example index d27ad4304f1..b035dbf41cf 100644 --- a/packages/twenty-website-new/.env.example +++ b/packages/twenty-website-new/.env.example @@ -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= diff --git a/packages/twenty-website-new/package.json b/packages/twenty-website-new/package.json index 67b684a8b57..f0eff2953dd 100644 --- a/packages/twenty-website-new/package.json +++ b/packages/twenty-website-new/package.json @@ -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" }, diff --git a/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts b/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts new file mode 100644 index 00000000000..2d6d30603e5 --- /dev/null +++ b/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts @@ -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 = [ + '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 = + ['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 }, + ); + } +} diff --git a/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts b/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts new file mode 100644 index 00000000000..f0870ecc7ec --- /dev/null +++ b/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts b/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts new file mode 100644 index 00000000000..42f4362e0a8 --- /dev/null +++ b/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts b/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts new file mode 100644 index 00000000000..529b18ec263 --- /dev/null +++ b/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-website-new/src/app/api/enterprise/status/route.ts b/packages/twenty-website-new/src/app/api/enterprise/status/route.ts new file mode 100644 index 00000000000..33f0c2bbc7e --- /dev/null +++ b/packages/twenty-website-new/src/app/api/enterprise/status/route.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts b/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts new file mode 100644 index 00000000000..7c841792bbf --- /dev/null +++ b/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts @@ -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 }, + ); + } +} diff --git a/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx b/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx new file mode 100644 index 00000000000..fb23a66e239 --- /dev/null +++ b/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx @@ -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(null); + const [error, setError] = useState(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 = + 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 ( + + {'Enterprise activation'} + + {loading &&

{'Activating your enterprise license…'}

} + + {error !== null && {error}} + + {result !== null && ( +
+ + {'Your enterprise license has been activated successfully.'} + + + + {'Licensee:'} {result.licensee} + + + {'Your enterprise key'} + + { + 'Copy this key and paste it into your Twenty self-hosted instance settings.' + } + + + + {result.enterpriseKey} + void handleCopy()} + type="button" + > + {copied ? 'Copied!' : 'Copy'} + + + + + {'Next steps'} + +
  • {'Copy the enterprise key above.'}
  • +
  • + {'Open your Twenty self-hosted instance Settings → Enterprise.'} +
  • +
  • {'Paste the key and click Activate.'}
  • +
    +
    +
    + )} +
    + ); +} diff --git a/packages/twenty-website-new/src/app/enterprise/activate/page.tsx b/packages/twenty-website-new/src/app/enterprise/activate/page.tsx new file mode 100644 index 00000000000..2dcd23c1af6 --- /dev/null +++ b/packages/twenty-website-new/src/app/enterprise/activate/page.tsx @@ -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 ( +
    {'Loading activation…'}
    + ); +} + +export default function EnterpriseActivatePage() { + return ( + }> + + + ); +} diff --git a/packages/twenty-website-new/src/illustrations/WhyTwentyStepper/Logo.tsx b/packages/twenty-website-new/src/illustrations/WhyTwentyStepper/Logo.tsx index dc7d756f632..d015491fd1e 100644 --- a/packages/twenty-website-new/src/illustrations/WhyTwentyStepper/Logo.tsx +++ b/packages/twenty-website-new/src/illustrations/WhyTwentyStepper/Logo.tsx @@ -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; } `; diff --git a/packages/twenty-website-new/src/sections/Helped/components/Scene/Scene.tsx b/packages/twenty-website-new/src/sections/Helped/components/Scene/Scene.tsx index 5bd236be774..8282eb76bf4 100644 --- a/packages/twenty-website-new/src/sections/Helped/components/Scene/Scene.tsx +++ b/packages/twenty-website-new/src/sections/Helped/components/Scene/Scene.tsx @@ -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} /> - + diff --git a/packages/twenty-website-new/src/sections/HomeStepper/components/RightColumn/RightColumn.tsx b/packages/twenty-website-new/src/sections/HomeStepper/components/RightColumn/RightColumn.tsx index 060f0d2c14e..208bc0e4f20 100644 --- a/packages/twenty-website-new/src/sections/HomeStepper/components/RightColumn/RightColumn.tsx +++ b/packages/twenty-website-new/src/sections/HomeStepper/components/RightColumn/RightColumn.tsx @@ -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 {children}; + return ( + + {children} + + ); } diff --git a/packages/twenty-website-new/src/sections/HomeStepper/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/HomeStepper/components/Root/Root.tsx index 4067dca83fa..22652bc976c 100644 --- a/packages/twenty-website-new/src/sections/HomeStepper/components/Root/Root.tsx +++ b/packages/twenty-website-new/src/sections/HomeStepper/components/Root/Root.tsx @@ -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)}; diff --git a/packages/twenty-website-new/src/sections/HomeStepper/components/StepperVisualFrame/StepperVisualFrame.tsx b/packages/twenty-website-new/src/sections/HomeStepper/components/StepperVisualFrame/StepperVisualFrame.tsx index 809e44f92bc..97477a64b19 100644 --- a/packages/twenty-website-new/src/sections/HomeStepper/components/StepperVisualFrame/StepperVisualFrame.tsx +++ b/packages/twenty-website-new/src/sections/HomeStepper/components/StepperVisualFrame/StepperVisualFrame.tsx @@ -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} /> @@ -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} /> diff --git a/packages/twenty-website-new/src/sections/ProductStepper/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/ProductStepper/components/Root/Root.tsx index dc18abd87eb..aa6c216926f 100644 --- a/packages/twenty-website-new/src/sections/ProductStepper/components/Root/Root.tsx +++ b/packages/twenty-website-new/src/sections/ProductStepper/components/Root/Root.tsx @@ -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)}; diff --git a/packages/twenty-website-new/src/sections/ProductStepper/components/StepperVisualFrame/StepperVisualFrame.tsx b/packages/twenty-website-new/src/sections/ProductStepper/components/StepperVisualFrame/StepperVisualFrame.tsx index c2cd21feae1..e2076d68f29 100644 --- a/packages/twenty-website-new/src/sections/ProductStepper/components/StepperVisualFrame/StepperVisualFrame.tsx +++ b/packages/twenty-website-new/src/sections/ProductStepper/components/StepperVisualFrame/StepperVisualFrame.tsx @@ -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} /> @@ -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} /> diff --git a/packages/twenty-website-new/src/sections/ProductStepper/components/Visual/Visual.tsx b/packages/twenty-website-new/src/sections/ProductStepper/components/Visual/Visual.tsx index 41c36fad816..18bc27f2a99 100644 --- a/packages/twenty-website-new/src/sections/ProductStepper/components/Visual/Visual.tsx +++ b/packages/twenty-website-new/src/sections/ProductStepper/components/Visual/Visual.tsx @@ -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 ( - - {images.map((image, index) => { - if (!image) return null; + + + {images.map((image, index) => { + if (!image) return null; - return ( - - ); - })} - + return ( + + ); + })} + + ); } diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FamiliarInterfaceVisual.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FamiliarInterfaceVisual.tsx index cd4c3e88279..abcf137edde 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FamiliarInterfaceVisual.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FamiliarInterfaceVisual.tsx @@ -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` diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx index bcf6fa8f2b2..25199ecdfd5 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FastPathVisual.tsx @@ -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({ ))} - - - - - - - New Record - - - - - - - - - - - - - - - - - ⌘K - - + + + + + + + + New Record + + + + + + + + + + + + + + + + + ⌘K + + - - - - Type anything... - - + + - - + Type anything... + + + + - - Record Selection - - - - - Send email - - - - - - - - - Export selection as CSV - - - - - - Delete 8 records - + + Record Selection + + + + + Send email + + + + + + + + + Export selection as CSV + + + + + + Delete 8 records + - "Companies" object - - - - - Import data - - - - - - Create company - + "Companies" object + + + + + Import data + + + + + + Create company + - Navigate - - - - - Go to People - - G - then - P - - - - - - - Go to Opportunities - - G - then - O - - + Navigate + + + + + Go to People + + G + then + P + + + + + + + Go to Opportunities + + G + then + O + + - Settings - - - - - Go to settings - - G - then - S - - - - - - - Switch to dark mode - - - - - - + Settings + + + + + Go to settings + + G + then + S + + + + + + + Switch to dark mode + + + + + + + ); } diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FeatureCard.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FeatureCard.tsx index 4562c703500..705eb2dd6de 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FeatureCard.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/FeatureCard.tsx @@ -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; diff --git a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx index 23d9127b289..38a4e4db9d2 100644 --- a/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx +++ b/packages/twenty-website-new/src/sections/ThreeCards/components/FeatureCard/LiveDataVisual.tsx @@ -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} > - + - + + + ); diff --git a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root/Root.tsx b/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root/Root.tsx index 6b5ab3b2df4..645c7ebc251 100644 --- a/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root/Root.tsx +++ b/packages/twenty-website-new/src/sections/WhyTwentyStepper/components/Root/Root.tsx @@ -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)}; diff --git a/packages/twenty-website-new/src/shared/enterprise/enterprise-jwt.ts b/packages/twenty-website-new/src/shared/enterprise/enterprise-jwt.ts new file mode 100644 index 00000000000..ae105941f80 --- /dev/null +++ b/packages/twenty-website-new/src/shared/enterprise/enterprise-jwt.ts @@ -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, + 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 = >( + 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(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'); +}; diff --git a/packages/twenty-website-new/src/shared/enterprise/stripe-client.ts b/packages/twenty-website-new/src/shared/enterprise/stripe-client.ts new file mode 100644 index 00000000000..133801c30e1 --- /dev/null +++ b/packages/twenty-website-new/src/shared/enterprise/stripe-client.ts @@ -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; +}; diff --git a/packages/twenty-website-new/src/shared/enterprise/stripe-subscription-helpers.ts b/packages/twenty-website-new/src/shared/enterprise/stripe-subscription-helpers.ts new file mode 100644 index 00000000000..17af7e50c9e --- /dev/null +++ b/packages/twenty-website-new/src/shared/enterprise/stripe-subscription-helpers.ts @@ -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, +): number | null => { + const extended = + subscription as Stripe.Response; + const end = extended.current_period_end; + + return typeof end === 'number' ? end : null; +}; diff --git a/yarn.lock b/yarn.lock index 111aafd870c..596c937e353 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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