diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx
index 5d23305a64..10d273fcbf 100644
--- a/apps/mesh/src/web/index.tsx
+++ b/apps/mesh/src/web/index.tsx
@@ -154,7 +154,12 @@ const homeRoute = createRoute({
const onboardingRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/onboarding",
- beforeLoad: async () => {
+ validateSearch: z.object({
+ email: z.string().optional(),
+ }),
+ beforeLoad: async ({ search }) => {
+ // Skip guard when testing with ?email= override
+ if (search.email) return;
const { data: orgs } = await authClient.organization.list();
if (orgs && orgs.length > 0) {
throw redirect({ to: "/" });
diff --git a/apps/mesh/src/web/routes/onboarding.tsx b/apps/mesh/src/web/routes/onboarding.tsx
index 1949993c44..abd83b2ee4 100644
--- a/apps/mesh/src/web/routes/onboarding.tsx
+++ b/apps/mesh/src/web/routes/onboarding.tsx
@@ -1,22 +1,47 @@
import RequiredAuthLayout from "@/web/layouts/required-auth-layout";
import { authClient } from "@/web/lib/auth-client";
-import { KEYS } from "@/web/lib/query-keys";
import { Button } from "@deco/ui/components/button.tsx";
import { Input } from "@deco/ui/components/input.tsx";
-import { Label } from "@deco/ui/components/label.tsx";
+import { Textarea } from "@deco/ui/components/textarea.tsx";
import { cn } from "@deco/ui/lib/utils.ts";
import {
+ ArrowRight,
Building02,
+ Check,
CheckCircle,
+ Edit03,
+ Globe02,
Globe04,
+ LinkExternal01,
Loading01,
Palette,
+ Plus,
Users03,
+ X,
} from "@untitledui/icons";
-import { useMutation, useQuery } from "@tanstack/react-query";
-import { useRef, useState } from "react";
+import { useSearch } from "@tanstack/react-router";
+import { useState, useRef } from "react";
-const GENERIC_EMAIL_DOMAINS = new Set([
+const DEV_MODE = import.meta.env.DEV;
+
+// --- Types ---
+
+type StepStatus = "done" | "active" | "pending";
+
+type BrandData = {
+ name: string;
+ domain: string;
+ overview: string;
+ logo?: string | null;
+ favicon?: string | null;
+ ogImage?: string | null;
+ fonts?: { name: string; role: string }[] | null;
+ colors?: { label: string; value: string }[] | null;
+};
+
+// --- Data ---
+
+const GENERIC_DOMAINS = new Set([
"gmail.com",
"googlemail.com",
"outlook.com",
@@ -39,6 +64,42 @@ const GENERIC_EMAIL_DOMAINS = new Set([
"fastmail.com",
]);
+const SETUP_STEPS = [
+ { icon: Building02, label: "Creating organization", delay: 0 },
+ { icon: Globe04, label: "Claiming email domain", delay: 1500 },
+ { icon: Users03, label: "Enabling auto-join for your team", delay: 3000 },
+ { icon: Palette, label: "Extracting brand context", delay: 4500 },
+];
+
+const GRID_CELLS = [
+ { delay: 0 },
+ { delay: 100 },
+ { delay: 200 },
+ { delay: 100 },
+ { delay: 200 },
+ { delay: 200 },
+ { delay: 300 },
+ { delay: 300 },
+ { delay: 400 },
+];
+
+// --- Helpers ---
+
+function isGenericDomain(email: string): boolean {
+ const domain = email.split("@")[1]?.toLowerCase();
+ return GENERIC_DOMAINS.has(domain ?? "");
+}
+
+function getDomain(email: string): string {
+ return email.split("@")[1]?.toLowerCase() ?? "";
+}
+
+function getDomainLabel(email: string): string {
+ const domain = getDomain(email);
+ const name = domain.split(".")[0] ?? "";
+ return name.charAt(0).toUpperCase() + name.slice(1);
+}
+
function slugify(input: string): string {
return input
.toLowerCase()
@@ -48,373 +109,1179 @@ function slugify(input: string): string {
.replace(/^-+|-+$/g, "");
}
-interface DomainLookupResult {
- found: boolean;
- autoJoinEnabled?: boolean;
- organization?: { name: string; slug: string } | null;
-}
+// --- Grid loader ---
-interface DomainSetupResult {
- success: boolean;
- slug?: string;
- brandExtracted?: boolean;
- alreadyExists?: boolean;
- error?: string;
-}
+function GridLoader() {
+ const [cellColors] = useState(() => {
+ const chart = `var(--chart-${Math.ceil(Math.random() * 5)})`;
+ return GRID_CELLS.map(() =>
+ Math.random() < 0.6
+ ? "color-mix(in srgb, var(--muted-foreground) 25%, transparent)"
+ : chart,
+ );
+ });
-export default function OnboardingRoute() {
return (
-
-
-
+
+ {GRID_CELLS.map(({ delay }, i) => (
+
+ ))}
+
);
}
-function OnboardingPage() {
- const { data: session, isPending: sessionLoading } = authClient.useSession();
- const [orgName, setOrgName] = useState("");
-
- const userEmail = session?.user?.email ?? "";
- const emailDomain = userEmail.split("@")[1]?.toLowerCase() ?? "";
- const isCorporateEmail =
- emailDomain && !GENERIC_EMAIL_DOMAINS.has(emailDomain);
- const domainLabel =
- emailDomain.split(".")[0]?.charAt(0).toUpperCase() +
- (emailDomain.split(".")[0]?.slice(1) ?? "");
-
- const { data: domainLookup, isLoading: domainLoading } =
- useQuery({
- queryKey: KEYS.domainLookup(emailDomain),
- queryFn: async () => {
- const res = await fetch("/api/auth/custom/domain-lookup", {
- credentials: "include",
- });
- return res.json();
- },
- enabled: !!isCorporateEmail,
- });
+// --- Product preview (right panel placeholder) ---
- const joinOrgMutation = useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/auth/custom/domain-join", {
- method: "POST",
- credentials: "include",
- });
- const data = await res.json();
- if (!res.ok || !data.success) {
- throw new Error(data.error || "Failed to join organization");
- }
- window.location.href = `/${data.slug}`;
- },
- });
+function ProductPreview() {
+ return (
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+
+
+ );
+}
- const domainSetupMutation = useMutation({
- mutationFn: async (): Promise => {
- const res = await fetch("/api/auth/custom/domain-setup", {
- method: "POST",
- credentials: "include",
- });
- const data: DomainSetupResult = await res.json();
- if (!res.ok || !data.success) {
- throw new Error(data.error || "Failed to set up organization");
- }
- return data;
- },
- onSuccess: (data) => {
- if (data.slug) {
- window.location.href = `/${data.slug}`;
- }
- },
- });
+// --- Step row ---
- const createOrgMutation = useMutation({
- mutationFn: async (name: string) => {
- const slug = slugify(name);
- if (!slug) throw new Error("Invalid organization name");
+function StepRow({
+ step,
+ status,
+}: {
+ step: (typeof SETUP_STEPS)[number];
+ status: StepStatus;
+}) {
+ const Icon = step.icon;
- const result = await authClient.organization.create({ name, slug });
- if (result?.error) {
- throw new Error(
- result.error.message || "Failed to create organization",
- );
- }
- window.location.href = `/${result?.data?.slug ?? slug}`;
- },
- });
+ return (
+
+
+ {status === "done" ? (
+
+ ) : status === "active" ? (
+
+ ) : (
+
+ )}
+
+
+ {step.label}
+
+
+ );
+}
- if (sessionLoading) {
- return (
-
-
+// ============================================================================
+// Brand context cards — identical to settings (org-brand-context.tsx)
+// ============================================================================
+
+function BrandCard({
+ title,
+ children,
+ onEdit,
+ editing,
+ onSave,
+ onCancel,
+ className,
+}: {
+ title: string;
+ children: React.ReactNode;
+ onEdit?: () => void;
+ editing?: boolean;
+ onSave?: () => void;
+ onCancel?: () => void;
+ className?: string;
+}) {
+ return (
+
+
+
+ {title}
+
+ {editing ? (
+
+
+
+
+ ) : (
+ onEdit && (
+
+ )
+ )}
- );
- }
+ {children}
+
+ );
+}
- const hasMatchingOrg = domainLookup?.found && domainLookup?.organization;
- const canAutoJoin = hasMatchingOrg && domainLookup?.autoJoinEnabled;
+function OverviewSection({
+ brand,
+ onSave,
+}: {
+ brand: Partial
;
+ onSave: (data: Partial) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [name, setName] = useState(brand.name ?? "");
+ const [domain, setDomain] = useState(brand.domain ?? "");
+ const [overview, setOverview] = useState(brand.overview ?? "");
- // Animated workflow during domain setup
- if (domainSetupMutation.isPending) {
- return ;
- }
+ const startEdit = () => {
+ setName(brand.name ?? "");
+ setDomain(brand.domain ?? "");
+ setOverview(brand.overview ?? "");
+ setEditing(true);
+ };
- return (
-
-
-
-
Get started
-
- Create or join an organization to start using Studio.
-
-
+ const save = () => {
+ onSave({ name, domain, overview });
+ setEditing(false);
+ };
+
+ const isEmpty = !brand.name && !brand.domain && !brand.overview;
- {domainLoading ? (
-
-
setEditing(false)}
+ >
+ {editing ? (
+
+
+
+
+
- ) : canAutoJoin ? (
-
-
-
- {domainLookup.organization!.name}
-
-
- Your email matches this organization. Join to get started.
-
-
-
- {joinOrgMutation.error && (
-
- {joinOrgMutation.error instanceof Error
- ? joinOrgMutation.error.message
- : "Failed to join organization"}
-
+
+ ) : isEmpty ? (
+
+ No company info yet. Click edit to add your company name, domain, and
+ overview.
+
+ ) : (
+ <>
+
- ) : hasMatchingOrg ? (
-
-
- {domainLookup.organization!.name}
+ {brand.overview && (
+
+ {brand.overview}
-
- Your email domain matches this organization, but auto-join is not
- enabled. Contact an admin for an invitation.
-
-
- ) : isCorporateEmail ? (
-
-
-
Set up {domainLabel}
-
- Create your organization and claim {emailDomain}. Team members
- with matching emails will be able to join automatically.
-
-
-
- {domainSetupMutation.error && (
-
- {domainSetupMutation.error instanceof Error
- ? domainSetupMutation.error.message
- : "Failed to set up organization"}
-
- )}
+ )}
+ >
+ )}
+
+ );
+}
+
+function LogosSection({
+ brand,
+ onSave,
+}: {
+ brand: Partial
;
+ onSave: (data: Partial) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [logo, setLogo] = useState(brand.logo ?? "");
+ const [favicon, setFavicon] = useState(brand.favicon ?? "");
+ const [ogImage, setOgImage] = useState(brand.ogImage ?? "");
+
+ const startEdit = () => {
+ setLogo(brand.logo ?? "");
+ setFavicon(brand.favicon ?? "");
+ setOgImage(brand.ogImage ?? "");
+ setEditing(true);
+ };
+
+ const save = () => {
+ onSave({
+ logo: logo || null,
+ favicon: favicon || null,
+ ogImage: ogImage || null,
+ });
+ setEditing(false);
+ };
+
+ const hasLogos = brand.logo || brand.favicon || brand.ogImage;
+
+ return (
+ setEditing(false)}
+ >
+ {editing ? (
+
+
+
+ setLogo(e.target.value)}
+ placeholder="https://..."
+ />
- ) : null}
-
- {/* Separator when there's a corporate option above */}
- {isCorporateEmail && (
-
-
-
or
-
+
+
+ setFavicon(e.target.value)}
+ placeholder="https://..."
+ />
- )}
-
- {/* Manual creation */}
-