Files
twenty/packages/twenty-front/src/pages/onboarding/WorkspaceActivation.tsx
T
Félix Malfait adf6eb572b feat(billing): embed Stripe Payment Element in onboarding (#21759)
## What & why

Replaces the hosted Stripe Checkout redirect on the onboarding "Choose
your plan" step (credit-card trial) with an inline Stripe **Payment
Element**, so users never leave the app to enter card details.

## How it works

- **Frontend:** a deferred `<Elements mode="setup">` renders the Payment
Element, themed via the Appearance API. On Continue: `elements.submit()`
→ `checkoutSession` mutation creates the trialing subscription
server-side and returns its pending SetupIntent `clientSecret` →
`stripe.confirmSetup()` confirms the card (handling 3DS) → redirect to
the existing `/plan-required/payment-success`.
- **Backend:** new `BILLING_STRIPE_PUBLISHABLE_KEY` config var exposed
via `/client-config`; the card path creates the subscription with
`payment_behavior: default_incomplete` + a free trial (so Stripe
attaches a `pending_setup_intent`) and returns its client secret. The
hosted-Checkout code path is removed.
- The **no-credit-card** trial path is unchanged.
- Billing address collection is **disabled** in the Payment Element to
reduce friction; `automatic_tax` is correspondingly disabled (tax needs
an address — collect it later, e.g. at conversion / via the billing
portal).

## Required before this works
1. Set `BILLING_STRIPE_PUBLISHABLE_KEY` (`pk_…`) on the server (infra
change pending).
2. Run `nx run twenty-front:graphql:generate --configuration=metadata`
against a server exposing the updated schema (see inline note on the
hand-authored document).
3. Verify in Stripe test mode: happy path, 3DS (`4000 0025 0000 3155`),
a decline.

## Verified
typecheck (front + server), oxlint + oxfmt clean,
`client-config.service.spec` passing. Not run here: the app end-to-end /
Stripe test mode and `graphql:generate` (no server/DB in the dev
container).

I've left self-review comments inline flagging cleanup opportunities
plus a couple of architectural/tech-debt items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21759?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 11:40:55 +02:00

186 lines
5.6 KiB
TypeScript

import { styled } from '@linaria/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ModalContent } from 'twenty-ui/surfaces';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { ActivateWorkspaceDocument } from '~/generated-metadata/graphql';
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
width: 200px;
`;
const StyledLoaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[8]};
margin-top: ${themeCssVariables.spacing[8]};
width: 100%;
`;
type ActivationStep = 'pending' | 'database' | 'data-model' | 'prefill';
const StyledActivationStep = styled.div`
align-items: center;
display: flex;
justify-content: center;
width: 100%;
`;
export const WorkspaceActivation = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const { loadCurrentUser } = useLoadCurrentUser();
const [activateWorkspace, { loading: isActivating }] = useMutation(
ActivateWorkspaceDocument,
);
const [activationStep, setActivationStep] =
useState<ActivationStep>('pending');
const [hasFailed, setHasFailed] = useState(false);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const activate = useCallback(async () => {
setHasFailed(false);
const databaseTimeout = setTimeout(() => {
setActivationStep('database');
}, 500);
const dataModelTimeout = setTimeout(() => {
setActivationStep('data-model');
}, 2000);
const prefillTimeout = setTimeout(() => {
setActivationStep('prefill');
}, 5000);
const clearStepTimeouts = () => {
clearTimeout(databaseTimeout);
clearTimeout(dataModelTimeout);
clearTimeout(prefillTimeout);
};
try {
const result = await activateWorkspace({
variables: {
input: {},
},
});
if (isDefined(result.error)) {
throw result.error;
}
await loadCurrentUser();
setNextOnboardingStatus();
} catch (error) {
setActivationStep('pending');
setHasFailed(true);
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
} finally {
clearStepTimeouts();
}
}, [
activateWorkspace,
enqueueErrorSnackBar,
loadCurrentUser,
setNextOnboardingStatus,
]);
// Guard the one-shot trigger with a ref, not state: a ref mutation is
// synchronous and survives StrictMode's double-invocation of effects, whereas
// a state flag stays false in the second (same-closure) invocation and fires
// a second concurrent activation that trips the server's lock.
// oxlint-disable-next-line twenty/no-state-useref
const hasTriggeredRef = useRef(false);
useEffect(() => {
if (hasTriggeredRef.current || !isDefined(currentWorkspace)) {
return;
}
hasTriggeredRef.current = true;
void activate();
}, [activate, currentWorkspace]);
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<Logo
primaryLogo={
isNonEmptyString(currentWorkspace?.logo)
? currentWorkspace?.logo
: undefined
}
/>
<Title>
{hasFailed ? (
<Trans>Workspace creation failed</Trans>
) : (
<Trans>Creating your workspace</Trans>
)}
</Title>
{hasFailed ? (
<>
<SubTitle>
<Trans>
Something went wrong while creating your workspace. Please try
again.
</Trans>
</SubTitle>
<StyledButtonContainer>
<MainButton
title={t`Retry`}
onClick={() => {
void activate();
}}
disabled={isActivating}
fullWidth
/>
</StyledButtonContainer>
</>
) : (
<>
<StyledActivationStep>
{activationStep === 'database' && (
<SubTitle>
<Trans>Setting up your database...</Trans>
</SubTitle>
)}
{activationStep === 'data-model' && (
<SubTitle>
<Trans>Creating your data model...</Trans>
</SubTitle>
)}
{activationStep === 'prefill' && (
<SubTitle>
<Trans>Prefilling your workspace data...</Trans>
</SubTitle>
)}
</StyledActivationStep>
<StyledLoaderContainer>
<Loader color="gray" />
</StyledLoaderContainer>
</>
)}
</ModalContent>
);
};