mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
refactor(fe): share the SaaS PAYG experience with desktop via a cloud/ layer (#6649)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
ef0deef4f2
commit
cd7264a76a
@@ -0,0 +1,168 @@
|
||||
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 { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
|
||||
interface SaasOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Drop the closing "desktop-install" slide. Set by the desktop app, which
|
||||
* reuses this flow but has no reason to pitch its own download. Defaults to
|
||||
* false (slide shown) so the web (saas) flow is unchanged.
|
||||
*/
|
||||
hideDesktopInstall?: boolean;
|
||||
}
|
||||
|
||||
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const flow = useSaasOnboardingState(props);
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
flowState,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
ButtonDefinition,
|
||||
type FlowState,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
};
|
||||
flowState: FlowState;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function renderButtons({
|
||||
slideDefinition,
|
||||
flowState,
|
||||
onAction,
|
||||
t,
|
||||
}: RenderButtonsProps) {
|
||||
const leftButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "left",
|
||||
);
|
||||
const rightButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
? {
|
||||
root: {
|
||||
background: "var(--onboarding-primary-button-bg)",
|
||||
color: "var(--onboarding-primary-button-text)",
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? "";
|
||||
if (!label) return "";
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split(".").pop() || label;
|
||||
return t(label, fallback);
|
||||
};
|
||||
|
||||
const renderButton = (button: ButtonDefinition) => {
|
||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||
|
||||
if (button.type === "icon") {
|
||||
return (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--onboarding-secondary-button-bg)",
|
||||
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||
color: "var(--onboarding-secondary-button-text)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === "chevron-left" && (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = button.variant ?? "secondary";
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (leftButtons.length === 0) {
|
||||
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
if (rightButtons.length === 0) {
|
||||
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
export interface SaasFlowInputs {
|
||||
/** Free-tier wallet with one-time allowance remaining — show the usage meter. */
|
||||
showUsageSlide: boolean;
|
||||
/** Team leaders only — invited members and anonymous guests skip the team slide. */
|
||||
showTeamSlide: boolean;
|
||||
/**
|
||||
* Drop the closing "desktop-install" slide. The web (saas) flow pitches the
|
||||
* desktop download, but the desktop app reuses this same flow and is already
|
||||
* the desktop app, so it omits that slide. Defaults to false (slide shown).
|
||||
*/
|
||||
hideDesktopInstall?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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";
|
||||
|
||||
export type SlideId = "free-editor" | "usage" | "team" | "desktop-install";
|
||||
|
||||
export type HeroType = "logo" | "bolt" | "team" | "dual-icon";
|
||||
|
||||
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;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
type: 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 interface SlideDefinition {
|
||||
id: SlideId;
|
||||
createSlide: (params: SlideFactoryParams) => SlideConfig;
|
||||
hero: HeroDefinition;
|
||||
buttons: ButtonDefinition[];
|
||||
}
|
||||
|
||||
const BACK_BUTTON: ButtonDefinition = {
|
||||
key: "back",
|
||||
type: "icon",
|
||||
icon: "chevron-left",
|
||||
group: "left",
|
||||
action: "prev",
|
||||
};
|
||||
|
||||
const NEXT_BUTTON: ButtonDefinition = {
|
||||
key: "next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
};
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
"free-editor": {
|
||||
id: "free-editor",
|
||||
createSlide: () => FreeEditorSlide(),
|
||||
hero: { type: "logo" },
|
||||
buttons: [{ ...NEXT_BUTTON, key: "free-editor-next" }],
|
||||
},
|
||||
usage: {
|
||||
id: "usage",
|
||||
createSlide: () => UsageSnapshotSlide(),
|
||||
hero: { type: "bolt" },
|
||||
buttons: [
|
||||
{ ...BACK_BUTTON, key: "usage-back" },
|
||||
{ ...NEXT_BUTTON, key: "usage-next" },
|
||||
],
|
||||
},
|
||||
team: {
|
||||
id: "team",
|
||||
createSlide: () => TeamSlide(),
|
||||
hero: { type: "team" },
|
||||
buttons: [
|
||||
{ ...BACK_BUTTON, key: "team-back" },
|
||||
{ ...NEXT_BUTTON, key: "team-next" },
|
||||
],
|
||||
},
|
||||
"desktop-install": {
|
||||
id: "desktop-install",
|
||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
|
||||
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: "dual-icon" },
|
||||
buttons: [
|
||||
{ ...BACK_BUTTON, key: "desktop-back" },
|
||||
{
|
||||
key: "desktop-skip",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.skipForNow",
|
||||
variant: "secondary",
|
||||
group: "left",
|
||||
action: "close",
|
||||
},
|
||||
{
|
||||
key: "desktop-download",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.download",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "download-selected",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
|
||||
|
||||
// Stirling logo red (sampled from modern-logo/logo512.png)
|
||||
const FREE_EDITOR_BACKGROUND = createLightSlideBackground(
|
||||
[142, 49, 49],
|
||||
"#F8E0E0",
|
||||
);
|
||||
|
||||
const FreeEditorBody = () => (
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="onboarding.saas.freeEditor.premium"
|
||||
components={{ strong: <strong /> }}
|
||||
defaults="We've added loads of new features, including <strong>Policies</strong> and <strong>Agent Chat</strong>."
|
||||
/>
|
||||
<span className={styles.freeLine}>
|
||||
<Trans
|
||||
i18nKey="onboarding.saas.freeEditor.freeLine"
|
||||
components={{ free: <strong className={styles.freeHighlight} /> }}
|
||||
defaults="The editor is now <free>completely free</free>."
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default function FreeEditorSlide(): SlideConfig {
|
||||
const title = (
|
||||
<Trans
|
||||
i18nKey="onboarding.saas.freeEditor.title"
|
||||
defaults="Welcome to Stirling"
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
key: "free-editor",
|
||||
title,
|
||||
body: <FreeEditorBody />,
|
||||
background: FREE_EDITOR_BACKGROUND,
|
||||
};
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/* Shared styles for the SaaS onboarding slides */
|
||||
|
||||
/* ── Slide 1: free editor pitch ─────────────────────────────────────────── */
|
||||
|
||||
.freeLine {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.freeHighlight {
|
||||
background: linear-gradient(135deg, #6366f1, #ec4899);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Slide 2: usage meter snapshot ──────────────────────────────────────── */
|
||||
|
||||
.usageMeterWrap {
|
||||
max-width: 420px;
|
||||
margin: 16px auto 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ── Slide 3: team ──────────────────────────────────────────────────────── */
|
||||
|
||||
.teamCard {
|
||||
max-width: 460px;
|
||||
margin: 12px auto 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.memberList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-default, #d1d5db);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.memberRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.memberRow + .memberRow {
|
||||
border-top: 1px solid var(--border-subtle, #e5e7eb);
|
||||
}
|
||||
|
||||
.memberIdentity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memberName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--onboarding-title, #111827);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memberEmail {
|
||||
font-size: 12px;
|
||||
color: var(--onboarding-body, #6b7280);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inviteRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inviteRow > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inviteFeedback {
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Badge, Button, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
|
||||
|
||||
const TEAM_BACKGROUND = createLightSlideBackground([79, 70, 229], "#E0E7FF");
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function useHasTeamMembers(): boolean {
|
||||
const { teamMembers, teamInvitations } = useSaaSTeam();
|
||||
const pendingInvitations = teamInvitations.filter(
|
||||
(invitation) => invitation.status === "PENDING",
|
||||
);
|
||||
// The leader themselves is always in teamMembers, so "has a team" means
|
||||
// anyone beyond them, or an invite already on its way.
|
||||
return teamMembers.length > 1 || pendingInvitations.length > 0;
|
||||
}
|
||||
|
||||
function TeamSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
const hasTeam = useHasTeamMembers();
|
||||
|
||||
return hasTeam
|
||||
? t("onboarding.saas.team.inviteTitle", "Invite members to your team")
|
||||
: t("onboarding.saas.team.createTitle", "Create your team");
|
||||
}
|
||||
|
||||
function InviteForm() {
|
||||
const { t } = useTranslation();
|
||||
const { inviteUser } = useSaaSTeam();
|
||||
const [email, setEmail] = useState("");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const emailValid = EMAIL_PATTERN.test(email);
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!emailValid) return;
|
||||
|
||||
setInviting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
await inviteUser(email);
|
||||
setSuccess(
|
||||
t("team.inviteSent", "Invitation sent to {{email}}", { email }),
|
||||
);
|
||||
setEmail("");
|
||||
} catch (err) {
|
||||
const inviteError = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
inviteError.response?.data?.error ||
|
||||
t("team.inviteError", "Failed to send invitation"),
|
||||
);
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleInvite}>
|
||||
<span className={styles.inviteRow}>
|
||||
<TextInput
|
||||
type="email"
|
||||
placeholder={t("team.invite.placeholder", "[email protected]")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={
|
||||
email && !emailValid
|
||||
? t("team.invite.invalidEmail", "Invalid email format")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Button type="submit" loading={inviting} disabled={!emailValid}>
|
||||
{t("onboarding.saas.team.addButton", "Add")}
|
||||
</Button>
|
||||
</span>
|
||||
{error && (
|
||||
<span
|
||||
className={styles.inviteFeedback}
|
||||
style={{ color: "var(--mantine-color-red-6)", display: "block" }}
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{success && (
|
||||
<span
|
||||
className={styles.inviteFeedback}
|
||||
style={{ color: "var(--mantine-color-green-7)", display: "block" }}
|
||||
>
|
||||
{success}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const TeamSlideBody = () => {
|
||||
const { t } = useTranslation();
|
||||
const { teamMembers, teamInvitations, refreshTeams } = useSaaSTeam();
|
||||
const hasTeam = useHasTeamMembers();
|
||||
const pendingInvitations = teamInvitations.filter(
|
||||
(invitation) => invitation.status === "PENDING",
|
||||
);
|
||||
|
||||
// Onboarding shows right after first login, so make sure team data is fresh.
|
||||
useEffect(() => {
|
||||
refreshTeams();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{hasTeam
|
||||
? t(
|
||||
"onboarding.saas.team.inviteBody",
|
||||
"Everyone on your team shares files, automations and your plan. Add teammates by email and they'll get an invite.",
|
||||
)
|
||||
: t(
|
||||
"onboarding.saas.team.createBody",
|
||||
"Work on documents together: teammates share files, automations and your plan. Add the first member by email to create your team.",
|
||||
)}
|
||||
<span className={styles.teamCard} style={{ display: "block" }}>
|
||||
{hasTeam && (
|
||||
<span className={styles.memberList} style={{ display: "flex" }}>
|
||||
{teamMembers.map((member) => (
|
||||
<span
|
||||
key={`member-${member.id}`}
|
||||
className={styles.memberRow}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<span
|
||||
className={styles.memberIdentity}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<span className={styles.memberName}>{member.username}</span>
|
||||
<span className={styles.memberEmail}>{member.email}</span>
|
||||
</span>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={member.role === "LEADER" ? "blue" : "gray"}
|
||||
variant="light"
|
||||
>
|
||||
{member.role}
|
||||
</Badge>
|
||||
</span>
|
||||
))}
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<span
|
||||
key={`invitation-${invitation.invitationId}`}
|
||||
className={styles.memberRow}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<span
|
||||
className={styles.memberIdentity}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<span className={styles.memberName}>
|
||||
{invitation.inviteeEmail.split("@")[0]}
|
||||
</span>
|
||||
<span className={styles.memberEmail}>
|
||||
{invitation.inviteeEmail}
|
||||
</span>
|
||||
</span>
|
||||
<Badge size="sm" color="yellow" variant="light">
|
||||
{t("team.members.pending", "PENDING")}
|
||||
</Badge>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<InviteForm />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TeamSlide(): SlideConfig {
|
||||
return {
|
||||
key: "team",
|
||||
title: <TeamSlideTitle />,
|
||||
body: <TeamSlideBody />,
|
||||
background: TEAM_BACKGROUND,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import {
|
||||
FreeMeterPanel,
|
||||
useFreeSnapshot,
|
||||
} from "@app/components/shared/config/configSections/usageMeters";
|
||||
import i18n from "@app/i18n";
|
||||
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
|
||||
|
||||
const USAGE_BACKGROUND = createLightSlideBackground([249, 115, 22], "#FFEDD5");
|
||||
|
||||
const UsageSnapshotBody = () => {
|
||||
const { t } = useTranslation();
|
||||
const snap = useFreeSnapshot();
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t(
|
||||
"onboarding.saas.usage.body",
|
||||
"Automations, AI and API requests draw from your free allowance. Manual editing never counts against it.",
|
||||
)}
|
||||
{/* .payg provides the CSS variables the meter styles are scoped to */}
|
||||
<span
|
||||
className={`payg ${styles.usageMeterWrap}`}
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<FreeMeterPanel snap={snap} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function UsageSnapshotSlide(): SlideConfig {
|
||||
return {
|
||||
key: "usage-snapshot",
|
||||
title: i18n.t(
|
||||
"onboarding.saas.usage.title",
|
||||
"Your free Processor allowance",
|
||||
),
|
||||
body: <UsageSnapshotBody />,
|
||||
background: USAGE_BACKGROUND,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useOs } from "@app/hooks/useOs";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver";
|
||||
import { DOWNLOAD_URLS } from "@app/constants/downloads";
|
||||
import { openExternal } from "@app/platform/openExternal";
|
||||
|
||||
interface UseSaasOnboardingStateResult {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]["createSlide"]>;
|
||||
flowState: FlowState;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
interface UseSaasOnboardingStateProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Drop the closing "desktop-install" slide. The desktop app reuses this
|
||||
* flow but has no reason to pitch its own download. Defaults to false
|
||||
* (slide shown) so the web (saas) flow is unchanged.
|
||||
*/
|
||||
hideDesktopInstall?: boolean;
|
||||
}
|
||||
|
||||
export function useSaasOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
hideDesktopInstall = false,
|
||||
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
const { loading } = useAuth();
|
||||
const { wallet } = useWallet();
|
||||
const { isTeamLeader } = useSaaSTeam();
|
||||
const osType = useOs();
|
||||
const selectedDownloadUrlRef = useRef<string>("");
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<number>(0);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Determine OS details for desktop download
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac":
|
||||
return { label: "Mac", url: DOWNLOAD_URLS.MAC };
|
||||
case "linux-x64":
|
||||
case "linux-arm64":
|
||||
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: "", url: "" };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||
{ label: "Mac", url: DOWNLOAD_URLS.MAC, value: "mac" },
|
||||
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||
];
|
||||
return options.filter((opt) => opt.url);
|
||||
}, []);
|
||||
|
||||
// Store selected download URL
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
// Usage meter only makes sense for free-tier wallets with allowance left;
|
||||
// the team slide is for leaders (anonymous guests are never leaders).
|
||||
const showUsageSlide = wallet?.status === "free" && wallet.freeRemaining > 0;
|
||||
const showTeamSlide = isTeamLeader;
|
||||
|
||||
const flowSlideIds = useMemo(
|
||||
() =>
|
||||
resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }),
|
||||
[showUsageSlide, showTeamSlide, hideDesktopInstall],
|
||||
);
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
// Ensure current step is within bounds
|
||||
useEffect(() => {
|
||||
if (currentStep >= flowSlideIds.length) {
|
||||
setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
|
||||
}
|
||||
}, [flowSlideIds.length, currentStep]);
|
||||
|
||||
const currentSlideId =
|
||||
flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
// Create slide with appropriate params - must be called before any early returns
|
||||
const currentSlide = useMemo(() => {
|
||||
if (!slideDefinition) return null;
|
||||
return slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
});
|
||||
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange]);
|
||||
|
||||
// Navigation functions
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
// Handle button actions
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case "next":
|
||||
// If on last slide, close modal
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
case "prev":
|
||||
goPrev();
|
||||
return;
|
||||
case "close":
|
||||
onClose();
|
||||
return;
|
||||
case "download-selected": {
|
||||
// Open the download URL in the user's browser via the platform seam
|
||||
// (saas opens a new tab, desktop hands off to the OS shell).
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url;
|
||||
if (downloadUrl) {
|
||||
void openExternal(downloadUrl);
|
||||
}
|
||||
// Then advance to next slide or close if last
|
||||
if (currentStep === maxIndex) {
|
||||
onClose();
|
||||
} else {
|
||||
goNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.warn(`Unhandled button action: ${action}`);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[currentStep, maxIndex, goNext, goPrev, onClose, os.url],
|
||||
);
|
||||
|
||||
const flowState: FlowState = {};
|
||||
|
||||
// Early return after all hooks have been called
|
||||
if (!slideDefinition || !currentSlide || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
flowState,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user