From 0eefc620509ddaa19de054d82ae5a2d59d823f75 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Sun, 29 Mar 2026 19:01:04 -0400 Subject: [PATCH 1/8] Enhance Create Team dialog styling and layout in PageClient component --- .../new-project/page-client.tsx | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index eaa15deb22..cbed81e969 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -1595,23 +1595,38 @@ export default function PageClient() { - - - Create Team - This team will be available immediately for project ownership. + + +
+
+ +
+
+ Create Team +
+
+ + This team will be available immediately for project ownership. +
-
- - setNewTeamName(event.target.value)} - placeholder="Acme Team" - /> +
+
+ + setNewTeamName(event.target.value)} + placeholder="Acme Team" + /> +
- + setIsCreateTeamOpen(false)} disabled={creatingTeam}> Cancel From 83563e0deea0ae87c4075b8af052b8a35491ffab Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Sat, 4 Apr 2026 17:21:03 -0400 Subject: [PATCH 2/8] Add neutral theme styles for AuthPage preview and enhance onboarding page layout - Introduced a new CSS class for the AuthPage preview to ensure consistent theming with neutral tokens. - Updated the onboarding page component to improve layout and user experience, including a shared animated stage wrapper and sticky action buttons for better navigation. - Added a new Stripe wordmark component for branding consistency. --- .../new-project/page-client.tsx | 1290 ++++++++--------- apps/dashboard/src/app/globals.css | 56 + .../src/components/stripe-wordmark.tsx | 46 + claude/CLAUDE-KNOWLEDGE.md | 6 + 4 files changed, 742 insertions(+), 656 deletions(-) create mode 100644 apps/dashboard/src/components/stripe-wordmark.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index cbed81e969..d7df159ff0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -1,10 +1,11 @@ 'use client'; import { AppIcon } from "@/components/app-square"; +import { StripeWordmark } from "@/components/stripe-wordmark"; import { DesignAlert } from "@/components/design-components/alert"; import { DesignBadge } from "@/components/design-components/badge"; import { DesignButton } from "@/components/design-components/button"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignCard, DesignPillToggle } from "@/components/design-components"; import { DesignInput } from "@/components/design-components/input"; import { DesignSelectorDropdown } from "@/components/design-components/select"; import { useRouter } from "@/components/router"; @@ -26,11 +27,6 @@ import { DialogHeader, DialogTitle, Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, Spinner, Switch, Tooltip, @@ -44,16 +40,13 @@ import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { - ArrowLeftIcon, ArrowsClockwiseIcon, ChartBarIcon, CheckCircleIcon, - LightningIcon, LinkBreakIcon, PlusCircleIcon, - ShieldIcon, - StripeLogoIcon, - WalletIcon, + ShieldCheckIcon, + SparkleIcon, WarningCircleIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; @@ -207,12 +200,13 @@ function deriveInitialSignInMethods(project: AdminOwnedProject, status: ProjectO methods.add("credential"); methods.add("magicLink"); methods.add("google"); + methods.add("github"); } return methods; } -function deriveInitialApps(config: ReturnType): Set { +function deriveInitialApps(config: ReturnType, status: ProjectOnboardingStatus): Set { const enabledApps = new Set(); for (const appId of ALL_APP_IDS) { @@ -221,7 +215,13 @@ function deriveInitialApps(config: ReturnType): } } - if (enabledApps.size === 0) { + const isInEarlyOnboardingStep = ( + status === "config_choice" + || status === "apps_selection" + || status === "auth_setup" + ); + + if (enabledApps.size === 0 || (isInEarlyOnboardingStep && enabledApps.size <= REQUIRED_APP_IDS.length)) { for (const primaryAppId of PRIMARY_APP_IDS) { enabledApps.add(primaryAppId); } @@ -238,67 +238,120 @@ function getStepIndex(steps: TimelineStep[], stepId: ProjectOnboardingStatus) { return steps.findIndex((step) => step.id === stepId); } -function OnboardingTimeline(props: { +function OnboardingPage(props: { + stepKey: string, + title: string, + subtitle?: string, steps: TimelineStep[], currentStep: ProjectOnboardingStatus, onStepClick?: (step: ProjectOnboardingStatus) => void, disabled?: boolean, + primaryAction: React.ReactNode, + secondaryAction?: React.ReactNode, + wide?: boolean, + actionsLayout?: "stacked" | "inline", + children: React.ReactNode, }) { - const currentIndex = props.steps.findIndex((step) => step.id === props.currentStep); + const currentIndex = props.steps.findIndex((s) => s.id === props.currentStep); return ( -
-
- {props.steps.map((step, index) => { - const isComplete = index < currentIndex; - const isCurrent = index === currentIndex; - const isClickable = isComplete && !props.disabled && props.onStepClick != null; - const circleClassName = isComplete - ? "bg-green-500 text-white" - : isCurrent - ? "bg-blue-600 text-white" - : "bg-muted text-muted-foreground"; - - return ( -
- {isClickable ? ( - - ) : ( -
-
- {isComplete ? : index + 1} -
- {step.label} +
+
+
+ + {props.title} + + {props.subtitle != null && ( + + {props.subtitle} + + )} +
+ +
+ {props.children} +
+ +
+ {props.actionsLayout === "inline" ? ( +
+ {props.primaryAction} + {props.secondaryAction != null && props.secondaryAction} +
+ ) : ( +
+ {props.primaryAction} + {props.secondaryAction != null && ( +
+ {props.secondaryAction}
)} - {index < props.steps.length - 1 &&
}
- ); - })} + )} +
+ +
+
+ {props.steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isClickable = isComplete && !props.disabled && props.onStepClick != null; + return ( +
+
+ +
); } + function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { if (stage === "alpha") { return "orange"; @@ -309,16 +362,6 @@ function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { return null; } -function appStageLabel(stage: (typeof ALL_APPS)[AppId]["stage"]) { - if (stage === "alpha") { - return "Alpha"; - } - if (stage === "beta") { - return "Beta"; - } - return null; -} - function OnboardingAppCard(props: { appId: AppId, selected: boolean, @@ -329,94 +372,49 @@ function OnboardingAppCard(props: { }) { const app = ALL_APPS[props.appId]; const stageBadgeColor = appStageBadgeColor(app.stage); - const stageLabel = appStageLabel(app.stage); return ( -
+
- {app.displayName} - {stageBadgeColor && ( + {app.displayName} + {props.required && ( + + )} + {!props.required && stageBadgeColor != null && ( )}
- + {app.subtitle}
@@ -454,27 +452,19 @@ function OnboardingEmailThemePreview(props: { function ModeNotImplementedCard(props: { onBack: () => void }) { return ( - +
-
- +
+ Go Back
- +
); } @@ -493,17 +483,17 @@ function ProjectOnboardingWizard(props: { const setProjectOnboardingStatus = setStatus; const finishProjectOnboarding = onComplete; const [saving, setSaving] = useState(false); - const [selectedApps, setSelectedApps] = useState>(() => deriveInitialApps(completeConfig)); + const [selectedApps, setSelectedApps] = useState>(() => deriveInitialApps(completeConfig, status)); const [signInMethods, setSignInMethods] = useState>(() => deriveInitialSignInMethods(project, status)); const [trustedDomain, setTrustedDomain] = useState(""); const [domainHandlerPath, setDomainHandlerPath] = useState("/handler"); const [managedSubdomain, setManagedSubdomain] = useState(""); const [managedSenderLocalPart, setManagedSenderLocalPart] = useState(""); const [managedDomainSetupStatus, setManagedDomainSetupStatus] = useState(null); - const [requiredAppsNotice, setRequiredAppsNotice] = useState(null); const [selectedEmailThemeId, setSelectedEmailThemeId] = useState(completeConfig.emails.selectedThemeId); const [selectedPaymentsCountry, setSelectedPaymentsCountry] = useState("US"); const [selectedConfigChoice, setSelectedConfigChoice] = useState<"create-new" | "link-existing">("create-new"); + const [authSetupMobileTab, setAuthSetupMobileTab] = useState<"methods" | "preview">("methods"); const previousProjectId = useRef(null); const runWithSaving = useCallback(async (fn: () => Promise) => { @@ -521,7 +511,7 @@ function ProjectOnboardingWizard(props: { } previousProjectId.current = project.id; - setSelectedApps(deriveInitialApps(completeConfig)); + setSelectedApps(deriveInitialApps(completeConfig, status)); setSignInMethods(deriveInitialSignInMethods(project, status)); const trustedDomains = Object.values(completeConfig.domains.trustedDomains) @@ -545,8 +535,8 @@ function ProjectOnboardingWizard(props: { setManagedSenderLocalPart(serverConfig.managedSenderLocalPart ?? ""); setSelectedEmailThemeId(completeConfig.emails.selectedThemeId); setManagedDomainSetupStatus(null); - setRequiredAppsNotice(null); setSelectedConfigChoice("create-new"); + setAuthSetupMobileTab("methods"); }, [completeConfig, project, project.id, status]); const emailThemes = project.app.useEmailThemes(); @@ -609,14 +599,10 @@ function ProjectOnboardingWizard(props: { setSelectedApps((previous) => { const next = new Set(previous); if (REQUIRED_APP_IDS.includes(appId)) { - if (next.has(appId)) { - setRequiredAppsNotice(`${ALL_APPS[appId].displayName} is required during onboarding and can't be turned off.`); - } next.add(appId); return next; } - setRequiredAppsNotice(null); if (next.has(appId)) { next.delete(appId); } else { @@ -635,22 +621,34 @@ function ProjectOnboardingWizard(props: { if (props.status === "config_choice" && props.mode === "link-existing") { return ( -
- -
- { + { props.setMode(null); setSelectedConfigChoice("create-new"); }} - /> -
-
+ > + Go Back + + } + > + { + props.setMode(null); + setSelectedConfigChoice("create-new"); + }} + /> + ); } @@ -659,104 +657,88 @@ function ProjectOnboardingWizard(props: { const linkExistingSelected = selectedConfigChoice === "link-existing"; return ( -
- - runAsynchronouslyWithAlert(() => runWithSaving(async () => { - if (selectedConfigChoice === "create-new") { - await props.setStatus("apps_selection"); - } else { - props.setMode("link-existing"); - } - }))} - > - Next - - } - > -
- - - + + -
-
-
+ )} +
+ +
+
+ Link Existing Config + Bring an existing config into this project. +
+ +
+ ); } @@ -764,99 +746,103 @@ function ProjectOnboardingWizard(props: { const orderedIds = orderedAppIds(); const primaryAppIds = orderedIds.filter((appId) => PRIMARY_APP_IDS.includes(appId)); const secondaryAppIds = orderedIds.filter((appId) => !PRIMARY_APP_IDS.includes(appId)); + const moreAppsSplitIndex = secondaryAppIds.length >= 10 ? Math.floor(secondaryAppIds.length / 2) : secondaryAppIds.length; + const moreAppsFirstRow = secondaryAppIds.slice(0, moreAppsSplitIndex); + const moreAppsSecondRow = secondaryAppIds.slice(moreAppsSplitIndex); return ( -
- - runAsynchronouslyWithAlert(() => runWithSaving(async () => { - const appConfigUpdateEntries = new Map( - ALL_APP_IDS.map((appId) => [ - `apps.installed.${appId}.enabled`, - selectedApps.has(appId), - ]) - ); - - const configUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: Object.fromEntries(appConfigUpdateEntries), - pushable: true, - }); - if (!configUpdated) { - return; - } - await props.setStatus("auth_setup"); - }))} - > - Next - - } - > - -
- {requiredAppsNotice && ( - - )} - -
- - Select apps - - - Start with the core Stack Auth apps now. You can enable or disable the rest later. - -
- -
-
- -
-
- {primaryAppIds.map((appId) => ( - toggleApp(appId)} - /> - ))} -
+ runAsynchronouslyWithAlert(() => runWithSaving(async () => { + const appConfigUpdateEntries = new Map( + ALL_APP_IDS.map((appId) => [ + `apps.installed.${appId}.enabled`, + selectedApps.has(appId), + ]) + ); + + const configUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: Object.fromEntries(appConfigUpdateEntries), + pushable: true, + }); + if (!configUpdated) { + return; + } + await props.setStatus("auth_setup"); + }))} + > + Continue + + } + > + +
+
+ + Core apps + +
+ {primaryAppIds.map((appId) => ( + toggleApp(appId)} + /> + ))}
+
-
-
- - More apps - +
+ + More apps + + {secondaryAppIds.length >= 10 ? ( +
+
+ {moreAppsFirstRow.map((appId) => ( + toggleApp(appId)} + /> + ))} +
+
+ {moreAppsSecondRow.map((appId) => ( + toggleApp(appId)} + /> + ))} +
-
+ ) : ( +
{secondaryAppIds.map((appId) => ( ))}
-
- - - Core apps are ready by default, and required apps stay enabled through onboarding. - + )}
- - -
+
+ + ); } if (props.status === "auth_setup") { return ( -
- + runAsynchronouslyWithAlert(() => runWithSaving(async () => { + if (signInMethods.size === 0) { + throw new Error("Select at least one sign-in method before continuing."); + } + + const authMethodsUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: { + "auth.password.allowSignIn": signInMethods.has("credential"), + "auth.otp.allowSignIn": signInMethods.has("magicLink"), + "auth.passkey.allowSignIn": signInMethods.has("passkey"), + }, + pushable: true, + }); + + if (!authMethodsUpdated) { + return; + } + + const providersUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: { + "auth.oauth.providers.google": signInMethods.has("google") ? { + type: "google", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + } : null, + "auth.oauth.providers.github": signInMethods.has("github") ? { + type: "github", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + } : null, + "auth.oauth.providers.microsoft": signInMethods.has("microsoft") ? { + type: "microsoft", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + } : null, + }, + pushable: false, + }); + + if (!providersUpdated) { + return; + } + + await props.setStatus("email_theme_setup"); + }))} + > + Continue + + } + > runAsynchronouslyWithAlert(() => runWithSaving(async () => { - if (signInMethods.size === 0) { - throw new Error("Select at least one sign-in method before continuing."); - } - - const authMethodsUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: { - "auth.password.allowSignIn": signInMethods.has("credential"), - "auth.otp.allowSignIn": signInMethods.has("magicLink"), - "auth.passkey.allowSignIn": signInMethods.has("passkey"), - }, - pushable: true, - }); - - if (!authMethodsUpdated) { - return; - } - - const providersUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: { - "auth.oauth.providers.google": signInMethods.has("google") ? { - type: "google", - isShared: true, - allowSignIn: true, - allowConnectedAccounts: true, - } : null, - "auth.oauth.providers.github": signInMethods.has("github") ? { - type: "github", - isShared: true, - allowSignIn: true, - allowConnectedAccounts: true, - } : null, - "auth.oauth.providers.microsoft": signInMethods.has("microsoft") ? { - type: "microsoft", - isShared: true, - allowSignIn: true, - allowConnectedAccounts: true, - } : null, - }, - pushable: false, - }); - - if (!providersUpdated) { - return; - } - - await props.setStatus("email_theme_setup"); - }))} - > - Next - - } + glassmorphic={false} + contentClassName="p-0 overflow-hidden" + className="border-0 bg-white/90 ring-1 ring-black/[0.06] dark:bg-white/[0.06] dark:ring-white/[0.10]" > -
-
-
-
- Sign-in methods - - More sign-in methods are available on the dashboard later. - -
- -
+
+ { setAuthSetupMobileTab(id === "preview" ? "preview" : "methods"); }} + size="sm" + gradient="default" + className="flex w-full max-w-md justify-center" + /> +
+
+
+
+ + Sign-in methods + +
{SIGN_IN_METHODS.map((method, index) => { const checked = signInMethods.has(method.id); return (
-
-
- -
-
-
+
+ +
+
+
+
- -
+
+
-
+ ); } @@ -1018,221 +1018,199 @@ function ProjectOnboardingWizard(props: { if (props.status === "email_theme_setup") { return ( -
- - runAsynchronouslyWithAlert(() => runWithSaving(async () => { - if (selectedEmailThemeId !== completeConfig.emails.selectedThemeId) { - const configUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: { - "emails.selectedThemeId": selectedEmailThemeId, - }, - pushable: true, - }); - if (!configUpdated) { - return; - } - } - - if (includePayments) { - await props.setStatus("payments_setup"); - } else { - await props.setStatus("completed"); - props.onComplete(); + runAsynchronouslyWithAlert(() => runWithSaving(async () => { + if (selectedEmailThemeId !== completeConfig.emails.selectedThemeId) { + const configUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: { + "emails.selectedThemeId": selectedEmailThemeId, + }, + pushable: true, + }); + if (!configUpdated) { + return; } - }))} - > - {includePayments ? "Next" : "Finish"} - - } - > -
- {emailThemes.length === 0 && ( - - )} - -
- {emailThemes.map((theme) => { - const isSelected = selectedEmailThemeId === theme.id; - return ( - - ); - })} -
+
+ + ); + })}
- -
+
+ ); } if (props.status === "payments_setup") { return ( -
- - - {selectedPaymentsCountry !== "US" && ( - setSelectedPaymentsCountry("US")} - > - - Back - - )} - runAsynchronouslyWithAlert(() => runWithSaving(async () => { - await finalizeOnboarding(); - }))} - > - Do This Later - - {selectedPaymentsCountry === "US" && ( - runAsynchronouslyWithAlert(() => runWithSaving(async () => { - const setup = await props.project.app.setupPayments(); - const redirectUrl = new URL(setup.url); - if (redirectUrl.protocol !== "https:") { - throw new Error("Payments setup redirect URL must use HTTPS."); - } - window.location.href = redirectUrl.toString(); - }))} - > - Continue Onboarding - - )} -
- } - > -
-
-
-
- + runAsynchronouslyWithAlert(() => runWithSaving(async () => { + await finalizeOnboarding(); + }))} + > + Do Later + + } + secondaryAction={selectedPaymentsCountry === "US" ? ( + runAsynchronouslyWithAlert(() => runWithSaving(async () => { + const setup = await props.project.app.setupPayments(); + const redirectUrl = new URL(setup.url); + if (redirectUrl.protocol !== "https:") { + throw new Error("Payments setup redirect URL must use HTTPS."); + } + window.location.href = redirectUrl.toString(); + }))} + > + Connect Stripe + + ) : undefined} + > +
+ +
+ + Built-in Billing + + +
+
+ + No webhooks or syncing required
- Setup Payments - - Let your users pay seamlessly and securely. - -
    -
  • - - No webhooks or syncing -
  • -
  • - - One-time and recurring -
  • -
  • - - Usage-based billing -
  • -
