From 9545c82808f105b7039547d3744791c23d6f7d1b Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 1 Apr 2026 09:42:57 -0300 Subject: [PATCH 1/2] feat(onboarding): add brand context storage, settings UI, and onboarding route - Add brand_context column to organization_settings (migration 063) - Implement OrganizationSettingsStorage get/upsert with brand context - Add ORGANIZATION_SETTINGS_GET/UPDATE tools with brand context support - Add Brand Context settings page under Settings > Context - Add dynamic buildBrandContextPrompt() for decopilot prompt injection - Add onboarding route with email detection and org creation flow Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/web/routes/onboarding.tsx | 145 +++++++++++++----------- 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/apps/mesh/src/web/routes/onboarding.tsx b/apps/mesh/src/web/routes/onboarding.tsx index 1949993c44..3b18f3cef1 100644 --- a/apps/mesh/src/web/routes/onboarding.tsx +++ b/apps/mesh/src/web/routes/onboarding.tsx @@ -3,9 +3,9 @@ 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 { cn } from "@deco/ui/lib/utils.ts"; import { + ArrowRight, Building02, CheckCircle, Globe04, @@ -16,6 +16,8 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; +// --- Constants --- + const GENERIC_EMAIL_DOMAINS = new Set([ "gmail.com", "googlemail.com", @@ -39,6 +41,15 @@ const GENERIC_EMAIL_DOMAINS = new Set([ "fastmail.com", ]); +const BRAND_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 }, +]; + +// --- Helpers --- + function slugify(input: string): string { return input .toLowerCase() @@ -48,6 +59,8 @@ function slugify(input: string): string { .replace(/^-+|-+$/g, ""); } +// --- Types --- + interface DomainLookupResult { found: boolean; autoJoinEnabled?: boolean; @@ -62,6 +75,8 @@ interface DomainSetupResult { error?: string; } +// --- Entry --- + export default function OnboardingRoute() { return ( @@ -70,6 +85,8 @@ export default function OnboardingRoute() { ); } +// --- Main page --- + function OnboardingPage() { const { data: session, isPending: sessionLoading } = authClient.useSession(); const [orgName, setOrgName] = useState(""); @@ -131,7 +148,6 @@ function OnboardingPage() { mutationFn: async (name: string) => { const slug = slugify(name); if (!slug) throw new Error("Invalid organization name"); - const result = await authClient.organization.create({ name, slug }); if (result?.error) { throw new Error( @@ -144,7 +160,7 @@ function OnboardingPage() { if (sessionLoading) { return ( -
+
); @@ -159,11 +175,13 @@ function OnboardingPage() { } return ( -
-
-
-

Get started

-

+

+
+
+

+ Get started +

+

Create or join an organization to start using Studio.

@@ -179,12 +197,12 @@ function OnboardingPage() {
) : canAutoJoin ? ( -
-
-

+

+
+

{domainLookup.organization!.name}

-

+

Your email matches this organization. Join to get started.

@@ -198,7 +216,10 @@ function OnboardingPage() { Joining... ) : ( - `Join ${domainLookup.organization!.name}` + <> + Join {domainLookup.organization!.name} + + )} {joinOrgMutation.error && ( @@ -210,20 +231,22 @@ function OnboardingPage() { )}
) : hasMatchingOrg ? ( -
-

+

+

{domainLookup.organization!.name}

-

+

Your email domain matches this organization, but auto-join is not enabled. Contact an admin for an invitation.

) : isCorporateEmail ? ( -
-
-

Set up {domainLabel}

-

+

+
+

+ Set up {domainLabel} +

+

Create your organization and claim {emailDomain}. Team members with matching emails will be able to join automatically.

@@ -233,6 +256,7 @@ function OnboardingPage() { onClick={() => domainSetupMutation.mutate()} > Set up {domainLabel} + {domainSetupMutation.error && (

@@ -253,9 +277,9 @@ function OnboardingPage() {

)} - {/* Manual creation */} + {/* Manual creation — generic email flow */}
{ e.preventDefault(); if (orgName.trim()) { @@ -263,13 +287,21 @@ function OnboardingPage() { } }} > -
- + {!isCorporateEmail && ( +
+

+ What's your company? +

+

+ We couldn't detect your company from your email. Tell us your + organization name and we'll set things up. +

+
+ )} + +
setOrgName(e.target.value)} disabled={createOrgMutation.isPending} @@ -291,7 +323,7 @@ function OnboardingPage() { @@ -314,32 +349,9 @@ function OnboardingPage() { } // ============================================================================ -// Setup Workflow — animated step progression +// Setup Workflow — animated step progression with brand context preview // ============================================================================ -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, - }, -]; - function SetupWorkflow({ domainLabel, domain, @@ -350,28 +362,27 @@ function SetupWorkflow({ const [activeStep, setActiveStep] = useState(0); const didSchedule = useRef(false); - // Schedule step progression once — useRef guard prevents double-fire - // in Strict Mode. Timers are short-lived and the component only unmounts - // on redirect, so cleanup is not critical. if (!didSchedule.current) { didSchedule.current = true; - for (let i = 1; i < SETUP_STEPS.length; i++) { - setTimeout(() => setActiveStep(i), SETUP_STEPS[i]!.delay); + for (let i = 1; i < BRAND_STEPS.length; i++) { + setTimeout(() => setActiveStep(i), BRAND_STEPS[i]!.delay); } } return ( -
-
-
-

Setting up {domainLabel}

-

+

+
+
+

+ Setting up {domainLabel} +

+

Getting everything ready from {domain}

- {SETUP_STEPS.map((step, i) => { + {BRAND_STEPS.map((step, i) => { const Icon = step.icon; const isActive = i === activeStep; const isDone = i < activeStep; @@ -385,7 +396,7 @@ function SetupWorkflow({ isPending ? "opacity-30" : "opacity-100", )} > -
+
{isDone ? ( From a4a6eaef1deb846aefd25a9f602763e92759bdd1 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 16:04:11 -0300 Subject: [PATCH 2/2] onboarding ui update --- apps/mesh/src/web/index.tsx | 7 +- apps/mesh/src/web/routes/onboarding.tsx | 1488 ++++++++++++++++++----- 2 files changed, 1178 insertions(+), 317 deletions(-) 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 3b18f3cef1..abd83b2ee4 100644 --- a/apps/mesh/src/web/routes/onboarding.tsx +++ b/apps/mesh/src/web/routes/onboarding.tsx @@ -1,24 +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 { 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"; -// --- Constants --- +const DEV_MODE = import.meta.env.DEV; -const GENERIC_EMAIL_DOMAINS = new Set([ +// --- 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", @@ -41,15 +64,42 @@ const GENERIC_EMAIL_DOMAINS = new Set([ "fastmail.com", ]); -const BRAND_STEPS = [ +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() @@ -59,25 +109,844 @@ function slugify(input: string): string { .replace(/^-+|-+$/g, ""); } -// --- Types --- +// --- Grid loader --- + +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, + ); + }); + + return ( +
+ {GRID_CELLS.map(({ delay }, i) => ( +
+ ))} +
+ ); +} + +// --- Product preview (right panel placeholder) --- + +function ProductPreview() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +// --- Step row --- + +function StepRow({ + step, + status, +}: { + step: (typeof SETUP_STEPS)[number]; + status: StepStatus; +}) { + const Icon = step.icon; + + return ( +
+
+ {status === "done" ? ( + + ) : status === "active" ? ( + + ) : ( + + )} +
+ + {step.label} + +
+ ); +} + +// ============================================================================ +// 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} +
+ ); +} + +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 ?? ""); + + const startEdit = () => { + setName(brand.name ?? ""); + setDomain(brand.domain ?? ""); + setOverview(brand.overview ?? ""); + setEditing(true); + }; + + const save = () => { + onSave({ name, domain, overview }); + setEditing(false); + }; + + const isEmpty = !brand.name && !brand.domain && !brand.overview; + + return ( + setEditing(false)} + > + {editing ? ( +
+
+
+ + setName(e.target.value)} + placeholder="Acme Corp" + /> +
+
+ + setDomain(e.target.value)} + placeholder="acme.com" + /> +
+
+
+ +