0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-14 23:11:31 -05:00

refactor(console): re-use guide components in new api resource creation process ()

This commit is contained in:
Charles Zhao 2023-09-11 12:03:41 +08:00 committed by GitHub
parent f6caeacb5a
commit a54efc84cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 649 additions and 461 deletions
packages/console/src
assets/docs/guides
m2m-general/components
web-gpt-plugin/components
web-outline/components
components/Guide
mdx-components
ApplicationCredentials
Sample
Steps
UriInputField
pages
ApplicationDetails
Applications
components
Guide
GuideLibrary
GuideLibraryModal
GuideModal
index.tsx
GetStarted

View file

@ -2,20 +2,22 @@ import { useContext, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { GuideContext } from '@/components/Guide';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import useApi from '@/hooks/use-api';
import { GuideContext } from '@/pages/Applications/components/Guide';
export default function EnableAdminAccess() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
app: { id: appId, isAdmin },
} = useContext(GuideContext);
const { app } = useContext(GuideContext);
const { id: appId, isAdmin = false } = app ?? {};
const [value, setValue] = useState(isAdmin);
const api = useApi();
const onSubmit = async (value: boolean) => {
if (!appId) {
return;
}
setValue(value);
try {
await api.patch(`api/applications/${appId}`, {

View file

@ -2,25 +2,25 @@ import { useContext, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { GuideContext } from '@/components/Guide';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import useApi from '@/hooks/use-api';
import { GuideContext } from '@/pages/Applications/components/Guide';
export default function AlwaysIssueRefreshToken() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
app: {
id: appId,
customClientMetadata: { alwaysIssueRefreshToken },
},
} = useContext(GuideContext);
const [value, setValue] = useState(alwaysIssueRefreshToken ?? false);
const { app } = useContext(GuideContext);
const { alwaysIssueRefreshToken = false } = app?.customClientMetadata ?? {};
const [value, setValue] = useState(alwaysIssueRefreshToken);
const api = useApi();
const onSubmit = async (value: boolean) => {
if (!app?.id) {
return;
}
setValue(value);
try {
await api.patch(`api/applications/${appId}`, {
await api.patch(`api/applications/${app.id}`, {
json: {
customClientMetadata: {
alwaysIssueRefreshToken: value,

View file

@ -1,24 +1,27 @@
import { useContext } from 'react';
import { GuideContext } from '@/components/Guide';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import { GuideContext } from '@/pages/Applications/components/Guide';
import * as styles from './index.module.scss';
export default function ClientBasics() {
const {
app: { id, secret },
} = useContext(GuideContext);
const { app } = useContext(GuideContext);
const { id, secret } = app ?? {};
return (
<div className={styles.basic}>
<FormField title="application_details.application_id" className={styles.item}>
<CopyToClipboard value={id} variant="border" />
</FormField>
<FormField title="application_details.application_secret" className={styles.item}>
<CopyToClipboard hasVisibilityToggle value={secret} variant="border" />
</FormField>
{id && (
<FormField title="application_details.application_id" className={styles.item}>
<CopyToClipboard value={id} variant="border" />
</FormField>
)}
{secret && (
<FormField title="application_details.application_secret" className={styles.item}>
<CopyToClipboard hasVisibilityToggle value={secret} variant="border" />
</FormField>
)}
</div>
);
}

View file

@ -2,15 +2,14 @@ import { type SnakeCaseOidcConfig } from '@logto/schemas';
import { useContext } from 'react';
import useSWR from 'swr';
import { GuideContext } from '@/components/Guide';
import { openIdProviderConfigPath } from '@/consts/oidc';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import { type RequestError } from '@/hooks/use-api';
import { GuideContext } from '@/pages/Applications/components/Guide';
export default function EnvironmentVariables() {
const {
app: { id, secret },
} = useContext(GuideContext);
const { app } = useContext(GuideContext);
const { id, secret } = app ?? {};
const { data } = useSWR<SnakeCaseOidcConfig, RequestError>(openIdProviderConfigPath);
const authorizationEndpoint = data?.authorization_endpoint ?? '[LOADING]';
const tokenEndpoint = data?.token_endpoint ?? '[LOADING]';
@ -26,20 +25,24 @@ export default function EnvironmentVariables() {
</tr>
</thead>
<tbody>
<tr>
<td>OIDC_CLIENT_ID</td>
<td>App ID</td>
<td>
<CopyToClipboard value={id} />
</td>
</tr>
<tr>
<td>OIDC_CLIENT_SECRET</td>
<td>App secret</td>
<td>
<CopyToClipboard value={secret} />
</td>
</tr>
{id && (
<tr>
<td>OIDC_CLIENT_ID</td>
<td>App ID</td>
<td>
<CopyToClipboard value={id} />
</td>
</tr>
)}
{secret && (
<tr>
<td>OIDC_CLIENT_SECRET</td>
<td>App secret</td>
<td>
<CopyToClipboard value={secret} />
</td>
</tr>
)}
<tr>
<td>OIDC_AUTH_URI</td>
<td>Authorization endpoint</td>

View file

@ -7,7 +7,7 @@ import GuideCard, { type SelectedGuide } from '../GuideCard';
import * as styles from './index.module.scss';
type GuideGroupProps = {
type Props = {
className?: string;
categoryName?: string;
guides?: readonly Guide[];
@ -16,8 +16,8 @@ type GuideGroupProps = {
onClickGuide: (data: SelectedGuide) => void;
};
function GuideGroup(
{ className, categoryName, guides, hasCardBorder, hasCardButton, onClickGuide }: GuideGroupProps,
function GuideCardGroup(
{ className, categoryName, guides, hasCardBorder, hasCardButton, onClickGuide }: Props,
ref: Ref<HTMLDivElement>
) {
if (!guides?.length) {
@ -42,4 +42,4 @@ function GuideGroup(
);
}
export default forwardRef<HTMLDivElement, GuideGroupProps>(GuideGroup);
export default forwardRef<HTMLDivElement, Props>(GuideCardGroup);

View file

@ -0,0 +1,32 @@
@use '@/scss/underscore' as _;
@use '@/scss/dimensions' as dim;
.actionBar {
inset: auto 0 0 0;
width: 100%;
padding: _.unit(4) _.unit(6);
background-color: var(--color-layer-1);
box-shadow: var(--shadow-3);
z-index: 1;
.wrapper {
display: flex;
align-items: center;
justify-content: space-between;
max-width: dim.$guide-main-content-max-width;
margin: 0 auto;
}
.text {
font: var(--font-body-2);
color: var(--color-text);
margin-right: _.unit(3);
@include _.multi-line-ellipsis(2);
}
}
@media screen and (max-width: dim.$guide-content-max-width) {
.actionBar .wrapper {
margin: 0 0 0 _.unit(62.5);
}
}

View file

@ -0,0 +1,25 @@
import { type AdminConsoleKey } from '@logto/phrases';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import * as styles from './index.module.scss';
type Props = {
content: AdminConsoleKey;
buttonText: AdminConsoleKey;
onClick: () => void;
};
export default function ModalFooter({ content, buttonText, onClick }: Props) {
return (
<nav className={styles.actionBar}>
<div className={styles.wrapper}>
<span className={styles.text}>
<DynamicT forKey={content} />
</span>
<Button size="large" title={buttonText} type="outline" onClick={onClick} />
</div>
</nav>
);
}

View file

@ -1,9 +1,11 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import TextInput from '@/ds-components/TextInput';
@ -11,13 +13,24 @@ import useMeCustomData from '@/hooks/use-me-custom-data';
import * as modalStyles from '@/scss/modal.module.scss';
type Props = {
title: AdminConsoleKey;
fieldLabel: AdminConsoleKey;
fieldPlaceholder: AdminConsoleKey;
successMessage: AdminConsoleKey;
isOpen: boolean;
onClose: () => void;
};
export default function RequestGuide({ isOpen, onClose }: Props) {
const [inputValue, setInputValue] = useState('');
export default function RequestForm({
title,
fieldLabel,
fieldPlaceholder,
successMessage,
isOpen,
onClose,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { data, update } = useMeCustomData();
const guideRequests = data?.guideRequests;
@ -37,7 +50,7 @@ export default function RequestGuide({ isOpen, onClose }: Props) {
setIsLoading(false);
setInputValue('');
onClose();
toast.success(t('applications.guide.request_guide_successfully'));
toast.success(String(t(successMessage)));
};
return (
@ -49,7 +62,7 @@ export default function RequestGuide({ isOpen, onClose }: Props) {
onRequestClose={onClose}
>
<ModalLayout
title="applications.guide.cannot_find_guide"
title={<DynamicT forKey={title} />}
footer={
<Button
size="large"
@ -62,9 +75,10 @@ export default function RequestGuide({ isOpen, onClose }: Props) {
}
onClose={onClose}
>
<FormField title="applications.guide.describe_guide_looking_for">
<FormField title={<DynamicT forKey={fieldLabel} />}>
<TextInput
placeholder={t('applications.guide.describe_guide_looking_for_placeholder')}
/** The i18n value is already string here. */
placeholder={String(t(fieldPlaceholder))}
value={inputValue}
onChange={({ currentTarget: { value } }) => {
setInputValue(value);

View file

@ -1,3 +1,4 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { useCallback, useState } from 'react';
import Box from '@/assets/icons/box.svg';
@ -9,14 +10,30 @@ import CardTitle from '@/ds-components/CardTitle';
import IconButton from '@/ds-components/IconButton';
import Spacer from '@/ds-components/Spacer';
import RequestGuide from './RequestGuide';
import RequestForm from './RequestForm';
import * as styles from './index.module.scss';
type Props = {
title: AdminConsoleKey;
subtitle: AdminConsoleKey;
buttonText: AdminConsoleKey;
requestFormTitle?: AdminConsoleKey;
requestFormFieldLabel: AdminConsoleKey;
requestFormFieldPlaceholder: AdminConsoleKey;
requestSuccessMessage: AdminConsoleKey;
onClose: () => void;
};
function GuideHeader({ onClose }: Props) {
function ModalHeader({
title,
subtitle,
buttonText,
requestFormTitle = buttonText,
requestFormFieldLabel,
requestFormFieldPlaceholder,
requestSuccessMessage,
onClose,
}: Props) {
const [isRequestGuideOpen, setIsRequestGuideOpen] = useState(false);
const onRequestGuideClose = useCallback(() => {
setIsRequestGuideOpen(false);
@ -28,17 +45,13 @@ function GuideHeader({ onClose }: Props) {
<Close className={styles.closeIcon} />
</IconButton>
<div className={styles.separator} />
<CardTitle
size="small"
title="applications.guide.modal_header_title"
subtitle="applications.guide.header_subtitle"
/>
<CardTitle size="small" title={title} subtitle={subtitle} />
<Spacer />
<Button
className={styles.requestSdkButton}
type="outline"
icon={<Box />}
title="applications.guide.cannot_find_guide"
title={buttonText}
onClick={() => {
if (isCloud) {
setIsRequestGuideOpen(true);
@ -47,9 +60,18 @@ function GuideHeader({ onClose }: Props) {
}
}}
/>
{isCloud && <RequestGuide isOpen={isRequestGuideOpen} onClose={onRequestGuideClose} />}
{isCloud && (
<RequestForm
title={requestFormTitle}
successMessage={requestSuccessMessage}
fieldLabel={requestFormFieldLabel}
fieldPlaceholder={requestFormFieldPlaceholder}
isOpen={isRequestGuideOpen}
onClose={onRequestGuideClose}
/>
)}
</div>
);
}
export default GuideHeader;
export default ModalHeader;

View file

@ -0,0 +1,94 @@
import { useCallback, useMemo } from 'react';
import guides from '@/assets/docs/guides';
import { type Guide } from '@/assets/docs/guides/types';
import { type AppGuideCategory, type StructuredAppGuideMetadata } from '@/types/applications';
const defaultStructuredMetadata: StructuredAppGuideMetadata = {
featured: [],
Traditional: [],
SPA: [],
Native: [],
MachineToMachine: [],
};
type FilterOptions = {
categories?: AppGuideCategory[];
keyword?: string;
};
export const useAppGuideMetadata = (): {
getFilteredAppGuideMetadata: (filters?: FilterOptions) => readonly Guide[] | undefined;
getStructuredAppGuideMetadata: (
filters?: FilterOptions
) => Record<AppGuideCategory, readonly Guide[]>;
} => {
const appGuides = useMemo(
() => guides.filter(({ metadata: { target } }) => target !== 'API'),
[]
);
const getFilteredAppGuideMetadata = useCallback(
(filters?: FilterOptions) => {
const { categories: filterCategories, keyword } = filters ?? {};
// If no filter is applied, return all metadata
if (!filterCategories?.length && !keyword) {
return appGuides;
}
// Keyword only, return partial name matched result
if (keyword && !filterCategories?.length) {
return appGuides.filter(({ metadata: { name } }) =>
name.toLowerCase().includes(keyword.toLowerCase())
);
}
// Categories only, return selected categories
if (!keyword && filterCategories?.length) {
return appGuides.filter(({ metadata: { target, isFeatured } }) =>
filterCategories.some(
(filterCategory) =>
filterCategory === target || (isFeatured && filterCategory === 'featured')
)
);
}
// Keyword and categories, return partial name matched result in selected categories
if (keyword && filterCategories?.length) {
return appGuides.filter(
({ metadata: { name, target, isFeatured } }) =>
name.toLowerCase().includes(keyword.toLowerCase()) &&
filterCategories.some(
(filterCategory) =>
filterCategory === target || (isFeatured && filterCategory === 'featured')
)
);
}
},
[appGuides]
);
const getStructuredAppGuideMetadata = useCallback(
(filters?: FilterOptions) => {
const filteredMetadata = getFilteredAppGuideMetadata(filters) ?? [];
return filteredMetadata.reduce((accumulated, guide) => {
const { target, isFeatured } = guide.metadata;
// Rule out API target guides to make TypeScript happy
if (target === 'API') {
return accumulated;
}
return {
...accumulated,
[target]: [...accumulated[target], guide],
...(isFeatured && {
featured: [...accumulated.featured, guide],
}),
};
}, defaultStructuredMetadata);
},
[getFilteredAppGuideMetadata]
);
return { getFilteredAppGuideMetadata, getStructuredAppGuideMetadata };
};

View file

@ -0,0 +1,113 @@
import { type ApplicationResponse } from '@logto/schemas';
import { MDXProvider } from '@mdx-js/react';
import classNames from 'classnames';
import { type LazyExoticComponent, Suspense, createContext, useContext } from 'react';
import guides from '@/assets/docs/guides';
import { type GuideMetadata } from '@/assets/docs/guides/types';
import Button from '@/ds-components/Button';
import CodeEditor from '@/ds-components/CodeEditor';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import TextLink from '@/ds-components/TextLink';
import DetailsSummary from '@/mdx-components/DetailsSummary';
import NotFound from '@/pages/NotFound';
import StepsSkeleton from './StepsSkeleton';
import * as styles from './index.module.scss';
export type GuideContextType = {
metadata: Readonly<GuideMetadata>;
Logo?: LazyExoticComponent<SvgComponent>;
isCompact: boolean;
app?: ApplicationResponse;
endpoint?: string;
alternativeEndpoint?: string;
redirectUris?: string[];
postLogoutRedirectUris?: string[];
sampleUrls?: {
origin: string;
callback: string;
};
};
type Props = {
className?: string;
guideId: string;
isEmpty?: boolean;
isLoading?: boolean;
onClose: () => void;
};
export const GuideContext = createContext<GuideContextType>({
// The following `as` is for context initialization, they won't be used in production except for
// HMR.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
metadata: {} as GuideMetadata,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
app: {} as ApplicationResponse,
endpoint: '',
redirectUris: [],
postLogoutRedirectUris: [],
isCompact: false,
sampleUrls: { origin: '', callback: '' },
});
function Guide({ className, guideId, isEmpty, isLoading, onClose }: Props) {
const guide = guides.find(({ id }) => id === guideId);
const GuideComponent = guide?.Component;
const isApiResourceGuide = guide?.metadata.target === 'API';
const context = useContext(GuideContext);
return (
<>
<OverlayScrollbar className={classNames(styles.content, className)}>
{isLoading && <StepsSkeleton />}
{isEmpty && !guide && <NotFound className={styles.notFound} />}
<MDXProvider
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(String(className ?? '')) ?? [];
return language ? (
<CodeEditor
isReadonly
// We need to transform `ts` to `typescript` for prismjs, and
// it's weird since it worked in the original Guide component.
// To be investigated.
language={language === 'ts' ? 'typescript' : language}
value={String(children).trimEnd()}
/>
) : (
<code>{String(children).trimEnd()}</code>
);
},
a: ({ children, ...props }) => (
<TextLink {...props} target="_blank" rel="noopener noreferrer">
{children}
</TextLink>
),
details: DetailsSummary,
}}
>
<Suspense fallback={<StepsSkeleton />}>
{GuideComponent && <GuideComponent {...context} />}
</Suspense>
</MDXProvider>
</OverlayScrollbar>
{!isApiResourceGuide && (
<nav className={styles.actionBar}>
<div className={styles.layout}>
<Button
size="large"
title="applications.guide.finish_and_done"
type="primary"
onClick={onClose}
/>
</div>
</nav>
)}
</>
);
}
export default Guide;

View file

@ -1,49 +1,52 @@
import { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { GuideContext } from '@/components/Guide';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import TextLink from '@/ds-components/TextLink';
import { GuideContext } from '@/pages/Applications/components/Guide';
import * as styles from './index.module.scss';
function ApplicationCredentials() {
const {
app: { id, secret },
} = useContext(GuideContext);
const { app } = useContext(GuideContext);
const { id, secret } = app ?? {};
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.container}>
<FormField
title="application_details.application_id"
tip={(closeTipHandler) => (
<Trans
components={{
a: (
<TextLink
href="https://openid.net/specs/openid-connect-core-1_0.html"
target="_blank"
onClick={closeTipHandler}
/>
),
}}
>
{t('application_details.application_id_tip')}
</Trans>
)}
>
<CopyToClipboard value={id} variant="border" className={styles.textField} />
</FormField>
<FormField title="application_details.application_secret">
<CopyToClipboard
hasVisibilityToggle
value={secret}
variant="border"
className={styles.textField}
/>
</FormField>
{id && (
<FormField
title="application_details.application_id"
tip={(closeTipHandler) => (
<Trans
components={{
a: (
<TextLink
href="https://openid.net/specs/openid-connect-core-1_0.html"
target="_blank"
onClick={closeTipHandler}
/>
),
}}
>
{t('application_details.application_id_tip')}
</Trans>
)}
>
<CopyToClipboard value={id} variant="border" className={styles.textField} />
</FormField>
)}
{secret && (
<FormField title="application_details.application_secret">
<CopyToClipboard
hasVisibilityToggle
value={secret}
variant="border"
className={styles.textField}
/>
</FormField>
)}
</div>
);
}

View file

@ -1,11 +1,11 @@
import { appendPath } from '@silverhand/essentials';
import { useContext } from 'react';
import { GuideContext } from '@/components/Guide';
import { githubOrgLink } from '@/consts';
import { LinkButton } from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import Spacer from '@/ds-components/Spacer';
import { GuideContext } from '@/pages/Applications/components/Guide';
import * as styles from './index.module.scss';

View file

@ -2,8 +2,8 @@ import { type Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { useRef, type ReactElement, useEffect, useState, useMemo, useContext } from 'react';
import { GuideContext } from '@/components/Guide';
import useScroll from '@/hooks/use-scroll';
import { GuideContext } from '@/pages/Applications/components/Guide';
import { onKeyDownHandler } from '@/utils/a11y';
import Sample from '../Sample';
@ -37,19 +37,17 @@ export default function Steps({ children: reactChildren }: Props) {
const stepReferences = useRef<Array<Nullable<HTMLElement>>>([]);
const { scrollTop } = useScroll(findScrollableElement(contentRef.current));
const [activeIndex, setActiveIndex] = useState(-1);
const { isCompact, metadata } = useContext(GuideContext);
const isApiResourceGuide = metadata.target === 'API';
const furtherReadings = useMemo(
() => <FurtherReadings title="Further readings" subtitle="4 articles" />,
[]
);
const children: Array<ReactElement<StepProps, typeof Step>> = useMemo(
() =>
Array.isArray(reactChildren)
? reactChildren.concat(furtherReadings)
: [reactChildren, furtherReadings],
[furtherReadings, reactChildren]
);
const { isCompact } = useContext(GuideContext);
const children: Array<ReactElement<StepProps, typeof Step>> = useMemo(() => {
const steps = Array.isArray(reactChildren) ? reactChildren : [reactChildren];
return isApiResourceGuide ? steps : steps.concat(furtherReadings);
}, [isApiResourceGuide, furtherReadings, reactChildren]);
useEffect(() => {
// Make sure the step references length matches the number of children.

View file

@ -8,6 +8,7 @@ import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import { GuideContext } from '@/components/Guide';
import MultiTextInputField from '@/components/MultiTextInputField';
import Button from '@/ds-components/Button';
import {
@ -16,7 +17,6 @@ import {
} from '@/ds-components/MultiTextInput/utils';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { GuideContext } from '@/pages/Applications/components/Guide';
import type { GuideForm } from '@/types/guide';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
@ -38,10 +38,9 @@ function UriInputField({ name, defaultValue }: Props) {
reset,
formState: { isSubmitting },
} = methods;
const {
app: { id: appId },
} = useContext(GuideContext);
const { data, mutate } = useSWR<Application, RequestError>(`api/applications/${appId}`);
const { app } = useContext(GuideContext);
const appId = app?.id;
const { data, mutate } = useSWR<Application, RequestError>(appId && `api/applications/${appId}`);
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -52,6 +51,9 @@ function UriInputField({ name, defaultValue }: Props) {
: 'application_details.post_sign_out_redirect_uri';
const onSubmit = trySubmitSafe(async (value: string[]) => {
if (!appId) {
return;
}
const updatedApp = await api
.patch(`api/applications/${appId}`, {
json: {

View file

@ -0,0 +1,84 @@
@use '@/scss/underscore' as _;
@use '@/scss/dimensions' as dim;
.content {
flex: 1;
width: 100%;
position: relative;
section {
h3 {
font: var(--font-title-2);
color: var(--color-text-secondary);
margin: _.unit(6) 0 _.unit(3);
}
p {
font: var(--font-body-2);
margin: _.unit(4) 0;
}
ul > li,
ol > li {
font: var(--font-body-2);
margin-block: _.unit(2);
padding-inline-start: _.unit(1);
}
table {
border-spacing: 0;
border: 1px solid var(--color-border);
font: var(--font-body-2);
tr {
width: 100%;
}
td,
th {
padding: _.unit(2) _.unit(4);
}
thead {
font: var(--font-title-3);
}
tbody td {
border-top: 1px solid var(--color-border);
}
}
code:not(pre > code) {
background: var(--color-layer-2);
font: var(--font-body-2);
padding: _.unit(1);
border-radius: 4px;
}
}
}
.notFound {
width: 100%;
svg {
margin-top: 10%;
}
}
.actionBar {
flex-shrink: 0;
padding: _.unit(4) _.unit(6);
background-color: var(--color-layer-1);
box-shadow: var(--shadow-3);
z-index: 1;
.layout {
margin: 0 auto;
max-width: dim.$guide-main-content-max-width;
> button {
margin-right: 0;
margin-left: auto;
}
}
}

View file

@ -0,0 +1,61 @@
import { DomainStatus, type ApplicationResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useContext, useMemo } from 'react';
import guides from '@/assets/docs/guides';
import Guide, { GuideContext, type GuideContextType } from '@/components/Guide';
import { AppDataContext } from '@/contexts/AppDataProvider';
import useCustomDomain from '@/hooks/use-custom-domain';
type Props = {
className?: string;
guideId: string;
app?: ApplicationResponse;
isCompact?: boolean;
onClose: () => void;
};
function AppGuide({ className, guideId, app, isCompact, onClose }: Props) {
const { tenantEndpoint } = useContext(AppDataContext);
const { data: customDomain } = useCustomDomain();
const isCustomDomainActive = customDomain?.status === DomainStatus.Active;
const guide = guides.find(({ id }) => id === guideId);
const GuideComponent = guide?.Component;
const memorizedContext = useMemo(
() =>
conditional(
!!guide &&
!!app && {
metadata: guide.metadata,
Logo: guide.Logo,
app,
endpoint: tenantEndpoint?.toString() ?? '',
alternativeEndpoint: conditional(isCustomDomainActive && tenantEndpoint?.toString()),
redirectUris: app.oidcClientMetadata.redirectUris,
postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris,
isCompact: Boolean(isCompact),
sampleUrls: {
origin: 'http://localhost:3001/',
callback: 'http://localhost:3001/callback',
},
}
) satisfies GuideContextType | undefined,
[guide, app, tenantEndpoint, isCustomDomainActive, isCompact]
);
return memorizedContext ? (
<GuideContext.Provider value={memorizedContext}>
<Guide
className={className}
guideId={guideId}
isEmpty={!app && !guide}
isLoading={!app}
onClose={onClose}
/>
</GuideContext.Provider>
) : null;
}
export default AppGuide;

View file

@ -4,12 +4,13 @@ import { useTranslation } from 'react-i18next';
import ArrowLeft from '@/assets/icons/arrow-left.svg';
import Close from '@/assets/icons/close.svg';
import { type SelectedGuide } from '@/components/Guide/GuideCard';
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
import { useAppGuideMetadata } from '@/components/Guide/hooks';
import IconButton from '@/ds-components/IconButton';
import Spacer from '@/ds-components/Spacer';
import Guide from '@/pages/Applications/components/Guide';
import { type SelectedGuide } from '@/pages/Applications/components/GuideCard';
import GuideGroup from '@/pages/Applications/components/GuideGroup';
import useAppGuideMetadata from '@/pages/Applications/components/GuideLibrary/hook';
import AppGuide from '../AppGuide';
import * as styles from './index.module.scss';
@ -20,12 +21,12 @@ type Props = {
function GuideDrawer({ app, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.applications.guide' });
const [_, getStructuredMetadata] = useAppGuideMetadata();
const { getStructuredAppGuideMetadata } = useAppGuideMetadata();
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
const structuredMetadata = useMemo(
() => getStructuredMetadata({ categories: [app.type] }),
[getStructuredMetadata, app.type]
() => getStructuredAppGuideMetadata({ categories: [app.type] }),
[getStructuredAppGuideMetadata, app.type]
);
const hasSingleGuide = useMemo(() => {
@ -73,7 +74,7 @@ function GuideDrawer({ app, onClose }: Props) {
</IconButton>
</div>
{!selectedGuide && (
<GuideGroup
<GuideCardGroup
className={styles.cardGroup}
categoryName={t(`categories.${app.type}`)}
guides={structuredMetadata[app.type]}
@ -83,7 +84,7 @@ function GuideDrawer({ app, onClose }: Props) {
/>
)}
{selectedGuide && (
<Guide
<AppGuide
isCompact
className={styles.guide}
guideId={selectedGuide.id}

View file

@ -0,0 +1,36 @@
import type { ApplicationResponse } from '@logto/schemas';
import Modal from 'react-modal';
import ModalHeader from '@/components/Guide/ModalHeader';
import * as modalStyles from '@/scss/modal.module.scss';
import AppGuide from '../AppGuide';
import * as styles from './index.module.scss';
type Props = {
guideId: string;
app?: ApplicationResponse;
onClose: () => void;
};
function GuideModal({ guideId, app, onClose }: Props) {
return (
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={onClose}>
<div className={styles.modalContainer}>
<ModalHeader
title="applications.guide.modal_header_title"
subtitle="applications.guide.header_subtitle"
buttonText="applications.guide.cannot_find_guide"
requestFormFieldLabel="applications.guide.describe_guide_looking_for"
requestFormFieldPlaceholder="applications.guide.describe_guide_looking_for_placeholder"
requestSuccessMessage="applications.guide.request_guide_successfully"
onClose={onClose}
/>
<AppGuide className={styles.guide} guideId={guideId} app={app} onClose={onClose} />
</div>
</Modal>
);
}
export default GuideModal;

View file

@ -36,10 +36,9 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import GuideModal from '../Applications/components/GuideModal';
import AdvancedSettings from './components/AdvancedSettings';
import GuideDrawer from './components/GuideDrawer';
import GuideModal from './components/GuideModal';
import Settings from './components/Settings';
import * as styles from './index.module.scss';
@ -150,7 +149,7 @@ function ApplicationDetails() {
<GuideModal
guideId={guideId}
app={data}
onClose={(id) => {
onClose={() => {
navigate(`/applications/${id}`);
}}
/>

View file

@ -1,145 +0,0 @@
import { DomainStatus, type ApplicationResponse } from '@logto/schemas';
import { MDXProvider } from '@mdx-js/react';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useContext, Suspense, createContext, useMemo, type LazyExoticComponent } from 'react';
import guides from '@/assets/docs/guides';
import { type GuideMetadata } from '@/assets/docs/guides/types';
import { AppDataContext } from '@/contexts/AppDataProvider';
import Button from '@/ds-components/Button';
import CodeEditor from '@/ds-components/CodeEditor';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import TextLink from '@/ds-components/TextLink';
import useCustomDomain from '@/hooks/use-custom-domain';
import DetailsSummary from '@/mdx-components/DetailsSummary';
import NotFound from '@/pages/NotFound';
import StepsSkeleton from '../StepsSkeleton';
import * as styles from './index.module.scss';
type GuideContextType = {
metadata: Readonly<GuideMetadata>;
Logo?: LazyExoticComponent<SvgComponent>;
app: ApplicationResponse;
endpoint: string;
alternativeEndpoint?: string;
redirectUris: string[];
postLogoutRedirectUris: string[];
isCompact: boolean;
sampleUrls: {
origin: string;
callback: string;
};
};
export const GuideContext = createContext<GuideContextType>({
// The following `as` is for context initialization, they won't be used in production except for
// HMR.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
metadata: {} as GuideMetadata,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
app: {} as ApplicationResponse,
endpoint: '',
redirectUris: [],
postLogoutRedirectUris: [],
isCompact: false,
sampleUrls: { origin: '', callback: '' },
});
type Props = {
className?: string;
guideId: string;
app?: ApplicationResponse;
isCompact?: boolean;
onClose: () => void;
};
function Guide({ className, guideId, app, isCompact, onClose }: Props) {
const { tenantEndpoint } = useContext(AppDataContext);
const { data: customDomain } = useCustomDomain();
const isCustomDomainActive = customDomain?.status === DomainStatus.Active;
const guide = guides.find(({ id }) => id === guideId);
const GuideComponent = guide?.Component;
const memorizedContext = useMemo(
() =>
conditional(
!!guide &&
!!app && {
metadata: guide.metadata,
Logo: guide.Logo,
app,
endpoint: tenantEndpoint?.toString() ?? '',
alternativeEndpoint: conditional(isCustomDomainActive && tenantEndpoint?.toString()),
redirectUris: app.oidcClientMetadata.redirectUris,
postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris,
isCompact: Boolean(isCompact),
sampleUrls: {
origin: 'http://localhost:3001/',
callback: 'http://localhost:3001/callback',
},
}
) satisfies GuideContextType | undefined,
[guide, app, tenantEndpoint, isCustomDomainActive, isCompact]
);
return (
<>
<OverlayScrollbar className={classNames(styles.content, className)}>
{!app && <StepsSkeleton />}
{!!app && !guide && <NotFound className={styles.notFound} />}
{memorizedContext && (
<GuideContext.Provider value={memorizedContext}>
<MDXProvider
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(String(className ?? '')) ?? [];
return language ? (
<CodeEditor
isReadonly
// We need to transform `ts` to `typescript` for prismjs, and
// it's weird since it worked in the original Guide component.
// To be investigated.
language={language === 'ts' ? 'typescript' : language}
value={String(children).trimEnd()}
/>
) : (
<code>{String(children).trimEnd()}</code>
);
},
a: ({ children, ...props }) => (
<TextLink {...props} target="_blank" rel="noopener noreferrer">
{children}
</TextLink>
),
details: DetailsSummary,
}}
>
<Suspense fallback={<StepsSkeleton />}>
{tenantEndpoint && GuideComponent && <GuideComponent {...memorizedContext} />}
</Suspense>
</MDXProvider>
</GuideContext.Provider>
)}
</OverlayScrollbar>
{memorizedContext && (
<nav className={styles.actionBar}>
<div className={styles.layout}>
<Button
size="large"
title="applications.guide.finish_and_done"
type="primary"
onClick={onClose}
/>
</div>
</nav>
)}
</>
);
}
export default Guide;

View file

@ -1,86 +0,0 @@
import { useCallback } from 'react';
import guides from '@/assets/docs/guides';
import { type Guide } from '@/assets/docs/guides/types';
import { type AppGuideCategory, type StructuredAppGuideMetadata } from '@/types/applications';
const defaultStructuredMetadata: StructuredAppGuideMetadata = {
featured: [],
Traditional: [],
SPA: [],
Native: [],
MachineToMachine: [],
};
type FilterOptions = {
categories?: AppGuideCategory[];
keyword?: string;
};
const useAppGuideMetadata = (): [
(filters?: FilterOptions) => readonly Guide[] | undefined,
(filters?: FilterOptions) => Record<AppGuideCategory, readonly Guide[]>,
] => {
const getFilteredMetadata = useCallback((filters?: FilterOptions) => {
const { categories: filterCategories, keyword } = filters ?? {};
// If no filter is applied, return all metadata
if (!filterCategories?.length && !keyword) {
return guides;
}
// Keyword only, return partial name matched result
if (keyword && !filterCategories?.length) {
return guides.filter(({ metadata: { name } }) =>
name.toLowerCase().includes(keyword.toLowerCase())
);
}
// Categories only, return selected categories
if (!keyword && filterCategories?.length) {
return guides.filter(({ metadata: { target, isFeatured } }) =>
filterCategories.some(
(filterCategory) =>
filterCategory === target || (isFeatured && filterCategory === 'featured')
)
);
}
// Keyword and categories, return partial name matched result in selected categories
if (keyword && filterCategories?.length) {
return guides.filter(
({ metadata: { name, target, isFeatured } }) =>
name.toLowerCase().includes(keyword.toLowerCase()) &&
filterCategories.some(
(filterCategory) =>
filterCategory === target || (isFeatured && filterCategory === 'featured')
)
);
}
}, []);
const getStructuredMetadata = useCallback(
(filters?: FilterOptions) => {
const filteredMetadata = getFilteredMetadata(filters) ?? [];
return filteredMetadata.reduce((accumulated, guide) => {
const { target, isFeatured } = guide.metadata;
// Rule out API target guides to make TypeScript happy
if (target === 'API') {
return accumulated;
}
return {
...accumulated,
[target]: [...accumulated[target], guide],
...(isFeatured && {
featured: [...accumulated.featured, guide],
}),
};
}, defaultStructuredMetadata);
},
[getFilteredMetadata]
);
return [getFilteredMetadata, getStructuredMetadata];
};
export default useAppGuideMetadata;

View file

@ -5,6 +5,9 @@ import { useTranslation } from 'react-i18next';
import SearchIcon from '@/assets/icons/search.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import { type SelectedGuide } from '@/components/Guide/GuideCard';
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
import { useAppGuideMetadata } from '@/components/Guide/hooks';
import ProTag from '@/components/ProTag';
import { isCloud } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
@ -16,10 +19,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { allAppGuideCategories, type AppGuideCategory } from '@/types/applications';
import CreateForm from '../CreateForm';
import { type SelectedGuide } from '../GuideCard';
import GuideGroup from '../GuideGroup';
import useAppGuideMetadata from './hook';
import * as styles from './index.module.scss';
type Props = {
@ -35,7 +35,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
const [keyword, setKeyword] = useState<string>('');
const [filterCategories, setFilterCategories] = useState<AppGuideCategory[]>([]);
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
const [getFilteredMetadata, getStructuredMetadata] = useAppGuideMetadata();
const { getFilteredAppGuideMetadata, getStructuredAppGuideMetadata } = useAppGuideMetadata();
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
const { currentTenantId } = useContext(TenantsContext);
@ -43,13 +43,13 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
const isM2mDisabledForCurrentPlan = isCloud && currentPlan?.quota.machineToMachineLimit === 0;
const structuredMetadata = useMemo(
() => getStructuredMetadata({ categories: filterCategories }),
[getStructuredMetadata, filterCategories]
() => getStructuredAppGuideMetadata({ categories: filterCategories }),
[getStructuredAppGuideMetadata, filterCategories]
);
const filteredMetadata = useMemo(
() => getFilteredMetadata({ keyword, categories: filterCategories }),
[getFilteredMetadata, keyword, filterCategories]
() => getFilteredAppGuideMetadata({ keyword, categories: filterCategories }),
[getFilteredAppGuideMetadata, keyword, filterCategories]
);
const onClickGuide = useCallback((data: SelectedGuide) => {
@ -108,7 +108,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
)}
{keyword &&
(filteredMetadata?.length ? (
<GuideGroup
<GuideCardGroup
className={styles.guideGroup}
hasCardBorder={hasCardBorder}
hasCardButton={hasCardButton}
@ -122,7 +122,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
(filterCategories.length > 0 ? filterCategories : allAppGuideCategories).map(
(category) =>
structuredMetadata[category].length > 0 && (
<GuideGroup
<GuideCardGroup
key={category}
className={styles.guideGroup}
hasCardBorder={hasCardBorder}

View file

@ -17,34 +17,4 @@
flex: 1;
overflow: hidden;
}
.actionBar {
inset: auto 0 0 0;
width: 100%;
padding: _.unit(4) _.unit(6);
background-color: var(--color-layer-1);
box-shadow: var(--shadow-3);
z-index: 1;
.wrapper {
display: flex;
align-items: center;
justify-content: space-between;
max-width: dim.$guide-main-content-max-width;
margin: 0 auto;
}
.text {
font: var(--font-body-2);
color: var(--color-text);
margin-right: _.unit(3);
@include _.multi-line-ellipsis(2);
}
}
}
@media screen and (max-width: dim.$guide-content-max-width) {
.container .actionBar .wrapper {
margin: 0 0 0 _.unit(62.5);
}
}

View file

@ -1,13 +1,12 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import Button from '@/ds-components/Button';
import ModalFooter from '@/components/Guide/ModalFooter';
import ModalHeader from '@/components/Guide/ModalHeader';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import * as modalStyles from '@/scss/modal.module.scss';
import CreateForm from '../CreateForm';
import GuideHeader from '../GuideHeader';
import GuideLibrary from '../GuideLibrary';
import * as styles from './index.module.scss';
@ -18,7 +17,6 @@ type Props = {
};
function GuideLibraryModal({ isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.applications.guide' });
const { navigate } = useTenantPathname();
const [showCreateForm, setShowCreateForm] = useState(false);
return (
@ -29,21 +27,23 @@ function GuideLibraryModal({ isOpen, onClose }: Props) {
onRequestClose={onClose}
>
<div className={styles.container}>
<GuideHeader onClose={onClose} />
<ModalHeader
title="applications.guide.modal_header_title"
subtitle="applications.guide.header_subtitle"
buttonText="applications.guide.cannot_find_guide"
requestFormFieldLabel="applications.guide.describe_guide_looking_for"
requestFormFieldPlaceholder="applications.guide.describe_guide_looking_for_placeholder"
requestSuccessMessage="applications.guide.request_guide_successfully"
onClose={onClose}
/>
<GuideLibrary hasFilters hasCardButton className={styles.content} />
<nav className={styles.actionBar}>
<div className={styles.wrapper}>
<span className={styles.text}>{t('do_not_need_tutorial')}</span>
<Button
size="large"
title="applications.guide.create_without_framework"
type="outline"
onClick={() => {
setShowCreateForm(true);
}}
/>
</div>
</nav>
<ModalFooter
content="applications.guide.do_not_need_tutorial"
buttonText="applications.guide.create_without_framework"
onClick={() => {
setShowCreateForm(true);
}}
/>
</div>
{showCreateForm && (
<CreateForm

View file

@ -1,34 +0,0 @@
import type { ApplicationResponse } from '@logto/schemas';
import Modal from 'react-modal';
import * as modalStyles from '@/scss/modal.module.scss';
import Guide from '../Guide';
import GuideHeader from '../GuideHeader';
import * as styles from './index.module.scss';
type Props = {
guideId: string;
app?: ApplicationResponse;
onClose: (id: string) => void;
};
function GuideModal({ guideId, app, onClose }: Props) {
const closeModal = () => {
if (app) {
onClose(app.id);
}
};
return (
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={closeModal}>
<div className={styles.modalContainer}>
<GuideHeader onClose={closeModal} />
<Guide className={styles.guide} guideId={guideId} app={app} onClose={closeModal} />
</div>
</Modal>
);
}
export default GuideModal;

View file

@ -1,6 +1,5 @@
import { withAppInsights } from '@logto/app-insights/react';
import type { Application } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import useSWR from 'swr';
@ -30,14 +29,6 @@ const pageSize = defaultPageSize;
const applicationsPathname = '/applications';
const createApplicationPathname = `${applicationsPathname}/create`;
const buildDetailsPathname = (id: string) => `${applicationsPathname}/${id}`;
const buildGuidePathname = (id: string) => `${buildDetailsPathname(id)}/guide`;
const buildNavigatePathPostAppCreation = ({ type, id }: Application) => {
const build =
type === ApplicationType.MachineToMachine ? buildDetailsPathname : buildGuidePathname;
return build(id);
};
function Applications() {
const { search } = useLocation();

View file

@ -10,6 +10,9 @@ import CreateRoleDark from '@/assets/icons/create-role-dark.svg';
import CreateRole from '@/assets/icons/create-role.svg';
import SocialDark from '@/assets/icons/social-dark.svg';
import Social from '@/assets/icons/social.svg';
import { type SelectedGuide } from '@/components/Guide/GuideCard';
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
import { useAppGuideMetadata } from '@/components/Guide/hooks';
import PageMeta from '@/components/PageMeta';
import { ConnectorsTabs } from '@/consts';
import { AppDataContext } from '@/contexts/AppDataProvider';
@ -22,9 +25,6 @@ import useTheme from '@/hooks/use-theme';
import useWindowResize from '@/hooks/use-window-resize';
import CreateForm from '../Applications/components/CreateForm';
import { type SelectedGuide } from '../Applications/components/GuideCard';
import GuideGroup from '../Applications/components/GuideGroup';
import useAppGuideMetadata from '../Applications/components/GuideLibrary/hook';
import FreePlanNotification from './FreePlanNotification';
import * as styles from './index.module.scss';
@ -39,7 +39,7 @@ function GetStarted() {
const { navigate } = useTenantPathname();
const { tenantEndpoint } = useContext(AppDataContext);
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
const [_, getStructuredMetadata] = useAppGuideMetadata();
const { getStructuredAppGuideMetadata } = useAppGuideMetadata();
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
// The number of visible guide cards to show in one row per the current screen width
const [visibleCardCount, setVisibleCardCount] = useState(4);
@ -50,7 +50,7 @@ function GetStarted() {
useWindowResize(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
// Responsive breakpoints (1080, 680px) are defined in `GuideGroup` component SCSS,
// Responsive breakpoints (1080, 680px) are defined in `GuideCardGroup` component SCSS,
// and we need to keep them consistent.
setVisibleCardCount(containerWidth > 1080 ? 4 : containerWidth > 680 ? 3 : 2);
});
@ -59,8 +59,8 @@ function GetStarted() {
* Slice the guide metadata as we only need to show 1 row of guide cards in get-started page
*/
const featuredAppGuides = useMemo(
() => getStructuredMetadata().featured.slice(0, visibleCardCount),
[visibleCardCount, getStructuredMetadata]
() => getStructuredAppGuideMetadata().featured.slice(0, visibleCardCount),
[visibleCardCount, getStructuredAppGuideMetadata]
);
const onClickGuide = useCallback((data: SelectedGuide) => {
@ -90,7 +90,7 @@ function GetStarted() {
<FreePlanNotification />
<Card className={styles.card}>
<div className={styles.title}>{t('get_started.develop.title')}</div>
<GuideGroup
<GuideCardGroup
ref={containerRef}
hasCardBorder
guides={featuredAppGuides}