mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(console): extract the onboarding app from the admin console (#3340)
This commit is contained in:
parent
19f5efc107
commit
fb6a65bd46
57 changed files with 359 additions and 235 deletions
|
@ -18,10 +18,10 @@ import initI18n from '@/i18n/init';
|
|||
import { adminTenantEndpoint } from './consts';
|
||||
import { isCloud } from './consts/cloud';
|
||||
import ErrorBoundary from './containers/ErrorBoundary';
|
||||
import TenantAppContainer from './containers/TenantAppContainer';
|
||||
import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
|
||||
import AppEndpointsProvider from './contexts/AppEndpointsProvider';
|
||||
import TenantsProvider, { TenantsContext } from './contexts/TenantsProvider';
|
||||
import Main from './pages/Main';
|
||||
|
||||
void initI18n();
|
||||
|
||||
|
@ -39,6 +39,7 @@ const Content = () => {
|
|||
meApi.indicator
|
||||
)
|
||||
);
|
||||
|
||||
const scopes = [
|
||||
UserScope.Email,
|
||||
UserScope.Identities,
|
||||
|
@ -63,7 +64,7 @@ const Content = () => {
|
|||
{!isCloud || isSettle ? (
|
||||
<AppEndpointsProvider>
|
||||
<AppConfirmModalProvider>
|
||||
<Main />
|
||||
<TenantAppContainer />
|
||||
</AppConfirmModalProvider>
|
||||
</AppEndpointsProvider>
|
||||
) : (
|
||||
|
@ -81,4 +82,5 @@ const App = () => {
|
|||
</TenantsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
.onBoarding {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
|
||||
import { OnboardingPage } from '@/cloud/types';
|
||||
import { getOnboardPagePathname } from '@/cloud/utils';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
import About from '../About';
|
||||
import Congrats from '../Congrats';
|
||||
import SignInExperience from '../SignInExperience';
|
||||
import Welcome from '../Welcome';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const welcomePathname = getOnboardPagePathname(OnboardingPage.Welcome);
|
||||
|
||||
const Onboarding = () => {
|
||||
const {
|
||||
data: { questionnaire },
|
||||
isLoaded,
|
||||
} = useUserOnboardingData();
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.onBoarding}>
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
|
||||
<Route
|
||||
path={OnboardingPage.AboutUser}
|
||||
element={questionnaire ? <About /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
<Route
|
||||
path={OnboardingPage.SignInExperience}
|
||||
element={questionnaire ? <SignInExperience /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
<Route
|
||||
path={OnboardingPage.Congrats}
|
||||
element={questionnaire ? <Congrats /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
|
@ -1 +0,0 @@
|
|||
$questionnaire-content-width: 858px;
|
|
@ -1,79 +1,3 @@
|
|||
import type { SignInIdentifier } from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
export enum CloudRoute {
|
||||
Callback = 'callback',
|
||||
Onboarding = 'onboarding',
|
||||
}
|
||||
|
||||
export enum OnboardingPage {
|
||||
Welcome = 'welcome',
|
||||
AboutUser = 'about-user',
|
||||
SignInExperience = 'sign-in-experience',
|
||||
Congrats = 'congrats',
|
||||
}
|
||||
|
||||
export enum Project {
|
||||
Personal = 'personal',
|
||||
Company = 'company',
|
||||
}
|
||||
|
||||
export enum DeploymentType {
|
||||
OpenSource = 'open-source',
|
||||
Cloud = 'cloud',
|
||||
}
|
||||
|
||||
export enum Title {
|
||||
Developer = 'developer',
|
||||
TeamLead = 'team-lead',
|
||||
Ceo = 'ceo',
|
||||
Cto = 'cto',
|
||||
Product = 'product',
|
||||
Others = 'others',
|
||||
}
|
||||
|
||||
export enum CompanySize {
|
||||
Scale1 = '1',
|
||||
Scale2 = '1-49',
|
||||
Scale3 = '50-199',
|
||||
Scale4 = '200-999',
|
||||
Scale5 = '1000+',
|
||||
}
|
||||
|
||||
export enum Reason {
|
||||
Adoption = 'adoption',
|
||||
Replacement = 'replacement',
|
||||
Evaluation = 'evaluation',
|
||||
Experimentation = 'experimentation',
|
||||
Aesthetics = 'aesthetics',
|
||||
Others = 'others',
|
||||
}
|
||||
|
||||
export const questionnaireGuard = z.object({
|
||||
project: z.nativeEnum(Project),
|
||||
deploymentType: z.nativeEnum(DeploymentType),
|
||||
titles: z.array(z.nativeEnum(Title)).optional(),
|
||||
companyName: z.string().optional(),
|
||||
companySize: z.nativeEnum(CompanySize).optional(),
|
||||
reasons: z.array(z.nativeEnum(Reason)).optional(),
|
||||
});
|
||||
|
||||
export type Questionnaire = z.infer<typeof questionnaireGuard>;
|
||||
|
||||
export const userOnboardingDataGuard = z.object({
|
||||
questionnaire: questionnaireGuard.optional(),
|
||||
isOnboardingDone: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserOnboardingData = z.infer<typeof userOnboardingDataGuard>;
|
||||
|
||||
export enum Authentication {
|
||||
Password = 'password',
|
||||
VerificationCode = 'verificationCode',
|
||||
}
|
||||
|
||||
export type OnboardingSieConfig = {
|
||||
color: string;
|
||||
identifier: SignInIdentifier;
|
||||
authentications: Authentication[];
|
||||
};
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
import type { OnboardingPage } from './types';
|
||||
import { CloudRoute } from './types';
|
||||
|
||||
export const getOnboardPagePathname = (page: OnboardingPage) => `/${CloudRoute.Onboarding}/${page}`;
|
|
@ -2,19 +2,20 @@ import classNames from 'classnames';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import EarlyBirdGift from '@/cloud/components/EarlyBirdGift';
|
||||
import Spacer from '@/components/Spacer';
|
||||
import { isCloud } from '@/consts/cloud';
|
||||
import EarlyBirdGift from '@/onboarding/components/EarlyBirdGift';
|
||||
import GetStartedProgress from '@/pages/GetStarted/components/GetStartedProgress';
|
||||
|
||||
import UserInfo from '../UserInfo';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
isLogoOnly?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Topbar = ({ className }: Props) => {
|
||||
const Topbar = ({ isLogoOnly = false, className }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
|
@ -23,9 +24,13 @@ const Topbar = ({ className }: Props) => {
|
|||
<div className={styles.line} />
|
||||
<div className={styles.text}>{t('title')}</div>
|
||||
<Spacer />
|
||||
<GetStartedProgress />
|
||||
{isCloud && <EarlyBirdGift />}
|
||||
<UserInfo />
|
||||
{!isLogoOnly && (
|
||||
<>
|
||||
<GetStartedProgress />
|
||||
{isCloud && <EarlyBirdGift />}
|
||||
<UserInfo />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,10 +4,6 @@ import { useEffect, useRef } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, useHref, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import Broadcast from '@/cloud/components/Broadcast';
|
||||
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
|
||||
import { OnboardingPage } from '@/cloud/types';
|
||||
import { getOnboardPagePathname } from '@/cloud/utils';
|
||||
import AppError from '@/components/AppError';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import SessionExpired from '@/components/SessionExpired';
|
||||
|
@ -15,6 +11,7 @@ import { isCloud } from '@/consts/cloud';
|
|||
import useConfigs from '@/hooks/use-configs';
|
||||
import useScroll from '@/hooks/use-scroll';
|
||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||
import Broadcast from '@/onboarding/components/Broadcast';
|
||||
|
||||
import { getPath } from '../ConsoleContent/Sidebar';
|
||||
import { useSidebarMenuItems } from '../ConsoleContent/Sidebar/hook';
|
||||
|
@ -27,16 +24,8 @@ const AppContent = () => {
|
|||
const href = useHref('/callback');
|
||||
const { isLoading: isPreferencesLoading } = useUserPreferences();
|
||||
const { isLoading: isConfigsLoading } = useConfigs();
|
||||
const {
|
||||
data: { isOnboardingDone },
|
||||
isLoading: isOnboardingDataLoading,
|
||||
isLoaded: isOnboardingDataLoaded,
|
||||
} = useUserOnboardingData();
|
||||
|
||||
const isLoading =
|
||||
isPreferencesLoading || isConfigsLoading || (isCloud && isOnboardingDataLoading);
|
||||
|
||||
const isOnboarding = isCloud && isOnboardingDataLoaded && !isOnboardingDone;
|
||||
const isLoading = isPreferencesLoading || isConfigsLoading;
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
@ -54,13 +43,9 @@ const AppContent = () => {
|
|||
useEffect(() => {
|
||||
// Navigate to the first menu item after configs are loaded.
|
||||
if (!isLoading && location.pathname === '/') {
|
||||
navigate(
|
||||
isOnboarding
|
||||
? getOnboardPagePathname(OnboardingPage.Welcome)
|
||||
: getPath(firstItem?.title ?? '')
|
||||
);
|
||||
navigate(getPath(firstItem?.title ?? ''), { replace: true });
|
||||
}
|
||||
}, [firstItem?.title, isOnboardingDone, isLoading, isOnboarding, location.pathname, navigate]);
|
||||
}, [firstItem?.title, isLoading, location.pathname, navigate]);
|
||||
|
||||
if (error) {
|
||||
if (error instanceof LogtoClientError) {
|
||||
|
|
30
packages/console/src/containers/TenantAppContainer/index.tsx
Normal file
30
packages/console/src/containers/TenantAppContainer/index.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useContext } from 'react';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import { isCloud } from '@/consts/cloud';
|
||||
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
|
||||
import OnboardingApp from '@/onboarding/App';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
import ConsoleApp from '@/pages/Main';
|
||||
|
||||
const TenantAppContainer = () => {
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
const {
|
||||
data: { isOnboardingDone },
|
||||
isLoaded,
|
||||
} = useUserOnboardingData();
|
||||
|
||||
if (!userEndpoint || (isCloud && !isLoaded)) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
const isOnboarding = isCloud && !isOnboardingDone;
|
||||
|
||||
if (isOnboarding) {
|
||||
return <OnboardingApp />;
|
||||
}
|
||||
|
||||
return <ConsoleApp />;
|
||||
};
|
||||
|
||||
export default TenantAppContainer;
|
|
@ -3,6 +3,7 @@ import { defaultManagementApi } from '@logto/schemas';
|
|||
import { conditional, noop } from '@silverhand/essentials';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, createContext, useState } from 'react';
|
||||
import type { NavigateOptions } from 'react-router-dom';
|
||||
|
||||
import { isCloud } from '@/consts/cloud';
|
||||
import { getUserTenantId } from '@/consts/tenants';
|
||||
|
@ -17,7 +18,7 @@ export type Tenants = {
|
|||
setTenants: (tenants: TenantInfo[]) => void;
|
||||
setIsSettle: (isSettle: boolean) => void;
|
||||
currentTenantId: string;
|
||||
navigate: (tenantId: string) => void;
|
||||
navigate: (tenantId: string, options?: NavigateOptions) => void;
|
||||
};
|
||||
|
||||
const { tenantId, indicator } = defaultManagementApi.resource;
|
||||
|
@ -37,8 +38,13 @@ const TenantsProvider = ({ children }: Props) => {
|
|||
const [isSettle, setIsSettle] = useState(false);
|
||||
const [currentTenantId, setCurrentTenantId] = useState(getUserTenantId());
|
||||
|
||||
const navigate = useCallback((tenantId: string) => {
|
||||
window.history.pushState({}, '', '/' + tenantId);
|
||||
const navigate = useCallback((tenantId: string, options?: NavigateOptions) => {
|
||||
if (options?.replace) {
|
||||
window.history.replaceState(options.state ?? {}, '', '/' + tenantId);
|
||||
|
||||
return;
|
||||
}
|
||||
window.history.pushState(options?.state ?? {}, '', '/' + tenantId);
|
||||
setCurrentTenantId(tenantId);
|
||||
}, []);
|
||||
|
||||
|
|
6
packages/console/src/onboarding/App.module.scss
Normal file
6
packages/console/src/onboarding/App.module.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
70
packages/console/src/onboarding/App.tsx
Normal file
70
packages/console/src/onboarding/App.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import Toast from '@/components/Toast';
|
||||
import { getBasename } from '@/consts';
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
import * as styles from './App.module.scss';
|
||||
import AppContent from './containers/AppContent';
|
||||
import useUserOnboardingData from './hooks/use-user-onboarding-data';
|
||||
import About from './pages/About';
|
||||
import Congrats from './pages/Congrats';
|
||||
import SignInExperience from './pages/SignInExperience';
|
||||
import Welcome from './pages/Welcome';
|
||||
import { OnboardingPage, OnboardingRoute } from './types';
|
||||
import { getOnboardingPage } from './utils';
|
||||
|
||||
const welcomePathname = getOnboardingPage(OnboardingPage.Welcome);
|
||||
|
||||
const App = () => {
|
||||
const swrOptions = useSwrOptions();
|
||||
|
||||
const {
|
||||
data: { questionnaire },
|
||||
isLoaded,
|
||||
} = useUserOnboardingData();
|
||||
|
||||
if (!isLoaded) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={getBasename()}>
|
||||
<div className={styles.app}>
|
||||
<SWRConfig value={swrOptions}>
|
||||
<AppBoundary>
|
||||
<Toast />
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={`/${OnboardingRoute.Onboarding}`} element={<AppContent />}>
|
||||
<Route index element={<Navigate replace to={welcomePathname} />} />
|
||||
<Route path={OnboardingPage.Welcome} element={<Welcome />} />
|
||||
<Route
|
||||
path={OnboardingPage.AboutUser}
|
||||
element={questionnaire ? <About /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
<Route
|
||||
path={OnboardingPage.SignInExperience}
|
||||
element={
|
||||
questionnaire ? <SignInExperience /> : <Navigate replace to={welcomePathname} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={OnboardingPage.Congrats}
|
||||
element={questionnaire ? <Congrats /> : <Navigate replace to={welcomePathname} />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</AppBoundary>
|
||||
</SWRConfig>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
|
@ -1,7 +1,7 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { logtoBlogLink } from '@/cloud/constants';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { logtoBlogLink } from '@/onboarding/constants';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
|
@ -4,8 +4,8 @@ import type { ReactNode } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Calendar from '@/assets/images/calendar.svg';
|
||||
import { reservationLink } from '@/cloud/constants';
|
||||
import Button from '@/components/Button';
|
||||
import { reservationLink } from '@/onboarding/constants';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import * as styles from './index.module.scss';
|
|
@ -0,0 +1,13 @@
|
|||
.app {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import Topbar from '@/containers/AppContent/components/Topbar';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const AppContent = () => (
|
||||
<div className={styles.app}>
|
||||
<Topbar isLogoOnly />
|
||||
<div className={styles.content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AppContent;
|
|
@ -5,18 +5,18 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Case from '@/assets/images/case.svg';
|
||||
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
|
||||
import * as pageLayout from '@/cloud/scss/layout.module.scss';
|
||||
import Button from '@/components/Button';
|
||||
import FormField from '@/components/FormField';
|
||||
import OverlayScrollbar from '@/components/OverlayScrollbar';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
|
||||
|
||||
import ActionBar from '../../components/ActionBar';
|
||||
import { CardSelector, MultiCardSelector } from '../../components/CardSelector';
|
||||
import type { Questionnaire } from '../../types';
|
||||
import { OnboardingPage } from '../../types';
|
||||
import { getOnboardPagePathname } from '../../utils';
|
||||
import { getOnboardingPage } from '../../utils';
|
||||
import * as styles from './index.module.scss';
|
||||
import { titleOptions, companySizeOptions, reasonOptions } from './options';
|
||||
|
||||
|
@ -43,11 +43,11 @@ const About = () => {
|
|||
|
||||
const onNext = async () => {
|
||||
await onSubmit();
|
||||
navigate(getOnboardPagePathname(OnboardingPage.SignInExperience));
|
||||
navigate(getOnboardingPage(OnboardingPage.SignInExperience), { replace: true });
|
||||
};
|
||||
|
||||
const onBack = async () => {
|
||||
navigate(getOnboardPagePathname(OnboardingPage.Welcome));
|
||||
navigate(getOnboardingPage(OnboardingPage.Welcome), { replace: true });
|
||||
};
|
||||
|
||||
return (
|
|
@ -1,4 +1,4 @@
|
|||
import type { Option as SelectorOption } from '@/cloud/components/CardSelector';
|
||||
import type { Option as SelectorOption } from '@/onboarding/components/CardSelector';
|
||||
|
||||
import { CompanySize, Reason, Title } from '../../types';
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
import { AppearanceMode } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useContext } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import CalendarOutline from '@/assets/images/calendar-outline.svg';
|
||||
import CongratsImageDark from '@/assets/images/congrats-dark.svg';
|
||||
import CongratsImageLight from '@/assets/images/congrats.svg';
|
||||
import Reservation from '@/cloud/components/Reservation';
|
||||
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
|
||||
import * as pageLayout from '@/cloud/scss/layout.module.scss';
|
||||
import Button from '@/components/Button';
|
||||
import Divider from '@/components/Divider';
|
||||
import OverlayScrollbar from '@/components/OverlayScrollbar';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import Reservation from '@/onboarding/components/Reservation';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -21,12 +22,12 @@ const Congrats = () => {
|
|||
const theme = useTheme();
|
||||
const CongratsImage = theme === AppearanceMode.LightMode ? CongratsImageLight : CongratsImageDark;
|
||||
const { update } = useUserOnboardingData();
|
||||
const { navigate, currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const enterAdminConsole = async () => {
|
||||
await update({ isOnboardingDone: true });
|
||||
navigate('/');
|
||||
const enterAdminConsole = () => {
|
||||
void update({ isOnboardingDone: true });
|
||||
// Note: navigate to the admin console page directly instead of using the router
|
||||
navigate(currentTenantId, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
|
@ -8,19 +8,19 @@ import useSWR from 'swr';
|
|||
|
||||
import Bulb from '@/assets/images/bulb.svg';
|
||||
import Tools from '@/assets/images/tools.svg';
|
||||
import ActionBar from '@/cloud/components/ActionBar';
|
||||
import { CardSelector, MultiCardSelector } from '@/cloud/components/CardSelector';
|
||||
import { defaultOnboardingSieConfig } from '@/cloud/constants';
|
||||
import * as pageLayout from '@/cloud/scss/layout.module.scss';
|
||||
import type { OnboardingSieConfig } from '@/cloud/types';
|
||||
import { OnboardingPage } from '@/cloud/types';
|
||||
import { getOnboardPagePathname } from '@/cloud/utils';
|
||||
import Button from '@/components/Button';
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import FormField from '@/components/FormField';
|
||||
import OverlayScrollbar from '@/components/OverlayScrollbar';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import ActionBar from '@/onboarding/components/ActionBar';
|
||||
import { CardSelector, MultiCardSelector } from '@/onboarding/components/CardSelector';
|
||||
import { defaultOnboardingSieConfig } from '@/onboarding/constants';
|
||||
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
|
||||
import { OnboardingPage } from '@/onboarding/types';
|
||||
import type { OnboardingSieConfig } from '@/onboarding/types';
|
||||
import { getOnboardingPage } from '@/onboarding/utils';
|
||||
|
||||
import Preview from './components/Preview';
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -78,7 +78,7 @@ const SignInExperience = () => {
|
|||
});
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(getOnboardPagePathname(OnboardingPage.AboutUser));
|
||||
navigate(getOnboardingPage(OnboardingPage.AboutUser), { replace: true });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
|
@ -88,7 +88,7 @@ const SignInExperience = () => {
|
|||
|
||||
const handleNext = async () => {
|
||||
await onSubmit();
|
||||
navigate(getOnboardPagePathname(OnboardingPage.Congrats));
|
||||
navigate(getOnboardingPage(OnboardingPage.Congrats), { replace: true });
|
||||
};
|
||||
|
||||
return (
|
|
@ -5,8 +5,8 @@ import Keyboard from '@/assets/images/keyboard.svg';
|
|||
import Label from '@/assets/images/label.svg';
|
||||
import Lock from '@/assets/images/lock.svg';
|
||||
import Mobile from '@/assets/images/mobile.svg';
|
||||
import type { Option as SelectorOption } from '@/cloud/components/CardSelector';
|
||||
import { Authentication } from '@/cloud/types';
|
||||
import type { Option as SelectorOption } from '@/onboarding/components/CardSelector';
|
||||
import { Authentication } from '@/onboarding/types';
|
||||
|
||||
export const identifierOptions: SelectorOption[] = [
|
||||
{
|
|
@ -1,8 +1,8 @@
|
|||
import type { SignInExperience } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import type { OnboardingSieConfig } from '@/cloud/types';
|
||||
import { Authentication } from '@/cloud/types';
|
||||
import type { OnboardingSieConfig } from '@/onboarding/types';
|
||||
import { Authentication } from '@/onboarding/types';
|
||||
|
||||
const signInExperienceToOnboardSieConfig = (
|
||||
signInExperience: SignInExperience
|
|
@ -5,17 +5,17 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Congrats from '@/assets/images/congrats.svg';
|
||||
import ActionBar from '@/cloud/components/ActionBar';
|
||||
import { CardSelector } from '@/cloud/components/CardSelector';
|
||||
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
|
||||
import * as pageLayout from '@/cloud/scss/layout.module.scss';
|
||||
import Button from '@/components/Button';
|
||||
import FormField from '@/components/FormField';
|
||||
import OverlayScrollbar from '@/components/OverlayScrollbar';
|
||||
import ActionBar from '@/onboarding/components/ActionBar';
|
||||
import { CardSelector } from '@/onboarding/components/CardSelector';
|
||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
|
||||
|
||||
import type { Questionnaire } from '../../types';
|
||||
import { OnboardingPage } from '../../types';
|
||||
import { getOnboardPagePathname } from '../../utils';
|
||||
import { getOnboardingPage } from '../../utils';
|
||||
import * as styles from './index.module.scss';
|
||||
import { deploymentTypeOptions, projectOptions } from './options';
|
||||
|
||||
|
@ -45,7 +45,7 @@ const Welcome = () => {
|
|||
|
||||
const onNext = async () => {
|
||||
await onSubmit();
|
||||
navigate(getOnboardPagePathname(OnboardingPage.AboutUser));
|
||||
navigate(getOnboardingPage(OnboardingPage.AboutUser), { replace: true });
|
||||
};
|
||||
|
||||
return (
|
|
@ -2,7 +2,7 @@ import Building from '@/assets/images/building.svg';
|
|||
import Cloud from '@/assets/images/cloud.svg';
|
||||
import Database from '@/assets/images/database.svg';
|
||||
import Pizza from '@/assets/images/pizza.svg';
|
||||
import type { Option as SelectorOption } from '@/cloud/components/CardSelector';
|
||||
import type { Option as SelectorOption } from '@/onboarding/components/CardSelector';
|
||||
|
||||
import { DeploymentType, Project } from '../../types';
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
@use './cloud-page-size.scss' as size;
|
||||
|
||||
.page {
|
||||
height: 100%;
|
||||
|
@ -16,7 +15,7 @@
|
|||
|
||||
.content {
|
||||
margin: 0 auto;
|
||||
max-width: size.$questionnaire-content-width;
|
||||
max-width: 858px;
|
||||
border-radius: 16px;
|
||||
padding: _.unit(12);
|
||||
background-color: var(--color-layer-1);
|
78
packages/console/src/onboarding/types.ts
Normal file
78
packages/console/src/onboarding/types.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import type { SignInIdentifier } from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
export enum OnboardingRoute {
|
||||
Onboarding = 'onboarding',
|
||||
}
|
||||
|
||||
export enum OnboardingPage {
|
||||
Welcome = 'welcome',
|
||||
AboutUser = 'about-user',
|
||||
SignInExperience = 'sign-in-experience',
|
||||
Congrats = 'congrats',
|
||||
}
|
||||
|
||||
export enum Project {
|
||||
Personal = 'personal',
|
||||
Company = 'company',
|
||||
}
|
||||
|
||||
export enum DeploymentType {
|
||||
OpenSource = 'open-source',
|
||||
Cloud = 'cloud',
|
||||
}
|
||||
|
||||
export enum Title {
|
||||
Developer = 'developer',
|
||||
TeamLead = 'team-lead',
|
||||
Ceo = 'ceo',
|
||||
Cto = 'cto',
|
||||
Product = 'product',
|
||||
Others = 'others',
|
||||
}
|
||||
|
||||
export enum CompanySize {
|
||||
Scale1 = '1',
|
||||
Scale2 = '1-49',
|
||||
Scale3 = '50-199',
|
||||
Scale4 = '200-999',
|
||||
Scale5 = '1000+',
|
||||
}
|
||||
|
||||
export enum Reason {
|
||||
Adoption = 'adoption',
|
||||
Replacement = 'replacement',
|
||||
Evaluation = 'evaluation',
|
||||
Experimentation = 'experimentation',
|
||||
Aesthetics = 'aesthetics',
|
||||
Others = 'others',
|
||||
}
|
||||
|
||||
export const questionnaireGuard = z.object({
|
||||
project: z.nativeEnum(Project),
|
||||
deploymentType: z.nativeEnum(DeploymentType),
|
||||
titles: z.array(z.nativeEnum(Title)).optional(),
|
||||
companyName: z.string().optional(),
|
||||
companySize: z.nativeEnum(CompanySize).optional(),
|
||||
reasons: z.array(z.nativeEnum(Reason)).optional(),
|
||||
});
|
||||
|
||||
export type Questionnaire = z.infer<typeof questionnaireGuard>;
|
||||
|
||||
export const userOnboardingDataGuard = z.object({
|
||||
questionnaire: questionnaireGuard.optional(),
|
||||
isOnboardingDone: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UserOnboardingData = z.infer<typeof userOnboardingDataGuard>;
|
||||
|
||||
export enum Authentication {
|
||||
Password = 'password',
|
||||
VerificationCode = 'verificationCode',
|
||||
}
|
||||
|
||||
export type OnboardingSieConfig = {
|
||||
color: string;
|
||||
identifier: SignInIdentifier;
|
||||
authentications: Authentication[];
|
||||
};
|
7
packages/console/src/onboarding/utils.ts
Normal file
7
packages/console/src/onboarding/utils.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { joinPath } from '@silverhand/essentials';
|
||||
|
||||
import type { OnboardingPage } from './types';
|
||||
import { OnboardingRoute } from './types';
|
||||
|
||||
export const getOnboardingPage = (page: OnboardingPage) =>
|
||||
joinPath(OnboardingRoute.Onboarding, page);
|
|
@ -1,18 +1,11 @@
|
|||
import { useContext } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data';
|
||||
import Onboarding from '@/cloud/pages/Onboarding';
|
||||
import { CloudRoute } from '@/cloud/types';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import Toast from '@/components/Toast';
|
||||
import { getBasename } from '@/consts';
|
||||
import { isCloud } from '@/consts/cloud';
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import AppContent from '@/containers/AppContent';
|
||||
import ConsoleContent from '@/containers/ConsoleContent';
|
||||
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
|
||||
import useSwrOptions from '@/hooks/use-swr-options';
|
||||
import Callback from '@/pages/Callback';
|
||||
import Welcome from '@/pages/Welcome';
|
||||
|
@ -21,17 +14,6 @@ import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
|||
|
||||
const Main = () => {
|
||||
const swrOptions = useSwrOptions();
|
||||
const { userEndpoint } = useContext(AppEndpointsContext);
|
||||
const {
|
||||
data: { isOnboardingDone },
|
||||
isLoaded,
|
||||
} = useUserOnboardingData();
|
||||
|
||||
if (!userEndpoint || (isCloud && !isLoaded)) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
const isOnboarding = isCloud && !isOnboardingDone;
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={getBasename()}>
|
||||
|
@ -43,14 +25,7 @@ const Main = () => {
|
|||
<Route path="welcome" element={<Welcome />} />
|
||||
<Route path="handle-social" element={<HandleSocialCallback />} />
|
||||
<Route element={<AppContent />}>
|
||||
{isOnboarding ? (
|
||||
<Route>
|
||||
<Route index element={<Navigate replace to={`/${CloudRoute.Onboarding}`} />} />
|
||||
<Route path={`/${CloudRoute.Onboarding}/*`} element={<Onboarding />} />
|
||||
</Route>
|
||||
) : (
|
||||
<Route path="/*" element={<ConsoleContent />} />
|
||||
)}
|
||||
<Route path="/*" element={<ConsoleContent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AppBoundary>
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
padding: _.unit(6);
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
|
|
|
@ -13,10 +13,12 @@ const NotFound = () => {
|
|||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card className={styles.container}>
|
||||
{theme === AppearanceMode.LightMode ? <NotFoundImage /> : <NotFoundDarkImage />}
|
||||
<div className={styles.message}>{t('errors.page_not_found')}</div>
|
||||
</Card>
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.content}>
|
||||
{theme === AppearanceMode.LightMode ? <NotFoundImage /> : <NotFoundDarkImage />}
|
||||
<div className={styles.message}>{t('errors.page_not_found')}</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -68,6 +68,66 @@ describe('smoke testing for cloud', () => {
|
|||
expect(page.url()).toBe(new URL(`/${tenantId ?? ''}/onboarding/welcome`, logtoCloudUrl).href);
|
||||
});
|
||||
|
||||
it('can complete the onboarding welcome process and enter the user survey page', async () => {
|
||||
// Select the project type option
|
||||
await expect(page).toClick('div[role=radio]:has(input[name=project][value=personal])');
|
||||
|
||||
// Select the deployment type option
|
||||
await expect(page).toClick(
|
||||
'div[role=radio]:has(input[name=deploymentType][value=open-source])'
|
||||
);
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button:first-child');
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
|
||||
text: 'A little bit about you',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/onboarding/about-user')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete the onboarding user survey process and enter the sie page', async () => {
|
||||
// Select the reason option
|
||||
await expect(page).toClick('div[class$=option]', { text: 'Others' });
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button:first-child');
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=config] div[class$=title]', {
|
||||
text: 'Let’s first customize your experience with ease',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/onboarding/sign-in-experience')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete the sie configuration process and enter the congrats page', async () => {
|
||||
// Click the finish button
|
||||
await expect(page).toClick('div[class$=continueActions] button:last-child');
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
|
||||
text: 'Great news! You are qualified to earn Logto Cloud early credit!',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/onboarding/congrats')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete the onboarding process and enter the admin console', async () => {
|
||||
// Click the enter ac button
|
||||
await expect(page).toClick('div[class$=content] >button');
|
||||
|
||||
// Wait for the admin console to load
|
||||
const mainContent = await page.waitForSelector('div[class$=main]:has(div[class$=title])');
|
||||
await expect(mainContent).toMatchElement('div[class$=title]', {
|
||||
text: 'How do you want to get started with Logto?',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can sign out of admin console', async () => {
|
||||
await expect(page).toClick('div[class$=topbar] > div[class$=container]');
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue