adf6eb572b
## 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>
186 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
};
|