-
- - +
+ + One-time and recurring payments
-
- - Powered by Stripe +
+ + Usage-based billing support
-
- {selectedPaymentsCountry === "US" ? ( - - ) : ( - - )} -
- -
+
+ Country of residence + ({ value: c.value, label: c.label }))} + size="md" + /> +
+ + Powered by + +
+ {selectedPaymentsCountry !== "US" && ( + + Payments is currently only available in the United States. + + )} +
+
+ +
+ ); } diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 7d76c55280..86e45593cd 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -130,6 +130,62 @@ --radius: inherit !important; } +/* Embedded AuthPage preview in dashboard: neutral tokens so tabs/inputs match hosted + handler UX. Dashboard :root uses lavender --muted (250°); stack-scope inherits it. */ +.auth-preview-host-theme { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-in-card: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-in-card: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-in-card: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --border-in-card: 240 5.9% 90%; + --input: 240 5.9% 90%; + --input-in-card: 240 5.9% 90%; + --ring: 240 10% 3.9%; +} + +.dark .auth-preview-host-theme { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-in-card: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-in-card: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-in-card: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 50%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 35.9%; + --border-in-card: 240 3.7% 35.9%; + --input: 240 3.7% 25.9%; + --input-in-card: 240 3.7% 25.9%; + --ring: 240 4.9% 83.9%; +} + @layer base { * { @apply border-border; diff --git a/apps/dashboard/src/components/stripe-wordmark.tsx b/apps/dashboard/src/components/stripe-wordmark.tsx new file mode 100644 index 0000000000..fd59a4f313 --- /dev/null +++ b/apps/dashboard/src/components/stripe-wordmark.tsx @@ -0,0 +1,46 @@ +"use client"; + +/** + * Stripe wordmark (paths from Wikimedia Commons: Stripe Logo, revised 2016). + * Fills use currentColor for theme-aware brand tint on parent. + */ +export function StripeWordmark(props: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 96389c4aca..b1793b8cb2 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -153,3 +153,9 @@ A: In `packages/template/src/components-page/stack-handler-client.tsx`, parse ha Q: What is the current `app.urls` contract after deprecating runtime URL mutation? A: `app.urls` is now static (`getUrls(...)` only) and no longer injects runtime `after_auth_return_to` / `stack_cross_domain_*` params from `window.location`. For navigation flows, examples and consumers should use `redirectToXyz()` methods instead (for example `redirectToSignIn()` / `redirectToSignOut()`), while tests for hosted flows should assert dynamic params on actual redirect methods, not on `app.urls`. + +Q: How should the dashboard onboarding pages get a calmer "Linear-like" transition without changing flow logic? +A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`, use a shared animated stage wrapper keyed by onboarding status plus a centered hero/surface pattern for each step. A ~420ms fade-and-drop animation (`opacity` + small negative `translateY`) makes step changes feel deliberate without being sluggish. + +Q: How can onboarding CTA buttons stay visible without leaving bottom-of-page actions on every step? +A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`, move step actions into a shared sticky top header right below the timeline (`OnboardingStickyTop`) and keep the page body focused on the step content. This removes duplicated footer CTAs and prevents scrolling just to reach `Continue` or `Do This Later`. From ac81a109d9eef3024ea2303ef17e245c918e2166 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 14 Apr 2026 12:48:31 -0400 Subject: [PATCH 3/8] Fix pr comments --- .../new-project/page-client.test.tsx | 129 +++++++++ .../new-project/page-client.tsx | 268 +++++++++++------- apps/dashboard/src/app/globals.css | 25 ++ claude/CLAUDE-KNOWLEDGE.md | 4 +- 4 files changed, 328 insertions(+), 98 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx new file mode 100644 index 0000000000..eb6dbd50b7 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import type { ButtonHTMLAttributes } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal(); + + type MockButtonProps = ButtonHTMLAttributes & { + variant?: string, + }; + + return { + ...actual, + Button: ({ children, type, variant: _variant, ...props }: MockButtonProps) => ( + + ), + }; +}); + +import { TooltipProvider } from "@/components/ui"; + +import { + beginPendingAction, + DomainSetupTransitionState, + endPendingAction, + OnboardingAppCard, + OnboardingPage, +} from "./page-client"; + +afterEach(() => { + cleanup(); +}); + +describe("beginPendingAction", () => { + it("blocks duplicate starts until the action finishes", () => { + const pendingRef = { current: false }; + const setPending = vi.fn(); + + expect(beginPendingAction(pendingRef, setPending)).toBe(true); + expect(beginPendingAction(pendingRef, setPending)).toBe(false); + expect(setPending.mock.calls).toEqual([[true]]); + + endPendingAction(pendingRef, setPending); + + expect(pendingRef.current).toBe(false); + expect(setPending.mock.calls).toEqual([[true], [false]]); + }); +}); + +describe("OnboardingPage", () => { + it("uses hover-exit-only transitions and accessible labels for progress dots", () => { + render( + Continue} + > +
Step body
+
, + ); + + const completedStepButton = screen.getByRole("button", { name: "Go to step: Config" }); + const currentStepButton = screen.getByRole("button", { name: "Apps" }); + const className = completedStepButton.getAttribute("class") ?? ""; + + expect(className).toContain("transition-colors"); + expect(className).toContain("hover:transition-none"); + expect(currentStepButton.getAttribute("aria-current")).toBe("step"); + }); +}); + +describe("OnboardingAppCard", () => { + it("marks required cards as non-keyboard-interactive", () => { + const onToggle = vi.fn(); + + render( + + + , + ); + + const button = screen.getByRole("button"); + fireEvent.click(button); + + expect(button.getAttribute("aria-disabled")).toBe("true"); + expect(button.getAttribute("tabindex")).toBe("-1"); + expect(onToggle).not.toHaveBeenCalled(); + }); +}); + +describe("DomainSetupTransitionState", () => { + it("shows a retryable fallback when auto-advance fails", () => { + const onRetry = vi.fn(); + const onOpenProject = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + fireEvent.click(screen.getByRole("button", { name: "Open Project" })); + + expect(screen.getByText("Domain setup transition failed")).toBeTruthy(); + expect(screen.getByText("Network request failed.")).toBeTruthy(); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onOpenProject).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index d7df159ff0..4d8dfafdf3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -165,6 +165,27 @@ function buildTimeline(includePayments: boolean): TimelineStep[] { return timeline; } +export function beginPendingAction( + pendingRef: { current: boolean }, + setPending: (value: boolean) => void, +) { + if (pendingRef.current) { + return false; + } + + pendingRef.current = true; + setPending(true); + return true; +} + +export function endPendingAction( + pendingRef: { current: boolean }, + setPending: (value: boolean) => void, +) { + pendingRef.current = false; + setPending(false); +} + function deriveInitialSignInMethods(project: AdminOwnedProject, status: ProjectOnboardingStatus): Set { const config = project.config; const methods = new Set(); @@ -238,7 +259,7 @@ function getStepIndex(steps: TimelineStep[], stepId: ProjectOnboardingStatus) { return steps.findIndex((step) => step.id === stepId); } -function OnboardingPage(props: { +export function OnboardingPage(props: { stepKey: string, title: string, subtitle?: string, @@ -309,8 +330,10 @@ function OnboardingPage(props: { type="button" disabled={!isClickable} onClick={() => { if (isClickable) props.onStepClick?.(step.id); }} + aria-label={isClickable ? `Go to step: ${step.label}` : step.label} + aria-current={isCurrent ? "step" : undefined} className={cn( - "rounded-full transition-all duration-300", + "rounded-full transition-colors duration-300 hover:transition-none", isCurrent ? "h-[6px] w-5 bg-foreground" : isComplete @@ -323,30 +346,6 @@ function OnboardingPage(props: { })}
- -
); } @@ -362,7 +361,7 @@ function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { return null; } -function OnboardingAppCard(props: { +export function OnboardingAppCard(props: { appId: AppId, selected: boolean, required: boolean, @@ -380,6 +379,8 @@ function OnboardingAppCard(props: { type="button" onClick={props.required ? undefined : props.onToggle} disabled={props.disabled} + aria-disabled={props.required ? true : undefined} + tabIndex={props.required ? -1 : undefined} className={cn( "group flex flex-col items-center gap-1.5 rounded-xl p-1 transition-opacity duration-150 hover:transition-none", props.primary ? "w-[100px]" : "w-[90px]", @@ -431,6 +432,49 @@ function OnboardingAppCard(props: { ); } +export function DomainSetupTransitionState(props: { + advancing: boolean, + errorMessage: string | null, + onRetry: () => void, + onOpenProject: () => void, +}) { + if (props.errorMessage == null) { + return ( +
+ +
+ ); + } + + return ( +
+ + + We couldn't continue onboarding + + Retry the automatic transition to email setup, or open the project and continue from there. + + + + + + Domain setup transition failed + {props.errorMessage} + +
+ + +
+
+
+
+ ); +} + function OnboardingEmailThemePreview(props: { adminApp: AdminOwnedProject["app"], themeId: string, @@ -468,7 +512,7 @@ function ModeNotImplementedCard(props: { onBack: () => void }) { ); } -function ProjectOnboardingWizard(props: { +export function ProjectOnboardingWizard(props: { project: AdminOwnedProject, status: ProjectOnboardingStatus, mode: string | null, @@ -494,6 +538,8 @@ function ProjectOnboardingWizard(props: { const [selectedPaymentsCountry, setSelectedPaymentsCountry] = useState("US"); const [selectedConfigChoice, setSelectedConfigChoice] = useState<"create-new" | "link-existing">("create-new"); const [authSetupMobileTab, setAuthSetupMobileTab] = useState<"methods" | "preview">("methods"); + const [domainSetupAutoAdvanceError, setDomainSetupAutoAdvanceError] = useState(null); + const [domainSetupAutoAdvancing, setDomainSetupAutoAdvancing] = useState(false); const previousProjectId = useRef(null); const runWithSaving = useCallback(async (fn: () => Promise) => { @@ -537,6 +583,8 @@ function ProjectOnboardingWizard(props: { setManagedDomainSetupStatus(null); setSelectedConfigChoice("create-new"); setAuthSetupMobileTab("methods"); + setDomainSetupAutoAdvanceError(null); + setDomainSetupAutoAdvancing(false); }, [completeConfig, project, project.id, status]); const emailThemes = project.app.useEmailThemes(); @@ -558,15 +606,28 @@ function ProjectOnboardingWizard(props: { }); }, [currentTimelineIndex, setMode, setStatus, timelineSteps]); + const advanceFromDomainSetup = useCallback(() => { + return runAsynchronouslyWithAlert(async () => { + setDomainSetupAutoAdvanceError(null); + setDomainSetupAutoAdvancing(true); + try { + await setStatus("email_theme_setup"); + } catch (error) { + setDomainSetupAutoAdvanceError(error instanceof Error ? error.message : "Failed to continue to the email theme step."); + throw error; + } finally { + setDomainSetupAutoAdvancing(false); + } + }); + }, [setStatus]); + useEffect(() => { if (status !== "domain_setup") { return; } - runAsynchronouslyWithAlert(async () => { - await setStatus("email_theme_setup"); - }); - }, [setStatus, status]); + advanceFromDomainSetup(); + }, [advanceFromDomainSetup, status]); const authPreviewProject = useMemo(() => { return { @@ -1010,9 +1071,12 @@ function ProjectOnboardingWizard(props: { if (props.status === "domain_setup") { return ( -
- -
+ router.push(`/projects/${encodeURIComponent(project.id)}`)} + /> ); } @@ -1256,6 +1320,8 @@ export default function PageClient() { const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(true); const [isCreateTeamOpen, setIsCreateTeamOpen] = useState(false); const [newTeamName, setNewTeamName] = useState(""); + const creatingTeamRef = useRef(false); + const creatingProjectRef = useRef(false); useEffect(() => { if (selectedTeamId != null) { @@ -1515,56 +1581,61 @@ export default function PageClient() { runAsynchronouslyWithAlert(async () => { - const trimmedProjectName = projectName.trim(); - if (trimmedProjectName.length === 0) { - throw new Error("Project name is required."); - } - - const firstTeam = teams.at(0); - const teamId = selectedTeamId ?? user.selectedTeam?.id ?? firstTeam?.id; - if (teamId === undefined) { - throw new Error("Select a team before creating the project."); + onClick={() => { + if (!beginPendingAction(creatingProjectRef, setCreatingProject)) { + return; } - setCreatingProject(true); - try { - const newProject = await user.createProject({ - displayName: trimmedProjectName, - teamId, - onboardingStatus: "config_choice", - }); - - setProjectStatuses((previous) => { - const next = new Map(previous); - next.set(newProject.id, "config_choice"); - return next; - }); - - if (redirectToNeonConfirmWith != null) { - const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith); - confirmSearchParams.set("default_selected_project_id", newProject.id); - router.push(`/integrations/neon/confirm?${confirmSearchParams.toString()}`); - await wait(2000); - return; + return runAsynchronouslyWithAlert(async () => { + const trimmedProjectName = projectName.trim(); + if (trimmedProjectName.length === 0) { + throw new Error("Project name is required."); } - if (redirectToConfirmWith != null) { - const confirmSearchParams = new URLSearchParams(redirectToConfirmWith); - confirmSearchParams.set("default_selected_project_id", newProject.id); - router.push(`/integrations/custom/confirm?${confirmSearchParams.toString()}`); - await wait(2000); - return; + const firstTeam = teams.at(0); + const teamId = selectedTeamId ?? user.selectedTeam?.id ?? firstTeam?.id; + if (teamId === undefined) { + throw new Error("Select a team before creating the project."); } - updateSearchParams({ - project_id: newProject.id, - mode: null, - }); - } finally { - setCreatingProject(false); - } - })} + try { + const newProject = await user.createProject({ + displayName: trimmedProjectName, + teamId, + onboardingStatus: "config_choice", + }); + + setProjectStatuses((previous) => { + const next = new Map(previous); + next.set(newProject.id, "config_choice"); + return next; + }); + + if (redirectToNeonConfirmWith != null) { + const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith); + confirmSearchParams.set("default_selected_project_id", newProject.id); + router.push(`/integrations/neon/confirm?${confirmSearchParams.toString()}`); + await wait(2000); + return; + } + + if (redirectToConfirmWith != null) { + const confirmSearchParams = new URLSearchParams(redirectToConfirmWith); + confirmSearchParams.set("default_selected_project_id", newProject.id); + router.push(`/integrations/custom/confirm?${confirmSearchParams.toString()}`); + await wait(2000); + return; + } + + updateSearchParams({ + project_id: newProject.id, + mode: null, + }); + } finally { + endPendingAction(creatingProjectRef, setCreatingProject); + } + }); + }} > Create Project @@ -1611,25 +1682,30 @@ export default function PageClient() { runAsynchronouslyWithAlert(async () => { - const trimmedTeamName = newTeamName.trim(); - if (trimmedTeamName.length === 0) { - throw new Error("Team name is required."); + onClick={() => { + if (!beginPendingAction(creatingTeamRef, setCreatingTeam)) { + return; } - setCreatingTeam(true); - try { - const createdTeam = await user.createTeam({ - displayName: trimmedTeamName, - }); - await user.setSelectedTeam(createdTeam.id); - setSelectedTeamId(createdTeam.id); - setNewTeamName(""); - setIsCreateTeamOpen(false); - } finally { - setCreatingTeam(false); - } - })} + return runAsynchronouslyWithAlert(async () => { + const trimmedTeamName = newTeamName.trim(); + if (trimmedTeamName.length === 0) { + throw new Error("Team name is required."); + } + + try { + const createdTeam = await user.createTeam({ + displayName: trimmedTeamName, + }); + await user.setSelectedTeam(createdTeam.id); + setSelectedTeamId(createdTeam.id); + setNewTeamName(""); + setIsCreateTeamOpen(false); + } finally { + endPendingAction(creatingTeamRef, setCreatingTeam); + } + }); + }} > Create Team diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 86e45593cd..96b9cc3c27 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -186,6 +186,31 @@ --ring: 240 4.9% 83.9%; } +@keyframes onboarding-cascade-in { + 0% { + opacity: 0; + transform: translateY(18px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.onboarding-cascade { + opacity: 0; + animation: onboarding-cascade-in 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation-delay: calc(var(--cascade-i, 0) * 80ms + 60ms); +} + +@media (prefers-reduced-motion: reduce) { + .onboarding-cascade { + animation: none; + opacity: 1; + } +} + @layer base { * { @apply border-border; diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index f95ffe06f2..010724296e 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -159,10 +159,10 @@ Q: What is the current `app.urls` contract after deprecating runtime URL mutatio A: `app.urls` is now static (`getUrls(...)` only) and no longer injects runtime `after_auth_return_to` / `stack_cross_domain_*` params from `window.location`. For navigation flows, examples and consumers should use `redirectToXyz()` methods instead (for example `redirectToSignIn()` / `redirectToSignOut()`), while tests for hosted flows should assert dynamic params on actual redirect methods, not on `app.urls`. Q: How should the dashboard onboarding pages get a calmer "Linear-like" transition without changing flow logic? -A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`, use a shared animated stage wrapper keyed by onboarding status plus a centered hero/surface pattern for each step. A ~420ms fade-and-drop animation (`opacity` + small negative `translateY`) makes step changes feel deliberate without being sluggish. +A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`, use a shared animated stage wrapper keyed by onboarding status plus a centered hero/surface pattern for each step. The current transition is a 500ms fade-and-drop animation (`opacity` + small negative `translateY`), which keeps step changes feeling deliberate without changing the flow logic. Q: How can onboarding CTA buttons stay visible without leaving bottom-of-page actions on every step? -A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`, move step actions into a shared sticky top header right below the timeline (`OnboardingStickyTop`) and keep the page body focused on the step content. This removes duplicated footer CTAs and prevents scrolling just to reach `Continue` or `Do This Later`. +A: In the current onboarding implementation, step actions are rendered by the shared `OnboardingPage` layout rather than a dedicated `OnboardingStickyTop` component in `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx`. Keep the page body focused on step content and rely on that shared layout for visible `Continue` / `Do This Later` actions instead of adding duplicated footer CTAs. Q: How should user signup time be exposed in JWT claims before production rollout? A: Use `signed_up_at` (OIDC-style naming) in access tokens and encode it as Unix seconds in `apps/backend/src/lib/tokens.tsx` (`Math.floor(user.signed_up_at_millis / 1000)`). Since this is pre-prod, the payload schema can require `signed_up_at` directly without a backward-compat optional shim. From 0acfc66eabf8e66e88e04de32a3d3b660d090a8c Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 14 Apr 2026 14:08:03 -0400 Subject: [PATCH 4/8] Refactor project onboarding components and logic - Split the `page-client.tsx` into multiple files for better organization, including `content.tsx`, `components.tsx`, `project-onboarding-wizard.tsx`, and `shared.ts`. - Introduced a new `OnboardingPage` component to manage the onboarding steps and UI. - Enhanced the `ProjectOnboardingWizard` to handle project setup more effectively. - Updated shared utilities for onboarding processes, including sign-in methods and app management. - Improved code readability and maintainability by modularizing the onboarding logic. --- .../page-client-parts/components.tsx | 290 +++ .../new-project/page-client-parts/content.tsx | 478 +++++ .../project-onboarding-wizard.tsx | 841 ++++++++ .../new-project/page-client-parts/shared.ts | 203 ++ .../new-project/page-client.tsx | 1742 +---------------- claude/CLAUDE-KNOWLEDGE.md | 3 + .../apps/implementations/client-app-impl.ts | 6 +- 7 files changed, 1831 insertions(+), 1732 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/shared.ts diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx new file mode 100644 index 0000000000..0c8956a897 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx @@ -0,0 +1,290 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; + +import { AppIcon } from "@/components/app-square"; +import { DesignAlert } from "@/components/design-components/alert"; +import { DesignBadge } from "@/components/design-components/badge"; +import { DesignButton } from "@/components/design-components/button"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Spinner, + Tooltip, + TooltipContent, + TooltipTrigger, + Typography, + cn, +} from "@/components/ui"; +import { CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { AdminOwnedProject } from "@stackframe/stack"; +import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; + +import type { TimelineStep } from "./shared"; + +export type OnboardingPageProps = { + stepKey: string, + title: string, + subtitle?: string, + steps: TimelineStep[], + currentStep: TimelineStep["id"], + onStepClick?: (step: TimelineStep["id"]) => void, + disabled?: boolean, + primaryAction: ReactNode, + secondaryAction?: ReactNode, + wide?: boolean, + actionsLayout?: "stacked" | "inline", + children: ReactNode, +}; + +export function OnboardingPage(props: OnboardingPageProps) { + const currentIndex = props.steps.findIndex((step) => step.id === props.currentStep); + + return ( +
+
+
+ + {props.title} + + {props.subtitle != null && ( + + {props.subtitle} + + )} +
+ +
+ {props.children} +
+ +
+ {props.actionsLayout === "inline" ? ( +
+ {props.primaryAction} + {props.secondaryAction != null && props.secondaryAction} +
+ ) : ( +
+ {props.primaryAction} + {props.secondaryAction != null && ( +
+ {props.secondaryAction} +
+ )} +
+ )} +
+
+ +
+
+ {props.steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isClickable = isComplete && !props.disabled && props.onStepClick != null; + + return ( +
+
+
+ ); +} + +function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { + if (stage === "alpha") { + return "orange"; + } + if (stage === "beta") { + return "blue"; + } + return null; +} + +export type OnboardingAppCardProps = { + appId: AppId, + selected: boolean, + required: boolean, + primary: boolean, + disabled?: boolean, + onToggle: () => void, +}; + +export function OnboardingAppCard(props: OnboardingAppCardProps) { + const app = ALL_APPS[props.appId]; + const stageBadgeColor = appStageBadgeColor(app.stage); + + return ( + + + + + +
+
+ {app.displayName} + {props.required && ( + + )} + {!props.required && stageBadgeColor != null && ( + + )} +
+ + {app.subtitle} + +
+
+
+ ); +} + +export type DomainSetupTransitionStateProps = { + advancing: boolean, + errorMessage: string | null, + onRetry: () => void, + onOpenProject: () => void, +}; + +export function DomainSetupTransitionState(props: DomainSetupTransitionStateProps) { + if (props.errorMessage == null) { + return ( +
+ +
+ ); + } + + return ( +
+ + + We couldn't continue onboarding + + Retry the automatic transition to email setup, or open the project and continue from there. + + + + + + Domain setup transition failed + {props.errorMessage} + +
+ + +
+
+
+
+ ); +} + +export function OnboardingEmailThemePreview(props: { + adminApp: AdminOwnedProject["app"], + themeId: string, +}) { + const previewHtml = props.adminApp.useEmailPreview({ + themeId: props.themeId, + templateTsxSource: previewTemplateSource, + }); + + return ( +