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 ? ( +
+
+
+ + setName(e.target.value)} + placeholder="Acme Corp" + /> +
+
+ + setDomain(e.target.value)} + placeholder="acme.com" + /> +
+
+
+ +