Feature/onboarding updates for policies and portal (#6926)

This commit is contained in:
EthanHealy01
2026-07-11 12:41:48 +01:00
committed by GitHub
parent 142544c9af
commit d23318cfa6
44 changed files with 2308 additions and 932 deletions
@@ -1,15 +1,16 @@
import React from "react";
import { Modal, Stack } from "@mantine/core";
import BoltRoundedIcon from "@mui/icons-material/BoltRounded";
import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded";
import { useTranslation } from "react-i18next";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
import { renderButtons } from "@app/components/onboarding/renderButtons";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import OnboardingSlideShell, {
ShellHero,
type ShellButton,
} from "@app/components/onboarding/OnboardingSlideShell";
import { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState";
import { BASE_PATH } from "@app/constants/app";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import {
type SlideId,
type ButtonAction,
type ButtonDefinition,
} from "@app/components/onboarding/saasOnboardingFlowConfig";
interface SaasOnboardingModalProps {
opened: boolean;
@@ -20,6 +21,7 @@ interface SaasOnboardingModalProps {
* false (slide shown) so the web (saas) flow is unchanged.
*/
hideDesktopInstall?: boolean;
slideIds?: SlideId[];
}
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
@@ -39,130 +41,45 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === "dual-icon") {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling icon"
className={styles.downloadIcon}
/>
</div>
</div>
);
}
if (slideDefinition.hero.type === "logo") {
return (
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling logo"
className={styles.standaloneIcon}
/>
);
}
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "bolt" && (
<BoltRoundedIcon sx={{ fontSize: 64, color: "#000000" }} />
)}
{slideDefinition.hero.type === "team" && (
<GroupAddRoundedIcon sx={{ fontSize: 56, color: "#000000" }} />
)}
</div>
const heroType = slideDefinition.hero.type;
const hero =
heroType === "dual-icon" || heroType === "logo" ? (
<ShellHero appIcon />
) : (
<ShellHero>
{heroType === "bolt" && <BoltRoundedIcon sx={{ fontSize: 30 }} />}
{heroType === "team" && <GroupAddRoundedIcon sx={{ fontSize: 30 }} />}
</ShellHero>
);
const resolveLabel = (button: ButtonDefinition) => {
const label = button.label ?? "";
if (!label) return "";
const fallback = label.split(".").pop() || label;
return t(label, fallback);
};
const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({
key: button.key,
back: button.type === "icon",
label: resolveLabel(button),
primary: (button.variant ?? "secondary") === "primary",
action: button.action,
disabled: button.disabledWhen?.(flowState) ?? false,
}));
return (
<Modal
<OnboardingSlideShell
opened={props.opened}
hero={hero}
slideKey={currentSlide.key}
title={currentSlide.title}
body={currentSlide.body}
stepIndex={currentStep}
stepCount={totalSteps}
buttons={buttons}
onAction={(action) => handleButtonAction(action as ButtonAction)}
onClose={props.onClose}
closeOnClickOutside={false}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack
gap={0}
className={styles.modalContent}
style={{
height: "100%",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
}}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
isActive
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div
key={`body-${currentSlide.key}`}
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper
totalSteps={totalSteps}
activeStep={currentStep}
/>
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
flowState,
onAction: handleButtonAction,
t,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
/>
);
}
@@ -1,4 +1,8 @@
import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
import {
resolveFlowIds,
type FlowStep,
} from "@app/components/onboarding/onboardingSlideTypes";
export interface SaasFlowInputs {
/** Free-tier wallet with one-time allowance remaining — show the usage meter. */
@@ -13,22 +17,23 @@ export interface SaasFlowInputs {
hideDesktopInstall?: boolean;
}
// The SaaS flow as data: free-editor and desktop-install bookend it; the usage
// meter and team slides slot in when their conditions hold. This is the same
// "flow = the steps that apply, in order" shape the core flow uses, resolved
// through the shared {@link resolveFlowIds} helper.
const SAAS_FLOW: FlowStep<SlideId, SaasFlowInputs>[] = [
{ id: "free-editor", when: () => true },
{ id: "usage", when: (input) => input.showUsageSlide },
{ id: "team", when: (input) => input.showTeamSlide },
{ id: "desktop-install", when: (input) => !input.hideDesktopInstall },
];
/**
* Resolves the SaaS onboarding slide sequence. The free-editor pitch and
* desktop install bookend the flow; the usage meter and team slides slot in
* when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is
* set, the closing desktop-install slide is dropped (used by the desktop app,
* which has no reason to pitch its own download).
* Resolves the SaaS onboarding slide sequence. When
* {@link SaasFlowInputs.hideDesktopInstall} is set, the closing desktop-install
* slide is dropped (used by the desktop app, which has no reason to pitch its
* own download).
*/
export function resolveSaasFlow({
showUsageSlide,
showTeamSlide,
hideDesktopInstall = false,
}: SaasFlowInputs): SlideId[] {
return [
"free-editor",
...(showUsageSlide ? (["usage"] as const) : []),
...(showTeamSlide ? (["team"] as const) : []),
...(hideDesktopInstall ? [] : (["desktop-install"] as const)),
];
export function resolveSaasFlow(inputs: SaasFlowInputs): SlideId[] {
return resolveFlowIds(SAAS_FLOW, inputs);
}
@@ -2,7 +2,14 @@ import FreeEditorSlide from "@app/components/onboarding/slides/FreeEditorSlide";
import UsageSnapshotSlide from "@app/components/onboarding/slides/UsageSnapshotSlide";
import TeamSlide from "@app/components/onboarding/slides/TeamSlide";
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
import { SlideConfig } from "@app/types/types";
import type {
OSOption,
ButtonDefinition as ButtonDefinitionBase,
HeroDefinition as HeroDefinitionBase,
SlideDefinition as SlideDefinitionBase,
} from "@app/components/onboarding/onboardingSlideTypes";
export type { OSOption };
export type SlideId = "free-editor" | "usage" | "team" | "desktop-install";
@@ -12,12 +19,6 @@ export type ButtonAction = "next" | "prev" | "close" | "download-selected";
export type FlowState = Record<string, never>;
export interface OSOption {
label: string;
url: string;
value: string;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
@@ -25,27 +26,17 @@ export interface SlideFactoryParams {
onDownloadUrlChange?: (url: string) => void;
}
export interface HeroDefinition {
type: HeroType;
}
export type HeroDefinition = HeroDefinitionBase<HeroType>;
export interface ButtonDefinition {
key: string;
type: "button" | "icon";
label?: string;
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export type ButtonDefinition = ButtonDefinitionBase<ButtonAction, FlowState>;
export interface SlideDefinition {
id: SlideId;
createSlide: (params: SlideFactoryParams) => SlideConfig;
hero: HeroDefinition;
buttons: ButtonDefinition[];
}
export type SlideDefinition = SlideDefinitionBase<
SlideId,
ButtonAction,
FlowState,
HeroType,
SlideFactoryParams
>;
const BACK_BUTTON: ButtonDefinition = {
key: "back",
@@ -95,7 +86,6 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: "dual-icon" },
buttons: [
{ ...BACK_BUTTON, key: "desktop-back" },
{
key: "desktop-skip",
type: "button",
@@ -31,12 +31,14 @@ interface UseSaasOnboardingStateProps {
* (slide shown) so the web (saas) flow is unchanged.
*/
hideDesktopInstall?: boolean;
slideIds?: SlideId[];
}
export function useSaasOnboardingState({
opened,
onClose,
hideDesktopInstall = false,
slideIds,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { loading } = useAuth();
const { wallet } = useWallet();
@@ -89,8 +91,9 @@ export function useSaasOnboardingState({
const flowSlideIds = useMemo(
() =>
slideIds ??
resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }),
[showUsageSlide, showTeamSlide, hideDesktopInstall],
[slideIds, showUsageSlide, showTeamSlide, hideDesktopInstall],
);
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);