Remove v1 onboarding and rely only on v2 (#22398)

https://github.com/user-attachments/assets/a6bfaac3-6c79-4fd5-999a-e6a70cff8ac8


Removes the old (v1) signup and onboarding flow now that v2 is the only
path, and drops the `isOnboardingV2` flag entirely. The surviving
(formerly-v2) pages reclaim the canonical `AppPath` members and clean
URLs (`/welcome`, `/verify`, `/workspace-activation`, `/create/profile`,
`/sync/emails`, `/install-apps`, `/invite-team`, `/plan-required`).

- Deletes the v1 pages, the v1 workspace-creation form, the
`isOnboardingV2State` flag + `onboardingV2` URL-param plumbing, and
`InstallAppsAutoSkipEffect`.
- Collapses the router and page-change navigation matrix to a single set
of paths, and renames the v2 components/stories to drop the `V2` suffix.

Follow-up fixes so the single flow behaves correctly on every
deployment:

- Restore the captcha-token, query-param and pageview effects on the
default (root) domain, and serve `/authorize` there so OAuth login keeps
working.
- Gate the invite-team → `/plan-required` interception on billing so
billing-disabled instances aren't trapped on the upgrade page.
- On a cold boot to an auth/onboarding path, show the onboarding loader
instead of the CRM skeleton, and add `/verify-email` and
`/plan-required/payment-success` to that loader path list.
- Add a retry to PaymentSuccess after the confirmation timeout, fix the
InstallApps icon crossfade, restyle the book-call pages for the
full-page layout, and delete code orphaned by the v1 removal.
- Extract the pageview/captcha/query-param logic out of
`PageChangeEffect` into standalone Effect components shared by the root
and workspace app trees.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22398?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: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Raphaël Bosi
2026-07-03 18:12:34 +02:00
committed by GitHub
parent 9f4efa57ff
commit 566c3b6629
158 changed files with 3579 additions and 5374 deletions
@@ -0,0 +1,46 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
setSessionId,
useEventTracker,
} from '@/analytics/hooks/useEventTracker';
import { AnalyticsType } from '~/generated-metadata/graphql';
import { getPageTitleFromPath } from '~/utils/title-utils';
const PAGEVIEW_TRACKING_DELAY_IN_MS = 500;
const stripQueryAndHash = (url: string): string => {
try {
const { origin, pathname } = new URL(url);
return `${origin}${pathname}`;
} catch {
return '';
}
};
export const TrackPageViewEffect = () => {
const location = useLocation();
const eventTracker = useEventTracker();
useEffect(() => {
const timeoutId = setTimeout(() => {
setSessionId();
eventTracker(AnalyticsType['PAGEVIEW'], {
name: getPageTitleFromPath(location.pathname),
properties: {
pathname: location.pathname,
locale: navigator.language,
userAgent: window.navigator.userAgent,
href: stripQueryAndHash(window.location.href),
referrer: stripQueryAndHash(document.referrer),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
}, PAGEVIEW_TRACKING_DELAY_IN_MS);
return () => clearTimeout(timeoutId);
}, [eventTracker, location.pathname]);
return null;
};
@@ -76,7 +76,6 @@ export const useApolloFactory = (options: Partial<Options> = {}) => {
setCurrentUserWorkspace(null);
if (
!isMatchingLocation(locationRef.current, AppPath.Verify) &&
!isMatchingLocation(locationRef.current, AppPath.VerifyV2) &&
!isMatchingLocation(locationRef.current, AppPath.SignInUp) &&
!isMatchingLocation(locationRef.current, AppPath.Invite) &&
!isMatchingLocation(locationRef.current, AppPath.ResetPassword)
@@ -1,4 +1,4 @@
import { AppRouter } from '@/app/components/AppRouter';
import { DomainShell } from '@/app/components/DomainShell';
import { I18nActivationGate } from '@/app/components/I18nActivationGate';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
@@ -35,7 +35,7 @@ export const App = () => {
<ClickOutsideListenerContext.Provider
value={{ excludedClickOutsideId: undefined }}
>
<AppRouter />
<DomainShell />
</ClickOutsideListenerContext.Provider>
</HelmetProvider>
</ExceptionHandlerProvider>
@@ -1,98 +0,0 @@
import { AgentChatProvider } from '@/ai/components/AgentChatProvider';
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { CommandMenuConfirmationModalManager } from '@/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager';
import { MinimalMetadataGater } from '@/metadata-store/components/MinimalMetadataGater';
import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components/IsMinimalMetadataReadyEffect';
import { GotoHotkeysEffectsProvider } from '@/app/effect-components/GotoHotkeysEffectsProvider';
import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect';
import { AuthProvider } from '@/auth/components/AuthProvider';
import { SignOutOnOtherTabSignOutEffect } from '@/auth/effect-components/SignOutOnOtherTabSignOutEffect';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { ApolloAdminProvider } from '@/settings/admin-panel/apollo/components/ApolloAdminProvider';
import { EndTrialAfterPaymentMethodGater } from '@/settings/billing/components/EndTrialAfterPaymentMethodGater';
import { CommandRunner } from '@/command-menu-item/engine-command/components/CommandRunner';
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
import { SupportChatEffect } from '@/support/components/SupportChatEffect';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogComponentInstanceContext } from '@/ui/feedback/dialog-manager/contexts/DialogComponentInstanceContext';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { GlobalFilePreviewModal } from '@/ui/field/display/components/GlobalFilePreviewModal';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { getPageTitleFromPath } from '~/utils/title-utils';
export const AppRouterProviders = () => {
const { pathname } = useLocation();
const pageTitle = getPageTitleFromPath(pathname);
return (
<ApolloProvider>
<BaseThemeProvider>
<ClientConfigProviderEffect />
<UserMetadataProviderInitialEffect />
<MinimalMetadataLoadEffect />
<IsMinimalMetadataReadyEffect />
<WorkspaceProviderEffect />
<ClientConfigProvider>
<CaptchaProvider>
<MinimalMetadataGater>
<AuthProvider>
<ApolloCoreProvider>
<ApolloAdminProvider>
<SSEProvider>
<PreComputedChipGeneratorsProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<ErrorMessageEffect />
<AgentChatProvider>
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<EndTrialAfterPaymentMethodGater />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
<CommandMenuConfirmationModalManager />
<CommandRunner />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
</AgentChatProvider>
</SnackBarProvider>
<MainContextStoreProvider />
<SupportChatEffect />
<PageChangeEffect />
<SignOutOnOtherTabSignOutEffect />
</PreComputedChipGeneratorsProvider>
</SSEProvider>
</ApolloAdminProvider>
</ApolloCoreProvider>
</AuthProvider>
</MinimalMetadataGater>
</CaptchaProvider>
</ClientConfigProvider>
</BaseThemeProvider>
</ApolloProvider>
);
};
@@ -0,0 +1,40 @@
import { BrowserRouter } from 'react-router-dom';
import { RootApp } from '@/app/components/RootApp';
import { SharedAppProviders } from '@/app/components/SharedAppProviders';
import { WorkspaceApp } from '@/app/components/WorkspaceApp';
import { isOnOnboardingTransitionPath } from '@/auth/utils/isOnOnboardingTransitionPath';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain';
import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
export const DomainShell = () => {
const { isLoadedOnce } = useAtomStateValue(clientConfigApiStatusState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
if (!isLoadedOnce) {
return (
<BrowserRouter>
<SharedAppProviders>
{isOnOnboardingTransitionPath(window.location.pathname) ? (
<OnboardingPageLoader />
) : (
<UserOrMetadataLoader />
)}
</SharedAppProviders>
</BrowserRouter>
);
}
if (!isMultiWorkspaceEnabled) {
return <WorkspaceApp />;
}
return isDefaultDomain ? <RootApp /> : <WorkspaceApp />;
};
@@ -1,10 +1,8 @@
import { ONBOARDING_V2_PATHS } from '@/auth/constants/OnboardingV2Paths';
import { isOnOnboardingTransitionPath } from '@/auth/utils/isOnOnboardingTransitionPath';
import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
import { i18n } from '@lingui/core';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode, useEffect, useState } from 'react';
import { matchPath } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
type I18nActivationGateProps = {
children: ReactNode;
@@ -29,11 +27,9 @@ export const I18nActivationGate = ({ children }: I18nActivationGateProps) => {
}, []);
if (!isLocaleActivated) {
const isOnboardingLocation = ONBOARDING_V2_PATHS.some((onboardingPath) =>
isDefined(matchPath(onboardingPath, window.location.pathname)),
);
return isOnboardingLocation ? <OnboardingPageLoader /> : null;
return isOnOnboardingTransitionPath(window.location.pathname) ? (
<OnboardingPageLoader />
) : null;
}
return <>{children}</>;
@@ -0,0 +1,7 @@
import { RouterProvider } from 'react-router-dom';
import { useCreateRootAppRouter } from '@/app/hooks/useCreateRootAppRouter';
export const RootApp = () => {
return <RouterProvider router={useCreateRootAppRouter()} />;
};
@@ -0,0 +1,55 @@
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { TrackPageViewEffect } from '@/analytics/components/TrackPageViewEffect';
import { SharedAppProviders } from '@/app/components/SharedAppProviders';
import { InitializeQueryParamStateEffect } from '@/app/effect-components/InitializeQueryParamStateEffect';
import { AuthProvider } from '@/auth/components/AuthProvider';
import { SignOutOnOtherTabSignOutEffect } from '@/auth/effect-components/SignOutOnOtherTabSignOutEffect';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { RequestFreshCaptchaTokenEffect } from '@/captcha/components/RequestFreshCaptchaTokenEffect';
import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogComponentInstanceContext } from '@/ui/feedback/dialog-manager/contexts/DialogComponentInstanceContext';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { getPageTitleFromPath } from '~/utils/title-utils';
export const RootAppProviders = () => {
const { pathname } = useLocation();
const pageTitle = getPageTitleFromPath(pathname);
return (
<SharedAppProviders>
<CaptchaProvider>
<UserMetadataProviderInitialEffect />
<WorkspaceProviderEffect />
<AuthProvider>
<SnackBarProvider>
<ErrorMessageEffect />
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<InitializeQueryParamStateEffect />
<TrackPageViewEffect />
<RequestFreshCaptchaTokenEffect />
<SignOutOnOtherTabSignOutEffect />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
</SnackBarProvider>
</AuthProvider>
</CaptchaProvider>
</SharedAppProviders>
);
};
@@ -0,0 +1,19 @@
import { type PropsWithChildren } from 'react';
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
type SharedAppProvidersProps = PropsWithChildren;
export const SharedAppProviders = ({ children }: SharedAppProvidersProps) => {
return (
<ApolloProvider>
<BaseThemeProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>{children}</ClientConfigProvider>
</BaseThemeProvider>
</ApolloProvider>
);
};
@@ -1,10 +1,10 @@
import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter';
import { currentUserState } from '@/auth/states/currentUserState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { RouterProvider } from 'react-router-dom';
export const AppRouter = () => {
// We want to disable logic function settings but keep the code for now
import { useCreateWorkspaceAppRouter } from '@/app/hooks/useCreateWorkspaceAppRouter';
import { currentUserState } from '@/auth/states/currentUserState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const WorkspaceApp = () => {
const isFunctionSettingsEnabled = false;
const currentUser = useAtomStateValue(currentUserState);
@@ -15,7 +15,10 @@ export const AppRouter = () => {
return (
<RouterProvider
router={useCreateAppRouter(isFunctionSettingsEnabled, isAdminPageEnabled)}
router={useCreateWorkspaceAppRouter(
isFunctionSettingsEnabled,
isAdminPageEnabled,
)}
/>
);
};
@@ -0,0 +1,92 @@
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { AgentChatProvider } from '@/ai/components/AgentChatProvider';
import { TrackPageViewEffect } from '@/analytics/components/TrackPageViewEffect';
import { SharedAppProviders } from '@/app/components/SharedAppProviders';
import { GotoHotkeysEffectsProvider } from '@/app/effect-components/GotoHotkeysEffectsProvider';
import { InitializeQueryParamStateEffect } from '@/app/effect-components/InitializeQueryParamStateEffect';
import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect';
import { AuthProvider } from '@/auth/components/AuthProvider';
import { SignOutOnOtherTabSignOutEffect } from '@/auth/effect-components/SignOutOnOtherTabSignOutEffect';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { RequestFreshCaptchaTokenEffect } from '@/captcha/components/RequestFreshCaptchaTokenEffect';
import { CommandMenuConfirmationModalManager } from '@/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager';
import { CommandRunner } from '@/command-menu-item/engine-command/components/CommandRunner';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components/IsMinimalMetadataReadyEffect';
import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
import { ApolloAdminProvider } from '@/settings/admin-panel/apollo/components/ApolloAdminProvider';
import { EndTrialAfterPaymentMethodGater } from '@/settings/billing/components/EndTrialAfterPaymentMethodGater';
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
import { SupportChatEffect } from '@/support/components/SupportChatEffect';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogComponentInstanceContext } from '@/ui/feedback/dialog-manager/contexts/DialogComponentInstanceContext';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { GlobalFilePreviewModal } from '@/ui/field/display/components/GlobalFilePreviewModal';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { UserContextProvider } from '@/users/components/UserContextProvider';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { getPageTitleFromPath } from '~/utils/title-utils';
export const WorkspaceAppProviders = () => {
const { pathname } = useLocation();
const pageTitle = getPageTitleFromPath(pathname);
return (
<SharedAppProviders>
<UserMetadataProviderInitialEffect />
<MinimalMetadataLoadEffect />
<IsMinimalMetadataReadyEffect />
<WorkspaceProviderEffect />
<CaptchaProvider>
<UserContextProvider>
<AuthProvider>
<ApolloCoreProvider>
<ApolloAdminProvider>
<SSEProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<ErrorMessageEffect />
<AgentChatProvider>
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<EndTrialAfterPaymentMethodGater />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
<CommandMenuConfirmationModalManager />
<CommandRunner />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
</AgentChatProvider>
</SnackBarProvider>
<MainContextStoreProvider />
<SupportChatEffect />
<InitializeQueryParamStateEffect />
<TrackPageViewEffect />
<RequestFreshCaptchaTokenEffect />
<PageChangeEffect />
<SignOutOnOtherTabSignOutEffect />
</SSEProvider>
</ApolloAdminProvider>
</ApolloCoreProvider>
</AuthProvider>
</UserContextProvider>
</CaptchaProvider>
</SharedAppProviders>
);
};
@@ -0,0 +1,125 @@
import { render, screen } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { DomainShell } from '@/app/components/DomainShell';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
let isDefaultDomainValue = true;
jest.mock('@/apollo/components/ApolloProvider', () => ({
ApolloProvider: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
jest.mock('@/ui/theme/components/BaseThemeProvider', () => ({
BaseThemeProvider: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
jest.mock('@/client-config/components/ClientConfigProviderEffect', () => ({
ClientConfigProviderEffect: () => null,
}));
jest.mock('@/client-config/components/ClientConfigProvider', () => ({
ClientConfigProvider: ({ children }: React.PropsWithChildren) => (
<>{children}</>
),
}));
jest.mock('@/app/components/RootApp', () => ({
RootApp: () => <div>ROOT_APP</div>,
}));
jest.mock('@/app/components/WorkspaceApp', () => ({
WorkspaceApp: () => <div>WORKSPACE_APP</div>,
}));
jest.mock('~/loading/components/UserOrMetadataLoader', () => ({
UserOrMetadataLoader: () => <div>LOADER</div>,
}));
jest.mock('@/onboarding/components/OnboardingPageLoader', () => ({
OnboardingPageLoader: () => <div>ONBOARDING_LOADER</div>,
}));
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain', () => ({
useIsCurrentLocationOnDefaultDomain: () => ({
isDefaultDomain: isDefaultDomainValue,
}),
}));
const renderShell = () =>
render(
<JotaiProvider store={jotaiStore}>
<DomainShell />
</JotaiProvider>,
);
const setClientConfigLoaded = (isLoadedOnce: boolean) => {
jotaiStore.set(clientConfigApiStatusState.atom, {
isLoadedOnce,
isLoading: false,
isErrored: false,
isSaved: isLoadedOnce,
});
};
describe('DomainShell', () => {
beforeEach(() => {
resetJotaiStore();
isDefaultDomainValue = true;
window.history.pushState({}, '', '/');
});
it('shows the loader until the client config has loaded', () => {
setClientConfigLoaded(false);
jotaiStore.set(isMultiWorkspaceEnabledState.atom, true);
renderShell();
expect(screen.getByText('LOADER')).toBeInTheDocument();
});
it('shows the onboarding loader on onboarding paths until the client config has loaded', () => {
setClientConfigLoaded(false);
jotaiStore.set(isMultiWorkspaceEnabledState.atom, true);
window.history.pushState({}, '', '/welcome');
renderShell();
expect(screen.getByText('ONBOARDING_LOADER')).toBeInTheDocument();
});
it('mounts the workspace app directly in single-workspace mode', () => {
setClientConfigLoaded(true);
jotaiStore.set(isMultiWorkspaceEnabledState.atom, false);
isDefaultDomainValue = true;
renderShell();
expect(screen.getByText('WORKSPACE_APP')).toBeInTheDocument();
});
it('mounts the root app on the default domain in multi-workspace mode', () => {
setClientConfigLoaded(true);
jotaiStore.set(isMultiWorkspaceEnabledState.atom, true);
isDefaultDomainValue = true;
renderShell();
expect(screen.getByText('ROOT_APP')).toBeInTheDocument();
});
it('mounts the workspace app on a workspace domain in multi-workspace mode', () => {
setClientConfigLoaded(true);
jotaiStore.set(isMultiWorkspaceEnabledState.atom, true);
isDefaultDomainValue = false;
renderShell();
expect(screen.getByText('WORKSPACE_APP')).toBeInTheDocument();
});
});
@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
export const InitializeQueryParamStateEffect = () => {
const location = useLocation();
const { initializeQueryParamState } = useInitializeQueryParamState();
useEffect(() => {
initializeQueryParamState();
}, [initializeQueryParamState, location]);
return null;
};
@@ -1,15 +1,7 @@
import {
setSessionId,
useEventTracker,
} from '@/analytics/hooks/useEventTracker';
import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { useReturnToPath } from '@/auth/hooks/useReturnToPath';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useIsOnAuthOrOnboardingPage } from '@/auth/hooks/useIsOnAuthOrOnboardingPage';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
@@ -43,18 +35,9 @@ import {
} from 'react-router-dom';
import { AppBasePath, AppPath, SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated-metadata/graphql';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { getPageTitleFromPath } from '~/utils/title-utils';
const AUTH_AND_ONBOARDING_PATHS = [
...ONGOING_USER_CREATION_PATHS,
...ONBOARDING_PATHS,
AppPath.ResetPassword,
];
// TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook
@@ -69,10 +52,6 @@ export const PageChangeEffect = () => {
const pageChangeEffectNavigateLocation =
usePageChangeEffectNavigateLocation();
const eventTracker = useEventTracker();
const { initializeQueryParamState } = useInitializeQueryParamState();
//TODO: refactor useResetTableRowSelection hook to not throw when the argument `recordTableId` is an empty string
// - replace CoreObjectNamePlural.Person
const objectNamePlural =
@@ -114,9 +93,7 @@ export const PageChangeEffect = () => {
const { saveReturnToPath, getReturnToPath, clearReturnToPath } =
useReturnToPath();
const isOnAuthOrOnboardingPage = AUTH_AND_ONBOARDING_PATHS.some((appPath) =>
isMatchingLocation(location, appPath),
);
const isOnAuthOrOnboardingPage = useIsOnAuthOrOnboardingPage();
const closeSidePanelUnlessNotRelevant = useCallback(() => {
const currentPage = store.get(sidePanelPageState.atom);
@@ -159,8 +136,6 @@ export const PageChangeEffect = () => {
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
useEffect(() => {
initializeQueryParamState();
if (
isDefined(pageChangeEffectNavigateLocation) &&
isAppEffectRedirectEnabled
@@ -186,7 +161,6 @@ export const PageChangeEffect = () => {
}, [
navigate,
pageChangeEffectNavigateLocation,
initializeQueryParamState,
isAppEffectRedirectEnabled,
isOnAuthOrOnboardingPage,
saveReturnToPath,
@@ -253,8 +227,7 @@ export const PageChangeEffect = () => {
}
break;
}
case isMatchingLocation(location, AppPath.SignInUp):
case isMatchingLocation(location, AppPath.SignInUpV2): {
case isMatchingLocation(location, AppPath.SignInUp): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.SignInUp,
@@ -302,8 +275,7 @@ export const PageChangeEffect = () => {
});
break;
}
case isMatchingLocation(location, AppPath.WorkspaceActivation):
case isMatchingLocation(location, AppPath.WorkspaceActivationV2): {
case isMatchingLocation(location, AppPath.WorkspaceActivation): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.WorkspaceActivation,
@@ -335,8 +307,7 @@ export const PageChangeEffect = () => {
});
break;
}
case isMatchingLocation(location, AppPath.InviteTeam):
case isMatchingLocation(location, AppPath.InviteTeamV2): {
case isMatchingLocation(location, AppPath.InviteTeam): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.InviteTeam,
@@ -401,31 +372,5 @@ export const PageChangeEffect = () => {
store,
]);
useEffect(() => {
setTimeout(() => {
setSessionId();
eventTracker(AnalyticsType['PAGEVIEW'], {
name: getPageTitleFromPath(location.pathname),
properties: {
pathname: location.pathname,
locale: navigator.language,
userAgent: window.navigator.userAgent,
href: window.location.href,
referrer: document.referrer,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
}, 500);
}, [eventTracker, location.pathname]);
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
useEffect(() => {
if (isCaptchaScriptLoaded && isCaptchaRequiredForPath(location.pathname)) {
requestFreshCaptchaToken();
}
}, [isCaptchaScriptLoaded, location.pathname, requestFreshCaptchaToken]);
return <></>;
};
@@ -1,401 +0,0 @@
import { AppRouterProviders } from '@/app/components/AppRouterProviders';
import { LazyRoute } from '@/app/components/LazyRoute';
import { SettingsRoutes } from '@/app/components/SettingsRoutes';
import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
import { OnboardingV2TransitionOutlet } from '@/onboarding/components/OnboardingV2TransitionOutlet';
import { VerifyV2 } from '~/pages/onboarding/VerifyV2';
import { lazyWithPreload } from '~/utils/lazyWithPreload';
import { RecordIndexSkeletonLoader } from '@/object-record/record-index/components/RecordIndexSkeletonLoader';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { MainAppLayoutWithSidePanel } from '@/ui/layout/page/components/MainAppLayoutWithSidePanel';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { lazy } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Route,
} from 'react-router-dom';
const RecordIndexPage = lazy(() =>
import('~/pages/object-record/RecordIndexPage').then((module) => ({
default: module.RecordIndexPage,
})),
);
const RecordShowPage = lazy(() =>
import('~/pages/object-record/RecordShowPage').then((module) => ({
default: module.RecordShowPage,
})),
);
const SignInUp = lazy(() =>
import('~/pages/auth/SignInUp').then((module) => ({
default: module.SignInUp,
})),
);
const SignInUpV2 = lazy(() =>
import('~/pages/auth/SignInUpV2').then((module) => ({
default: module.SignInUpV2,
})),
);
const PasswordReset = lazy(() =>
import('~/pages/auth/PasswordReset').then((module) => ({
default: module.PasswordReset,
})),
);
const Authorize = lazy(() =>
import('~/pages/auth/Authorize').then((module) => ({
default: module.Authorize,
})),
);
const WorkspaceActivation = lazy(() =>
import('~/pages/onboarding/WorkspaceActivation').then((module) => ({
default: module.WorkspaceActivation,
})),
);
const WorkspaceActivationV2 = lazyWithPreload(() =>
import('~/pages/onboarding/WorkspaceActivationV2').then((module) => ({
default: module.WorkspaceActivationV2,
})),
);
const CreateProfile = lazy(() =>
import('~/pages/onboarding/CreateProfile').then((module) => ({
default: module.CreateProfile,
})),
);
const CreateProfileV2 = lazyWithPreload(() =>
import('~/pages/onboarding/CreateProfileV2').then((module) => ({
default: module.CreateProfileV2,
})),
);
const SyncEmails = lazy(() =>
import('~/pages/onboarding/SyncEmails').then((module) => ({
default: module.SyncEmails,
})),
);
const SyncEmailsV2 = lazyWithPreload(() =>
import('~/pages/onboarding/SyncEmailsV2').then((module) => ({
default: module.SyncEmailsV2,
})),
);
const InstallAppsV2 = lazyWithPreload(() =>
import('~/pages/onboarding/InstallAppsV2').then((module) => ({
default: module.InstallAppsV2,
})),
);
const InviteTeam = lazy(() =>
import('~/pages/onboarding/InviteTeam').then((module) => ({
default: module.InviteTeam,
})),
);
const InviteTeamV2 = lazyWithPreload(() =>
import('~/pages/onboarding/InviteTeamV2').then((module) => ({
default: module.InviteTeamV2,
})),
);
const ChooseYourPlan = lazy(() =>
import('~/pages/onboarding/ChooseYourPlan').then((module) => ({
default: module.ChooseYourPlan,
})),
);
const ChooseYourPlanV2 = lazyWithPreload(() =>
import('~/pages/onboarding/ChooseYourPlanV2').then((module) => ({
default: module.ChooseYourPlanV2,
})),
);
const PaymentSuccess = lazy(() =>
import('~/pages/onboarding/PaymentSuccess').then((module) => ({
default: module.PaymentSuccess,
})),
);
const BookCallDecision = lazy(() =>
import('~/pages/onboarding/BookCallDecision').then((module) => ({
default: module.BookCallDecision,
})),
);
const BookCall = lazy(() =>
import('~/pages/onboarding/BookCall').then((module) => ({
default: module.BookCall,
})),
);
const StandalonePageLayoutPage = lazy(() =>
import('~/pages/page-layout/StandalonePageLayoutPage').then((module) => ({
default: module.StandalonePageLayoutPage,
})),
);
const NotFound = lazy(() =>
import('~/pages/not-found/NotFound').then((module) => ({
default: module.NotFound,
})),
);
const preloadOnboardingV2Pages = () => {
void WorkspaceActivationV2.preload();
void CreateProfileV2.preload();
void SyncEmailsV2.preload();
void InstallAppsV2.preload();
void InviteTeamV2.preload();
void ChooseYourPlanV2.preload();
return null;
};
export const useCreateAppRouter = (
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
<Route
element={<AppRouterProviders />}
// To switch state to `loading` temporarily to enable us
// to set scroll position before the page is rendered
loader={async () => Promise.resolve(null)}
>
<Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyLoginTokenEffect />} />
<Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} />
<Route
path={AppPath.SignInUp}
element={
<LazyRoute fallback={null}>
<SignInUp />
</LazyRoute>
}
/>
<Route
path={AppPath.Invite}
element={
<LazyRoute fallback={null}>
<SignInUp />
</LazyRoute>
}
/>
<Route
path={AppPath.ResetPassword}
element={
<LazyRoute fallback={null}>
<PasswordReset />
</LazyRoute>
}
/>
<Route
path={AppPath.WorkspaceActivation}
element={
<LazyRoute fallback={null}>
<WorkspaceActivation />
</LazyRoute>
}
/>
<Route
path={AppPath.CreateProfile}
element={
<LazyRoute fallback={null}>
<CreateProfile />
</LazyRoute>
}
/>
<Route
path={AppPath.SyncEmails}
element={
<LazyRoute fallback={null}>
<SyncEmails />
</LazyRoute>
}
/>
<Route
path={AppPath.InviteTeam}
element={
<LazyRoute fallback={null}>
<InviteTeam />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequired}
element={
<LazyRoute fallback={null}>
<ChooseYourPlan />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequiredSuccess}
element={
<LazyRoute fallback={null}>
<PaymentSuccess />
</LazyRoute>
}
/>
<Route
path={AppPath.BookCallDecision}
element={
<LazyRoute fallback={null}>
<BookCallDecision />
</LazyRoute>
}
/>
<Route
path={AppPath.BookCall}
element={
<LazyRoute fallback={null}>
<BookCall />
</LazyRoute>
}
/>
<Route element={<MainAppLayoutWithSidePanel />}>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute fallback={<RecordIndexSkeletonLoader />}>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
{/* Deep link for twenty.com/dpa → in-app generator. This route is
inside the authenticated layout, so an unauthenticated hit is
login-gated and returns here after sign-in. */}
<Route
path={AppPath.Dpa}
element={
<Navigate to={getSettingsPath(SettingsPath.LegalDpa)} replace />
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
</Route>
</Route>
<Route element={<BlankLayout />}>
<Route
element={<OnboardingV2TransitionOutlet />}
loader={preloadOnboardingV2Pages}
>
<Route
path={AppPath.SignInUpV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SignInUpV2 />
</LazyRoute>
}
/>
<Route path={AppPath.VerifyV2} element={<VerifyV2 />} />
<Route
path={AppPath.WorkspaceActivationV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<WorkspaceActivationV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.CreateProfileV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<CreateProfileV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.SyncEmailsV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SyncEmailsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.InstallAppsV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<InstallAppsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.InviteTeamV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<InviteTeamV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequiredV2}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<ChooseYourPlanV2 />
</LazyRoute>
}
/>
</Route>
<Route
path={AppPath.Authorize}
element={
<LazyRoute>
<Authorize />
</LazyRoute>
}
/>
</Route>
</Route>,
),
);
@@ -0,0 +1,91 @@
import { lazy, useState } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Route,
} from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { LazyRoute } from '@/app/components/LazyRoute';
import { RootAppProviders } from '@/app/components/RootAppProviders';
import { VerifyEmail } from '@/auth/components/VerifyEmail';
import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet';
import { AuthFlowLayout } from '@/ui/layout/page/components/AuthFlowLayout';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
const SignInUp = lazy(() =>
import('~/pages/auth/SignInUp').then((module) => ({
default: module.SignInUp,
})),
);
const Authorize = lazy(() =>
import('~/pages/auth/Authorize').then((module) => ({
default: module.Authorize,
})),
);
const PasswordReset = lazy(() =>
import('~/pages/auth/PasswordReset').then((module) => ({
default: module.PasswordReset,
})),
);
const createRootAppRouter = () =>
createBrowserRouter(
createRoutesFromElements(
<Route element={<RootAppProviders />} loader={async () => null}>
<Route element={<BlankLayout />}>
<Route element={<OnboardingTransitionOutlet />}>
<Route
path={AppPath.SignInUp}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SignInUp />
</LazyRoute>
}
/>
<Route
path={AppPath.Invite}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SignInUp />
</LazyRoute>
}
/>
</Route>
</Route>
<Route element={<AuthFlowLayout />}>
<Route path={AppPath.VerifyEmail} element={<VerifyEmail />} />
<Route
path={AppPath.ResetPassword}
element={
<LazyRoute fallback={null}>
<PasswordReset />
</LazyRoute>
}
/>
</Route>
<Route
path={AppPath.Authorize}
element={
<LazyRoute>
<Authorize />
</LazyRoute>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={<Navigate to={AppPath.SignInUp} replace />}
/>
</Route>,
),
);
export const useCreateRootAppRouter = () => {
const [router] = useState(createRootAppRouter);
return router;
};
@@ -0,0 +1,341 @@
import { lazy, useMemo } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Route,
} from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { LazyRoute } from '@/app/components/LazyRoute';
import { SettingsRoutes } from '@/app/components/SettingsRoutes';
import { WorkspaceAppProviders } from '@/app/components/WorkspaceAppProviders';
import { VerifyEmail } from '@/auth/components/VerifyEmail';
import { MinimalMetadataGate } from '@/metadata-store/components/MinimalMetadataGate';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { OnboardingActivationOutlet } from '@/onboarding/components/OnboardingActivationOutlet';
import { OnboardingPageLoader } from '@/onboarding/components/OnboardingPageLoader';
import { OnboardingStepLayout } from '@/onboarding/components/OnboardingStepLayout';
import { OnboardingStepPageLoader } from '@/onboarding/components/OnboardingStepPageLoader';
import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet';
import { RecordIndexSkeletonLoader } from '@/object-record/record-index/components/RecordIndexSkeletonLoader';
import { AuthFlowLayout } from '@/ui/layout/page/components/AuthFlowLayout';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { MainAppLayoutWithSidePanel } from '@/ui/layout/page/components/MainAppLayoutWithSidePanel';
import { Verify } from '~/pages/onboarding/Verify';
import { lazyWithPreload } from '~/utils/lazyWithPreload';
const RecordIndexPage = lazy(() =>
import('~/pages/object-record/RecordIndexPage').then((module) => ({
default: module.RecordIndexPage,
})),
);
const RecordShowPage = lazy(() =>
import('~/pages/object-record/RecordShowPage').then((module) => ({
default: module.RecordShowPage,
})),
);
const SignInUp = lazy(() =>
import('~/pages/auth/SignInUp').then((module) => ({
default: module.SignInUp,
})),
);
const PasswordReset = lazy(() =>
import('~/pages/auth/PasswordReset').then((module) => ({
default: module.PasswordReset,
})),
);
const Authorize = lazy(() =>
import('~/pages/auth/Authorize').then((module) => ({
default: module.Authorize,
})),
);
const WorkspaceActivation = lazyWithPreload(() =>
import('~/pages/onboarding/WorkspaceActivation').then((module) => ({
default: module.WorkspaceActivation,
})),
);
const CreateProfile = lazyWithPreload(() =>
import('~/pages/onboarding/CreateProfile').then((module) => ({
default: module.CreateProfile,
})),
);
const SyncEmails = lazyWithPreload(() =>
import('~/pages/onboarding/SyncEmails').then((module) => ({
default: module.SyncEmails,
})),
);
const InstallApps = lazyWithPreload(() =>
import('~/pages/onboarding/InstallApps').then((module) => ({
default: module.InstallApps,
})),
);
const InviteTeam = lazyWithPreload(() =>
import('~/pages/onboarding/InviteTeam').then((module) => ({
default: module.InviteTeam,
})),
);
const ChooseYourPlan = lazyWithPreload(() =>
import('~/pages/onboarding/ChooseYourPlan').then((module) => ({
default: module.ChooseYourPlan,
})),
);
const PaymentSuccess = lazy(() =>
import('~/pages/onboarding/PaymentSuccess').then((module) => ({
default: module.PaymentSuccess,
})),
);
const BookCallDecision = lazy(() =>
import('~/pages/onboarding/BookCallDecision').then((module) => ({
default: module.BookCallDecision,
})),
);
const BookCall = lazy(() =>
import('~/pages/onboarding/BookCall').then((module) => ({
default: module.BookCall,
})),
);
const StandalonePageLayoutPage = lazy(() =>
import('~/pages/page-layout/StandalonePageLayoutPage').then((module) => ({
default: module.StandalonePageLayoutPage,
})),
);
const NotFound = lazy(() =>
import('~/pages/not-found/NotFound').then((module) => ({
default: module.NotFound,
})),
);
const preloadOnboardingPages = () => {
void WorkspaceActivation.preload();
void CreateProfile.preload();
void SyncEmails.preload();
void InstallApps.preload();
void InviteTeam.preload();
void ChooseYourPlan.preload();
return null;
};
const createWorkspaceAppRouter = (
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
<Route
element={<WorkspaceAppProviders />}
loader={async () => Promise.resolve(null)}
>
<Route element={<MinimalMetadataGate />}>
<Route element={<DefaultLayout />}>
<Route element={<MainAppLayoutWithSidePanel />}>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute fallback={<RecordIndexSkeletonLoader />}>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.Dpa}
element={
<Navigate
to={getSettingsPath(SettingsPath.LegalDpa)}
replace
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
</Route>
</Route>
</Route>
<Route element={<AuthFlowLayout />}>
<Route path={AppPath.VerifyEmail} element={<VerifyEmail />} />
<Route
path={AppPath.ResetPassword}
element={
<LazyRoute fallback={null}>
<PasswordReset />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequiredSuccess}
element={
<LazyRoute fallback={null}>
<PaymentSuccess />
</LazyRoute>
}
/>
<Route
path={AppPath.BookCallDecision}
element={
<LazyRoute fallback={null}>
<BookCallDecision />
</LazyRoute>
}
/>
<Route
path={AppPath.BookCall}
element={
<LazyRoute fallback={null}>
<BookCall />
</LazyRoute>
}
/>
</Route>
<Route element={<BlankLayout />}>
<Route
element={<OnboardingTransitionOutlet />}
loader={preloadOnboardingPages}
>
<Route
path={AppPath.SignInUp}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SignInUp />
</LazyRoute>
}
/>
<Route
path={AppPath.Invite}
element={
<LazyRoute fallback={<OnboardingPageLoader />}>
<SignInUp />
</LazyRoute>
}
/>
</Route>
<Route
element={<OnboardingActivationOutlet />}
loader={preloadOnboardingPages}
>
<Route path={AppPath.Verify} element={<Verify />} />
<Route
path={AppPath.WorkspaceActivation}
element={
<LazyRoute fallback={null}>
<WorkspaceActivation />
</LazyRoute>
}
/>
</Route>
<Route
element={<OnboardingStepLayout />}
loader={preloadOnboardingPages}
>
<Route
path={AppPath.CreateProfile}
element={
<LazyRoute fallback={<OnboardingStepPageLoader />}>
<CreateProfile />
</LazyRoute>
}
/>
<Route
path={AppPath.SyncEmails}
element={
<LazyRoute fallback={<OnboardingStepPageLoader />}>
<SyncEmails />
</LazyRoute>
}
/>
<Route
path={AppPath.InstallApps}
element={
<LazyRoute fallback={<OnboardingStepPageLoader />}>
<InstallApps />
</LazyRoute>
}
/>
<Route
path={AppPath.InviteTeam}
element={
<LazyRoute fallback={<OnboardingStepPageLoader />}>
<InviteTeam />
</LazyRoute>
}
/>
<Route
path={AppPath.PlanRequired}
element={
<LazyRoute fallback={<OnboardingStepPageLoader />}>
<ChooseYourPlan />
</LazyRoute>
}
/>
</Route>
<Route
path={AppPath.Authorize}
element={
<LazyRoute>
<Authorize />
</LazyRoute>
}
/>
</Route>
</Route>,
),
);
export const useCreateWorkspaceAppRouter = (
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
) =>
useMemo(
() =>
createWorkspaceAppRouter(isFunctionSettingsEnabled, isAdminPageEnabled),
[isFunctionSettingsEnabled, isAdminPageEnabled],
);
@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
@@ -51,11 +50,6 @@ export const useInitializeQueryParamState = () => {
store.set(returnToPathState.atom, value);
}
},
onboardingV2: (value: string) => {
if (value === 'true') {
store.set(isOnboardingV2State.atom, true);
}
},
};
const queryParams = new URLSearchParams(window.location.search);
@@ -1,45 +0,0 @@
import { AuthModalMountEffect } from '@/auth/components/AuthModalMountEffect';
import { AUTH_MODAL_ID } from '@/auth/constants/AuthModalId';
import { getAuthModalConfig } from '@/auth/utils/getAuthModalConfig';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { styled } from '@linaria/react';
import React from 'react';
import { useLocation } from 'react-router-dom';
const StyledContent = styled.div`
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 320px;
`;
type AuthModalProps = {
children: React.ReactNode;
};
export const AuthModal = ({ children }: AuthModalProps) => {
const location = useLocation();
const config = getAuthModalConfig(location);
return (
<>
<AuthModalMountEffect />
<ModalStatefulWrapper
modalInstanceId={AUTH_MODAL_ID}
padding="none"
size={config.size}
overlay={config.overlay}
>
{config.showScrollWrapper ? (
<ScrollWrapper componentInstanceId="scroll-wrapper-modal-content">
<StyledContent>{children}</StyledContent>
</ScrollWrapper>
) : (
<>{children}</>
)}
</ModalStatefulWrapper>
</>
);
};
@@ -1,19 +0,0 @@
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useEffect } from 'react';
import { AUTH_MODAL_ID } from '@/auth/constants/AuthModalId';
// TODO: Remove this component when we refactor the auth modal to open it directly in the PageChangeEffect
export const AuthModalMountEffect = () => {
const { openModal, closeModal } = useModal();
useEffect(() => {
openModal(AUTH_MODAL_ID);
return () => {
closeModal(AUTH_MODAL_ID);
};
}, [openModal, closeModal]);
return null;
};
@@ -0,0 +1,33 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { OnboardingVerifyLayout } from '@/onboarding/components/OnboardingVerifyLayout';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ModalContent } from 'twenty-ui/surfaces';
export const VerifyEmail = () => {
const { t } = useLingui();
const [searchParams] = useSearchParams();
const [isError, setIsError] = useState(false);
const email = searchParams.get('email');
if (isError) {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
</ModalContent>
);
}
return (
<>
<VerifyEmailEffect onError={() => setIsError(true)} />
<OnboardingVerifyLayout>
<SubTitle>{t`Verifying your email`}</SubTitle>
</OnboardingVerifyLayout>
</>
);
};
@@ -1,26 +1,27 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { AppPath } from 'twenty-shared/types';
import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
import { useAuth } from '@/auth/hooks/useAuth';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { ModalContent } from 'twenty-ui/surfaces';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useLingui } from '@lingui/react/macro';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const VerifyEmailEffect = () => {
type VerifyEmailEffectProps = {
onError: () => void;
};
export const VerifyEmailEffect = ({ onError }: VerifyEmailEffectProps) => {
const {
verifyEmailAndGetLoginToken,
verifyEmailAndGetWorkspaceAgnosticToken,
@@ -29,7 +30,6 @@ export const VerifyEmailEffect = () => {
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [searchParams] = useSearchParams();
const [isError, setIsError] = useState(false);
const setVerifyEmailRedirectPath = useSetAtomState(
verifyEmailRedirectPathState,
@@ -109,7 +109,7 @@ export const VerifyEmailEffect = () => {
navigate(AppPath.SignInUp);
}
setIsError(true);
onError();
}
};
@@ -123,13 +123,5 @@ export const VerifyEmailEffect = () => {
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [clientConfigApiStatus.isLoadedOnce]);
if (isError) {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
</ModalContent>
);
}
return <></>;
};
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
@@ -21,17 +21,21 @@ export const VerifyLoginTokenEffect = () => {
clientConfigApiStatusState,
);
// oxlint-disable-next-line twenty/no-state-useref
const hasVerifiedRef = useRef(false);
useEffect(() => {
if (!clientConfigLoaded) {
if (!clientConfigLoaded || hasVerifiedRef.current) {
return;
}
hasVerifiedRef.current = true;
if (isDefined(loginToken)) {
verifyLoginToken(loginToken);
} else if (!hasAccessTokenPair) {
navigate(AppPath.SignInUp);
}
// Verify only needs to run once at mount
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [clientConfigLoaded]);
@@ -1,14 +1,14 @@
import { type VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import { type VerifyEmail } from '@/auth/components/VerifyEmail';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
// Mock component that just renders the error state of VerifyEmailEffect directly
// (since normal VerifyEmailEffect has async logic that's hard to test in Storybook)
// Mock component that just renders the error state of VerifyEmail directly
// (since normal VerifyEmail has async logic that's hard to test in Storybook)
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { ModalContent } from 'twenty-ui/surfaces';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => {
const VerifyEmailErrorState = ({ email = 'user@example.com' }) => {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
@@ -16,9 +16,9 @@ const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => {
);
};
const meta: Meta<typeof VerifyEmailEffectErrorState> = {
title: 'Modules/Auth/VerifyEmailEffect',
component: VerifyEmailEffectErrorState,
const meta: Meta<typeof VerifyEmailErrorState> = {
title: 'Modules/Auth/VerifyEmail',
component: VerifyEmailErrorState,
decorators: [
(Story) => (
<div style={{ padding: '24px' }}>
@@ -29,13 +29,13 @@ const meta: Meta<typeof VerifyEmailEffectErrorState> = {
],
parameters: {
codeSection: {
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with ModalContent to maintain consistent styling.',
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmail, always wrap it with ModalContent to maintain consistent styling.',
},
},
};
export default meta;
type Story = StoryObj<typeof VerifyEmailEffect>;
type Story = StoryObj<typeof VerifyEmail>;
export const ErrorState: Story = {
args: {
@@ -43,7 +43,7 @@ export const ErrorState: Story = {
},
};
export const IntegratedExample: StoryObj<typeof VerifyEmailEffect> = {
export const IntegratedExample: StoryObj<typeof VerifyEmail> = {
render: () => (
<MemoryRouter
initialEntries={[
@@ -53,7 +53,7 @@ export const IntegratedExample: StoryObj<typeof VerifyEmailEffect> = {
<Routes>
<Route
path="/verify-email"
element={<VerifyEmailEffectErrorState email="user@example.com" />}
element={<VerifyEmailErrorState email="user@example.com" />}
/>
</Routes>
</MemoryRouter>
@@ -7,7 +7,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import { VerifyEmail } from '@/auth/components/VerifyEmail';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import {
jotaiStore,
@@ -60,7 +60,7 @@ jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
}),
}));
// Rendered by VerifyEmailEffect in the error state; isolate it from Apollo.
// Rendered by VerifyEmail in the error state; isolate it from Apollo.
jest.mock(
'@/auth/sign-in-up/hooks/useHandleResendEmailVerificationToken',
() => ({
@@ -76,20 +76,20 @@ dynamicActivate(SOURCE_LOCALE);
const VERIFY_EMAIL_URL =
'/verify-email?email=user%40example.com&emailVerificationToken=valid-token';
const renderEffect = (initialEntry: string) =>
const renderVerifyEmail = (initialEntry: string) =>
render(
<JotaiProvider store={jotaiStore}>
<ThemeProvider colorScheme="light">
<I18nProvider i18n={i18n}>
<MemoryRouter initialEntries={[initialEntry]}>
<VerifyEmailEffect />
<VerifyEmail />
</MemoryRouter>
</I18nProvider>
</ThemeProvider>
</JotaiProvider>,
);
describe('VerifyEmailEffect', () => {
describe('VerifyEmail', () => {
beforeEach(() => {
jest.clearAllMocks();
resetJotaiStore();
@@ -106,7 +106,7 @@ describe('VerifyEmailEffect', () => {
it('navigates to the SignInUp page after a successful workspace-agnostic verification on the central domain', async () => {
verifyEmailAndGetWorkspaceAgnosticTokenMock.mockResolvedValue(undefined);
renderEffect(VERIFY_EMAIL_URL);
renderVerifyEmail(VERIFY_EMAIL_URL);
await waitFor(() => {
expect(verifyEmailAndGetWorkspaceAgnosticTokenMock).toHaveBeenCalledWith(
@@ -128,7 +128,7 @@ describe('VerifyEmailEffect', () => {
new Error('verification failed'),
);
renderEffect(VERIFY_EMAIL_URL);
renderVerifyEmail(VERIFY_EMAIL_URL);
await waitFor(() => {
expect(enqueueErrorSnackBarMock).toHaveBeenCalled();
@@ -143,7 +143,7 @@ describe('VerifyEmailEffect', () => {
workspaceUrls: { subdomainUrl: 'https://foo.twenty.com/' },
});
renderEffect(VERIFY_EMAIL_URL);
renderVerifyEmail(VERIFY_EMAIL_URL);
await waitFor(() => {
expect(verifyEmailAndGetLoginTokenMock).toHaveBeenCalledWith(
@@ -0,0 +1,72 @@
import { act, render, waitFor } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { StrictMode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
const verifyLoginTokenMock = jest.fn();
const navigateMock = jest.fn();
jest.mock('@/auth/hooks/useVerifyLogin', () => ({
useVerifyLogin: () => ({ verifyLoginToken: verifyLoginTokenMock }),
}));
jest.mock('~/hooks/useNavigateApp', () => ({
useNavigateApp: () => navigateMock,
}));
jest.mock('@/auth/hooks/useHasAccessTokenPair', () => ({
useHasAccessTokenPair: () => false,
}));
const setClientConfigSaved = (isSaved: boolean) => {
jotaiStore.set(clientConfigApiStatusState.atom, {
isLoadedOnce: true,
isLoading: false,
isErrored: false,
isSaved,
});
};
const renderEffect = (initialEntry: string) =>
render(
<JotaiProvider store={jotaiStore}>
<MemoryRouter initialEntries={[initialEntry]}>
<StrictMode>
<VerifyLoginTokenEffect />
</StrictMode>
</MemoryRouter>
</JotaiProvider>,
);
describe('VerifyLoginTokenEffect', () => {
beforeEach(() => {
jest.clearAllMocks();
resetJotaiStore();
setClientConfigSaved(true);
});
it('verifies the login token at most once even when the gating config re-triggers the effect', async () => {
renderEffect('/verify?loginToken=login-token');
await waitFor(() => {
expect(verifyLoginTokenMock).toHaveBeenCalledWith('login-token');
});
expect(verifyLoginTokenMock).toHaveBeenCalledTimes(1);
await act(async () => {
setClientConfigSaved(false);
});
await act(async () => {
setClientConfigSaved(true);
});
expect(verifyLoginTokenMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,9 @@
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { AppPath } from 'twenty-shared/types';
export const AUTH_AND_ONBOARDING_PATHS = [
...ONGOING_USER_CREATION_PATHS,
...ONBOARDING_PATHS,
AppPath.ResetPassword,
];
@@ -1,24 +0,0 @@
import { type ModalOverlay, type ModalSize } from 'twenty-ui/surfaces';
import { AppPath } from 'twenty-shared/types';
type AuthModalConfigType = {
size: ModalSize;
overlay: ModalOverlay;
showScrollWrapper: boolean;
};
export const AUTH_MODAL_CONFIG: {
default: AuthModalConfigType;
[key: string]: AuthModalConfigType;
} = {
default: {
size: 'medium',
overlay: 'dark',
showScrollWrapper: true,
},
[AppPath.BookCall]: {
size: 'extraLarge',
overlay: 'transparent',
showScrollWrapper: false,
},
};
@@ -1 +0,0 @@
export const AUTH_MODAL_ID = 'auth-modal';
@@ -2,16 +2,11 @@ import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_PATHS = [
AppPath.WorkspaceActivation,
AppPath.WorkspaceActivationV2,
AppPath.CreateProfile,
AppPath.CreateProfileV2,
AppPath.SyncEmails,
AppPath.SyncEmailsV2,
AppPath.InstallAppsV2,
AppPath.InstallApps,
AppPath.InviteTeam,
AppPath.InviteTeamV2,
AppPath.PlanRequired,
AppPath.PlanRequiredV2,
AppPath.PlanRequiredSuccess,
AppPath.BookCallDecision,
AppPath.BookCall,
@@ -0,0 +1,15 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_TRANSITION_PATHS = [
AppPath.SignInUp,
AppPath.Invite,
AppPath.Verify,
AppPath.VerifyEmail,
AppPath.WorkspaceActivation,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InstallApps,
AppPath.InviteTeam,
AppPath.PlanRequired,
AppPath.PlanRequiredSuccess,
];
@@ -1,12 +0,0 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_V2_PATHS = [
AppPath.SignInUpV2,
AppPath.VerifyV2,
AppPath.WorkspaceActivationV2,
AppPath.CreateProfileV2,
AppPath.SyncEmailsV2,
AppPath.InstallAppsV2,
AppPath.InviteTeamV2,
AppPath.PlanRequiredV2,
];
@@ -3,8 +3,6 @@ import { AppPath } from 'twenty-shared/types';
export const ONGOING_USER_CREATION_PATHS = [
AppPath.Invite,
AppPath.SignInUp,
AppPath.SignInUpV2,
AppPath.VerifyEmail,
AppPath.Verify,
AppPath.VerifyV2,
];
@@ -0,0 +1,11 @@
import { AUTH_AND_ONBOARDING_PATHS } from '@/auth/constants/AuthAndOnboardingPaths';
import { useLocation } from 'react-router-dom';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useIsOnAuthOrOnboardingPage = () => {
const location = useLocation();
return AUTH_AND_ONBOARDING_PATHS.some((appPath) =>
isMatchingLocation(location, appPath),
);
};
@@ -3,13 +3,15 @@ import { Trans } from '@lingui/react/macro';
import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCopyContainer = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
max-width: 280px;
line-height: 1.4;
max-width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
text-align: center;
& > a {
@@ -8,6 +8,8 @@ import { FormProvider } from 'react-hook-form';
import { ClickToActionLink, UndecoratedLink } from 'twenty-ui/navigation';
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { OnboardingStepAnimatedItem } from '@/onboarding/components/OnboardingStepAnimatedItem';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
@@ -36,6 +38,11 @@ import {
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledContentContainer = styled(StyledOnboardingContentContainer)`
max-width: 100%;
width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
`;
const StyledWorkspaceContainer = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.light};
@@ -156,70 +163,82 @@ export const SignInUpGlobalScopeForm = () => {
);
};
const availableWorkspacesList = [
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
];
return (
<>
{signInUpStep === SignInUpStep.WorkspaceSelection && (
<StyledOnboardingContentContainer>
<StyledContentContainer>
<StyledWorkspaceContainer>
{[
...availableWorkspaces.availableWorkspacesForSignIn,
...availableWorkspaces.availableWorkspacesForSignUp,
].map((availableWorkspace) => (
<UndecoratedLink
{availableWorkspacesList.map((availableWorkspace, index) => (
<OnboardingStepAnimatedItem
key={availableWorkspace.id}
to={getAvailableWorkspaceUrl(availableWorkspace)}
index={index}
>
<StyledWorkspaceItem>
<UndecoratedLink
to={getAvailableWorkspaceUrl(availableWorkspace)}
>
<StyledWorkspaceItem>
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
size="lg"
/>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>
{availableWorkspace.displayName ||
availableWorkspace.id}
</StyledWorkspaceName>
<StyledWorkspaceUrl>
{
new URL(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
).hostname
}
</StyledWorkspaceUrl>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</UndecoratedLink>
</OnboardingStepAnimatedItem>
))}
{!isDDLLocked && (
<OnboardingStepAnimatedItem
index={availableWorkspacesList.length}
>
<StyledWorkspaceItem
onClick={() =>
setSignInUpStep(SignInUpStep.WorkspaceCreation)
}
>
<StyledWorkspaceContent>
<Avatar
placeholder={availableWorkspace.displayName || ''}
avatarUrl={getAbsoluteImageUrl(
availableWorkspace.logo ?? DEFAULT_WORKSPACE_LOGO,
)}
size="lg"
/>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>
{availableWorkspace.displayName ||
availableWorkspace.id}
</StyledWorkspaceName>
<StyledWorkspaceUrl>
{
new URL(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
).hostname
}
</StyledWorkspaceUrl>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</UndecoratedLink>
))}
{!isDDLLocked && (
<StyledWorkspaceItem
onClick={() => setSignInUpStep(SignInUpStep.WorkspaceCreation)}
>
<StyledWorkspaceContent>
<StyledWorkspaceLogo>
<IconPlus size={theme.icon.size.lg} />
</StyledWorkspaceLogo>
<StyledWorkspaceTextContainer>
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
</StyledWorkspaceTextContainer>
<StyledChevronIcon>
<IconChevronRight size={theme.icon.size.md} />
</StyledChevronIcon>
</StyledWorkspaceContent>
</StyledWorkspaceItem>
</OnboardingStepAnimatedItem>
)}
</StyledWorkspaceContainer>
</StyledOnboardingContentContainer>
</StyledContentContainer>
)}
{signInUpStep !== SignInUpStep.WorkspaceSelection && (
<StyledOnboardingContentContainer>
<StyledContentContainer>
{authProviders.google && (
<SignInUpWithGoogle
action="list-available-workspaces"
@@ -233,7 +252,9 @@ export const SignInUpGlobalScopeForm = () => {
/>
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
<HorizontalSeparator
color={themeCssVariables.background.transparent.light}
/>
)}
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
@@ -248,7 +269,7 @@ export const SignInUpGlobalScopeForm = () => {
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledOnboardingContentContainer>
</StyledContentContainer>
)}
</>
);
@@ -3,13 +3,28 @@ import { Title } from '@/auth/components/Title';
import { FooterNote } from '@/auth/sign-in-up/components/FooterNote';
import { WorkspaceSelectionFooter } from '@/auth/sign-in-up/components/WorkspaceSelectionFooter';
import { SignInUpStep } from '@/auth/states/signInUpStepState';
import { styled } from '@linaria/react';
import { type JSX } from 'react';
import { AppPath } from 'twenty-shared/types';
import { AnimatedEaseIn } from 'twenty-ui/layout';
import { ModalContent } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type PublicWorkspaceData } from '~/generated-metadata/graphql';
type SignInUpV2StandardContentProps = {
const StyledTitleContainer = styled.div`
line-height: 1.2;
margin-top: ${themeCssVariables.spacing[10]};
`;
const StyledFormContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
margin-bottom: ${themeCssVariables.spacing[6]};
margin-top: ${themeCssVariables.spacing[6]};
`;
type SignInUpStandardContentProps = {
workspacePublicData: PublicWorkspaceData | null;
signInUpForm: JSX.Element | null;
signInUpStep: SignInUpStep;
@@ -17,13 +32,13 @@ type SignInUpV2StandardContentProps = {
onClickOnLogo: () => void;
};
export const SignInUpV2StandardContent = ({
export const SignInUpStandardContent = ({
workspacePublicData,
signInUpForm,
signInUpStep,
title,
onClickOnLogo,
}: SignInUpV2StandardContentProps) => {
}: SignInUpStandardContentProps) => {
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<AnimatedEaseIn>
@@ -31,11 +46,13 @@ export const SignInUpV2StandardContent = ({
secondaryLogo={workspacePublicData?.logo}
placeholder={workspacePublicData?.displayName}
onClick={onClickOnLogo}
to={AppPath.SignInUpV2}
to={AppPath.SignInUp}
/>
</AnimatedEaseIn>
<Title animate>{title}</Title>
{signInUpForm}
<StyledTitleContainer>
<Title animate>{title}</Title>
</StyledTitleContainer>
<StyledFormContainer>{signInUpForm}</StyledFormContainer>
{signInUpStep === SignInUpStep.WorkspaceSelection && (
<WorkspaceSelectionFooter />
)}
@@ -1,77 +0,0 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { motion, useReducedMotion } from 'framer-motion';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme-constants';
const STEP_OPACITIES = [1, 0.4, 0.12];
const VISIBLE_STEP_COUNT = STEP_OPACITIES.length;
const STEP_HEIGHT_IN_PX = 28;
const STEPS_CONTAINER_HEIGHT_IN_PX = STEP_HEIGHT_IN_PX * VISIBLE_STEP_COUNT;
const StyledStepsContainer = styled.div`
height: ${STEPS_CONTAINER_HEIGHT_IN_PX}px;
position: relative;
width: 100%;
`;
const StyledStepBase = styled.div`
left: 0;
position: absolute;
right: 0;
top: 0;
`;
const StyledStep = motion.create(StyledStepBase);
type SignInUpWorkspaceActivationV2Props = {
messageIndex: number;
};
export const SignInUpWorkspaceActivationV2 = ({
messageIndex,
}: SignInUpWorkspaceActivationV2Props) => {
const { i18n } = useLingui();
const { theme } = useContext(ThemeContext);
const shouldReduceMotion = useReducedMotion();
const messages = WORKSPACE_ACTIVATION_MESSAGES.map((message) =>
i18n._(message),
);
return (
<>
<OnboardingPulsingLogo />
<StyledStepsContainer>
{messages.map((message, index) => {
const stepOffset = index - messageIndex;
const isVisible = stepOffset >= 0 && stepOffset < VISIBLE_STEP_COUNT;
return (
<StyledStep
key={message}
initial={false}
animate={{
opacity: isVisible ? STEP_OPACITIES[stepOffset] : 0,
y: stepOffset * STEP_HEIGHT_IN_PX,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}
}
>
<SubTitle>{message}</SubTitle>
</StyledStep>
);
})}
</StyledStepsContainer>
</>
);
};
@@ -1,28 +0,0 @@
import { SignInUpWorkspaceActivationV2 } from '@/auth/sign-in-up/components/SignInUpWorkspaceActivationV2';
import { SignInUpWorkspaceActivationV2Effect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect';
import { styled } from '@linaria/react';
import { useState } from 'react';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
min-height: 100%;
width: 100%;
`;
export const SignInUpWorkspaceCreationLoader = () => {
const [messageIndex, setMessageIndex] = useState(0);
return (
<StyledContainer>
<SignInUpWorkspaceActivationV2Effect
messageIndex={messageIndex}
setMessageIndex={setMessageIndex}
/>
<SignInUpWorkspaceActivationV2 messageIndex={messageIndex} />
</StyledContainer>
);
};
@@ -1,38 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { SignInUpWorkspaceActivationV2 } from '@/auth/sign-in-up/components/SignInUpWorkspaceActivationV2';
import { SignInUpWorkspaceActivationV2Effect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect';
import { useState } from 'react';
import { ModalContent } from 'twenty-ui/surfaces';
import { ComponentDecorator } from 'twenty-ui/testing';
const RenderWithModalContent = () => {
const [messageIndex, setMessageIndex] = useState(0);
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<SignInUpWorkspaceActivationV2Effect
messageIndex={messageIndex}
setMessageIndex={setMessageIndex}
/>
<SignInUpWorkspaceActivationV2 messageIndex={messageIndex} />
</ModalContent>
);
};
const meta: Meta<typeof SignInUpWorkspaceActivationV2> = {
title: 'Modules/Auth/SignInUpWorkspaceActivationV2',
component: SignInUpWorkspaceActivationV2,
decorators: [ComponentDecorator],
parameters: {
codeSection: {
docs: 'This component should always be wrapped with ModalContent in the app.\n\nCorrect usage:\n```tsx\n<ModalContent isVerticallyCentered isHorizontallyCentered>\n <SignInUpWorkspaceActivationV2 />\n</ModalContent>\n```\n',
},
},
render: RenderWithModalContent,
};
export default meta;
type Story = StoryObj<typeof SignInUpWorkspaceActivationV2>;
export const Default: Story = {};
@@ -5,15 +5,16 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPillContainer = styled.span`
position: absolute;
right: calc(-1 * ${themeCssVariables.spacing[5]});
top: calc(-1 * ${themeCssVariables.spacing[2]});
right: -14px;
top: -10px;
> span {
background: ${themeCssVariables.color.blue3};
border: 1px solid ${themeCssVariables.color.blue5};
border-radius: ${themeCssVariables.border.radius.pill};
color: ${themeCssVariables.color.blue};
background: ${themeCssVariables.accent.accent3};
border: 1px solid ${themeCssVariables.accent.accent5};
border-radius: ${themeCssVariables.border.radius.md};
color: ${themeCssVariables.accent.accent9};
font-weight: ${themeCssVariables.font.weight.semiBold};
height: ${themeCssVariables.spacing[5]};
}
`;
@@ -17,13 +17,11 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { OTPInput, type SlotProps } from 'input-otp';
import { useState } from 'react';
import { Controller } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -183,7 +181,6 @@ export const SignInUpTOTPVerification = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const navigate = useNavigateApp();
const location = useLocation();
const { readCaptchaToken } = useReadCaptchaToken();
const { isCaptchaReady } = useCaptcha();
const loginToken = useAtomStateValue(loginTokenState);
@@ -206,11 +203,7 @@ export const SignInUpTOTPVerification = () => {
const captchaToken = readCaptchaToken();
if (!loginToken) {
return navigate(
isMatchingLocation(location, AppPath.SignInUpV2)
? AppPath.SignInUpV2
: AppPath.SignInUp,
);
return navigate(AppPath.SignInUp);
}
await getAuthTokensFromOTP(values.otp, loginToken, captchaToken);
@@ -1,51 +1,137 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { OnboardingAnimatedReveal } from '@/onboarding/components/OnboardingAnimatedReveal';
import { OnboardingStepAnimatedItem } from '@/onboarding/components/OnboardingStepAnimatedItem';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { Avatar } from 'twenty-ui/data-display';
import { IconTrash, IconUpload } from 'twenty-ui/icon';
import { Button, LightIconButton, MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSection = styled.div`
margin-top: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[6]};
width: 100%;
`;
const StyledAvailableHint = styled.div`
color: ${themeCssVariables.color.green};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.regular};
margin-top: ${themeCssVariables.spacing[0.5]};
`;
const StyledUnavailableHint = styled.div`
color: ${themeCssVariables.color.red};
const StyledContentContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[14]};
max-width: 100%;
width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
`;
const StyledHeading = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
line-height: 1.2;
`;
const StyledSubtitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.4;
`;
const StyledFormSection = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[8]};
padding-bottom: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledLogoRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledLogoAvatar = styled(Avatar)`
height: ${themeCssVariables.spacing[8]};
width: ${themeCssVariables.spacing[8]};
`;
const StyledLogoButtons = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledHiddenFileInput = styled.input`
display: none;
`;
const StyledSubdomainSection = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledAlternativesBox = styled.div`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledAlternativesLabel = styled.span`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
padding-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledAlternativeRows = styled.div`
display: flex;
flex-direction: column;
font-size: ${themeCssVariables.font.size.xs};
gap: ${themeCssVariables.spacing[1]};
margin-top: ${themeCssVariables.spacing[0.5]};
`;
const StyledAlternativeRow = styled.button`
align-items: center;
background: transparent;
border: none;
color: ${themeCssVariables.color.green};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[1]};
padding: 2px 0;
text-align: left;
`;
const StyledAvailabilityDotBox = styled.div`
display: flex;
padding: ${themeCssVariables.spacing[1]};
`;
const StyledAvailabilityDot = styled.div`
background-color: ${themeCssVariables.color.green};
border-radius: 50%;
box-shadow: 0 0 0 3px ${themeCssVariables.color.green5};
flex-shrink: 0;
height: 6px;
width: 6px;
`;
export const SignInUpWorkspaceCreationForm = () => {
@@ -56,33 +142,37 @@ export const SignInUpWorkspaceCreationForm = () => {
isMultiWorkspaceEnabledState,
);
const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State);
const [isSubmitting, setIsSubmitting] = useState(false);
const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState);
const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
undefined,
);
const hiddenFileInputRef = useRef<HTMLInputElement>(null);
const {
workspaceName,
subdomain,
status,
errorMessage,
suggestion,
suggestions,
isAvailable,
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
applySuggestionValue,
} = useWorkspaceSubdomainField({
isSubdomainEnabled: isMultiWorkspaceEnabled,
});
const isContinueDisabled =
workspaceName.trim() === '' ||
isSubmitting ||
isCreatingWorkspace ||
(isMultiWorkspaceEnabled && !isAvailable);
const openFilePicker = () => {
hiddenFileInputRef.current?.click();
};
const handleLogoUpload = (file: File) => {
if (!isDefined(file)) {
return;
@@ -111,16 +201,16 @@ export const SignInUpWorkspaceCreationForm = () => {
return;
}
setIsSubmitting(true);
setIsOnboardingV2(false);
try {
await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
} finally {
setIsSubmitting(false);
setIsCreatingWorkspace(true);
const isWorkspaceCreated = await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
if (!isWorkspaceCreated) {
setIsCreatingWorkspace(false);
}
};
@@ -144,75 +234,122 @@ export const SignInUpWorkspaceCreationForm = () => {
: undefined;
return (
<StyledOnboardingContentContainer>
<SubTitle>
{isMultiWorkspaceEnabled
? t`Pick a name and a web address for your new workspace.`
: t`Pick a name and a logo for your new workspace.`}
</SubTitle>
<StyledSection>
<InputLabel>{t`Workspace logo`}</InputLabel>
<ImageInput
picture={logoPreviewUrl}
onUpload={handleLogoUpload}
onRemove={handleLogoRemove}
/>
</StyledSection>
<StyledSection>
<TextInput
autoFocus
label={t`Workspace name`}
value={workspaceName}
placeholder={t`Apple`}
onChange={handleWorkspaceNameChange}
onKeyDown={handleKeyDown}
fullWidth
/>
</StyledSection>
{isMultiWorkspaceEnabled && (
<StyledSection>
<StyledContentContainer>
<StyledHeading>
<OnboardingStepAnimatedItem index={0}>
<StyledTitle>{t`Create your workspace`}</StyledTitle>
</OnboardingStepAnimatedItem>
<OnboardingStepAnimatedItem index={1}>
<StyledSubtitle>
{t`Move work forward across teams and agents`}
</StyledSubtitle>
</OnboardingStepAnimatedItem>
</StyledHeading>
<StyledFormSection>
<OnboardingStepAnimatedItem index={2}>
<StyledLogoRow>
<StyledLogoAvatar
avatarUrl={logoPreviewUrl}
placeholder={
isNonEmptyString(workspaceName) ? workspaceName : '?'
}
placeholderColorSeed={workspaceName}
type="squared"
size="xl"
onClick={openFilePicker}
/>
<StyledHiddenFileInput
type="file"
ref={hiddenFileInputRef}
accept="image/jpeg, image/png, image/gif"
onChange={(event) => {
const file = event.target.files?.[0];
if (isDefined(file)) {
handleLogoUpload(file);
}
event.target.value = '';
}}
/>
<StyledLogoButtons>
<Button
Icon={IconUpload}
title={t`Upload logo`}
variant="secondary"
onClick={openFilePicker}
/>
<LightIconButton
Icon={IconTrash}
accent="tertiary"
size="medium"
onClick={handleLogoRemove}
disabled={!isDefined(logoPreviewUrl)}
aria-label={t`Remove logo`}
/>
</StyledLogoButtons>
</StyledLogoRow>
</OnboardingStepAnimatedItem>
<OnboardingStepAnimatedItem index={3}>
<TextInput
label={t`Workspace address`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
autoFocus
label={t`Name`}
value={workspaceName}
placeholder={t`Apple`}
onChange={handleWorkspaceNameChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
{status === 'checking' && <InputHint>{t`Checking…`}</InputHint>}
{status === 'available' && (
<StyledAvailableHint>
{t`This address is available`}
</StyledAvailableHint>
)}
{status === 'unavailable' && (
<StyledUnavailableHint>
{subdomainError}
{isDefined(suggestion) && (
<ClickToActionLink onClick={applySuggestion}>
{t`Use ${suggestion} instead`}
</ClickToActionLink>
)}
</StyledUnavailableHint>
)}
</StyledSection>
)}
<StyledButtonContainer>
</OnboardingStepAnimatedItem>
{isMultiWorkspaceEnabled && (
<OnboardingStepAnimatedItem index={4}>
<StyledSubdomainSection>
<TextInput
label={t`Subdomain`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
<OnboardingAnimatedReveal isVisible={status === 'unavailable'}>
<StyledAlternativesBox>
<StyledAlternativesLabel>
{t`Subdomain already in use, here are some alternatives:`}
</StyledAlternativesLabel>
<StyledAlternativeRows>
{suggestions.map((alternative) => (
<StyledAlternativeRow
key={alternative}
type="button"
onClick={() => applySuggestionValue(alternative)}
>
<StyledAvailabilityDotBox>
<StyledAvailabilityDot />
</StyledAvailabilityDotBox>
{alternative}
</StyledAlternativeRow>
))}
</StyledAlternativeRows>
</StyledAlternativesBox>
</OnboardingAnimatedReveal>
</StyledSubdomainSection>
</OnboardingStepAnimatedItem>
)}
</StyledFormSection>
<OnboardingStepAnimatedItem index={isMultiWorkspaceEnabled ? 5 : 4}>
<MainButton
title={t`Continue`}
title={t`Create workspace`}
onClick={handleSubmit}
disabled={isContinueDisabled}
Icon={() => (isSubmitting ? <Loader /> : null)}
fullWidth
/>
</StyledButtonContainer>
</StyledOnboardingContentContainer>
</OnboardingStepAnimatedItem>
</StyledContentContainer>
);
};
@@ -1,297 +0,0 @@
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { IconTrash, IconUpload } from 'twenty-ui/icon';
import { Button, LightIconButton, MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHeading = styled.div`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const StyledTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledSubtitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
margin-top: ${themeCssVariables.spacing[1]};
`;
const StyledSection = styled.div`
margin-top: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledLogoRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledHiddenFileInput = styled.input`
display: none;
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[6]};
width: 100%;
`;
const StyledAlternativesBox = styled.div`
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
margin-top: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledAlternativesLabel = styled.span`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.xs};
`;
const StyledAlternativeRow = styled.button`
align-items: center;
background: transparent;
border: none;
color: ${themeCssVariables.color.green};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
padding: 0;
text-align: left;
`;
const StyledAvailabilityDot = styled.div`
background-color: ${themeCssVariables.color.green};
border-radius: 50%;
flex-shrink: 0;
height: 6px;
width: 6px;
`;
export const SignInUpWorkspaceCreationFormV2 = () => {
const { t } = useLingui();
const { createWorkspace } = useSignUpInNewWorkspace();
const { frontDomain } = useAtomStateValue(domainConfigurationState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState);
const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState);
const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
undefined,
);
const hiddenFileInputRef = useRef<HTMLInputElement>(null);
const {
workspaceName,
subdomain,
status,
errorMessage,
suggestions,
isAvailable,
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestionValue,
} = useWorkspaceSubdomainField({
isSubdomainEnabled: isMultiWorkspaceEnabled,
});
const isContinueDisabled =
workspaceName.trim() === '' ||
isCreatingWorkspace ||
(isMultiWorkspaceEnabled && !isAvailable);
const openFilePicker = () => {
hiddenFileInputRef.current?.click();
};
const handleLogoUpload = (file: File) => {
if (!isDefined(file)) {
return;
}
setLogo(file);
setLogoPreviewUrl(URL.createObjectURL(file));
};
const handleLogoRemove = () => {
setLogo(undefined);
setLogoPreviewUrl(undefined);
};
useEffect(() => {
if (!isDefined(logoPreviewUrl)) {
return;
}
return () => {
URL.revokeObjectURL(logoPreviewUrl);
};
}, [logoPreviewUrl]);
const handleSubmit = async () => {
if (isContinueDisabled) {
return;
}
setIsCreatingWorkspace(true);
setIsOnboardingV2(true);
const isWorkspaceCreated = await createWorkspace({
displayName: workspaceName.trim(),
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
if (!isWorkspaceCreated) {
setIsCreatingWorkspace(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) {
return;
}
if (event.key === Key.Enter) {
event.preventDefault();
handleSubmit();
}
};
const subdomainError =
status === 'invalid'
? errorMessage
: status === 'unavailable'
? t`This address is already taken`
: status === 'error'
? t`Couldn't check availability. Please try again.`
: undefined;
return (
<StyledOnboardingContentContainer>
<StyledHeading>
<StyledTitle>{t`Create your workspace`}</StyledTitle>
<StyledSubtitle>
{t`Move work forward across teams and agents`}
</StyledSubtitle>
</StyledHeading>
<StyledSection>
<StyledLogoRow>
<Avatar
avatarUrl={logoPreviewUrl}
placeholder={isNonEmptyString(workspaceName) ? workspaceName : '?'}
placeholderColorSeed={workspaceName}
type="squared"
size="xl"
onClick={openFilePicker}
/>
<StyledHiddenFileInput
type="file"
ref={hiddenFileInputRef}
accept="image/jpeg, image/png, image/gif"
onChange={(event) => {
const file = event.target.files?.[0];
if (isDefined(file)) {
handleLogoUpload(file);
}
event.target.value = '';
}}
/>
<Button
Icon={IconUpload}
title={t`Upload logo`}
variant="secondary"
onClick={openFilePicker}
/>
<LightIconButton
Icon={IconTrash}
accent="tertiary"
onClick={handleLogoRemove}
disabled={!isDefined(logoPreviewUrl)}
aria-label={t`Remove logo`}
/>
</StyledLogoRow>
</StyledSection>
<StyledSection>
<TextInput
autoFocus
label={t`Name`}
value={workspaceName}
placeholder={t`Apple`}
onChange={handleWorkspaceNameChange}
onKeyDown={handleKeyDown}
fullWidth
/>
</StyledSection>
{isMultiWorkspaceEnabled && (
<StyledSection>
<TextInput
label={t`Subdomain`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
{status === 'unavailable' && (
<StyledAlternativesBox>
<StyledAlternativesLabel>
{t`Subdomain already in use, here are some alternatives:`}
</StyledAlternativesLabel>
{suggestions.map((alternative) => (
<StyledAlternativeRow
key={alternative}
type="button"
onClick={() => applySuggestionValue(alternative)}
>
<StyledAvailabilityDot />
{alternative}
</StyledAlternativeRow>
))}
</StyledAlternativesBox>
)}
</StyledSection>
)}
<StyledButtonContainer>
<MainButton
title={t`Create workspace`}
onClick={handleSubmit}
disabled={isContinueDisabled}
fullWidth
/>
</StyledButtonContainer>
</StyledOnboardingContentContainer>
);
};
@@ -6,6 +6,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
@@ -14,7 +15,7 @@ import {
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
const createWorkspaceMock = jest.fn();
const applySuggestionMock = jest.fn();
const applySuggestionValueMock = jest.fn();
const handleSubdomainChangeMock = jest.fn();
const handleWorkspaceNameChangeMock = jest.fn();
const useWorkspaceSubdomainFieldMock = jest.fn();
@@ -56,11 +57,11 @@ describe('SignInUpWorkspaceCreationForm', () => {
subdomain: 'apple',
status: 'available',
errorMessage: undefined,
suggestion: undefined,
suggestions: [],
isAvailable: true,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
applySuggestionValue: applySuggestionValueMock,
});
});
@@ -69,35 +70,18 @@ describe('SignInUpWorkspaceCreationForm', () => {
setMultiWorkspaceEnabled(true);
});
it('keeps Continue disabled until a workspace name is entered', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: '',
subdomain: '',
status: 'idle',
errorMessage: undefined,
suggestion: undefined,
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
it('creates the workspace with the chosen name and subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
const createButton = screen.getByRole('button', {
name: 'Create workspace',
});
renderForm();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
it('creates the workspace with the chosen name and address in the same tab', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
expect(createButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
fireEvent.click(createButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
@@ -107,61 +91,73 @@ describe('SignInUpWorkspaceCreationForm', () => {
});
});
it('passes the picked logo file when creating the workspace', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
it('keeps the loader on through a successful creation, until the redirect', async () => {
let resolveCreateWorkspace: () => void = () => {};
createWorkspaceMock.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCreateWorkspace = () => resolve(true);
}),
);
const { container } = renderForm();
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const logoFile = new File(['logo'], 'logo.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [logoFile] } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: logoFile,
});
});
it('routes name edits back through the field hook', () => {
renderForm();
fireEvent.change(screen.getByLabelText('Workspace name'), {
target: { value: 'Acme' },
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(handleWorkspaceNameChangeMock).toHaveBeenCalledWith('Acme');
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
expect(createWorkspaceMock).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreateWorkspace();
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
});
it('offers a one-click suggestion when the address is taken', () => {
it('returns to the form when workspace creation fails', async () => {
createWorkspaceMock.mockResolvedValue(false);
renderForm();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(false);
});
it('lists available alternatives and applies the picked one when the subdomain is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
workspaceName: 'Stripe',
subdomain: 'stripe',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
suggestions: ['stripe-2', 'mystripe', 'stripeeinc'],
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
applySuggestionValue: applySuggestionValueMock,
});
renderForm();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
expect(
screen.getByText(
'Subdomain already in use, here are some alternatives:',
),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Create workspace' }),
).toBeDisabled();
fireEvent.click(screen.getByText('Use apple-2 instead'));
fireEvent.click(screen.getByRole('button', { name: 'mystripe' }));
expect(applySuggestionMock).toHaveBeenCalledTimes(1);
expect(applySuggestionValueMock).toHaveBeenCalledWith('mystripe');
});
});
@@ -170,40 +166,18 @@ describe('SignInUpWorkspaceCreationForm', () => {
setMultiWorkspaceEnabled(false);
});
it('hides the workspace address field', () => {
renderForm();
expect(screen.getByLabelText('Workspace name')).toBeInTheDocument();
expect(
screen.queryByLabelText('Workspace address'),
).not.toBeInTheDocument();
});
it('enables Continue based on the name only and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
// An unavailable subdomain status must not block submission when the
// address field is hidden.
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
it('hides the subdomain field and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.queryByLabelText('Subdomain')).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(continueButton);
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
@@ -1,192 +0,0 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { SignInUpWorkspaceCreationFormV2 } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
const createWorkspaceMock = jest.fn();
const applySuggestionValueMock = jest.fn();
const handleSubdomainChangeMock = jest.fn();
const handleWorkspaceNameChangeMock = jest.fn();
const useWorkspaceSubdomainFieldMock = jest.fn();
jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({
useSignUpInNewWorkspace: () => ({ createWorkspace: createWorkspaceMock }),
}));
jest.mock('@/auth/sign-in-up/hooks/useWorkspaceSubdomainField', () => ({
useWorkspaceSubdomainField: () => useWorkspaceSubdomainFieldMock(),
}));
global.URL.createObjectURL = jest.fn(() => 'blob:logo-preview');
global.URL.revokeObjectURL = jest.fn();
dynamicActivate(SOURCE_LOCALE);
const setMultiWorkspaceEnabled = (isEnabled: boolean) => {
jotaiStore.set(isMultiWorkspaceEnabledState.atom, isEnabled);
};
const renderForm = () =>
render(
<JotaiProvider store={jotaiStore}>
<ThemeProvider colorScheme="light">
<I18nProvider i18n={i18n}>
<SignInUpWorkspaceCreationFormV2 />
</I18nProvider>
</ThemeProvider>
</JotaiProvider>,
);
describe('SignInUpWorkspaceCreationFormV2', () => {
beforeEach(() => {
jest.clearAllMocks();
resetJotaiStore();
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'available',
errorMessage: undefined,
suggestions: [],
isAvailable: true,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestionValue: applySuggestionValueMock,
});
});
describe('multi-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(true);
});
it('creates the workspace with the chosen name and subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
const createButton = screen.getByRole('button', {
name: 'Create workspace',
});
expect(createButton).toBeEnabled();
await act(async () => {
fireEvent.click(createButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: undefined,
});
});
it('keeps the loader on through a successful creation, until the redirect', async () => {
let resolveCreateWorkspace: () => void = () => {};
createWorkspaceMock.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCreateWorkspace = () => resolve(true);
}),
);
renderForm();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
expect(createWorkspaceMock).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreateWorkspace();
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true);
});
it('returns to the form when workspace creation fails', async () => {
createWorkspaceMock.mockResolvedValue(false);
renderForm();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(false);
});
it('lists available alternatives and applies the picked one when the subdomain is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Stripe',
subdomain: 'stripe',
status: 'unavailable',
errorMessage: undefined,
suggestions: ['stripe-2', 'mystripe', 'stripeeinc'],
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestionValue: applySuggestionValueMock,
});
renderForm();
expect(
screen.getByText(
'Subdomain already in use, here are some alternatives:',
),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Create workspace' }),
).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'mystripe' }));
expect(applySuggestionValueMock).toHaveBeenCalledWith('mystripe');
});
});
describe('single-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(false);
});
it('hides the subdomain field and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(true);
renderForm();
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.queryByLabelText('Subdomain')).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: 'Create workspace' }),
);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
logo: undefined,
});
expect(createWorkspaceMock.mock.calls[0][0]).not.toHaveProperty(
'subdomain',
);
});
});
});
@@ -140,7 +140,6 @@ describe('useWorkspaceSubdomainField', () => {
'taken-3',
'taken-4',
]);
expect(result.current.suggestion).toBe('taken-2');
expect(result.current.isAvailable).toBe(false);
});
@@ -1,5 +1,4 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -14,7 +13,6 @@ import {
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { useLingui } from '@lingui/react/macro';
import { useStore } from 'jotai';
export const useSignUpInNewWorkspace = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
@@ -22,7 +20,6 @@ export const useSignUpInNewWorkspace = () => {
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const store = useStore();
const { enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
@@ -76,11 +73,9 @@ export const useSignUpInNewWorkspace = () => {
return true;
}
const isOnboardingV2 = store.get(isOnboardingV2State.atom);
await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
isOnboardingV2 ? AppPath.VerifyV2 : AppPath.Verify,
AppPath.Verify,
{ loginToken },
'_self',
);
@@ -166,27 +166,15 @@ export const useWorkspaceSubdomainField = ({
debouncedAvailabilityCheck(value, { adoptSuggestion: false });
};
const suggestion: string | undefined = suggestions[0];
const applySuggestion = () => {
if (!isDefined(suggestion)) {
return;
}
applySuggestionValue(suggestion);
};
return {
workspaceName,
subdomain,
status,
errorMessage,
suggestion,
suggestions,
isAvailable: status === 'available',
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
applySuggestionValue,
};
};
@@ -1,49 +0,0 @@
import { createStore } from 'jotai';
import { type isOnboardingV2State as IsOnboardingV2State } from '@/auth/states/isOnboardingV2State';
const loadAtom = (): typeof IsOnboardingV2State => {
let state: typeof IsOnboardingV2State | undefined;
jest.isolateModules(() => {
state = require('@/auth/states/isOnboardingV2State').isOnboardingV2State;
});
if (state === undefined) {
throw new Error('Failed to load isOnboardingV2State');
}
return state;
};
describe('isOnboardingV2State', () => {
afterEach(() => {
sessionStorage.clear();
jest.resetModules();
});
it('hydrates from sessionStorage on load so the flag survives the email-connect OAuth round-trip', () => {
sessionStorage.setItem('isOnboardingV2State', JSON.stringify(true));
const isOnboardingV2State = loadAtom();
expect(createStore().get(isOnboardingV2State.atom)).toBe(true);
});
it('writes through to sessionStorage when set', () => {
const isOnboardingV2State = loadAtom();
const store = createStore();
store.set(isOnboardingV2State.atom, true);
expect(sessionStorage.getItem('isOnboardingV2State')).toBe(
JSON.stringify(true),
);
});
it('defaults to false when nothing is persisted', () => {
const isOnboardingV2State = loadAtom();
expect(createStore().get(isOnboardingV2State.atom)).toBe(false);
});
});
@@ -1,7 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isOnboardingV2State = createAtomState<boolean>({
key: 'isOnboardingV2State',
defaultValue: false,
useSessionStorage: true,
});
@@ -1,18 +0,0 @@
import { AUTH_MODAL_CONFIG } from '@/auth/constants/AuthModalConfig';
import { type Location } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const getAuthModalConfig = (location: Location) => {
for (const path of Object.values(AppPath)) {
if (
isMatchingLocation(location, path) &&
isDefined(AUTH_MODAL_CONFIG[path])
) {
return AUTH_MODAL_CONFIG[path];
}
}
return AUTH_MODAL_CONFIG.default;
};
@@ -0,0 +1,9 @@
import { matchPath } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { ONBOARDING_TRANSITION_PATHS } from '@/auth/constants/OnboardingTransitionPaths';
export const isOnOnboardingTransitionPath = (pathname: string) =>
ONBOARDING_TRANSITION_PATHS.some((onboardingPath) =>
isDefined(matchPath(onboardingPath, pathname)),
);
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const RequestFreshCaptchaTokenEffect = () => {
const location = useLocation();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
useEffect(() => {
if (isCaptchaScriptLoaded && isCaptchaRequiredForPath(location.pathname)) {
requestFreshCaptchaToken();
}
}, [isCaptchaScriptLoaded, location.pathname, requestFreshCaptchaToken]);
return null;
};
@@ -2,9 +2,7 @@ import { AppPath } from 'twenty-shared/types';
export const CAPTCHA_PROTECTED_PATHS: string[] = [
AppPath.SignInUp,
AppPath.SignInUpV2,
AppPath.Verify,
AppPath.VerifyV2,
AppPath.VerifyEmail,
AppPath.ResetPassword,
AppPath.Invite,
@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue';
import { isNonEmptyString } from '@sniptt/guards';
@@ -12,7 +11,6 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => {
const buildSearchParamsFromUrlSyncedStates = useCallback(async () => {
const billingCheckoutSession = store.get(billingCheckoutSessionState.atom);
const returnToPath = store.get(returnToPathState.atom);
const isOnboardingV2 = store.get(isOnboardingV2State.atom);
const output = {
...(billingCheckoutSession !== BILLING_CHECKOUT_SESSION_DEFAULT_VALUE
@@ -21,7 +19,6 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => {
}
: {}),
...(isNonEmptyString(returnToPath) ? { returnToPath } : {}),
...(isOnboardingV2 ? { onboardingV2: 'true' } : {}),
};
return output;
@@ -0,0 +1,20 @@
import { Outlet } from 'react-router-dom';
import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMetadataReadyState';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
export const MinimalMetadataGate = () => {
const isMinimalMetadataReady = useAtomStateValue(isMinimalMetadataReadyState);
if (!isMinimalMetadataReady) {
return <UserOrMetadataLoader />;
}
return (
<PreComputedChipGeneratorsProvider>
<Outlet />
</PreComputedChipGeneratorsProvider>
);
};
@@ -1,49 +0,0 @@
import React from 'react';
import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMetadataReadyState';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { UserContext } from '@/users/contexts/UserContext';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => {
const isMinimalMetadataReady = useAtomStateValue(isMinimalMetadataReadyState);
const location = useLocation();
const { dateFormat, timeFormat, timeZone } = useDateTimeFormat();
const isOnExcludedPath =
isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(location, AppPath.VerifyV2) ||
isMatchingLocation(location, AppPath.VerifyEmail) ||
isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(location, AppPath.SignInUpV2) ||
isMatchingLocation(location, AppPath.Invite) ||
isMatchingLocation(location, AppPath.ResetPassword) ||
isMatchingLocation(location, AppPath.WorkspaceActivation) ||
isMatchingLocation(location, AppPath.WorkspaceActivationV2) ||
isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) ||
isMatchingLocation(location, AppPath.Authorize);
const shouldShowLoader = !isMinimalMetadataReady && !isOnExcludedPath;
if (shouldShowLoader) {
return <UserOrMetadataLoader />;
}
return (
<UserContext.Provider
value={{
dateFormat,
timeFormat,
timeZone,
}}
>
{children}
</UserContext.Provider>
);
};
@@ -1,4 +1,5 @@
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { useIsOnAuthOrOnboardingPage } from '@/auth/hooks/useIsOnAuthOrOnboardingPage';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
@@ -21,8 +22,11 @@ export const MinimalMetadataLoadEffect = () => {
const { loadMinimalMetadata } = useLoadMinimalMetadata();
const { loadStaleMetadataEntities } = useLoadStaleMetadataEntities();
const isOnAuthOrOnboardingPage = useIsOnAuthOrOnboardingPage();
const isActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace);
const shouldLoadRealMetadata = hasAccessTokenPair && isActiveWorkspace;
const shouldLoadRealMetadata =
hasAccessTokenPair && isActiveWorkspace && !isOnAuthOrOnboardingPage;
useEffect(() => {
if (!isCurrentUserLoaded && !isDefined(currentUser)) {
@@ -0,0 +1,23 @@
import { Outlet } from 'react-router-dom';
import { OnboardingActivationStepsProgress } from '@/onboarding/components/OnboardingActivationStepsProgress';
import { OnboardingVerifyLayout } from '@/onboarding/components/OnboardingVerifyLayout';
import { onboardingActivationFailedState } from '@/onboarding/states/onboardingActivationFailedState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const OnboardingActivationOutlet = () => {
const onboardingActivationFailed = useAtomStateValue(
onboardingActivationFailedState,
);
return (
<>
{!onboardingActivationFailed && (
<OnboardingVerifyLayout>
<OnboardingActivationStepsProgress />
</OnboardingVerifyLayout>
)}
<Outlet />
</>
);
};
@@ -0,0 +1,62 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition';
import { styled } from '@linaria/react';
import { type MessageDescriptor } from '@lingui/core';
import { useLingui } from '@lingui/react/macro';
import { motion } from 'framer-motion';
const STEP_OPACITIES = [1, 0.4, 0.12];
const VISIBLE_STEP_COUNT = STEP_OPACITIES.length;
const STEP_HEIGHT_IN_PX = 28;
const STEPS_CONTAINER_HEIGHT_IN_PX = STEP_HEIGHT_IN_PX * VISIBLE_STEP_COUNT;
const StyledStepsContainer = styled.div`
height: ${STEPS_CONTAINER_HEIGHT_IN_PX}px;
position: relative;
width: 100%;
`;
const StyledStepBase = styled.div`
left: 0;
position: absolute;
right: 0;
top: 0;
`;
const StyledStep = motion.create(StyledStepBase);
type OnboardingActivationStepsProps = {
messages: MessageDescriptor[];
messageIndex: number;
};
export const OnboardingActivationSteps = ({
messages,
messageIndex,
}: OnboardingActivationStepsProps) => {
const { i18n } = useLingui();
const transition = useOnboardingMotionTransition();
return (
<StyledStepsContainer>
{messages.map((message, index) => {
const stepOffset = index - messageIndex;
const isVisible = stepOffset >= 0 && stepOffset < VISIBLE_STEP_COUNT;
return (
<StyledStep
key={message.id}
initial={false}
animate={{
opacity: isVisible ? STEP_OPACITIES[stepOffset] : 0,
y: stepOffset * STEP_HEIGHT_IN_PX,
}}
transition={transition}
>
<SubTitle>{i18n._(message)}</SubTitle>
</StyledStep>
);
})}
</StyledStepsContainer>
);
};
@@ -1,20 +1,20 @@
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { type Dispatch, type SetStateAction, useEffect } from 'react';
const MESSAGE_INTERVAL_IN_MS = 1000;
type SignInUpWorkspaceActivationV2EffectProps = {
type OnboardingActivationStepsEffectProps = {
messageIndex: number;
setMessageIndex: Dispatch<SetStateAction<number>>;
messageCount: number;
};
export const SignInUpWorkspaceActivationV2Effect = ({
export const OnboardingActivationStepsEffect = ({
messageIndex,
setMessageIndex,
}: SignInUpWorkspaceActivationV2EffectProps) => {
messageCount,
}: OnboardingActivationStepsEffectProps) => {
useEffect(() => {
const isLastMessage =
messageIndex >= WORKSPACE_ACTIVATION_MESSAGES.length - 1;
const isLastMessage = messageIndex >= messageCount - 1;
if (isLastMessage) {
return;
@@ -25,7 +25,7 @@ export const SignInUpWorkspaceActivationV2Effect = ({
}, MESSAGE_INTERVAL_IN_MS);
return () => clearTimeout(timeout);
}, [messageIndex, setMessageIndex]);
}, [messageIndex, setMessageIndex, messageCount]);
return <></>;
};
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { useMatch } from 'react-router-dom';
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
import { OnboardingActivationSteps } from '@/onboarding/components/OnboardingActivationSteps';
import { OnboardingActivationStepsEffect } from '@/onboarding/components/OnboardingActivationStepsEffect';
import { ONBOARDING_ACTIVATION_MESSAGES } from '@/onboarding/constants/OnboardingActivationMessages';
import { WORKSPACE_ACTIVATION_FIRST_MESSAGE_INDEX } from '@/onboarding/constants/WorkspaceActivationFirstMessageIndex';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { OnboardingStatus } from '~/generated-metadata/graphql';
export const OnboardingActivationStepsProgress = () => {
const isActivating = isDefined(useMatch(AppPath.WorkspaceActivation));
const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState);
const onboardingStatus = useOnboardingStatus();
const shouldShowWorkspaceActivationMessages =
isActivating ||
isCreatingWorkspace ||
onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION;
const messages = shouldShowWorkspaceActivationMessages
? ONBOARDING_ACTIVATION_MESSAGES
: ONBOARDING_ACTIVATION_MESSAGES.slice(
0,
WORKSPACE_ACTIVATION_FIRST_MESSAGE_INDEX,
);
const [messageIndex, setMessageIndex] = useState(
isActivating ? WORKSPACE_ACTIVATION_FIRST_MESSAGE_INDEX : 0,
);
useEffect(() => {
setMessageIndex(
isActivating ? WORKSPACE_ACTIVATION_FIRST_MESSAGE_INDEX : 0,
);
}, [isActivating]);
return (
<>
{isActivating && (
<OnboardingActivationStepsEffect
messageIndex={messageIndex}
setMessageIndex={setMessageIndex}
messageCount={ONBOARDING_ACTIVATION_MESSAGES.length}
/>
)}
<OnboardingActivationSteps
messages={messages}
messageIndex={messageIndex}
/>
</>
);
};
@@ -0,0 +1,41 @@
import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition';
import { styled } from '@linaria/react';
import { AnimatePresence, motion } from 'framer-motion';
import { type ReactNode } from 'react';
const StyledAnimatedRevealBase = styled.div`
max-width: 100%;
overflow: hidden;
`;
const StyledAnimatedReveal = motion.create(StyledAnimatedRevealBase);
type OnboardingAnimatedRevealProps = {
isVisible: boolean;
children: ReactNode;
className?: string;
};
export const OnboardingAnimatedReveal = ({
isVisible,
children,
className,
}: OnboardingAnimatedRevealProps) => {
const transition = useOnboardingMotionTransition();
return (
<AnimatePresence initial={false}>
{isVisible && (
<StyledAnimatedReveal
className={className}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={transition}
>
{children}
</StyledAnimatedReveal>
)}
</AnimatePresence>
);
};
@@ -1,23 +1,25 @@
import { useOnboardingContentWidth } from '@/onboarding/hooks/useOnboardingContentWidth';
import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconCoins, IconInfoCircle } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const HEADER_CENTER_WIDTH = 340;
const StyledHeader = styled.div`
align-items: center;
align-items: flex-start;
box-sizing: border-box;
display: flex;
justify-content: space-between;
padding: ${themeCssVariables.spacing[8]};
padding: ${themeCssVariables.spacing[8]} ${themeCssVariables.spacing[8]} 1px;
width: 100%;
`;
const StyledSide = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1 1 0;
min-width: 0;
@@ -28,19 +30,20 @@ const StyledLeftSide = styled(StyledSide)`
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledCenter = styled.div`
const StyledCenter = styled.div<{ contentWidth: number }>`
align-items: center;
display: flex;
flex: 0 1 ${HEADER_CENTER_WIDTH}px;
flex: 0 1 ${({ contentWidth }) => `${contentWidth}px`};
justify-content: flex-start;
min-width: 0;
`;
const StyledRightSide = styled(StyledSide)`
justify-content: flex-end;
padding-left: ${themeCssVariables.spacing[1]};
`;
const StyledLogo = styled.div`
const StyledLogoBase = styled.div`
background-image: url('/images/integrations/twenty-logo.svg');
background-size: cover;
height: ${themeCssVariables.spacing[6]};
@@ -48,6 +51,8 @@ const StyledLogo = styled.div`
width: ${themeCssVariables.spacing[6]};
`;
const StyledLogo = motion.create(StyledLogoBase);
const StyledFreeCredits = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
@@ -89,20 +94,23 @@ const StyledInfoTag = styled.div`
display: flex;
height: ${themeCssVariables.spacing[6]};
justify-content: center;
padding: 0 ${themeCssVariables.spacing['1.5']};
padding: 0 ${themeCssVariables.spacing['1.5']} 0
${themeCssVariables.spacing[1]};
`;
type OnboardingV2HeaderProps = {
type OnboardingHeaderProps = {
onBack?: () => void;
freeCredits?: number;
};
export const OnboardingV2Header = ({
export const OnboardingHeader = ({
onBack,
freeCredits,
}: OnboardingV2HeaderProps) => {
}: OnboardingHeaderProps) => {
const { t } = useLingui();
const theme = useTheme();
const contentWidth = useOnboardingContentWidth();
const transition = useOnboardingMotionTransition();
return (
<StyledHeader>
@@ -111,14 +119,14 @@ export const OnboardingV2Header = ({
<LightIconButton
Icon={IconChevronLeft}
accent="tertiary"
size="medium"
size="small"
onClick={onBack}
aria-label={t`Go back`}
/>
)}
</StyledLeftSide>
<StyledCenter>
<StyledLogo />
<StyledCenter contentWidth={contentWidth}>
<StyledLogo layout transition={transition} />
</StyledCenter>
<StyledRightSide>
{isDefined(freeCredits) && (
@@ -1,4 +1,4 @@
import { OnboardingV2Header } from '@/onboarding/components/OnboardingV2Header';
import { OnboardingHeader } from '@/onboarding/components/OnboardingHeader';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -11,19 +11,19 @@ const StyledBackground = styled.div`
width: 100%;
`;
type OnboardingV2LayoutProps = {
type OnboardingLayoutProps = {
children: ReactNode;
onBack?: () => void;
freeCredits?: number;
};
export const OnboardingV2Layout = ({
export const OnboardingLayout = ({
children,
onBack,
freeCredits,
}: OnboardingV2LayoutProps) => (
}: OnboardingLayoutProps) => (
<StyledBackground>
<OnboardingV2Header onBack={onBack} freeCredits={freeCredits} />
<OnboardingHeader onBack={onBack} freeCredits={freeCredits} />
{children}
</StyledBackground>
);
@@ -4,7 +4,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: center;
background: ${themeCssVariables.background.primary};
background: ${themeCssVariables.background.secondary};
display: flex;
flex-direction: column;
height: 100dvh;
@@ -0,0 +1,34 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSkipButton = styled.button`
background-color: transparent;
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.semiBold};
height: ${themeCssVariables.spacing[8]};
padding: 0 ${themeCssVariables.spacing[5]};
`;
type OnboardingSkipButtonProps = {
onClick: () => void;
disabled?: boolean;
};
export const OnboardingSkipButton = ({
onClick,
disabled,
}: OnboardingSkipButtonProps) => {
const { t } = useLingui();
return (
<StyledSkipButton type="button" onClick={onClick} disabled={disabled}>
{t`Skip`}
</StyledSkipButton>
);
};
@@ -0,0 +1,44 @@
import { styled } from '@linaria/react';
import { motion, useReducedMotion } from 'framer-motion';
import { type ReactNode } from 'react';
import { ONBOARDING_MOTION_SLIDE_OFFSET } from '@/onboarding/constants/OnboardingMotionSlideOffset';
import { ONBOARDING_MOTION_STAGGER_DELAY } from '@/onboarding/constants/OnboardingMotionStaggerDelay';
import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition';
const StyledAnimatedItemBase = styled.div`
max-width: 100%;
`;
const StyledAnimatedItem = motion.create(StyledAnimatedItemBase);
type OnboardingStepAnimatedItemProps = {
index: number;
children: ReactNode;
className?: string;
};
export const OnboardingStepAnimatedItem = ({
index,
children,
className,
}: OnboardingStepAnimatedItemProps) => {
const transition = useOnboardingMotionTransition();
const shouldReduceMotion = useReducedMotion();
return (
<StyledAnimatedItem
className={className}
initial={{
opacity: 0,
y: shouldReduceMotion ? 0 : ONBOARDING_MOTION_SLIDE_OFFSET,
}}
animate={{ opacity: 1, y: 0 }}
transition={{
...transition,
delay: shouldReduceMotion ? 0 : index * ONBOARDING_MOTION_STAGGER_DELAY,
}}
>
{children}
</StyledAnimatedItem>
);
};
@@ -0,0 +1,13 @@
import { OnboardingLayout } from '@/onboarding/components/OnboardingLayout';
import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet';
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
export const OnboardingStepLayout = () => {
const freeCredits = useOnboardingFreeCreditsTotal();
return (
<OnboardingLayout freeCredits={freeCredits}>
<OnboardingTransitionOutlet />
</OnboardingLayout>
);
};
@@ -0,0 +1,17 @@
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
import { styled } from '@linaria/react';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
justify-content: center;
min-height: 0;
width: 100%;
`;
export const OnboardingStepPageLoader = () => (
<StyledContainer>
<OnboardingPulsingLogo />
</StyledContainer>
);
@@ -1,42 +0,0 @@
import { ONBOARDING_SYNC_EMAILS_OPTIONS } from '@/onboarding/constants/OnboardingSyncEmailsOptions';
import { SettingsAccountsRadioSettingsCard } from '@/settings/accounts/components/SettingsAccountsRadioSettingsCard';
import { SettingsAccountsVisibilityIcon } from '@/settings/accounts/components/SettingsAccountsVisibilityIcon';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MessageChannelVisibility } from '~/generated/graphql';
type OnboardingSyncEmailsSettingsCardProps = {
onChange: (nextValue: MessageChannelVisibility) => void;
value?: MessageChannelVisibility;
};
const StyledCardMediaContainer = styled.div`
width: ${themeCssVariables.spacing[10]};
`;
export const OnboardingSyncEmailsSettingsCard = ({
onChange,
value = MessageChannelVisibility.SHARE_EVERYTHING,
}: OnboardingSyncEmailsSettingsCardProps) => {
const optionsWithCardMedia = ONBOARDING_SYNC_EMAILS_OPTIONS.map((option) => ({
...option,
cardMedia: (
<StyledCardMediaContainer>
<SettingsAccountsVisibilityIcon
metadata={option.cardMediaProps.metadata}
subject={option.cardMediaProps.subject}
body={option.cardMediaProps.body}
/>
</StyledCardMediaContainer>
),
}));
return (
<SettingsAccountsRadioSettingsCard
name="sync-emails-visibility"
options={optionsWithCardMedia}
value={value}
onChange={onChange}
/>
);
};
@@ -1,8 +1,8 @@
import { ONBOARDING_MOTION_SLIDE_OFFSET } from '@/onboarding/constants/OnboardingMotionSlideOffset';
import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition';
import { styled } from '@linaria/react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { useContext } from 'react';
import { useLocation, useOutlet } from 'react-router-dom';
import { ThemeContext } from 'twenty-ui/theme-constants';
const StyledTransitionContainer = styled.div`
display: flex;
@@ -14,38 +14,32 @@ const StyledTransitionContainer = styled.div`
const StyledTransitionPage = styled(motion.div)`
display: flex;
flex-direction: column;
inset: 0;
min-height: 0;
min-width: 0;
position: absolute;
`;
export const OnboardingV2TransitionOutlet = () => {
export const OnboardingTransitionOutlet = () => {
const { pathname } = useLocation();
const outlet = useOutlet();
const shouldReduceMotion = useReducedMotion();
const { theme } = useContext(ThemeContext);
const transition = useOnboardingMotionTransition();
return (
<StyledTransitionContainer>
<AnimatePresence initial={false}>
<StyledTransitionPage
key={pathname}
initial={{ opacity: 0, y: theme.spacingMultiplicator }}
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
y: -theme.spacingMultiplicator,
y: shouldReduceMotion ? 0 : -ONBOARDING_MOTION_SLIDE_OFFSET,
pointerEvents: 'none',
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: theme.animation.duration.normal,
ease: 'easeInOut',
}
}
transition={transition}
>
{outlet}
</StyledTransitionPage>
@@ -0,0 +1,27 @@
import { OnboardingPulsingLogo } from '@/onboarding/components/OnboardingPulsingLogo';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: center;
background: ${themeCssVariables.background.secondary};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
type OnboardingVerifyLayoutProps = {
children: ReactNode;
};
export const OnboardingVerifyLayout = ({
children,
}: OnboardingVerifyLayoutProps) => (
<StyledContainer>
<OnboardingPulsingLogo />
{children}
</StyledContainer>
);
@@ -0,0 +1,11 @@
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledOnboardingStepHeading = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
max-width: 100%;
width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
`;
@@ -0,0 +1,16 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledOnboardingStepPage = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
box-sizing: border-box;
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[14]};
min-height: 0;
overflow-y: auto;
padding: ${themeCssVariables.spacing[16]} ${themeCssVariables.spacing[8]};
width: 100%;
`;
@@ -0,0 +1,9 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledOnboardingStepSubtitle = styled.p`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.4;
margin: 0;
`;
@@ -0,0 +1,9 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledOnboardingStepTagsRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[1]};
`;
@@ -0,0 +1,10 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledOnboardingStepTitle = styled.h1`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
line-height: 1.2;
margin: 0;
`;
@@ -0,0 +1,38 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { OnboardingActivationSteps } from '@/onboarding/components/OnboardingActivationSteps';
import { OnboardingActivationStepsEffect } from '@/onboarding/components/OnboardingActivationStepsEffect';
import { ONBOARDING_ACTIVATION_MESSAGES } from '@/onboarding/constants/OnboardingActivationMessages';
import { useState } from 'react';
import { ModalContent } from 'twenty-ui/surfaces';
import { ComponentDecorator } from 'twenty-ui/testing';
const RenderWithModalContent = () => {
const [messageIndex, setMessageIndex] = useState(0);
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<OnboardingActivationStepsEffect
messageIndex={messageIndex}
setMessageIndex={setMessageIndex}
messageCount={ONBOARDING_ACTIVATION_MESSAGES.length}
/>
<OnboardingActivationSteps
messages={ONBOARDING_ACTIVATION_MESSAGES}
messageIndex={messageIndex}
/>
</ModalContent>
);
};
const meta: Meta<typeof OnboardingActivationSteps> = {
title: 'Modules/Onboarding/OnboardingActivationSteps',
component: OnboardingActivationSteps,
decorators: [ComponentDecorator],
render: RenderWithModalContent,
};
export default meta;
type Story = StoryObj<typeof OnboardingActivationSteps>;
export const Default: Story = {};
@@ -7,7 +7,7 @@ const StyledTag = styled.div`
align-items: center;
background-color: ${themeCssVariables.color.green3};
border: 1px solid ${themeCssVariables.color.green4};
border-radius: ${themeCssVariables.border.radius.pill};
border-radius: ${themeCssVariables.border.radius.xxl};
box-sizing: border-box;
color: ${themeCssVariables.color.green9};
display: flex;
@@ -20,11 +20,13 @@ const StyledTag = styled.div`
const StyledLabel = styled.span`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: 1.4;
`;
const StyledSuffix = styled.span`
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: 1.4;
`;
type OnboardingCreditsRewardTagProps = {
@@ -2,24 +2,25 @@ import { OnboardingImportPreviewCompanies } from '@/onboarding/components/import
import { OnboardingImportPreviewEmails } from '@/onboarding/components/import-contacts/OnboardingImportPreviewEmails';
import { OnboardingImportPreviewSyncBadge } from '@/onboarding/components/import-contacts/OnboardingImportPreviewSyncBadge';
import { OnboardingImportPrivacyNote } from '@/onboarding/components/import-contacts/OnboardingImportPrivacyNote';
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const PREVIEW_WIDTH = 340;
const PREVIEW_HEIGHT = 200;
const PREVIEW_HEIGHT = 198;
const StyledCard = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.xl};
border-radius: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
max-width: 100%;
overflow: hidden;
padding-bottom: ${themeCssVariables.spacing[3]};
width: ${PREVIEW_WIDTH}px;
width: ${ONBOARDING_CONTENT_BLOCK_WIDTH}px;
`;
const StyledColumns = styled.div`
@@ -37,7 +37,7 @@ const StyledEmailRow = styled.div<{ isUnread: boolean }>`
: themeCssVariables.font.color.tertiary};
display: flex;
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.xs};
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
height: ${EMAIL_ROW_HEIGHT}px;
padding: 0 ${themeCssVariables.spacing[3]};
@@ -46,7 +46,7 @@ const StyledEmailRow = styled.div<{ isUnread: boolean }>`
const StyledEmailCheckbox = styled.div`
border: 1px solid ${themeCssVariables.font.color.light};
border-radius: ${themeCssVariables.border.radius.xs};
border-radius: 1px;
box-sizing: border-box;
flex-shrink: 0;
height: ${EMAIL_CHECKBOX_SIZE}px;
@@ -67,29 +67,30 @@ const StyledEmailSender = styled.span<{ isUnread: boolean }>`
`;
const StyledEmailSubject = styled.span`
color: ${themeCssVariables.font.color.tertiary};
color: ${themeCssVariables.font.color.secondary};
`;
const StyledEventCard = styled.div<{ color: 'yellow' | 'blue' }>`
const StyledEventCard = styled.div<{ color: 'orange' | 'sky' }>`
background-color: ${({ color }) =>
color === 'yellow'
? themeCssVariables.color.yellow3
: themeCssVariables.color.blue3};
color === 'orange'
? themeCssVariables.color.orange3
: themeCssVariables.color.sky3};
border-left: 2px solid
${({ color }) =>
color === 'yellow'
? themeCssVariables.color.yellow8
: themeCssVariables.color.blue8};
border-radius: ${themeCssVariables.border.radius.sm};
box-shadow: ${themeCssVariables.boxShadow.light};
color === 'orange'
? themeCssVariables.color.orange11
: themeCssVariables.color.sky11};
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
box-sizing: border-box;
color: ${({ color }) =>
color === 'yellow'
? themeCssVariables.color.yellow11
: themeCssVariables.color.blue11};
color === 'orange'
? themeCssVariables.color.orange11
: themeCssVariables.color.sky11};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
line-height: 1.1;
padding: ${themeCssVariables.spacing[2]};
position: absolute;
width: 160px;
@@ -101,15 +102,15 @@ const StyledEventTitle = styled.span`
`;
const StyledEventTime = styled.span`
font-size: ${themeCssVariables.font.size.xs};
font-size: 10px;
`;
const EVENT_CARD_POSITIONS: Record<
ImportContactsPreviewCalendarEvent['color'],
{ top: number; left: number; rotate: number }
> = {
blue: { top: -6, left: 41, rotate: 10 },
yellow: { top: 150, left: 20, rotate: -7 },
orange: { top: 160, left: 22, rotate: -7 },
sky: { top: -4, left: 44, rotate: 10 },
};
const EmailRow = ({ email }: { email: ImportContactsPreviewEmail }) => (
@@ -9,8 +9,8 @@ const StyledBadge = styled.div`
backdrop-filter: blur(20px);
background-color: ${themeCssVariables.background.transparent.secondary};
border: 1px solid ${themeCssVariables.background.transparent.lighter};
border-radius: 0 ${themeCssVariables.border.radius.md}
${themeCssVariables.border.radius.md} 0;
border-left: none;
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
box-sizing: border-box;
display: flex;
@@ -18,7 +18,7 @@ const StyledBadge = styled.div`
left: 50%;
padding: ${themeCssVariables.spacing[2]};
position: absolute;
top: 84px;
top: 83px;
transform: translateX(-50%);
`;
@@ -29,6 +29,7 @@ const StyledDivider = styled.div`
`;
const StyledTwentyLogo = styled.img`
border-radius: ${themeCssVariables.border.radius.xs};
height: ${SYNC_BADGE_LOGO_SIZE}px;
width: ${SYNC_BADGE_LOGO_SIZE}px;
`;
@@ -13,6 +13,10 @@ const StyledNote = styled.div`
gap: ${themeCssVariables.spacing[1]};
`;
const StyledNoteText = styled.span`
line-height: 1.4;
`;
export const OnboardingImportPrivacyNote = () => {
const { t } = useLingui();
@@ -22,7 +26,9 @@ export const OnboardingImportPrivacyNote = () => {
size={PRIVACY_NOTE_ICON_SIZE}
color={themeCssVariables.font.color.tertiary}
/>
{t`Only you will be able to see your emails and events`}
<StyledNoteText>
{t`Only you will be able to see your emails and events`}
</StyledNoteText>
</StyledNote>
);
};
@@ -16,11 +16,13 @@ const TRUSTED_BY_LOGOS = [
const StyledRow = styled.div`
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[2]};
justify-content: center;
max-width: 100%;
`;
const StyledBadge = styled.div`
const StyledBadge = styled.div<{ hasClusterLeading: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
@@ -32,13 +34,18 @@ const StyledBadge = styled.div`
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[7]};
padding: 0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[1]};
overflow: hidden;
padding: 0 10px 0
${({ hasClusterLeading }) =>
hasClusterLeading
? themeCssVariables.spacing['0.5']
: themeCssVariables.spacing[1]};
`;
const StyledSeal = styled.img`
height: 20px;
height: 21px;
object-fit: contain;
width: 20px;
width: 21px;
`;
const StyledLogoCluster = styled.div`
@@ -48,9 +55,9 @@ const StyledLogoCluster = styled.div`
`;
const StyledClusterLogo = styled.img`
height: 18px;
height: ${themeCssVariables.spacing[6]};
object-fit: contain;
width: auto;
width: ${themeCssVariables.spacing[6]};
`;
const StyledBadgeLabel = styled.span`
@@ -58,12 +65,17 @@ const StyledBadgeLabel = styled.span`
`;
type TrustBadgeProps = {
hasClusterLeading?: boolean;
label: string;
leading: ReactNode;
};
const TrustBadge = ({ label, leading }: TrustBadgeProps) => (
<StyledBadge>
const TrustBadge = ({
hasClusterLeading = false,
label,
leading,
}: TrustBadgeProps) => (
<StyledBadge hasClusterLeading={hasClusterLeading}>
{leading}
<StyledBadgeLabel>{label}</StyledBadgeLabel>
</StyledBadge>
@@ -77,6 +89,7 @@ export const OnboardingTrustBadges = () => (
/>
<TrustBadge
label="+10k"
hasClusterLeading
leading={
<StyledLogoCluster>
{TRUSTED_BY_LOGOS.map((logo) => (
@@ -15,27 +15,32 @@ const StyledCard = styled.div`
width: 100%;
`;
const StyledHeader = styled.button<{ hasBody: boolean }>`
align-items: flex-start;
const StyledHeader = styled.button<{ hasBody: boolean; hasNote: boolean }>`
align-items: center;
background-color: transparent;
border: none;
border-bottom: ${({ hasBody }) =>
hasBody ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: space-between;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
gap: ${themeCssVariables.spacing[1]};
padding: ${({ hasNote }) =>
hasNote
? `${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]}`
: themeCssVariables.spacing[3]};
position: relative;
text-align: left;
width: 100%;
`;
const StyledHeaderLeft = styled.div`
const StyledHeaderLeft = styled.div<{ hasNote: boolean }>`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
gap: ${themeCssVariables.spacing[4]};
min-width: 0;
padding-right: ${({ hasNote }) =>
hasNote ? themeCssVariables.spacing[8] : '0'};
`;
const StyledTitleRow = styled.div`
@@ -48,35 +53,61 @@ const StyledTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: 1.4;
`;
const StyledTitleSuffix = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
const StyledTitleSuffix = styled.span<{ isEmphasized: boolean }>`
color: ${({ isEmphasized }) =>
isEmphasized
? themeCssVariables.font.color.tertiary
: themeCssVariables.font.color.extraLight};
font-size: ${({ isEmphasized }) =>
isEmphasized
? themeCssVariables.font.size.md
: themeCssVariables.font.size.sm};
font-weight: ${({ isEmphasized }) =>
isEmphasized
? themeCssVariables.font.weight.medium
: themeCssVariables.font.weight.regular};
line-height: 1.4;
`;
const StyledNote = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: 1.4;
`;
const StyledHeaderRight = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
gap: ${themeCssVariables.spacing[1]};
min-height: ${themeCssVariables.spacing[6]};
`;
const StyledBadge = styled.span`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
background-color: ${themeCssVariables.grayScale.gray3};
border-radius: ${themeCssVariables.border.radius.pill};
box-sizing: border-box;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
height: ${themeCssVariables.spacing[5]};
padding: 0 ${themeCssVariables.spacing[2]};
`;
const StyledRadioContainer = styled.div`
align-items: center;
display: flex;
height: ${themeCssVariables.spacing[6]};
justify-content: center;
position: absolute;
right: ${themeCssVariables.spacing[2]};
top: ${themeCssVariables.spacing[2]};
width: ${themeCssVariables.spacing[6]};
`;
const StyledBody = styled.div`
@@ -105,23 +136,37 @@ export const OnboardingPlanCard = ({
children,
}: OnboardingPlanCardProps) => {
const hasBody = isValidElement(children);
const hasNote = isDefined(note);
return (
<StyledCard>
<StyledHeader type="button" hasBody={hasBody} onClick={onSelect}>
<StyledHeaderLeft>
<StyledHeader
type="button"
hasBody={hasBody}
hasNote={hasNote}
onClick={onSelect}
>
<StyledHeaderLeft hasNote={hasNote}>
<StyledTitleRow>
<StyledTitle>{title}</StyledTitle>
{isDefined(titleSuffix) && (
<StyledTitleSuffix>{titleSuffix}</StyledTitleSuffix>
<StyledTitleSuffix isEmphasized={hasNote}>
{titleSuffix}
</StyledTitleSuffix>
)}
</StyledTitleRow>
{isDefined(note) && <StyledNote>{note}</StyledNote>}
{hasNote && <StyledNote>{note}</StyledNote>}
</StyledHeaderLeft>
<StyledHeaderRight>
{isDefined(badge) && <StyledBadge>{badge}</StyledBadge>}
<Radio checked={selected} />
</StyledHeaderRight>
{hasNote ? (
<StyledRadioContainer>
<Radio checked={selected} />
</StyledRadioContainer>
) : (
<StyledHeaderRight>
{isDefined(badge) && <StyledBadge>{badge}</StyledBadge>}
<Radio checked={selected} />
</StyledHeaderRight>
)}
</StyledHeader>
{hasBody && <StyledBody>{children}</StyledBody>}
</StyledCard>
@@ -2,7 +2,7 @@ export type ImportContactsPreviewCalendarEvent = {
id: string;
title: string;
time: string;
color: 'yellow' | 'blue';
color: 'orange' | 'sky';
};
export const IMPORT_CONTACTS_PREVIEW_CALENDAR_EVENTS = [
@@ -10,12 +10,12 @@ export const IMPORT_CONTACTS_PREVIEW_CALENDAR_EVENTS = [
id: 'tim-apple-anthropic',
title: 'Tim Apple x Anthropic',
time: '10:00am',
color: 'yellow',
color: 'orange',
},
{
id: 'dario-amodei',
title: 'Dario Amodei',
time: '3:00pm',
color: 'blue',
color: 'sky',
},
] satisfies ImportContactsPreviewCalendarEvent[];
@@ -0,0 +1,7 @@
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { msg } from '@lingui/core/macro';
export const ONBOARDING_ACTIVATION_MESSAGES = [
msg`Verifying your login token`,
...WORKSPACE_ACTIVATION_MESSAGES,
];
@@ -0,0 +1 @@
export const ONBOARDING_CONTENT_BLOCK_WIDTH = 340;
@@ -0,0 +1 @@
export const ONBOARDING_MOTION_SLIDE_OFFSET = 12;
@@ -0,0 +1 @@
export const ONBOARDING_MOTION_STAGGER_DELAY = 0.07;
@@ -1,46 +0,0 @@
import { msg } from '@lingui/core/macro';
import { MessageChannelVisibility } from '~/generated/graphql';
type OnboardingEmailVisibilityProps = {
metadata: 'active' | 'inactive';
subject: 'active' | 'inactive';
body: 'active' | 'inactive';
};
const { ONBOARDING_SYNC_EMAILS_OPTIONS } = {
ONBOARDING_SYNC_EMAILS_OPTIONS: [
{
title: msg`Everything`,
description: msg`Your emails and events content will be shared with your team.`,
value: MessageChannelVisibility.SHARE_EVERYTHING,
cardMediaProps: {
metadata: 'active',
subject: 'active',
body: 'active',
} as OnboardingEmailVisibilityProps,
},
{
title: msg`Subject and metadata`,
description: msg`Your email subjects and meeting titles will be shared with your team.`,
value: MessageChannelVisibility.SUBJECT,
cardMediaProps: {
metadata: 'active',
subject: 'active',
body: 'inactive',
} as OnboardingEmailVisibilityProps,
},
{
title: msg`Metadata`,
description: msg`Only the timestamp & participants will be shared with your team.`,
value: MessageChannelVisibility.METADATA,
cardMediaProps: {
metadata: 'active',
subject: 'inactive',
body: 'inactive',
} as OnboardingEmailVisibilityProps,
},
],
};
export { ONBOARDING_SYNC_EMAILS_OPTIONS };
@@ -0,0 +1 @@
export const UPGRADE_STEP_CONTENT_WIDTH = 440;
@@ -0,0 +1,5 @@
import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages';
import { ONBOARDING_ACTIVATION_MESSAGES } from '@/onboarding/constants/OnboardingActivationMessages';
export const WORKSPACE_ACTIVATION_FIRST_MESSAGE_INDEX =
ONBOARDING_ACTIVATION_MESSAGES.length - WORKSPACE_ACTIVATION_MESSAGES.length;
@@ -1,35 +0,0 @@
import { useTriggerInstallAppsOnboardingStep } from '@/onboarding/hooks/useTriggerInstallAppsOnboardingStep';
import { useEffect, useRef } from 'react';
type InstallAppsAutoSkipEffectProps = {
onError: () => void;
};
export const InstallAppsAutoSkipEffect = ({
onError,
}: InstallAppsAutoSkipEffectProps) => {
const triggerInstallAppsOnboardingStep =
useTriggerInstallAppsOnboardingStep();
// oxlint-disable-next-line twenty/no-state-useref
const hasSkippedRef = useRef(false);
useEffect(() => {
if (hasSkippedRef.current) {
return;
}
hasSkippedRef.current = true;
const skip = async () => {
try {
await triggerInstallAppsOnboardingStep([]);
} catch {
onError();
}
};
void skip();
}, [triggerInstallAppsOnboardingStep, onError]);
return null;
};
@@ -1,5 +1,3 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
@@ -17,7 +15,6 @@ import { type SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { GetInviteSuggestionsDocument } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { z } from 'zod';
const validationSchema = z.object({
@@ -28,15 +25,11 @@ type InviteTeamFormInput = z.infer<typeof validationSchema>;
export const useInviteTeam = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar } = useSnackBar();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
const onboardingConfig = useAtomStateValue(onboardingConfigState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
const hasCalendarBooking = isDefined(calendarBookingPageId);
const {
control,
@@ -125,13 +118,6 @@ export const useInviteTeam = () => {
return 'craig@apple.com';
};
const copyInviteLink = () => {
if (isDefined(currentWorkspace?.inviteHash)) {
const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`;
copyToClipboard(inviteLink, t`Link copied to clipboard`);
}
};
const onSubmit: SubmitHandler<InviteTeamFormInput> = useCallback(
async (data) => {
const emails = Array.from(
@@ -197,12 +183,8 @@ export const useInviteTeam = () => {
handleSubmit,
onSubmit,
handleSkip,
copyInviteLink,
getPlaceholder,
hasPrefilledSuggestions,
hasCalendarBooking,
isValid,
isSubmitting,
currentWorkspace,
};
};
@@ -0,0 +1,13 @@
import { ONBOARDING_CONTENT_BLOCK_WIDTH } from '@/onboarding/constants/OnboardingContentBlockWidth';
import { UPGRADE_STEP_CONTENT_WIDTH } from '@/onboarding/constants/UpgradeStepContentWidth';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const useOnboardingContentWidth = () => {
const location = useLocation();
return isMatchingLocation(location, AppPath.PlanRequired)
? UPGRADE_STEP_CONTENT_WIDTH
: ONBOARDING_CONTENT_BLOCK_WIDTH;
};
@@ -0,0 +1,11 @@
import { type Transition, useReducedMotion } from 'framer-motion';
import { useTheme } from 'twenty-ui/theme-constants';
export const useOnboardingMotionTransition = (): Transition => {
const theme = useTheme();
const shouldReduceMotion = useReducedMotion();
return shouldReduceMotion
? { duration: 0 }
: { duration: theme.animation.duration.normal, ease: 'easeInOut' };
};
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const onboardingActivationFailedState = createAtomState<boolean>({
key: 'onboardingActivationFailedState',
defaultValue: false,
});

Some files were not shown because too many files have changed in this diff Show More