mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 11:45:05 +02:00
update saas onboarding (#6619)
<img width="1002" height="487" alt="Screenshot 2026-06-11 at 6 20 10 PM" src="https://github.com/user-attachments/assets/5ee3cfc2-6c4f-4b35-9586-ef45fa216c6a" />
This commit is contained in:
+5
-6
@@ -34,8 +34,8 @@
|
||||
}
|
||||
|
||||
.standaloneIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
@@ -124,8 +124,6 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
position: relative;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
@@ -175,8 +173,8 @@
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
@@ -205,6 +203,7 @@
|
||||
}
|
||||
|
||||
.bodyCopy {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import {
|
||||
DesktopInstallTitle,
|
||||
type OSOption,
|
||||
@@ -47,9 +47,6 @@ export default function DesktopInstallSlide({
|
||||
),
|
||||
body: <DesktopInstallBody />,
|
||||
downloadUrl: osUrl,
|
||||
background: {
|
||||
gradientStops: ["#2563EB", "#0EA5E9"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
background: UNIFIED_LIGHT_BACKGROUND,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AnimatedCircleConfig } from "@app/types/types";
|
||||
import {
|
||||
AnimatedCircleConfig,
|
||||
AnimatedSlideBackgroundProps,
|
||||
} from "@app/types/types";
|
||||
|
||||
/**
|
||||
* Unified circle background configuration used across all onboarding slides.
|
||||
@@ -27,3 +30,36 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
||||
offsetY: 18,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a light slide background in a slide-specific accent colour: a white
|
||||
* hero fading into a pale horizon tint, with the unified sphere geometry
|
||||
* glowing in the accent on each sphere's inner edge.
|
||||
*/
|
||||
export function createLightSlideBackground(
|
||||
accentRgb: [number, number, number],
|
||||
horizon: string,
|
||||
): AnimatedSlideBackgroundProps {
|
||||
const [r, g, b] = accentRgb;
|
||||
const sphereColors = [
|
||||
`linear-gradient(45deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.3))`,
|
||||
`linear-gradient(225deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.26))`,
|
||||
];
|
||||
return {
|
||||
gradientStops: ["#FFFFFF", horizon],
|
||||
circles: UNIFIED_CIRCLE_CONFIG.map((circle, index) => ({
|
||||
...circle,
|
||||
color: sphereColors[index],
|
||||
})),
|
||||
tone: "light",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default light slide background: white with a light blue horizon and
|
||||
* blue-glow spheres.
|
||||
*/
|
||||
export const UNIFIED_LIGHT_BACKGROUND = createLightSlideBackground(
|
||||
[37, 99, 235],
|
||||
"#DBEAFE",
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface AnimatedCircleConfig {
|
||||
export interface AnimatedSlideBackgroundProps {
|
||||
gradientStops: [string, string];
|
||||
circles: AnimatedCircleConfig[];
|
||||
/** Overall background tone; controls on top of the hero (e.g. close button) adapt to stay visible. Defaults to "dark". */
|
||||
tone?: "light" | "dark";
|
||||
}
|
||||
|
||||
export interface SlideConfig {
|
||||
|
||||
@@ -21,7 +21,9 @@ export function PolicyDeleteConfirmModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("policies.deleteConfirmTitle", `Delete ${label} policy?`)}
|
||||
title={t("policies.deleteConfirmTitle", "Delete {{label}} policy?", {
|
||||
label,
|
||||
})}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { Modal, Stack } from "@mantine/core";
|
||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||
import BoltRoundedIcon from "@mui/icons-material/BoltRounded";
|
||||
import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
|
||||
import { renderButtons } from "@app/components/onboarding/renderButtons";
|
||||
@@ -48,18 +48,23 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
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 === "rocket" && (
|
||||
<LocalIcon
|
||||
icon="rocket-launch"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
{slideDefinition.hero.type === "bolt" && (
|
||||
<BoltRoundedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
)}
|
||||
{slideDefinition.hero.type === "diamond" && (
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
{slideDefinition.hero.type === "team" && (
|
||||
<GroupAddRoundedIcon sx={{ fontSize: 56, color: "#000000" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
import {
|
||||
FLOW_SEQUENCES,
|
||||
SlideId,
|
||||
} from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
|
||||
|
||||
export interface FlowConfig {
|
||||
type: "saas-trial" | "saas-paid";
|
||||
ids: SlideId[];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appropriate onboarding flow based on user's subscription status.
|
||||
*
|
||||
* @param trialStatus - User's trial information from Supabase
|
||||
* @param _isPro - Whether user has Pro subscription
|
||||
* @returns FlowConfig with the appropriate slide sequence
|
||||
* 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.
|
||||
*/
|
||||
export function resolveSaasFlow(
|
||||
trialStatus: TrialStatus | null,
|
||||
_isPro: boolean | null,
|
||||
): FlowConfig {
|
||||
// Show free trial card if:
|
||||
// 1. User has active trial (isTrialing = true)
|
||||
// 2. Trial has not expired (daysRemaining > 0)
|
||||
// 3. User is not paid Pro (or Pro is from trial)
|
||||
const hasActiveTrial =
|
||||
trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0;
|
||||
|
||||
if (hasActiveTrial) {
|
||||
return {
|
||||
type: "saas-trial",
|
||||
ids: FLOW_SEQUENCES.saasTrialUser,
|
||||
};
|
||||
}
|
||||
|
||||
// For paid users, expired trials, or no trial info
|
||||
return {
|
||||
type: "saas-paid",
|
||||
ids: FLOW_SEQUENCES.saasPaidUser,
|
||||
};
|
||||
export function resolveSaasFlow({
|
||||
showUsageSlide,
|
||||
showTeamSlide,
|
||||
}: SaasFlowInputs): SlideId[] {
|
||||
return [
|
||||
"free-editor",
|
||||
...(showUsageSlide ? (["usage"] as const) : []),
|
||||
...(showTeamSlide ? (["team"] as const) : []),
|
||||
"desktop-install",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
|
||||
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 FreeTrialSlide from "@app/components/onboarding/slides/FreeTrialSlide";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
|
||||
export type SlideId = "welcome" | "free-trial" | "desktop-install";
|
||||
export type SlideId = "free-editor" | "usage" | "team" | "desktop-install";
|
||||
|
||||
export type HeroType = "rocket" | "dual-icon" | "diamond";
|
||||
export type HeroType = "logo" | "bolt" | "team" | "dual-icon";
|
||||
|
||||
export type ButtonAction = "next" | "prev" | "close" | "download-selected";
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface SlideFactoryParams {
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
trialStatus?: TrialStatus | null;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
@@ -48,47 +47,46 @@ export interface SlideDefinition {
|
||||
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> = {
|
||||
welcome: {
|
||||
id: "welcome",
|
||||
createSlide: () => WelcomeSlide(),
|
||||
hero: { type: "rocket" },
|
||||
"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: [
|
||||
{
|
||||
key: "welcome-next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
},
|
||||
{ ...BACK_BUTTON, key: "usage-back" },
|
||||
{ ...NEXT_BUTTON, key: "usage-next" },
|
||||
],
|
||||
},
|
||||
"free-trial": {
|
||||
id: "free-trial",
|
||||
createSlide: ({ trialStatus }) => {
|
||||
if (!trialStatus) {
|
||||
throw new Error("Trial status is required for free-trial slide");
|
||||
}
|
||||
return FreeTrialSlide({ trialStatus });
|
||||
},
|
||||
hero: { type: "diamond" },
|
||||
team: {
|
||||
id: "team",
|
||||
createSlide: () => TeamSlide(),
|
||||
hero: { type: "team" },
|
||||
buttons: [
|
||||
{
|
||||
key: "trial-back",
|
||||
type: "icon",
|
||||
icon: "chevron-left",
|
||||
group: "left",
|
||||
action: "prev",
|
||||
},
|
||||
{
|
||||
key: "trial-next",
|
||||
type: "button",
|
||||
label: "onboarding.buttons.next",
|
||||
variant: "primary",
|
||||
group: "right",
|
||||
action: "next",
|
||||
},
|
||||
{ ...BACK_BUTTON, key: "team-back" },
|
||||
{ ...NEXT_BUTTON, key: "team-next" },
|
||||
],
|
||||
},
|
||||
"desktop-install": {
|
||||
@@ -97,13 +95,7 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: "dual-icon" },
|
||||
buttons: [
|
||||
{
|
||||
key: "desktop-back",
|
||||
type: "icon",
|
||||
icon: "chevron-left",
|
||||
group: "left",
|
||||
action: "prev",
|
||||
},
|
||||
{ ...BACK_BUTTON, key: "desktop-back" },
|
||||
{
|
||||
key: "desktop-skip",
|
||||
type: "button",
|
||||
@@ -123,8 +115,3 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FLOW_SEQUENCES = {
|
||||
saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[],
|
||||
saasPaidUser: ["welcome", "desktop-install"] as SlideId[],
|
||||
};
|
||||
|
||||
@@ -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 a whole host 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="And the best part? 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,
|
||||
};
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { TrialStatus } from "@app/auth/UseSession";
|
||||
|
||||
interface FreeTrialSlideProps {
|
||||
trialStatus: TrialStatus;
|
||||
}
|
||||
|
||||
function FreeTrialSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span>{t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format the trial end date
|
||||
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
// Determine which message to show based on payment method
|
||||
const afterTrialMessage = trialStatus.hasScheduledSub
|
||||
? t(
|
||||
"onboarding.freeTrial.afterTrialWithPayment",
|
||||
"Your Pro subscription will start automatically when the trial ends.",
|
||||
)
|
||||
: trialStatus.hasPaymentMethod
|
||||
? t(
|
||||
"onboarding.freeTrial.afterTrialWithPayment",
|
||||
"Your Pro subscription will start automatically when the trial ends.",
|
||||
)
|
||||
: t(
|
||||
"onboarding.freeTrial.afterTrialWithoutPayment",
|
||||
"After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access.",
|
||||
);
|
||||
|
||||
// Pluralize days remaining
|
||||
const daysText =
|
||||
trialStatus.daysRemaining === 1
|
||||
? t(
|
||||
"onboarding.freeTrial.daysRemainingSingular",
|
||||
"{{days}} day remaining",
|
||||
{ days: trialStatus.daysRemaining },
|
||||
)
|
||||
: t("onboarding.freeTrial.daysRemaining", "{{days}} days remaining", {
|
||||
days: trialStatus.daysRemaining,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<p>
|
||||
{t(
|
||||
"onboarding.freeTrial.body",
|
||||
"You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.",
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{daysText}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.9rem", opacity: 0.9 }}>
|
||||
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", {
|
||||
date: trialEndDate,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function FreeTrialSlide({
|
||||
trialStatus,
|
||||
}: FreeTrialSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "free-trial",
|
||||
title: <FreeTrialSlideTitle />,
|
||||
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
|
||||
background: {
|
||||
gradientStops: ["#10B981", "#06B6D4"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 — 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 — they'll get an invite right away.",
|
||||
)
|
||||
: 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/PaygFree";
|
||||
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 one-time free allowance. Here's where you stand - 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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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,
|
||||
@@ -28,7 +30,9 @@ export function useSaasOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
|
||||
const { trialStatus, isPro, loading } = useAuth();
|
||||
const { loading } = useAuth();
|
||||
const { wallet } = useWallet();
|
||||
const { isTeamLeader } = useSaaSTeam();
|
||||
const osType = useOs();
|
||||
const selectedDownloadUrlRef = useRef<string>("");
|
||||
|
||||
@@ -70,13 +74,15 @@ export function useSaasOnboardingState({
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
// Resolve flow based on trial status
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveSaasFlow(trialStatus, isPro),
|
||||
[trialStatus, isPro],
|
||||
);
|
||||
// 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 = resolvedFlow.ids;
|
||||
const flowSlideIds = useMemo(
|
||||
() => resolveSaasFlow({ showUsageSlide, showTeamSlide }),
|
||||
[showUsageSlide, showTeamSlide],
|
||||
);
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
@@ -99,16 +105,8 @@ export function useSaasOnboardingState({
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
trialStatus: trialStatus ?? undefined,
|
||||
});
|
||||
}, [
|
||||
slideDefinition,
|
||||
os.label,
|
||||
os.url,
|
||||
osOptions,
|
||||
handleDownloadUrlChange,
|
||||
trialStatus,
|
||||
]);
|
||||
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange]);
|
||||
|
||||
// Navigation functions
|
||||
const goNext = useCallback(() => {
|
||||
|
||||
@@ -209,9 +209,7 @@ function UsageHero({ wallet }: { wallet: Wallet }) {
|
||||
|
||||
const limit = wallet.billableLimit;
|
||||
const hasLimit = limit != null && limit > 0;
|
||||
const pct = hasLimit
|
||||
? Math.min(100, (wallet.billableUsed / limit) * 100)
|
||||
: 0;
|
||||
const pct = hasLimit ? Math.min(100, (wallet.billableUsed / limit) * 100) : 0;
|
||||
const state = hasLimit
|
||||
? pct >= 100
|
||||
? "DEGRADED"
|
||||
@@ -387,7 +385,13 @@ function CapEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) {
|
||||
function CapReadOnly({
|
||||
capUsd,
|
||||
noCap,
|
||||
}: {
|
||||
capUsd: number | null;
|
||||
noCap: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasCap = !noCap && capUsd != null;
|
||||
return (
|
||||
@@ -567,10 +571,7 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payg.activity.empty",
|
||||
"No billable activity yet this period.",
|
||||
)}
|
||||
{t("payg.activity.empty", "No billable activity yet this period.")}
|
||||
</Text>
|
||||
) : (
|
||||
<div>
|
||||
@@ -603,7 +604,11 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
|
||||
|
||||
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void> }) {
|
||||
function StripePortalLink({
|
||||
onOpenPortal,
|
||||
}: {
|
||||
onOpenPortal: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -662,7 +667,12 @@ function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void>
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────
|
||||
|
||||
const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) => {
|
||||
const Payg: React.FC<PaygProps> = ({
|
||||
role,
|
||||
wallet,
|
||||
onSaveCap,
|
||||
onOpenPortal,
|
||||
}) => {
|
||||
useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember");
|
||||
const { t } = useTranslation();
|
||||
const isLeader = role === "LEADER";
|
||||
@@ -681,10 +691,14 @@ const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) =>
|
||||
<div className="payg-planhead">
|
||||
<div className="payg-planhead__top">
|
||||
<span className="payg-planhead__eyebrow">
|
||||
{t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", {
|
||||
start: fmt(wallet.billingPeriodStart),
|
||||
end: fmt(wallet.billingPeriodEnd),
|
||||
})}
|
||||
{t(
|
||||
"payg.header.eyebrow",
|
||||
"Processor plan · {{start}} – {{end}}",
|
||||
{
|
||||
start: fmt(wallet.billingPeriodStart),
|
||||
end: fmt(wallet.billingPeriodEnd),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<span className="payg-role-pill" data-leader={isLeader}>
|
||||
{isLeader
|
||||
@@ -715,7 +729,10 @@ const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) =>
|
||||
|
||||
<div className="payg-planhead__col payg-planhead__col--meter">
|
||||
<div className="payg-planhead__lbl payg-planhead__lbl--meter">
|
||||
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
|
||||
<BoltIcon
|
||||
className="payg-planhead__lbl-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t("payg.header.meterLabel", "Metered")}
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
|
||||
@@ -227,7 +227,9 @@
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paygf-cta__button:hover {
|
||||
|
||||
@@ -43,7 +43,7 @@ import { DocHelp } from "./Payg";
|
||||
|
||||
// ─── Shared free-tier snapshot ────────────────────────────
|
||||
|
||||
interface FreeSnapshot {
|
||||
export interface FreeSnapshot {
|
||||
/** One-time free documents used so far (grant − remaining). */
|
||||
billableUsed: number;
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ interface FreeSnapshot {
|
||||
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
|
||||
* into the rendered UI and made the page look like nothing was wired up.
|
||||
*/
|
||||
function useFreeSnapshot(): FreeSnapshot {
|
||||
export function useFreeSnapshot(): FreeSnapshot {
|
||||
const { wallet } = useWallet();
|
||||
return useMemo(() => {
|
||||
if (wallet) {
|
||||
@@ -80,7 +80,10 @@ function useFreeSnapshot(): FreeSnapshot {
|
||||
type MeterState = "FULL" | "WARNED" | "DEGRADED";
|
||||
|
||||
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
|
||||
function meterState(used: number, limit: number): { state: MeterState; pct: number } {
|
||||
function meterState(
|
||||
used: number,
|
||||
limit: number,
|
||||
): { state: MeterState; pct: number } {
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
|
||||
const state: MeterState =
|
||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
||||
@@ -113,7 +116,10 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
|
||||
/>
|
||||
{t("payg.free.editor.eyebrow", "Editor plan · Always free")}
|
||||
</span>
|
||||
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}>
|
||||
<span
|
||||
className="payg-role-pill"
|
||||
data-leader={leader ? "true" : "false"}
|
||||
>
|
||||
{pill}
|
||||
</span>
|
||||
</div>
|
||||
@@ -132,7 +138,7 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
|
||||
|
||||
// ─── Compact one-time free meter (right column of the Processor card) ──────
|
||||
|
||||
function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
|
||||
const stateLabel =
|
||||
@@ -174,7 +180,9 @@ function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>{t("payg.free.hero.neverResets", "One-time, never resets")}</span>
|
||||
<span>
|
||||
{t("payg.free.hero.neverResets", "One-time — never resets")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -240,8 +248,10 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>{t("payg.free.cta.benefit3Title", "API access")}</strong>
|
||||
{": "}
|
||||
<strong>
|
||||
{t("payg.free.cta.benefit3Title", "API access")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit3Body",
|
||||
"call any Stirling endpoint programmatically",
|
||||
|
||||
@@ -59,7 +59,10 @@ const Plan: React.FC = () => {
|
||||
|
||||
if (error || !wallet) {
|
||||
return (
|
||||
<Alert color="red" title={t("payg.error.title", "Couldn't load your plan")}>
|
||||
<Alert
|
||||
color="red"
|
||||
title={t("payg.error.title", "Couldn't load your plan")}
|
||||
>
|
||||
{error ??
|
||||
t(
|
||||
"payg.error.body",
|
||||
|
||||
+7
-2
@@ -53,7 +53,10 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
transition:
|
||||
color 0.12s ease,
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
.scc-chip:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
@@ -82,7 +85,9 @@
|
||||
border: 1px dashed var(--scc-chip-border);
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
.scc-custom:hover {
|
||||
border-color: var(--border-strong);
|
||||
|
||||
+8
-2
@@ -62,7 +62,10 @@ export interface SpendCapControlProps {
|
||||
}
|
||||
|
||||
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
|
||||
function formatMinor(minor: number, currency: string | null | undefined): string {
|
||||
function formatMinor(
|
||||
minor: number,
|
||||
currency: string | null | undefined,
|
||||
): string {
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
@@ -212,7 +215,10 @@ const SpendCapControl: React.FC<SpendCapControlProps> = ({
|
||||
|
||||
{previewDocs != null && (
|
||||
<div className="scc-estimate">
|
||||
<DescriptionIcon className="scc-estimate__icon" sx={{ fontSize: 22 }} />
|
||||
<DescriptionIcon
|
||||
className="scc-estimate__icon"
|
||||
sx={{ fontSize: 22 }}
|
||||
/>
|
||||
<div>
|
||||
<div className="scc-estimate__main">
|
||||
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
||||
|
||||
+4
-3
@@ -148,8 +148,7 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const publishableKey =
|
||||
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
|
||||
// Dev preview route has no backend — skip the API call and go straight
|
||||
// to the mock placeholder so the design + completion path stay testable.
|
||||
@@ -214,7 +213,9 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
}
|
||||
if (cancelled) return;
|
||||
setClientSecret(data.client_secret);
|
||||
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"));
|
||||
setIsMock(
|
||||
Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
const msg =
|
||||
|
||||
@@ -112,7 +112,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
.upm-header__close:hover {
|
||||
background: var(--upm-divider);
|
||||
@@ -141,7 +143,9 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: -6px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
.upm-header__back:hover {
|
||||
background: var(--upm-divider);
|
||||
@@ -177,7 +181,10 @@
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
color 200ms ease;
|
||||
}
|
||||
.upm-step[data-state="active"] {
|
||||
color: var(--upm-text);
|
||||
|
||||
@@ -161,7 +161,10 @@ export default function UpgradeModal({
|
||||
<h2 className="upm-header__title">
|
||||
{step === "confirm"
|
||||
? t("payg.upgrade.title.confirm", "You're subscribed")
|
||||
: t("payg.upgrade.title.default", "Upgrade to Processor plan")}
|
||||
: t(
|
||||
"payg.upgrade.title.default",
|
||||
"Upgrade to Processor plan",
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -213,10 +216,7 @@ export default function UpgradeModal({
|
||||
>
|
||||
<span className="upm-step__dot">2</span>
|
||||
<span>
|
||||
{t(
|
||||
"payg.upgrade.steps.payment",
|
||||
"Add payment method",
|
||||
)}
|
||||
{t("payg.upgrade.steps.payment", "Add payment method")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,10 +342,7 @@ function CapStep({
|
||||
</div>
|
||||
|
||||
<h3 className="upm-section-title">
|
||||
{t(
|
||||
"payg.upgrade.cap.title",
|
||||
"Set your monthly spend ceiling",
|
||||
)}
|
||||
{t("payg.upgrade.cap.title", "Set your monthly spend ceiling")}
|
||||
</h3>
|
||||
<p className="upm-section-help">
|
||||
{t(
|
||||
@@ -377,18 +374,12 @@ function CapStep({
|
||||
|
||||
<div className="upm-help-card" style={{ marginTop: 16 }}>
|
||||
<span className="upm-help-card__title">
|
||||
{t(
|
||||
"payg.upgrade.help.title",
|
||||
"What we count toward billing",
|
||||
)}
|
||||
{t("payg.upgrade.help.title", "What we count toward billing")}
|
||||
</span>
|
||||
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
|
||||
<li>
|
||||
<strong>
|
||||
{t(
|
||||
"payg.upgrade.help.automationTitle",
|
||||
"Automation pipelines",
|
||||
)}
|
||||
{t("payg.upgrade.help.automationTitle", "Automation pipelines")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
@@ -397,9 +388,7 @@ function CapStep({
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
{t("payg.upgrade.help.aiTitle", "AI tools")}
|
||||
</strong>
|
||||
<strong>{t("payg.upgrade.help.aiTitle", "AI tools")}</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.aiBody",
|
||||
@@ -407,9 +396,7 @@ function CapStep({
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
{t("payg.upgrade.help.apiTitle", "API calls")}
|
||||
</strong>
|
||||
<strong>{t("payg.upgrade.help.apiTitle", "API calls")}</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.apiBody",
|
||||
@@ -447,10 +434,7 @@ function CheckoutStep({
|
||||
return (
|
||||
<>
|
||||
<h3 className="upm-section-title">
|
||||
{t(
|
||||
"payg.upgrade.checkout.title",
|
||||
"Add your payment method",
|
||||
)}
|
||||
{t("payg.upgrade.checkout.title", "Add your payment method")}
|
||||
</h3>
|
||||
<p className="upm-section-help">
|
||||
{t(
|
||||
@@ -498,10 +482,7 @@ function ConfirmationStep({
|
||||
<div className="upm-confirm">
|
||||
<CheckCircleIcon className="upm-confirm__icon" />
|
||||
<h3 className="upm-confirm__title">
|
||||
{t(
|
||||
"payg.confirm.title",
|
||||
"Welcome to the Processor plan",
|
||||
)}
|
||||
{t("payg.confirm.title", "Welcome to the Processor plan")}
|
||||
</h3>
|
||||
<p className="upm-confirm__body">
|
||||
{t(
|
||||
|
||||
@@ -151,11 +151,7 @@ function appendMcpSection(
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
{
|
||||
isDev = false,
|
||||
isAnonymous = false,
|
||||
t,
|
||||
}: CreateSaasConfigNavSectionsOptions,
|
||||
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
|
||||
): ConfigNavSection[] {
|
||||
const baseSections = createCoreConfigNavSections(false, false, false);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-bottom: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth {
|
||||
|
||||
@@ -112,9 +112,7 @@ describe("extractSignupCategory", () => {
|
||||
expect(
|
||||
extractSignupCategory({ response: { data: { category: 7 } } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
extractSignupCategory({ response: { data: {} } }),
|
||||
).toBeNull();
|
||||
expect(extractSignupCategory({ response: { data: {} } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user