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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+9
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
+15
@@ -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]);
|
||||
|
||||
|
||||
+11
-11
@@ -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>
|
||||
+8
-8
@@ -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(
|
||||
+72
@@ -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 {
|
||||
|
||||
+69
-48
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+23
-6
@@ -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 />
|
||||
)}
|
||||
-77
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-28
@@ -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>
|
||||
);
|
||||
};
|
||||
-38
@@ -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 = {};
|
||||
+7
-6
@@ -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]};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
+1
-8
@@ -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);
|
||||
|
||||
+246
-109
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-297
@@ -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>
|
||||
);
|
||||
};
|
||||
+68
-94
@@ -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({
|
||||
|
||||
-192
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-1
@@ -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)),
|
||||
);
|
||||
+21
@@ -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,
|
||||
|
||||
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
+5
-1
@@ -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)) {
|
||||
|
||||
+23
@@ -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>
|
||||
);
|
||||
};
|
||||
+7
-7
@@ -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 <></>;
|
||||
};
|
||||
+57
@@ -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>
|
||||
);
|
||||
};
|
||||
+22
-14
@@ -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) && (
|
||||
+5
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
+44
@@ -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>
|
||||
);
|
||||
-42
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+9
-15
@@ -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>
|
||||
);
|
||||
+11
@@ -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%;
|
||||
`;
|
||||
+9
@@ -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;
|
||||
`;
|
||||
+38
@@ -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 = {};
|
||||
+3
-1
@@ -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 = {
|
||||
|
||||
+5
-4
@@ -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`
|
||||
|
||||
+19
-18
@@ -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 }) => (
|
||||
|
||||
+4
-3
@@ -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;
|
||||
`;
|
||||
|
||||
+7
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+21
-8
@@ -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) => (
|
||||
|
||||
+67
-22
@@ -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>
|
||||
|
||||
+3
-3
@@ -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;
|
||||
+5
@@ -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;
|
||||
-35
@@ -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
Reference in New Issue
Block a user