From 11971158db205ca01f6c898d5cf836120d2e274c Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Thu, 26 Feb 2026 16:24:24 +0000 Subject: [PATCH 01/13] Add sidebar layout to dashboard with nav components - Create combined _auth.tsx layout route with auth check and plan-based routing - Add PageContainer component for consistent page layout - Create onboarding page for users without a plan - Update dashboard and settings pages to use NavigationHeader and PageContainer - Make branding prop optional in AppSidebar (no crashes without it) - Remove marketing onboarding page (replaced with auth onboarding) This tracer bullet establishes the architectural pattern that all future authenticated routes will follow. --- .../src/components/layout/page-container.tsx | 17 ++ .../web/src/components/nav/app-sidebar.tsx | 10 +- packages/web/src/routeTree.gen.ts | 86 ++++-- packages/web/src/routes/_auth.tsx | 60 ++++ packages/web/src/routes/_auth/app/index.tsx | 216 +++++++------- .../web/src/routes/_auth/app/settings.tsx | 280 +++++++++--------- packages/web/src/routes/_auth/onboarding.tsx | 51 ++++ .../web/src/routes/_marketing/onboarding.tsx | 32 -- 8 files changed, 423 insertions(+), 329 deletions(-) create mode 100644 packages/web/src/components/layout/page-container.tsx create mode 100644 packages/web/src/routes/_auth.tsx create mode 100644 packages/web/src/routes/_auth/onboarding.tsx delete mode 100644 packages/web/src/routes/_marketing/onboarding.tsx diff --git a/packages/web/src/components/layout/page-container.tsx b/packages/web/src/components/layout/page-container.tsx new file mode 100644 index 0000000..ca7d00d --- /dev/null +++ b/packages/web/src/components/layout/page-container.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; + +export function PageContainer({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/packages/web/src/components/nav/app-sidebar.tsx b/packages/web/src/components/nav/app-sidebar.tsx index f151922..1c75de5 100644 --- a/packages/web/src/components/nav/app-sidebar.tsx +++ b/packages/web/src/components/nav/app-sidebar.tsx @@ -106,13 +106,19 @@ const data = { interface AppSidebarProps extends React.ComponentProps { currentPathname: string; user: UserModelStub; - branding: BrandingModelStub; + branding?: BrandingModelStub; } +// Default branding stub when branding is not provided +const DEFAULT_BRANDING: BrandingModelStub = { + icon: null, + updatedAt: new Date().toISOString(), +}; + export function AppSidebar({ currentPathname, user, - branding, + branding = DEFAULT_BRANDING, ...props }: AppSidebarProps) { return ( diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index fc64086..2ee489f 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -12,14 +12,15 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as BlogRouteImport } from './routes/blog' import { Route as MarketingRouteImport } from './routes/_marketing' import { Route as LoginRouteImport } from './routes/_login' +import { Route as AuthRouteImport } from './routes/_auth' import { Route as MarketingIndexRouteImport } from './routes/_marketing/index' import { Route as UseCasesLoftRouteImport } from './routes/use-cases/loft' import { Route as UseCasesKitchenRouteImport } from './routes/use-cases/kitchen' import { Route as UseCasesExtensionRouteImport } from './routes/use-cases/extension' import { Route as MarketingTermsRouteImport } from './routes/_marketing/terms' import { Route as MarketingPrivacyRouteImport } from './routes/_marketing/privacy' -import { Route as MarketingOnboardingRouteImport } from './routes/_marketing/onboarding' import { Route as MarketingAboutRouteImport } from './routes/_marketing/about' +import { Route as AuthOnboardingRouteImport } from './routes/_auth/onboarding' import { Route as MarketingGuidesIndexRouteImport } from './routes/_marketing/guides/index' import { Route as LoginLoginIndexRouteImport } from './routes/_login/login/index' import { Route as AuthAppIndexRouteImport } from './routes/_auth/app/index' @@ -41,6 +42,10 @@ const LoginRoute = LoginRouteImport.update({ id: '/_login', getParentRoute: () => rootRouteImport, } as any) +const AuthRoute = AuthRouteImport.update({ + id: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) const MarketingIndexRoute = MarketingIndexRouteImport.update({ id: '/', path: '/', @@ -71,16 +76,16 @@ const MarketingPrivacyRoute = MarketingPrivacyRouteImport.update({ path: '/privacy', getParentRoute: () => MarketingRoute, } as any) -const MarketingOnboardingRoute = MarketingOnboardingRouteImport.update({ - id: '/onboarding', - path: '/onboarding', - getParentRoute: () => MarketingRoute, -} as any) const MarketingAboutRoute = MarketingAboutRouteImport.update({ id: '/about', path: '/about', getParentRoute: () => MarketingRoute, } as any) +const AuthOnboardingRoute = AuthOnboardingRouteImport.update({ + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => AuthRoute, +} as any) const MarketingGuidesIndexRoute = MarketingGuidesIndexRouteImport.update({ id: '/guides/', path: '/guides/', @@ -92,9 +97,9 @@ const LoginLoginIndexRoute = LoginLoginIndexRouteImport.update({ getParentRoute: () => LoginRoute, } as any) const AuthAppIndexRoute = AuthAppIndexRouteImport.update({ - id: '/_auth/app/', + id: '/app/', path: '/app/', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AuthRoute, } as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', @@ -112,16 +117,16 @@ const LoginLoginCodeRoute = LoginLoginCodeRouteImport.update({ getParentRoute: () => LoginRoute, } as any) const AuthAppSettingsRoute = AuthAppSettingsRouteImport.update({ - id: '/_auth/app/settings', + id: '/app/settings', path: '/app/settings', - getParentRoute: () => rootRouteImport, + getParentRoute: () => AuthRoute, } as any) export interface FileRoutesByFullPath { '/': typeof MarketingIndexRoute '/blog': typeof BlogRoute + '/onboarding': typeof AuthOnboardingRoute '/about': typeof MarketingAboutRoute - '/onboarding': typeof MarketingOnboardingRoute '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute '/use-cases/extension': typeof UseCasesExtensionRoute @@ -138,8 +143,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof MarketingIndexRoute '/blog': typeof BlogRoute + '/onboarding': typeof AuthOnboardingRoute '/about': typeof MarketingAboutRoute - '/onboarding': typeof MarketingOnboardingRoute '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute '/use-cases/extension': typeof UseCasesExtensionRoute @@ -155,11 +160,12 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/_auth': typeof AuthRouteWithChildren '/_login': typeof LoginRouteWithChildren '/_marketing': typeof MarketingRouteWithChildren '/blog': typeof BlogRoute + '/_auth/onboarding': typeof AuthOnboardingRoute '/_marketing/about': typeof MarketingAboutRoute - '/_marketing/onboarding': typeof MarketingOnboardingRoute '/_marketing/privacy': typeof MarketingPrivacyRoute '/_marketing/terms': typeof MarketingTermsRoute '/use-cases/extension': typeof UseCasesExtensionRoute @@ -179,8 +185,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/blog' - | '/about' | '/onboarding' + | '/about' | '/privacy' | '/terms' | '/use-cases/extension' @@ -197,8 +203,8 @@ export interface FileRouteTypes { to: | '/' | '/blog' - | '/about' | '/onboarding' + | '/about' | '/privacy' | '/terms' | '/use-cases/extension' @@ -213,11 +219,12 @@ export interface FileRouteTypes { | '/guides' id: | '__root__' + | '/_auth' | '/_login' | '/_marketing' | '/blog' + | '/_auth/onboarding' | '/_marketing/about' - | '/_marketing/onboarding' | '/_marketing/privacy' | '/_marketing/terms' | '/use-cases/extension' @@ -234,15 +241,14 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + AuthRoute: typeof AuthRouteWithChildren LoginRoute: typeof LoginRouteWithChildren MarketingRoute: typeof MarketingRouteWithChildren BlogRoute: typeof BlogRoute UseCasesExtensionRoute: typeof UseCasesExtensionRoute UseCasesKitchenRoute: typeof UseCasesKitchenRoute UseCasesLoftRoute: typeof UseCasesLoftRoute - AuthAppSettingsRoute: typeof AuthAppSettingsRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute - AuthAppIndexRoute: typeof AuthAppIndexRoute } declare module '@tanstack/react-router' { @@ -268,6 +274,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } '/_marketing/': { id: '/_marketing/' path: '/' @@ -310,13 +323,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingPrivacyRouteImport parentRoute: typeof MarketingRoute } - '/_marketing/onboarding': { - id: '/_marketing/onboarding' - path: '/onboarding' - fullPath: '/onboarding' - preLoaderRoute: typeof MarketingOnboardingRouteImport - parentRoute: typeof MarketingRoute - } '/_marketing/about': { id: '/_marketing/about' path: '/about' @@ -324,6 +330,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingAboutRouteImport parentRoute: typeof MarketingRoute } + '/_auth/onboarding': { + id: '/_auth/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof AuthOnboardingRouteImport + parentRoute: typeof AuthRoute + } '/_marketing/guides/': { id: '/_marketing/guides/' path: '/guides' @@ -343,7 +356,7 @@ declare module '@tanstack/react-router' { path: '/app' fullPath: '/app/' preLoaderRoute: typeof AuthAppIndexRouteImport - parentRoute: typeof rootRouteImport + parentRoute: typeof AuthRoute } '/api/auth/$': { id: '/api/auth/$' @@ -371,11 +384,25 @@ declare module '@tanstack/react-router' { path: '/app/settings' fullPath: '/app/settings' preLoaderRoute: typeof AuthAppSettingsRouteImport - parentRoute: typeof rootRouteImport + parentRoute: typeof AuthRoute } } } +interface AuthRouteChildren { + AuthOnboardingRoute: typeof AuthOnboardingRoute + AuthAppSettingsRoute: typeof AuthAppSettingsRoute + AuthAppIndexRoute: typeof AuthAppIndexRoute +} + +const AuthRouteChildren: AuthRouteChildren = { + AuthOnboardingRoute: AuthOnboardingRoute, + AuthAppSettingsRoute: AuthAppSettingsRoute, + AuthAppIndexRoute: AuthAppIndexRoute, +} + +const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) + interface LoginRouteChildren { LoginLoginCodeRoute: typeof LoginLoginCodeRoute LoginLoginIndexRoute: typeof LoginLoginIndexRoute @@ -390,7 +417,6 @@ const LoginRouteWithChildren = LoginRoute._addFileChildren(LoginRouteChildren) interface MarketingRouteChildren { MarketingAboutRoute: typeof MarketingAboutRoute - MarketingOnboardingRoute: typeof MarketingOnboardingRoute MarketingPrivacyRoute: typeof MarketingPrivacyRoute MarketingTermsRoute: typeof MarketingTermsRoute MarketingIndexRoute: typeof MarketingIndexRoute @@ -400,7 +426,6 @@ interface MarketingRouteChildren { const MarketingRouteChildren: MarketingRouteChildren = { MarketingAboutRoute: MarketingAboutRoute, - MarketingOnboardingRoute: MarketingOnboardingRoute, MarketingPrivacyRoute: MarketingPrivacyRoute, MarketingTermsRoute: MarketingTermsRoute, MarketingIndexRoute: MarketingIndexRoute, @@ -413,15 +438,14 @@ const MarketingRouteWithChildren = MarketingRoute._addFileChildren( ) const rootRouteChildren: RootRouteChildren = { + AuthRoute: AuthRouteWithChildren, LoginRoute: LoginRouteWithChildren, MarketingRoute: MarketingRouteWithChildren, BlogRoute: BlogRoute, UseCasesExtensionRoute: UseCasesExtensionRoute, UseCasesKitchenRoute: UseCasesKitchenRoute, UseCasesLoftRoute: UseCasesLoftRoute, - AuthAppSettingsRoute: AuthAppSettingsRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, - AuthAppIndexRoute: AuthAppIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/web/src/routes/_auth.tsx b/packages/web/src/routes/_auth.tsx new file mode 100644 index 0000000..ef862c5 --- /dev/null +++ b/packages/web/src/routes/_auth.tsx @@ -0,0 +1,60 @@ +import { + createFileRoute, + Outlet, + redirect, + useLocation, +} from "@tanstack/react-router"; +import { AppSidebar } from "@/components/nav/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { getAuth } from "@/lib/auth-server"; + +export const Route = createFileRoute("/_auth")({ + beforeLoad: async ({ location }) => { + const session = await getAuth(); + + if (!session) { + throw redirect({ to: "/login" }); + } + + // Redirect to onboarding if no plan + if (!session.user.plan && location.pathname !== "/onboarding") { + throw redirect({ to: "/onboarding" }); + } + + // Redirect to app if has plan and trying to access onboarding + if (session.user.plan && location.pathname === "/onboarding") { + throw redirect({ to: "/app" }); + } + + return { user: session.user, session: session.session }; + }, + component: AuthLayout, +}); + +function AuthLayout() { + const { user } = Route.useRouteContext(); + const pathname = useLocation({ select: (loc) => loc.pathname }); + + // Onboarding is standalone (no sidebar) + if (pathname === "/onboarding") { + return ; + } + + const userModel = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + workspaceName: user.workspaceName || "My Workspace", + plan: user.plan, + }; + + return ( + + + + + + + ); +} diff --git a/packages/web/src/routes/_auth/app/index.tsx b/packages/web/src/routes/_auth/app/index.tsx index fb8176e..3cb8168 100644 --- a/packages/web/src/routes/_auth/app/index.tsx +++ b/packages/web/src/routes/_auth/app/index.tsx @@ -1,127 +1,111 @@ -import * as React from 'react'; -import { createFileRoute } from '@tanstack/react-router'; -import { getAuth } from '@/lib/auth-server'; -import { Card } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { authClient } from '@/lib/auth-client'; -import { useRouter } from '@tanstack/react-router'; +import { createFileRoute } from "@tanstack/react-router"; +import { PageContainer } from "@/components/layout/page-container"; +import { NavigationHeader } from "@/components/nav/nav-header"; +import { Card } from "@/components/ui/card"; -export const Route = createFileRoute('/_auth/app/')({ - beforeLoad: async () => { - const context = await getAuth(); - return context; - }, - component: DashboardComponent, +export const Route = createFileRoute("/_auth/app/")({ + component: DashboardComponent, }); function DashboardComponent() { - const router = useRouter(); + return ( + <> + + +
+
+
+

+ Dashboard +

+

+ Welcome to your renovation assistant +

+
+
- const handleLogout = async () => { - try { - await authClient.signOut(); - router.navigate({ to: '/' as any }); - } catch (error) { - console.error('Logout error:', error); - } - }; +
+ +
🏠
+

+ My Projects +

+

+ View and manage your renovation projects +

+
- return ( -
-
-
-

- Dashboard -

-

- Welcome to your renovation assistant -

-
- -
+ +
📐
+

+ Floor Plans +

+

+ Upload and edit floor plans +

+
-
- -
🏠
-

- My Projects -

-

- View and manage your renovation projects -

-
+ +
💬
+

+ Ask The Clerk +

+

+ Get AI-powered renovation advice +

+
+
- -
📐
-

- Floor Plans -

-

- Upload and edit floor plans -

-
+ +

+ Getting Started +

+
+
+
+ 1 +
+
+

+ Upload your floor plan +

+

+ Start by uploading your existing floor plan +

+
+
- -
💬
-

- Ask The Clerk -

-

- Get AI-powered renovation advice -

-
-
+
+
+ 2 +
+
+

+ Describe your renovation +

+

+ Tell us what changes you want to make +

+
+
- -

- Getting Started -

-
-
-
- 1 -
-
-

- Upload your floor plan -

-

- Start by uploading your existing floor plan -

-
-
- -
-
- 2 -
-
-

- Describe your renovation -

-

- Tell us what changes you want to make -

-
-
- -
-
- 3 -
-
-

- Get AI recommendations -

-

- Receive expert guidance from The Clerk -

-
-
-
-
-
- ); +
+
+ 3 +
+
+

+ Get AI recommendations +

+

+ Receive expert guidance from The Clerk +

+
+
+
+ +
+
+ + ); } diff --git a/packages/web/src/routes/_auth/app/settings.tsx b/packages/web/src/routes/_auth/app/settings.tsx index 7883cdf..e55b089 100644 --- a/packages/web/src/routes/_auth/app/settings.tsx +++ b/packages/web/src/routes/_auth/app/settings.tsx @@ -1,25 +1,19 @@ -import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { getAuth } from "@/lib/auth-server"; +import * as React from "react"; +import { toast } from "sonner"; +import { PageContainer } from "@/components/layout/page-container"; +import { NavigationHeader } from "@/components/nav/nav-header"; +import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; -import { useRouter } from "@tanstack/react-router"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; export const Route = createFileRoute("/_auth/app/settings")({ - beforeLoad: async () => { - const context = await getAuth(); - return context; - }, component: SettingsComponent, }); function SettingsComponent() { - const router = useRouter(); const [activeTab, setActiveTab] = React.useState("profile"); const [isLoading, setIsLoading] = React.useState(false); @@ -33,152 +27,142 @@ function SettingsComponent() { }, 1000); }; - const handleLogout = async () => { - try { - await authClient.signOut(); - toast.success("Logged out successfully"); - router.navigate({ to: ("/") as any }); - } catch (error) { - toast.error("Failed to log out"); - console.error("Logout error:", error); - } - }; - return ( -
-
-

- Settings -

-

- Manage your account settings and preferences -

-
+ <> + + +
+
+

+ Settings +

+

+ Manage your account settings and preferences +

+
- - - Profile - Account - Preferences - + + + Profile + Account + Preferences + - - -

- Profile Information -

-
-
-
- - -
-
- - -
-
-
- - -
- -
-
-
- - - -
-

- Account Settings -

-

- Manage your account security and preferences -

-
- -
-
-
-
- Email Notifications + + +

+ Profile Information +

+
+
+
+ + +
+
+ + +
-
- Receive updates about your projects +
+ +
-
- -
+ + + + - -
- - - - - -

- Preferences -

-
-
+ +
-
- Dark Mode -
-
- Switch between light and dark themes +

+ Account Settings +

+

+ Manage your account security and preferences +

+
+ +
+
+
+
+ Email Notifications +
+
+ Receive updates about your projects +
+
+
- -
-
-
-
- Language + + + + + +

+ Preferences +

+
+
+
+
+ Dark Mode +
+
+ Switch between light and dark themes +
+
+
-
- Select your preferred language +
+
+
+ Language +
+
+ Select your preferred language +
+
+
- -
-
- - - -
+ + + +
+ + ); } diff --git a/packages/web/src/routes/_auth/onboarding.tsx b/packages/web/src/routes/_auth/onboarding.tsx new file mode 100644 index 0000000..a05c06c --- /dev/null +++ b/packages/web/src/routes/_auth/onboarding.tsx @@ -0,0 +1,51 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export const Route = createFileRoute("/_auth/onboarding")({ + component: OnboardingPage, +}); + +function OnboardingPage() { + const [name, setName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + // TODO: Replace with actual API call + console.log("Early access request:", { name }); + + // Simulate API delay + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsSubmitting(false); + }; + + return ( +
+
+
+

Apply for early access

+
+
+
+ + setName(e.target.value)} + placeholder="Enter your name" + required + /> +
+ +
+
+
+ ); +} diff --git a/packages/web/src/routes/_marketing/onboarding.tsx b/packages/web/src/routes/_marketing/onboarding.tsx deleted file mode 100644 index b491397..0000000 --- a/packages/web/src/routes/_marketing/onboarding.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createFileRoute, Link } from '@tanstack/react-router'; - -export const Route = createFileRoute('/_marketing/onboarding')({ - component: OnboardingPage, -}); - -function OnboardingPage() { - return ( -
-
-

Full App Coming Soon

- -

- You've signed up and can access our free renovation guides, but the full Structa app is still in development. -

- -
-

- In the meantime, check out our resources: -

- - - Browse Resources - -
-
-
- ); -} From 0800e83db195da7b8b2abb2f13da182a7b9e68be Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Thu, 26 Feb 2026 16:24:54 +0000 Subject: [PATCH 02/13] Mark issue #43 as COMPLETE --- specs/progress.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specs/progress.txt b/specs/progress.txt index e69de29..e825cb3 100644 --- a/specs/progress.txt +++ b/specs/progress.txt @@ -0,0 +1,4 @@ +[CURRENT_ISSUE] 43 +[CURRENT_BRANCH] 43-add-sidebar-layout-to-dashboard-with-nav-components +[STARTED] 2026-02-26T16:20:00+00:00 +COMPLETE From bebfa5bf973f48a6c4b40a70c07988c63a757d06 Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Thu, 26 Feb 2026 16:39:00 +0000 Subject: [PATCH 03/13] PR #44 ready for manual merge --- specs/progress.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/progress.txt b/specs/progress.txt index e825cb3..7529173 100644 --- a/specs/progress.txt +++ b/specs/progress.txt @@ -1,4 +1,4 @@ [CURRENT_ISSUE] 43 [CURRENT_BRANCH] 43-add-sidebar-layout-to-dashboard-with-nav-components [STARTED] 2026-02-26T16:20:00+00:00 -COMPLETE +PR_READY From 5008e54e0d0595621f1fc5aa0a77024787ef73e6 Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Thu, 26 Feb 2026 18:29:17 +0000 Subject: [PATCH 04/13] fix: add database migrations to CI workflows PR preview environments were failing with 'relation verification does not exist' because migrations weren't being run after SST deploy. This adds a migration step to both the PR preview and main deploy workflows. Fixes verification code 500 error on preview environments. --- .github/workflows/deploy.yml | 7 +++++++ .github/workflows/pr-preview-deploy.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7bfeaf5..2d8a945 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -105,6 +105,13 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }} + - name: Run database migrations + run: cd packages/core && npx sst shell --stage ${{ github.ref_name }} drizzle-kit push + env: + AWS_PROFILE: '' + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }} + - name: Output deployment info if: success() run: | diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index f811157..a857ccf 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -134,6 +134,13 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }} + - name: Run database migrations + run: cd packages/core && npx sst shell --stage ${{ env.STAGE_NAME }} drizzle-kit push + env: + AWS_PROFILE: '' + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }} + - name: Get preview URLs id: get_urls run: | From c3f75a312289facd51e9be1294b600475f60e80a Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Thu, 26 Feb 2026 21:54:12 +0000 Subject: [PATCH 05/13] feat: add mailing list subscriptions and waitlist functionality - Add MAILING_LISTS constant to core/marketing/loops.ts - Subscribe new users to marketing + product lists on signup - Create /api/waitlist endpoint for waitlist signups - Add TanStack Query waitlist mutation client - Create onboarding layout with Header and side gutters - Add thank-you page for successful waitlist signup - Redirect to thank-you page after waitlist submission - Only show name field if user signed up with email (no OAuth name) --- packages/backend/src/api/api.ts | 4 +- packages/backend/src/api/routes/waitlist.ts | 41 ++ packages/core/src/marketing/loops.ts | 453 ++++++++++-------- packages/web/src/clients/waitlist/index.ts | 1 + .../waitlist/waitlist.mutation.client.ts | 45 ++ packages/web/src/lib/api.ts | 19 + packages/web/src/lib/auth.ts | 6 +- packages/web/src/routeTree.gen.ts | 62 ++- packages/web/src/routes/_auth.tsx | 11 +- packages/web/src/routes/_auth/onboarding.tsx | 67 +-- .../web/src/routes/_auth/onboarding/index.tsx | 89 ++++ .../src/routes/_auth/onboarding/thank-you.tsx | 33 ++ 12 files changed, 571 insertions(+), 260 deletions(-) create mode 100644 packages/backend/src/api/routes/waitlist.ts create mode 100644 packages/web/src/clients/waitlist/index.ts create mode 100644 packages/web/src/clients/waitlist/waitlist.mutation.client.ts create mode 100644 packages/web/src/lib/api.ts create mode 100644 packages/web/src/routes/_auth/onboarding/index.tsx create mode 100644 packages/web/src/routes/_auth/onboarding/thank-you.tsx diff --git a/packages/backend/src/api/api.ts b/packages/backend/src/api/api.ts index eab3231..e343d91 100644 --- a/packages/backend/src/api/api.ts +++ b/packages/backend/src/api/api.ts @@ -7,6 +7,7 @@ import { AuthRoute } from "./routes/auth"; import { HealthRoute } from "./routes/health"; import { StorageRoute } from "./routes/storage"; import { UserRoute } from "./routes/user"; +import { WaitlistRoute } from "./routes/waitlist"; export class VisibleError extends Error { constructor( @@ -69,7 +70,8 @@ const routes = app .route("/auth", AuthRoute) .route("/user", UserRoute) .route("/storage", StorageRoute) - .route("/health", HealthRoute); + .route("/health", HealthRoute) + .route("/waitlist", WaitlistRoute); export const handler = handle(routes); export type RoutesType = typeof routes; diff --git a/packages/backend/src/api/routes/waitlist.ts b/packages/backend/src/api/routes/waitlist.ts new file mode 100644 index 0000000..65796d4 --- /dev/null +++ b/packages/backend/src/api/routes/waitlist.ts @@ -0,0 +1,41 @@ +import { zValidator } from "@hono/zod-validator"; +import { createContactInLoops, MAILING_LISTS } from "@structa/core/marketing"; +import { Hono } from "hono"; +import { z } from "zod"; + +const WaitlistSchema = z.object({ + email: z.string().email(), + name: z.string().optional(), +}); + +export const WaitlistRoute = new Hono().post( + "/", + zValidator("json", WaitlistSchema), + async (c) => { + const { email, name } = c.req.valid("json"); + + const nameParts = (name || "").trim().split(" "); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(" ") || undefined; + + const result = await createContactInLoops({ + email, + userId: email, // Use email as userId for unauthenticated waitlist signups + firstName, + lastName, + properties: { + source: "waitlist", + createdAt: new Date().toISOString(), + }, + mailingLists: { + [MAILING_LISTS.WAITLIST]: true, + }, + }); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + return c.json({ success: true }, 200); + }, +); diff --git a/packages/core/src/marketing/loops.ts b/packages/core/src/marketing/loops.ts index 8018fdf..eea42db 100644 --- a/packages/core/src/marketing/loops.ts +++ b/packages/core/src/marketing/loops.ts @@ -1,29 +1,42 @@ -import { Resource } from 'sst'; -import { LoopsClient, APIError, RateLimitExceededError } from 'loops'; +import { APIError, LoopsClient, RateLimitExceededError } from "loops"; +import { Resource } from "sst"; + +/** + * Loops mailing list IDs + * Get these from your Loops dashboard: Settings > API > Mailing Lists + */ +export const MAILING_LISTS = { + /** General marketing emails */ + MARKETING: "cmlr5hoqd01br0iwi1mctc2ju", + /** Product updates */ + PRODUCT: "cmlr5ipn400ux0iwd72wif6y2", + /** Waitlist/early access */ + WAITLIST: "cmlrr80fk1tm60is12m3g5aro", +} as const; /** * Loops API error interface for structured error responses */ export interface LoopsApiError { - error?: string; - message?: string; - [key: string]: unknown; + error?: string; + message?: string; + [key: string]: unknown; } /** * Loops contact interface */ export interface LoopsContact { - id: string; - email: string; - firstName?: string; - lastName?: string; - userId?: string; - source?: string; - subscribed?: boolean; - userGroup?: string; - optInStatus?: string | null; - [key: string]: unknown; + id: string; + email: string; + firstName?: string; + lastName?: string; + userId?: string; + source?: string; + subscribed?: boolean; + userGroup?: string; + optInStatus?: string | null; + [key: string]: unknown; } /** @@ -31,67 +44,76 @@ export interface LoopsContact { * Provides structured success/failure information with full error details */ export type LoopsResult = - | { success: true; data: T } - | { success: false; error: string; details?: LoopsApiError }; + | { success: true; data: T } + | { success: false; error: string; details?: LoopsApiError }; /** * Log prefix for consistent log formatting */ -const LOG_PREFIX = '[Loops]'; +const LOG_PREFIX = "[Loops]"; /** * Format timestamp for logs */ function timestamp(): string { - return new Date().toISOString(); + return new Date().toISOString(); } /** * Get a configured Loops client */ function getLoopsClient(): LoopsClient { - const apiKey = Resource.LoopsApiKey.value; - return new LoopsClient(apiKey); + const apiKey = Resource.LoopsApiKey.value; + return new LoopsClient(apiKey); } /** * Handle Loops API errors and convert to LoopsResult */ -function handleLoopsError(error: unknown, operation: string): LoopsResult { - if (error instanceof RateLimitExceededError) { - const message = `Rate limit exceeded (${error.limit} per second)`; - console.error(`${LOG_PREFIX} ${timestamp()} RATE LIMITED: ${message}`); - return { - success: false, - error: message, - }; - } - - if (error instanceof APIError) { - const errorDetails: LoopsApiError = (error.json as LoopsApiError) || { - message: `HTTP ${error.statusCode}`, - rawBody: error.rawBody, - }; - const errorMessage = errorDetails.message || errorDetails.error || `Loops API returned ${error.statusCode}`; - - console.error(`${LOG_PREFIX} ${timestamp()} FAILED: ${errorMessage}`); - console.error(`${LOG_PREFIX} ${timestamp()} Error details:`, JSON.stringify(errorDetails, null, 2)); - - return { - success: false, - error: errorMessage, - details: errorDetails, - }; - } - - // Non-API errors (network, etc.) - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`${LOG_PREFIX} ${timestamp()} EXCEPTION: ${errorMessage}`); - - return { - success: false, - error: `Loops API exception: ${errorMessage}`, - }; +function handleLoopsError( + error: unknown, + operation: string, +): LoopsResult { + if (error instanceof RateLimitExceededError) { + const message = `Rate limit exceeded (${error.limit} per second)`; + console.error(`${LOG_PREFIX} ${timestamp()} RATE LIMITED: ${message}`); + return { + success: false, + error: message, + }; + } + + if (error instanceof APIError) { + const errorDetails: LoopsApiError = (error.json as LoopsApiError) || { + message: `HTTP ${error.statusCode}`, + rawBody: error.rawBody, + }; + const errorMessage = + errorDetails.message || + errorDetails.error || + `Loops API returned ${error.statusCode}`; + + console.error(`${LOG_PREFIX} ${timestamp()} FAILED: ${errorMessage}`); + console.error( + `${LOG_PREFIX} ${timestamp()} Error details:`, + JSON.stringify(errorDetails, null, 2), + ); + + return { + success: false, + error: errorMessage, + details: errorDetails, + }; + } + + // Non-API errors (network, etc.) + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`${LOG_PREFIX} ${timestamp()} EXCEPTION: ${errorMessage}`); + + return { + success: false, + error: `Loops API exception: ${errorMessage}`, + }; } /** @@ -102,73 +124,77 @@ function handleLoopsError(error: unknown, operation: string): LoopsResult * @returns LoopsResult with success/failure and full error details */ export async function createContactInLoops({ - email, - userId, - firstName, - lastName, - properties, - mailingLists, + email, + userId, + firstName, + lastName, + properties, + mailingLists, }: { - email: string; - userId: string; - firstName?: string; - lastName?: string; - properties?: { - [key: string]: string | number | boolean | null; - }; - mailingLists?: { - [key: string]: boolean; - }; + email: string; + userId: string; + firstName?: string; + lastName?: string; + properties?: { + [key: string]: string | number | boolean | null; + }; + mailingLists?: { + [key: string]: boolean; + }; }): Promise> { - console.log( - `${LOG_PREFIX} ${timestamp()} Creating/updating contact for email=${email}, userId=${userId}` - ); - - try { - const loops = getLoopsClient(); - - // Build contact properties - const contactProperties: Record = { - ...properties, - }; - if (firstName) contactProperties.firstName = firstName; - if (lastName) contactProperties.lastName = lastName; - - // Use updateContact which creates OR updates based on userId - // This handles deduplication automatically - const response = await loops.updateContact({ - email, - userId, - properties: contactProperties, - mailingLists, - }); - - if (response.success && response.id) { - console.log( - `${LOG_PREFIX} ${timestamp()} SUCCESS: Contact created/updated with id=${response.id}` - ); - - return { - success: true, - data: { - id: response.id, - email, - userId, - firstName, - lastName, - }, - }; - } - - // Unexpected response - console.error(`${LOG_PREFIX} ${timestamp()} UNEXPECTED RESPONSE:`, response); - return { - success: false, - error: 'Unexpected response from Loops API', - }; - } catch (error) { - return handleLoopsError(error, 'createContact'); - } + console.log( + `${LOG_PREFIX} ${timestamp()} Creating/updating contact for email=${email}, userId=${userId}`, + ); + + try { + const loops = getLoopsClient(); + + // Build contact properties + const contactProperties: Record = + { + ...properties, + }; + if (firstName) contactProperties.firstName = firstName; + if (lastName) contactProperties.lastName = lastName; + + // Use updateContact which creates OR updates based on userId + // This handles deduplication automatically + const response = await loops.updateContact({ + email, + userId, + properties: contactProperties, + mailingLists, + }); + + if (response.success && response.id) { + console.log( + `${LOG_PREFIX} ${timestamp()} SUCCESS: Contact created/updated with id=${response.id}`, + ); + + return { + success: true, + data: { + id: response.id, + email, + userId, + firstName, + lastName, + }, + }; + } + + // Unexpected response + console.error( + `${LOG_PREFIX} ${timestamp()} UNEXPECTED RESPONSE:`, + response, + ); + return { + success: false, + error: "Unexpected response from Loops API", + }; + } catch (error) { + return handleLoopsError(error, "createContact"); + } } /** @@ -177,107 +203,120 @@ export async function createContactInLoops({ * @returns LoopsResult with success/failure */ export async function sendEventInLoops({ - eventName, - eventProperties, - contactProperties, - email, - userId, - mailingLists, + eventName, + eventProperties, + contactProperties, + email, + userId, + mailingLists, }: { - eventName: string; - eventProperties?: { - [key: string]: string | number | boolean; - }; - contactProperties?: { - [key: string]: string | number | boolean | null; - }; - email?: string; - userId?: string; - mailingLists?: { - [key: string]: boolean; - }; + eventName: string; + eventProperties?: { + [key: string]: string | number | boolean; + }; + contactProperties?: { + [key: string]: string | number | boolean | null; + }; + email?: string; + userId?: string; + mailingLists?: { + [key: string]: boolean; + }; }): Promise> { - console.log( - `${LOG_PREFIX} ${timestamp()} Sending event: ${eventName} for email=${email}, userId=${userId}` - ); - - try { - const loops = getLoopsClient(); - - const response = await loops.sendEvent({ - eventName, - email, - userId, - eventProperties, - contactProperties, - mailingLists, - }); - - if (response.success) { - console.log(`${LOG_PREFIX} ${timestamp()} SUCCESS: Event ${eventName} sent`); - return { success: true, data: { success: true } }; - } - - console.error(`${LOG_PREFIX} ${timestamp()} UNEXPECTED RESPONSE:`, response); - return { - success: false, - error: 'Unexpected response from Loops API', - }; - } catch (error) { - return handleLoopsError(error, 'sendEvent'); - } + console.log( + `${LOG_PREFIX} ${timestamp()} Sending event: ${eventName} for email=${email}, userId=${userId}`, + ); + + try { + const loops = getLoopsClient(); + + const response = await loops.sendEvent({ + eventName, + email, + userId, + eventProperties, + contactProperties, + mailingLists, + }); + + if (response.success) { + console.log( + `${LOG_PREFIX} ${timestamp()} SUCCESS: Event ${eventName} sent`, + ); + return { success: true, data: { success: true } }; + } + + console.error( + `${LOG_PREFIX} ${timestamp()} UNEXPECTED RESPONSE:`, + response, + ); + return { + success: false, + error: "Unexpected response from Loops API", + }; + } catch (error) { + return handleLoopsError(error, "sendEvent"); + } } /** * Test that the Loops API key is valid */ -export async function testLoopsApiKey(): Promise> { - console.log(`${LOG_PREFIX} ${timestamp()} Testing API key...`); - - try { - const loops = getLoopsClient(); - const response = await loops.testApiKey(); - - if (response.success && response.teamName) { - console.log(`${LOG_PREFIX} ${timestamp()} SUCCESS: API key valid for team "${response.teamName}"`); - return { success: true, data: { teamName: response.teamName } }; - } - - return { - success: false, - error: 'Invalid API key response', - }; - } catch (error) { - return handleLoopsError(error, 'testApiKey'); - } +export async function testLoopsApiKey(): Promise< + LoopsResult<{ teamName: string }> +> { + console.log(`${LOG_PREFIX} ${timestamp()} Testing API key...`); + + try { + const loops = getLoopsClient(); + const response = await loops.testApiKey(); + + if (response.success && response.teamName) { + console.log( + `${LOG_PREFIX} ${timestamp()} SUCCESS: API key valid for team "${response.teamName}"`, + ); + return { success: true, data: { teamName: response.teamName } }; + } + + return { + success: false, + error: "Invalid API key response", + }; + } catch (error) { + return handleLoopsError(error, "testApiKey"); + } } /** * Find a contact by email or userId */ export async function findContactInLoops({ - email, - userId, + email, + userId, }: { - email?: string; - userId?: string; + email?: string; + userId?: string; }): Promise> { - console.log(`${LOG_PREFIX} ${timestamp()} Finding contact: email=${email}, userId=${userId}`); - - try { - const loops = getLoopsClient(); - const response = await loops.findContact({ email, userId }); - - if (response && response.length > 0) { - console.log(`${LOG_PREFIX} ${timestamp()} SUCCESS: Found contact with id=${response[0].id}`); - return { success: true, data: response[0] as LoopsContact }; - } - - console.log(`${LOG_PREFIX} ${timestamp()} SUCCESS: No contact found`); - return { success: true, data: null }; - } catch (error) { - return handleLoopsError(error, 'findContact'); - } + console.log( + `${LOG_PREFIX} ${timestamp()} Finding contact: email=${email}, userId=${userId}`, + ); + + try { + const loops = getLoopsClient(); + const response = await loops.findContact({ email, userId }); + + if (response && response.length > 0) { + console.log( + `${LOG_PREFIX} ${timestamp()} SUCCESS: Found contact with id=${response[0].id}`, + ); + return { success: true, data: response[0] as LoopsContact }; + } + + console.log(`${LOG_PREFIX} ${timestamp()} SUCCESS: No contact found`); + return { success: true, data: null }; + } catch (error) { + return handleLoopsError(error, "findContact"); + } } // Re-export the error types for consumers diff --git a/packages/web/src/clients/waitlist/index.ts b/packages/web/src/clients/waitlist/index.ts new file mode 100644 index 0000000..ce25fa8 --- /dev/null +++ b/packages/web/src/clients/waitlist/index.ts @@ -0,0 +1 @@ +export * from "./waitlist.mutation.client"; diff --git a/packages/web/src/clients/waitlist/waitlist.mutation.client.ts b/packages/web/src/clients/waitlist/waitlist.mutation.client.ts new file mode 100644 index 0000000..7c19450 --- /dev/null +++ b/packages/web/src/clients/waitlist/waitlist.mutation.client.ts @@ -0,0 +1,45 @@ +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api } from "@/lib/api"; + +export interface WaitlistInput { + email: string; + name?: string; +} + +export async function joinWaitlist(input: WaitlistInput) { + const res = await api.waitlist.$post({ + json: input, + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => null); + console.error("Failed to join waitlist:", { + status: res.status, + statusText: res.statusText, + error: errorData, + }); + throw new Error( + `Could not join waitlist: ${res.status} ${res.statusText}${errorData ? ` - ${JSON.stringify(errorData)}` : ""}`, + ); + } + + return await res.json(); +} + +export function useJoinWaitlistMutation() { + return useMutation({ + mutationFn: joinWaitlist, + onSuccess: () => { + toast.success("You've been added to the waitlist!"); + }, + onError: (error) => { + console.error("Waitlist mutation error:", error); + const errorMessage = + error instanceof Error + ? error.message + : "Failed to join waitlist. Please try again."; + toast.error(errorMessage); + }, + }); +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts new file mode 100644 index 0000000..e10db8b --- /dev/null +++ b/packages/web/src/lib/api.ts @@ -0,0 +1,19 @@ +import type { RoutesType } from "@backend/api/api"; +import { hc } from "hono/client"; + +// Client-side: use relative URL (works in browser) +// Server-side: use platform URL from env (works with custom domains via proxy) +const getApiUrl = () => { + if (typeof window === "undefined") { + // Server-side: use the platform URL which proxies to API + return `${process.env.PLATFORM_URL}/api`; + } + // Client-side: use relative URL (proxied through same domain) + return "/api"; +}; + +export const api = hc(getApiUrl(), { + init: { + credentials: "include", + }, +}); diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts index 74f84a0..599b544 100644 --- a/packages/web/src/lib/auth.ts +++ b/packages/web/src/lib/auth.ts @@ -1,7 +1,7 @@ import { sendVerificationOTP } from "@backend/auth/email"; import { AuthSchema } from "@core/auth"; import { db } from "@core/drizzle"; -import { createContactInLoops } from "@core/marketing"; +import { createContactInLoops, MAILING_LISTS } from "@core/marketing"; import { extractIPAddress, getLocationFromIP } from "@core/utils/geolocation"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; @@ -117,6 +117,10 @@ export const auth = betterAuth({ source: "resource-signup", createdAt: user.createdAt.toISOString(), }, + mailingLists: { + [MAILING_LISTS.MARKETING]: true, + [MAILING_LISTS.PRODUCT]: true, + }, }); if (result.success) { diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 2ee489f..18359dd 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -23,10 +23,12 @@ import { Route as MarketingAboutRouteImport } from './routes/_marketing/about' import { Route as AuthOnboardingRouteImport } from './routes/_auth/onboarding' import { Route as MarketingGuidesIndexRouteImport } from './routes/_marketing/guides/index' import { Route as LoginLoginIndexRouteImport } from './routes/_login/login/index' +import { Route as AuthOnboardingIndexRouteImport } from './routes/_auth/onboarding/index' import { Route as AuthAppIndexRouteImport } from './routes/_auth/app/index' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as MarketingGuidesSlugRouteImport } from './routes/_marketing/guides/$slug' import { Route as LoginLoginCodeRouteImport } from './routes/_login/login/code' +import { Route as AuthOnboardingThankYouRouteImport } from './routes/_auth/onboarding/thank-you' import { Route as AuthAppSettingsRouteImport } from './routes/_auth/app/settings' const BlogRoute = BlogRouteImport.update({ @@ -96,6 +98,11 @@ const LoginLoginIndexRoute = LoginLoginIndexRouteImport.update({ path: '/login/', getParentRoute: () => LoginRoute, } as any) +const AuthOnboardingIndexRoute = AuthOnboardingIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthOnboardingRoute, +} as any) const AuthAppIndexRoute = AuthAppIndexRouteImport.update({ id: '/app/', path: '/app/', @@ -116,6 +123,11 @@ const LoginLoginCodeRoute = LoginLoginCodeRouteImport.update({ path: '/login/code', getParentRoute: () => LoginRoute, } as any) +const AuthOnboardingThankYouRoute = AuthOnboardingThankYouRouteImport.update({ + id: '/thank-you', + path: '/thank-you', + getParentRoute: () => AuthOnboardingRoute, +} as any) const AuthAppSettingsRoute = AuthAppSettingsRouteImport.update({ id: '/app/settings', path: '/app/settings', @@ -125,7 +137,7 @@ const AuthAppSettingsRoute = AuthAppSettingsRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof MarketingIndexRoute '/blog': typeof BlogRoute - '/onboarding': typeof AuthOnboardingRoute + '/onboarding': typeof AuthOnboardingRouteWithChildren '/about': typeof MarketingAboutRoute '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute @@ -133,17 +145,18 @@ export interface FileRoutesByFullPath { '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute '/app/settings': typeof AuthAppSettingsRoute + '/onboarding/thank-you': typeof AuthOnboardingThankYouRoute '/login/code': typeof LoginLoginCodeRoute '/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/app/': typeof AuthAppIndexRoute + '/onboarding/': typeof AuthOnboardingIndexRoute '/login/': typeof LoginLoginIndexRoute '/guides/': typeof MarketingGuidesIndexRoute } export interface FileRoutesByTo { '/': typeof MarketingIndexRoute '/blog': typeof BlogRoute - '/onboarding': typeof AuthOnboardingRoute '/about': typeof MarketingAboutRoute '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute @@ -151,10 +164,12 @@ export interface FileRoutesByTo { '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute '/app/settings': typeof AuthAppSettingsRoute + '/onboarding/thank-you': typeof AuthOnboardingThankYouRoute '/login/code': typeof LoginLoginCodeRoute '/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/app': typeof AuthAppIndexRoute + '/onboarding': typeof AuthOnboardingIndexRoute '/login': typeof LoginLoginIndexRoute '/guides': typeof MarketingGuidesIndexRoute } @@ -164,7 +179,7 @@ export interface FileRoutesById { '/_login': typeof LoginRouteWithChildren '/_marketing': typeof MarketingRouteWithChildren '/blog': typeof BlogRoute - '/_auth/onboarding': typeof AuthOnboardingRoute + '/_auth/onboarding': typeof AuthOnboardingRouteWithChildren '/_marketing/about': typeof MarketingAboutRoute '/_marketing/privacy': typeof MarketingPrivacyRoute '/_marketing/terms': typeof MarketingTermsRoute @@ -173,10 +188,12 @@ export interface FileRoutesById { '/use-cases/loft': typeof UseCasesLoftRoute '/_marketing/': typeof MarketingIndexRoute '/_auth/app/settings': typeof AuthAppSettingsRoute + '/_auth/onboarding/thank-you': typeof AuthOnboardingThankYouRoute '/_login/login/code': typeof LoginLoginCodeRoute '/_marketing/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/_auth/app/': typeof AuthAppIndexRoute + '/_auth/onboarding/': typeof AuthOnboardingIndexRoute '/_login/login/': typeof LoginLoginIndexRoute '/_marketing/guides/': typeof MarketingGuidesIndexRoute } @@ -193,17 +210,18 @@ export interface FileRouteTypes { | '/use-cases/kitchen' | '/use-cases/loft' | '/app/settings' + | '/onboarding/thank-you' | '/login/code' | '/guides/$slug' | '/api/auth/$' | '/app/' + | '/onboarding/' | '/login/' | '/guides/' fileRoutesByTo: FileRoutesByTo to: | '/' | '/blog' - | '/onboarding' | '/about' | '/privacy' | '/terms' @@ -211,10 +229,12 @@ export interface FileRouteTypes { | '/use-cases/kitchen' | '/use-cases/loft' | '/app/settings' + | '/onboarding/thank-you' | '/login/code' | '/guides/$slug' | '/api/auth/$' | '/app' + | '/onboarding' | '/login' | '/guides' id: @@ -232,10 +252,12 @@ export interface FileRouteTypes { | '/use-cases/loft' | '/_marketing/' | '/_auth/app/settings' + | '/_auth/onboarding/thank-you' | '/_login/login/code' | '/_marketing/guides/$slug' | '/api/auth/$' | '/_auth/app/' + | '/_auth/onboarding/' | '/_login/login/' | '/_marketing/guides/' fileRoutesById: FileRoutesById @@ -351,6 +373,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLoginIndexRouteImport parentRoute: typeof LoginRoute } + '/_auth/onboarding/': { + id: '/_auth/onboarding/' + path: '/' + fullPath: '/onboarding/' + preLoaderRoute: typeof AuthOnboardingIndexRouteImport + parentRoute: typeof AuthOnboardingRoute + } '/_auth/app/': { id: '/_auth/app/' path: '/app' @@ -379,6 +408,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLoginCodeRouteImport parentRoute: typeof LoginRoute } + '/_auth/onboarding/thank-you': { + id: '/_auth/onboarding/thank-you' + path: '/thank-you' + fullPath: '/onboarding/thank-you' + preLoaderRoute: typeof AuthOnboardingThankYouRouteImport + parentRoute: typeof AuthOnboardingRoute + } '/_auth/app/settings': { id: '/_auth/app/settings' path: '/app/settings' @@ -389,14 +425,28 @@ declare module '@tanstack/react-router' { } } +interface AuthOnboardingRouteChildren { + AuthOnboardingThankYouRoute: typeof AuthOnboardingThankYouRoute + AuthOnboardingIndexRoute: typeof AuthOnboardingIndexRoute +} + +const AuthOnboardingRouteChildren: AuthOnboardingRouteChildren = { + AuthOnboardingThankYouRoute: AuthOnboardingThankYouRoute, + AuthOnboardingIndexRoute: AuthOnboardingIndexRoute, +} + +const AuthOnboardingRouteWithChildren = AuthOnboardingRoute._addFileChildren( + AuthOnboardingRouteChildren, +) + interface AuthRouteChildren { - AuthOnboardingRoute: typeof AuthOnboardingRoute + AuthOnboardingRoute: typeof AuthOnboardingRouteWithChildren AuthAppSettingsRoute: typeof AuthAppSettingsRoute AuthAppIndexRoute: typeof AuthAppIndexRoute } const AuthRouteChildren: AuthRouteChildren = { - AuthOnboardingRoute: AuthOnboardingRoute, + AuthOnboardingRoute: AuthOnboardingRouteWithChildren, AuthAppSettingsRoute: AuthAppSettingsRoute, AuthAppIndexRoute: AuthAppIndexRoute, } diff --git a/packages/web/src/routes/_auth.tsx b/packages/web/src/routes/_auth.tsx index ef862c5..c151c5c 100644 --- a/packages/web/src/routes/_auth.tsx +++ b/packages/web/src/routes/_auth.tsx @@ -16,13 +16,16 @@ export const Route = createFileRoute("/_auth")({ throw redirect({ to: "/login" }); } + // Check if this is an onboarding route + const isOnboardingRoute = location.pathname.startsWith("/onboarding"); + // Redirect to onboarding if no plan - if (!session.user.plan && location.pathname !== "/onboarding") { + if (!session.user.plan && !isOnboardingRoute) { throw redirect({ to: "/onboarding" }); } // Redirect to app if has plan and trying to access onboarding - if (session.user.plan && location.pathname === "/onboarding") { + if (session.user.plan && isOnboardingRoute) { throw redirect({ to: "/app" }); } @@ -35,8 +38,8 @@ function AuthLayout() { const { user } = Route.useRouteContext(); const pathname = useLocation({ select: (loc) => loc.pathname }); - // Onboarding is standalone (no sidebar) - if (pathname === "/onboarding") { + // Onboarding routes are standalone (no sidebar) + if (pathname.startsWith("/onboarding")) { return ; } diff --git a/packages/web/src/routes/_auth/onboarding.tsx b/packages/web/src/routes/_auth/onboarding.tsx index a05c06c..b287655 100644 --- a/packages/web/src/routes/_auth/onboarding.tsx +++ b/packages/web/src/routes/_auth/onboarding.tsx @@ -1,50 +1,35 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { DiagonalDivider } from "@/components/layout"; +import { Header } from "@/components/layout/Header"; export const Route = createFileRoute("/_auth/onboarding")({ - component: OnboardingPage, + component: OnboardingLayout, }); -function OnboardingPage() { - const [name, setName] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - // TODO: Replace with actual API call - console.log("Early access request:", { name }); - - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - setIsSubmitting(false); - }; - +function OnboardingLayout() { return ( -
-
-
-

Apply for early access

+
+
+ + {/* Main content wrapper */} +
+ {/* Side gutters with noise texture */} +
+
+
+
+
-
-
- - setName(e.target.value)} - placeholder="Enter your name" - required - /> -
- -
+
+ + +
); diff --git a/packages/web/src/routes/_auth/onboarding/index.tsx b/packages/web/src/routes/_auth/onboarding/index.tsx new file mode 100644 index 0000000..e47aac2 --- /dev/null +++ b/packages/web/src/routes/_auth/onboarding/index.tsx @@ -0,0 +1,89 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useJoinWaitlistMutation } from "@/clients/waitlist"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useSession } from "@/lib/auth-client"; + +export const Route = createFileRoute("/_auth/onboarding/")({ + component: OnboardingPage, +}); + +function OnboardingPage() { + const { data: session } = useSession(); + const joinWaitlist = useJoinWaitlistMutation(); + const navigate = useNavigate(); + + // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) + // Check for non-empty string (handles "", null, undefined, whitespace-only) + const userName = session?.user?.name; + const userHasName = userName != null && userName.trim().length > 0; + const [name, setName] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!session?.user?.email) { + return; + } + + // Only send name if we're collecting it (user doesn't have one already) + joinWaitlist.mutate( + { + email: session.user.email, + name: !userHasName ? name || undefined : session.user.name || undefined, + }, + { + onSuccess: () => { + navigate({ to: "/onboarding/thank-you" }); + }, + }, + ); + }; + + return ( +
+
+ {/* Structa Introduction */} +
+

+ Renovate with confidence. +
+ + AI-powered workspace for UK homeowners. + +

+

+ AI assistant that understands your property and guides your + renovation. Request early access below. +

+
+ + {/* Name Input Form */} +
+ {/* Only show name field if user signed up with email (no OAuth name) */} + {!userHasName && ( +
+ setName(e.target.value)} + disabled={joinWaitlist.isPending} + /> +
+ )} + + +
+
+
+ ); +} diff --git a/packages/web/src/routes/_auth/onboarding/thank-you.tsx b/packages/web/src/routes/_auth/onboarding/thank-you.tsx new file mode 100644 index 0000000..cd88d95 --- /dev/null +++ b/packages/web/src/routes/_auth/onboarding/thank-you.tsx @@ -0,0 +1,33 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; + +export const Route = createFileRoute("/_auth/onboarding/thank-you")({ + component: ThankYouPage, +}); + +function ThankYouPage() { + return ( +
+
+
+

+ You're on the list! +

+

+ Thanks for joining the waitlist. We'll be in touch soon with early + access to Structa. +

+
+ +
+

+ In the meantime, check out our renovation guides. +

+ +
+
+
+ ); +} From 102ca138fe488dac63478c1eef3810adcea2754b Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Fri, 27 Feb 2026 16:48:37 +0000 Subject: [PATCH 06/13] feat: redesign onboarding with gutter layout and improve waitlist UX - Add full-screen gutter layout with diamond corners for onboarding routes - Implement server-side waitlist signup via TanStack Start server functions - Update user.plan to 'waitlist' on signup and prevent app access for waitlisted users - Add error state to Button component with shake animation - Lighten error color in dark mode for better visibility - Remove Hono client in favor of direct core package calls - Add updatePlanFromId to UserService - Add pricing components --- infra/email.ts | 6 + packages/core/src/drizzle.test.ts | 30 +- packages/core/src/marketing/loops.test.ts | 608 +++++++++--------- packages/core/src/user/user.service.ts | 28 +- packages/web/src/clients/waitlist/index.ts | 1 - .../waitlist/waitlist.mutation.client.ts | 45 -- .../src/components/auth/verify-code-form.tsx | 1 - packages/web/src/components/layout/Header.tsx | 4 +- .../src/components/pricing/PricingSection.tsx | 74 +++ .../src/components/pricing/PricingTierBar.tsx | 78 +++ packages/web/src/components/pricing/index.ts | 6 + packages/web/src/components/ui/avatar.tsx | 2 +- packages/web/src/components/ui/button.tsx | 10 +- packages/web/src/lib/api.ts | 24 +- packages/web/src/routeTree.gen.ts | 105 ++- packages/web/src/routes/_auth.tsx | 12 +- packages/web/src/routes/_auth/_onboarding.tsx | 67 ++ .../_auth/_onboarding/onboarding/index.tsx | 173 +++++ .../_onboarding/onboarding/thank-you.tsx | 49 ++ packages/web/src/routes/_auth/onboarding.tsx | 36 -- .../web/src/routes/_auth/onboarding/index.tsx | 89 --- .../src/routes/_auth/onboarding/thank-you.tsx | 33 - packages/web/src/styles/app.css | 29 +- 23 files changed, 908 insertions(+), 602 deletions(-) delete mode 100644 packages/web/src/clients/waitlist/index.ts delete mode 100644 packages/web/src/clients/waitlist/waitlist.mutation.client.ts create mode 100644 packages/web/src/components/pricing/PricingSection.tsx create mode 100644 packages/web/src/components/pricing/PricingTierBar.tsx create mode 100644 packages/web/src/components/pricing/index.ts create mode 100644 packages/web/src/routes/_auth/_onboarding.tsx create mode 100644 packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx create mode 100644 packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx delete mode 100644 packages/web/src/routes/_auth/onboarding.tsx delete mode 100644 packages/web/src/routes/_auth/onboarding/index.tsx delete mode 100644 packages/web/src/routes/_auth/onboarding/thank-you.tsx diff --git a/infra/email.ts b/infra/email.ts index ecac5a9..2536063 100644 --- a/infra/email.ts +++ b/infra/email.ts @@ -39,6 +39,12 @@ export const personalEmail = !IS_DEPLOYED_STAGE sender: 'harrison@structa.so', }); +export const anotherPersonalEmail = !IS_DEPLOYED_STAGE + ? null + : new sst.aws.Email('anotherPersonalEmail', { + sender: 'hester@structa.so', + }); + // Local email preview server (react-email) export const reactEmail = new sst.x.DevCommand('EmailServer', { dev: { diff --git a/packages/core/src/drizzle.test.ts b/packages/core/src/drizzle.test.ts index 7d6f129..651c2ca 100644 --- a/packages/core/src/drizzle.test.ts +++ b/packages/core/src/drizzle.test.ts @@ -5,27 +5,27 @@ * This verifies that the connection is properly configured for AWS Lambda. */ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock the neon function to return a SQL query function const mockSqlFunction = vi.fn(); const mockNeon = vi.hoisted(() => vi.fn(() => mockSqlFunction)); // Mock @neondatabase/serverless -vi.mock('@neondatabase/serverless', () => ({ +vi.mock("@neondatabase/serverless", () => ({ neon: mockNeon, })); // Mock SST Resource -vi.mock('sst', () => ({ +vi.mock("sst", () => ({ Resource: { Database: { - url: 'postgresql://test:test@localhost:5432/test', + url: "postgresql://test:test@localhost:5432/test", }, }, })); -describe('Database Connection', () => { +describe("Database Connection", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -34,26 +34,28 @@ describe('Database Connection', () => { vi.restoreAllMocks(); }); - it('should call neon with the database URL', async () => { + it("should call neon with the database URL", async () => { // Clear module cache to get fresh import vi.resetModules(); // Import the module to trigger the connection setup - await import('./drizzle'); + await import("./drizzle"); - expect(mockNeon).toHaveBeenCalledWith('postgresql://test:test@localhost:5432/test'); + expect(mockNeon).toHaveBeenCalledWith( + "postgresql://test:test@localhost:5432/test", + ); }); - it('should export a db instance with expected methods', async () => { + it("should export a db instance with expected methods", async () => { // Clear module cache to get fresh import vi.resetModules(); - const { db } = await import('./drizzle'); + const { db } = await import("./drizzle"); expect(db).toBeDefined(); - expect(db).toHaveProperty('select'); - expect(db).toHaveProperty('insert'); - expect(db).toHaveProperty('update'); - expect(db).toHaveProperty('delete'); + expect(db).toHaveProperty("select"); + expect(db).toHaveProperty("insert"); + expect(db).toHaveProperty("update"); + expect(db).toHaveProperty("delete"); }); }); diff --git a/packages/core/src/marketing/loops.test.ts b/packages/core/src/marketing/loops.test.ts index b8bb571..7dc364f 100644 --- a/packages/core/src/marketing/loops.test.ts +++ b/packages/core/src/marketing/loops.test.ts @@ -4,7 +4,7 @@ * Tests the Loops SDK integration for contact creation and event tracking. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Define mock functions that will be used in tests const mockUpdateContact = vi.hoisted(() => vi.fn()); @@ -13,308 +13,320 @@ const mockTestApiKey = vi.hoisted(() => vi.fn()); const mockFindContact = vi.hoisted(() => vi.fn()); // Mock the Loops SDK -vi.mock('loops', () => { - // Mock error classes that match the SDK's interface - class MockAPIError extends Error { - json: Record | null; - statusCode: number; - rawBody?: string; - - constructor( - statusCode: number, - json: Record | null, - rawBody?: string - ) { - super('API Error'); - this.json = json; - this.statusCode = statusCode; - this.rawBody = rawBody; - this.name = 'APIError'; - } - } - - class MockRateLimitExceededError extends Error { - limit: number; - remaining: number; - - constructor(limit: number, remaining: number) { - super('Rate limit exceeded'); - this.limit = limit; - this.remaining = remaining; - this.name = 'RateLimitExceededError'; - } - } - - return { - LoopsClient: vi.fn(() => ({ - updateContact: mockUpdateContact, - sendEvent: mockSendEvent, - testApiKey: mockTestApiKey, - findContact: mockFindContact, - })), - APIError: MockAPIError, - RateLimitExceededError: MockRateLimitExceededError, - }; +vi.mock("loops", () => { + // Mock error classes that match the SDK's interface + class MockAPIError extends Error { + json: Record | null; + statusCode: number; + rawBody?: string; + + constructor( + statusCode: number, + json: Record | null, + rawBody?: string, + ) { + super("API Error"); + this.json = json; + this.statusCode = statusCode; + this.rawBody = rawBody; + this.name = "APIError"; + } + } + + class MockRateLimitExceededError extends Error { + limit: number; + remaining: number; + + constructor(limit: number, remaining: number) { + super("Rate limit exceeded"); + this.limit = limit; + this.remaining = remaining; + this.name = "RateLimitExceededError"; + } + } + + return { + LoopsClient: vi.fn(() => ({ + updateContact: mockUpdateContact, + sendEvent: mockSendEvent, + testApiKey: mockTestApiKey, + findContact: mockFindContact, + })), + APIError: MockAPIError, + RateLimitExceededError: MockRateLimitExceededError, + }; }); // Mock SST Resource -vi.mock('sst', () => ({ - Resource: { - LoopsApiKey: { - value: 'test-api-key-12345', - }, - }, +vi.mock("sst", () => ({ + Resource: { + LoopsApiKey: { + value: "test-api-key-12345", + }, + }, })); // Import after mocking import { - createContactInLoops, - sendEventInLoops, - testLoopsApiKey, - findContactInLoops, -} from './loops'; - -describe('Loops Integration', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('createContactInLoops', () => { - it('should successfully create/update a contact', async () => { - mockUpdateContact.mockResolvedValueOnce({ - success: true, - id: 'contact-123', - }); - - const result = await createContactInLoops({ - email: 'test@example.com', - userId: 'user-456', - firstName: 'John', - lastName: 'Doe', - }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.id).toBe('contact-123'); - expect(result.data.email).toBe('test@example.com'); - } - - // Verify updateContact was called with correct parameters - expect(mockUpdateContact).toHaveBeenCalledWith({ - email: 'test@example.com', - userId: 'user-456', - properties: { - firstName: 'John', - lastName: 'Doe', - }, - mailingLists: undefined, - }); - }); - - it('should handle API errors', async () => { - const { APIError } = await import('loops'); - mockUpdateContact.mockRejectedValueOnce( - new APIError(400, { success: false, message: 'The email address is not valid' } as any) - ); - - const result = await createContactInLoops({ - email: 'invalid-email', - userId: 'user-456', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('email address is not valid'); - } - }); - - it('should handle unauthorized errors', async () => { - const { APIError } = await import('loops'); - mockUpdateContact.mockRejectedValueOnce( - new APIError(401, { error: 'Invalid API key' } as any) - ); - - const result = await createContactInLoops({ - email: 'test@example.com', - userId: 'user-456', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('Invalid API key'); - } - }); - - it('should handle rate limit errors', async () => { - const { RateLimitExceededError } = await import('loops'); - mockUpdateContact.mockRejectedValueOnce(new RateLimitExceededError(10, 0)); - - const result = await createContactInLoops({ - email: 'test@example.com', - userId: 'user-456', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('Rate limit exceeded'); - } - }); - - it('should include optional fields when provided', async () => { - mockUpdateContact.mockResolvedValueOnce({ - success: true, - id: 'contact-123', - }); - - await createContactInLoops({ - email: 'test@example.com', - userId: 'user-456', - firstName: 'Jane', - lastName: 'Smith', - properties: { - source: 'test', - tier: 1, - }, - mailingLists: { - newsletter: true, - updates: false, - }, - }); - - expect(mockUpdateContact).toHaveBeenCalledWith({ - email: 'test@example.com', - userId: 'user-456', - properties: { - source: 'test', - tier: 1, - firstName: 'Jane', - lastName: 'Smith', - }, - mailingLists: { - newsletter: true, - updates: false, - }, - }); - }); - - it('should handle network errors', async () => { - mockUpdateContact.mockRejectedValueOnce(new Error('Network error: Failed to connect')); - - const result = await createContactInLoops({ - email: 'test@example.com', - userId: 'user-456', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('Network error'); - } - }); - }); - - describe('sendEventInLoops', () => { - it('should successfully send an event', async () => { - mockSendEvent.mockResolvedValueOnce({ success: true }); - - const result = await sendEventInLoops({ - eventName: 'user_signup', - email: 'test@example.com', - userId: 'user-456', - }); - - expect(result.success).toBe(true); - - expect(mockSendEvent).toHaveBeenCalledWith({ - eventName: 'user_signup', - email: 'test@example.com', - userId: 'user-456', - eventProperties: undefined, - contactProperties: undefined, - mailingLists: undefined, - }); - }); - - it('should handle event API failures', async () => { - const { APIError } = await import('loops'); - mockSendEvent.mockRejectedValueOnce( - new APIError(404, { success: false, message: 'The specified event does not exist' } as any) - ); - - const result = await sendEventInLoops({ - eventName: 'invalid_event', - email: 'test@example.com', - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('event does not exist'); - } - }); - }); - - describe('testLoopsApiKey', () => { - it('should return team name when API key is valid', async () => { - mockTestApiKey.mockResolvedValueOnce({ - success: true, - teamName: 'Test Team', - }); - - const result = await testLoopsApiKey(); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.teamName).toBe('Test Team'); - } - }); - - it('should handle invalid API key', async () => { - const { APIError } = await import('loops'); - mockTestApiKey.mockRejectedValueOnce(new APIError(401, { error: 'Invalid API key' } as any)); - - const result = await testLoopsApiKey(); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain('Invalid API key'); - } - }); - }); - - describe('findContactInLoops', () => { - it('should return contact when found', async () => { - mockFindContact.mockResolvedValueOnce([ - { - id: 'contact-123', - email: 'test@example.com', - firstName: 'John', - userId: 'user-456', - }, - ]); - - const result = await findContactInLoops({ email: 'test@example.com' }); - - expect(result.success).toBe(true); - if (result.success && result.data) { - expect(result.data.id).toBe('contact-123'); - expect(result.data.email).toBe('test@example.com'); - } - }); - - it('should return null when contact not found', async () => { - mockFindContact.mockResolvedValueOnce([]); - - const result = await findContactInLoops({ userId: 'nonexistent' }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toBeNull(); - } - }); - }); + createContactInLoops, + findContactInLoops, + sendEventInLoops, + testLoopsApiKey, +} from "./loops"; + +describe("Loops Integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("createContactInLoops", () => { + it("should successfully create/update a contact", async () => { + mockUpdateContact.mockResolvedValueOnce({ + success: true, + id: "contact-123", + }); + + const result = await createContactInLoops({ + email: "test@example.com", + userId: "user-456", + firstName: "John", + lastName: "Doe", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe("contact-123"); + expect(result.data.email).toBe("test@example.com"); + } + + // Verify updateContact was called with correct parameters + expect(mockUpdateContact).toHaveBeenCalledWith({ + email: "test@example.com", + userId: "user-456", + properties: { + firstName: "John", + lastName: "Doe", + }, + mailingLists: undefined, + }); + }); + + it("should handle API errors", async () => { + const { APIError } = await import("loops"); + mockUpdateContact.mockRejectedValueOnce( + new APIError(400, { + success: false, + message: "The email address is not valid", + } as any), + ); + + const result = await createContactInLoops({ + email: "invalid-email", + userId: "user-456", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("email address is not valid"); + } + }); + + it("should handle unauthorized errors", async () => { + const { APIError } = await import("loops"); + mockUpdateContact.mockRejectedValueOnce( + new APIError(401, { error: "Invalid API key" } as any), + ); + + const result = await createContactInLoops({ + email: "test@example.com", + userId: "user-456", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Invalid API key"); + } + }); + + it("should handle rate limit errors", async () => { + const { RateLimitExceededError } = await import("loops"); + mockUpdateContact.mockRejectedValueOnce( + new RateLimitExceededError(10, 0), + ); + + const result = await createContactInLoops({ + email: "test@example.com", + userId: "user-456", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Rate limit exceeded"); + } + }); + + it("should include optional fields when provided", async () => { + mockUpdateContact.mockResolvedValueOnce({ + success: true, + id: "contact-123", + }); + + await createContactInLoops({ + email: "test@example.com", + userId: "user-456", + firstName: "Jane", + lastName: "Smith", + properties: { + source: "test", + tier: 1, + }, + mailingLists: { + newsletter: true, + updates: false, + }, + }); + + expect(mockUpdateContact).toHaveBeenCalledWith({ + email: "test@example.com", + userId: "user-456", + properties: { + source: "test", + tier: 1, + firstName: "Jane", + lastName: "Smith", + }, + mailingLists: { + newsletter: true, + updates: false, + }, + }); + }); + + it("should handle network errors", async () => { + mockUpdateContact.mockRejectedValueOnce( + new Error("Network error: Failed to connect"), + ); + + const result = await createContactInLoops({ + email: "test@example.com", + userId: "user-456", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Network error"); + } + }); + }); + + describe("sendEventInLoops", () => { + it("should successfully send an event", async () => { + mockSendEvent.mockResolvedValueOnce({ success: true }); + + const result = await sendEventInLoops({ + eventName: "user_signup", + email: "test@example.com", + userId: "user-456", + }); + + expect(result.success).toBe(true); + + expect(mockSendEvent).toHaveBeenCalledWith({ + eventName: "user_signup", + email: "test@example.com", + userId: "user-456", + eventProperties: undefined, + contactProperties: undefined, + mailingLists: undefined, + }); + }); + + it("should handle event API failures", async () => { + const { APIError } = await import("loops"); + mockSendEvent.mockRejectedValueOnce( + new APIError(404, { + success: false, + message: "The specified event does not exist", + } as any), + ); + + const result = await sendEventInLoops({ + eventName: "invalid_event", + email: "test@example.com", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("event does not exist"); + } + }); + }); + + describe("testLoopsApiKey", () => { + it("should return team name when API key is valid", async () => { + mockTestApiKey.mockResolvedValueOnce({ + success: true, + teamName: "Test Team", + }); + + const result = await testLoopsApiKey(); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.teamName).toBe("Test Team"); + } + }); + + it("should handle invalid API key", async () => { + const { APIError } = await import("loops"); + mockTestApiKey.mockRejectedValueOnce( + new APIError(401, { error: "Invalid API key" } as any), + ); + + const result = await testLoopsApiKey(); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Invalid API key"); + } + }); + }); + + describe("findContactInLoops", () => { + it("should return contact when found", async () => { + mockFindContact.mockResolvedValueOnce([ + { + id: "contact-123", + email: "test@example.com", + firstName: "John", + userId: "user-456", + }, + ]); + + const result = await findContactInLoops({ email: "test@example.com" }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + expect(result.data.id).toBe("contact-123"); + expect(result.data.email).toBe("test@example.com"); + } + }); + + it("should return null when contact not found", async () => { + mockFindContact.mockResolvedValueOnce([]); + + const result = await findContactInLoops({ userId: "nonexistent" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBeNull(); + } + }); + }); }); diff --git a/packages/core/src/user/user.service.ts b/packages/core/src/user/user.service.ts index ee7d827..53f3413 100644 --- a/packages/core/src/user/user.service.ts +++ b/packages/core/src/user/user.service.ts @@ -13,11 +13,7 @@ export const initialise = async () => { }; export const fromID = zod(UserModel.Schema.shape.id, async (id) => { - const result = await db - .select() - .from(user) - .where(eq(user.id, id)) - .execute(); + const result = await db.select().from(user).where(eq(user.id, id)).execute(); return UserModel.User.parse(result[0]); }); @@ -41,11 +37,7 @@ export const fromEmail = zod(UserModel.User.shape.email, async (email) => { }); export const avatarKeyFromId = zod(UserModel.User.shape.id, async (id) => { - const result = await db - .select() - .from(user) - .where(eq(user.id, id)) - .execute(); + const result = await db.select().from(user).where(eq(user.id, id)).execute(); const avatarUrl = result[0]?.image ?? null; if (!avatarUrl) { return null; @@ -69,6 +61,22 @@ export const updateNameFromId = zod( }, ); +export const updatePlanFromId = zod( + UserModel.User.pick({ id: true, plan: true }), + async (input) => { + const result = await db + .update(user) + .set({ + plan: input.plan, + updatedAt: new Date(), + }) + .where(eq(user.id, input.id)) + .returning() + .execute(); + return UserModel.User.parse(result[0]); + }, +); + export const updateProfileFromOnboarding = zod( UserModel.User.pick({ id: true, diff --git a/packages/web/src/clients/waitlist/index.ts b/packages/web/src/clients/waitlist/index.ts deleted file mode 100644 index ce25fa8..0000000 --- a/packages/web/src/clients/waitlist/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./waitlist.mutation.client"; diff --git a/packages/web/src/clients/waitlist/waitlist.mutation.client.ts b/packages/web/src/clients/waitlist/waitlist.mutation.client.ts deleted file mode 100644 index 7c19450..0000000 --- a/packages/web/src/clients/waitlist/waitlist.mutation.client.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { api } from "@/lib/api"; - -export interface WaitlistInput { - email: string; - name?: string; -} - -export async function joinWaitlist(input: WaitlistInput) { - const res = await api.waitlist.$post({ - json: input, - }); - - if (!res.ok) { - const errorData = await res.json().catch(() => null); - console.error("Failed to join waitlist:", { - status: res.status, - statusText: res.statusText, - error: errorData, - }); - throw new Error( - `Could not join waitlist: ${res.status} ${res.statusText}${errorData ? ` - ${JSON.stringify(errorData)}` : ""}`, - ); - } - - return await res.json(); -} - -export function useJoinWaitlistMutation() { - return useMutation({ - mutationFn: joinWaitlist, - onSuccess: () => { - toast.success("You've been added to the waitlist!"); - }, - onError: (error) => { - console.error("Waitlist mutation error:", error); - const errorMessage = - error instanceof Error - ? error.message - : "Failed to join waitlist. Please try again."; - toast.error(errorMessage); - }, - }); -} diff --git a/packages/web/src/components/auth/verify-code-form.tsx b/packages/web/src/components/auth/verify-code-form.tsx index 36e7074..e5b3f77 100644 --- a/packages/web/src/components/auth/verify-code-form.tsx +++ b/packages/web/src/components/auth/verify-code-form.tsx @@ -16,7 +16,6 @@ import { import { InputOTP, InputOTPGroup, - InputOTPSeparator, InputOTPSlot, } from '@/components/ui/input-otp'; import { authClient } from '@/lib/auth-client'; diff --git a/packages/web/src/components/layout/Header.tsx b/packages/web/src/components/layout/Header.tsx index 47ec370..21f01bf 100644 --- a/packages/web/src/components/layout/Header.tsx +++ b/packages/web/src/components/layout/Header.tsx @@ -55,12 +55,12 @@ export const Header = () => { start {user && ( - + - + {fallbackText} diff --git a/packages/web/src/components/pricing/PricingSection.tsx b/packages/web/src/components/pricing/PricingSection.tsx new file mode 100644 index 0000000..779ad2a --- /dev/null +++ b/packages/web/src/components/pricing/PricingSection.tsx @@ -0,0 +1,74 @@ +import { cn } from "@/lib/utils"; +import { PricingTierBar, type PricingTierBarProps } from "./PricingTierBar"; + +export interface PricingTier extends Omit { + /** Unique identifier for the tier */ + id?: string; +} + +export interface PricingSectionProps { + /** Array of pricing tiers to display */ + tiers: PricingTier[]; + /** Section title - defaults to "Pricing" */ + title?: string; + /** Current price display (e.g., "$179 /per year") */ + currentPrice?: number; + /** Currency symbol - defaults to £ */ + currency?: string; + /** Optional description text below the current price */ + description?: string; + /** Additional CSS classes */ + className?: string; +} + +/** + * A pricing section component matching the Digital Creator Club design. + * Displays the current price, a description, and multiple pricing tier bars. + */ +export function PricingSection({ + tiers, + title = "Pricing", + currentPrice, + currency = "£", + description, + className, +}: PricingSectionProps) { + // Find the current (first non-sold-out) tier price if not specified + const displayPrice = + currentPrice ?? tiers.find((t) => !t.isSoldOut)?.price ?? 0; + + return ( +
+ {/* Title */} +

{title}

+ + {/* Current price display */} +
+

+ {currency} + {displayPrice}{" "} + + /per year + +

+ {description && ( +

{description}

+ )} +
+ + {/* Tier bars */} +
+ {tiers.map((tier, index) => ( + + ))} +
+
+ ); +} diff --git a/packages/web/src/components/pricing/PricingTierBar.tsx b/packages/web/src/components/pricing/PricingTierBar.tsx new file mode 100644 index 0000000..8f8a69f --- /dev/null +++ b/packages/web/src/components/pricing/PricingTierBar.tsx @@ -0,0 +1,78 @@ +import { cn } from "@/lib/utils"; + +export interface PricingTierBarProps { + /** The price value (e.g., 99) */ + price: number; + /** Currency symbol - defaults to £ */ + currency?: string; + /** Whether this tier is sold out */ + isSoldOut?: boolean; + /** Number of spots already filled/taken (default: 0) */ + filledCount?: number; + /** Total number of bars to display (default: 50) */ + barCount?: number; + /** Additional CSS classes */ + className?: string; +} + +/** Number of vertical bars per tier (matching reference design) */ +const DEFAULT_BAR_COUNT = 50; + +/** + * A pricing tier bar component matching the Digital Creator Club design. + * Displays a price label with optional sold out badge, followed by + * a row of small vertical bars showing availability. + * + * - Sold out tiers: All bars filled with success green + * - Current tier: Filled bars in foreground, unfilled in muted + */ +export function PricingTierBar({ + price, + currency = "£", + isSoldOut = false, + filledCount = 0, + barCount = DEFAULT_BAR_COUNT, + className, +}: PricingTierBarProps) { + // Generate array of bar indices + const bars = Array.from({ length: barCount }, (_, i) => i); + + return ( +
+ {/* Label row: "$99 tier" + optional "Sold out" badge */} +

+ {currency} + {price} tier + {isSoldOut && ( + + Sold out + + )} +

+ + {/* Bar row: individual vertical bars */} +
+ {bars.map((index) => { + // Determine if this bar is "filled" (spot taken) + const isFilled = isSoldOut || index < filledCount; + + return ( + + ); + })} +
+
+ ); +} diff --git a/packages/web/src/components/pricing/index.ts b/packages/web/src/components/pricing/index.ts new file mode 100644 index 0000000..f56c8f1 --- /dev/null +++ b/packages/web/src/components/pricing/index.ts @@ -0,0 +1,6 @@ +export { + PricingSection, + type PricingSectionProps, + type PricingTier, +} from "./PricingSection"; +export { PricingTierBar, type PricingTierBarProps } from "./PricingTierBar"; diff --git a/packages/web/src/components/ui/avatar.tsx b/packages/web/src/components/ui/avatar.tsx index aab9e12..7355926 100644 --- a/packages/web/src/components/ui/avatar.tsx +++ b/packages/web/src/components/ui/avatar.tsx @@ -36,7 +36,7 @@ const AvatarFallback = React.forwardRef< { asChild?: boolean; isLoading?: boolean; + isError?: boolean; icon?: React.ReactNode; } @@ -56,6 +57,7 @@ const Button = React.forwardRef( size, asChild = false, isLoading = false, + isError = false, icon, children, ...props @@ -68,7 +70,11 @@ const Button = React.forwardRef( const componentProps = asChild ? { className: cn(buttonVariants({ variant, size, className })) } : { - className: cn(buttonVariants({ variant, size, className })), + className: cn( + buttonVariants({ variant, size, className }), + isError && + 'animate-shake bg-error border-error text-background' + ), ref, disabled: isLoading || props.disabled, ...props, diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index e10db8b..0dabb04 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,19 +1,19 @@ -import type { RoutesType } from "@backend/api/api"; -import { hc } from "hono/client"; +import type { RoutesType } from '@backend/api/api'; +import { hc } from 'hono/client'; // Client-side: use relative URL (works in browser) // Server-side: use platform URL from env (works with custom domains via proxy) const getApiUrl = () => { - if (typeof window === "undefined") { - // Server-side: use the platform URL which proxies to API - return `${process.env.PLATFORM_URL}/api`; - } - // Client-side: use relative URL (proxied through same domain) - return "/api"; + if (typeof window === 'undefined') { + // Server-side: use the platform URL which proxies to API + return `${process.env.PLATFORM_URL}/api`; + } + // Client-side: use relative URL (proxied through same domain) + return '/api'; }; -export const api = hc(getApiUrl(), { - init: { - credentials: "include", - }, +export const api = hc(`${process.env.VITE_API_URL}`, { + init: { + credentials: 'include', + }, }); diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 18359dd..d8910fe 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -20,16 +20,16 @@ import { Route as UseCasesExtensionRouteImport } from './routes/use-cases/extens import { Route as MarketingTermsRouteImport } from './routes/_marketing/terms' import { Route as MarketingPrivacyRouteImport } from './routes/_marketing/privacy' import { Route as MarketingAboutRouteImport } from './routes/_marketing/about' -import { Route as AuthOnboardingRouteImport } from './routes/_auth/onboarding' +import { Route as AuthOnboardingRouteImport } from './routes/_auth/_onboarding' import { Route as MarketingGuidesIndexRouteImport } from './routes/_marketing/guides/index' import { Route as LoginLoginIndexRouteImport } from './routes/_login/login/index' -import { Route as AuthOnboardingIndexRouteImport } from './routes/_auth/onboarding/index' import { Route as AuthAppIndexRouteImport } from './routes/_auth/app/index' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as MarketingGuidesSlugRouteImport } from './routes/_marketing/guides/$slug' import { Route as LoginLoginCodeRouteImport } from './routes/_login/login/code' -import { Route as AuthOnboardingThankYouRouteImport } from './routes/_auth/onboarding/thank-you' import { Route as AuthAppSettingsRouteImport } from './routes/_auth/app/settings' +import { Route as AuthOnboardingOnboardingIndexRouteImport } from './routes/_auth/_onboarding/onboarding/index' +import { Route as AuthOnboardingOnboardingThankYouRouteImport } from './routes/_auth/_onboarding/onboarding/thank-you' const BlogRoute = BlogRouteImport.update({ id: '/blog', @@ -84,8 +84,7 @@ const MarketingAboutRoute = MarketingAboutRouteImport.update({ getParentRoute: () => MarketingRoute, } as any) const AuthOnboardingRoute = AuthOnboardingRouteImport.update({ - id: '/onboarding', - path: '/onboarding', + id: '/_onboarding', getParentRoute: () => AuthRoute, } as any) const MarketingGuidesIndexRoute = MarketingGuidesIndexRouteImport.update({ @@ -98,11 +97,6 @@ const LoginLoginIndexRoute = LoginLoginIndexRouteImport.update({ path: '/login/', getParentRoute: () => LoginRoute, } as any) -const AuthOnboardingIndexRoute = AuthOnboardingIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AuthOnboardingRoute, -} as any) const AuthAppIndexRoute = AuthAppIndexRouteImport.update({ id: '/app/', path: '/app/', @@ -123,21 +117,27 @@ const LoginLoginCodeRoute = LoginLoginCodeRouteImport.update({ path: '/login/code', getParentRoute: () => LoginRoute, } as any) -const AuthOnboardingThankYouRoute = AuthOnboardingThankYouRouteImport.update({ - id: '/thank-you', - path: '/thank-you', - getParentRoute: () => AuthOnboardingRoute, -} as any) const AuthAppSettingsRoute = AuthAppSettingsRouteImport.update({ id: '/app/settings', path: '/app/settings', getParentRoute: () => AuthRoute, } as any) +const AuthOnboardingOnboardingIndexRoute = + AuthOnboardingOnboardingIndexRouteImport.update({ + id: '/onboarding/', + path: '/onboarding/', + getParentRoute: () => AuthOnboardingRoute, + } as any) +const AuthOnboardingOnboardingThankYouRoute = + AuthOnboardingOnboardingThankYouRouteImport.update({ + id: '/onboarding/thank-you', + path: '/onboarding/thank-you', + getParentRoute: () => AuthOnboardingRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof MarketingIndexRoute '/blog': typeof BlogRoute - '/onboarding': typeof AuthOnboardingRouteWithChildren '/about': typeof MarketingAboutRoute '/privacy': typeof MarketingPrivacyRoute '/terms': typeof MarketingTermsRoute @@ -145,14 +145,14 @@ export interface FileRoutesByFullPath { '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute '/app/settings': typeof AuthAppSettingsRoute - '/onboarding/thank-you': typeof AuthOnboardingThankYouRoute '/login/code': typeof LoginLoginCodeRoute '/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/app/': typeof AuthAppIndexRoute - '/onboarding/': typeof AuthOnboardingIndexRoute '/login/': typeof LoginLoginIndexRoute '/guides/': typeof MarketingGuidesIndexRoute + '/onboarding/thank-you': typeof AuthOnboardingOnboardingThankYouRoute + '/onboarding/': typeof AuthOnboardingOnboardingIndexRoute } export interface FileRoutesByTo { '/': typeof MarketingIndexRoute @@ -164,14 +164,14 @@ export interface FileRoutesByTo { '/use-cases/kitchen': typeof UseCasesKitchenRoute '/use-cases/loft': typeof UseCasesLoftRoute '/app/settings': typeof AuthAppSettingsRoute - '/onboarding/thank-you': typeof AuthOnboardingThankYouRoute '/login/code': typeof LoginLoginCodeRoute '/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/app': typeof AuthAppIndexRoute - '/onboarding': typeof AuthOnboardingIndexRoute '/login': typeof LoginLoginIndexRoute '/guides': typeof MarketingGuidesIndexRoute + '/onboarding/thank-you': typeof AuthOnboardingOnboardingThankYouRoute + '/onboarding': typeof AuthOnboardingOnboardingIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -179,7 +179,7 @@ export interface FileRoutesById { '/_login': typeof LoginRouteWithChildren '/_marketing': typeof MarketingRouteWithChildren '/blog': typeof BlogRoute - '/_auth/onboarding': typeof AuthOnboardingRouteWithChildren + '/_auth/_onboarding': typeof AuthOnboardingRouteWithChildren '/_marketing/about': typeof MarketingAboutRoute '/_marketing/privacy': typeof MarketingPrivacyRoute '/_marketing/terms': typeof MarketingTermsRoute @@ -188,21 +188,20 @@ export interface FileRoutesById { '/use-cases/loft': typeof UseCasesLoftRoute '/_marketing/': typeof MarketingIndexRoute '/_auth/app/settings': typeof AuthAppSettingsRoute - '/_auth/onboarding/thank-you': typeof AuthOnboardingThankYouRoute '/_login/login/code': typeof LoginLoginCodeRoute '/_marketing/guides/$slug': typeof MarketingGuidesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/_auth/app/': typeof AuthAppIndexRoute - '/_auth/onboarding/': typeof AuthOnboardingIndexRoute '/_login/login/': typeof LoginLoginIndexRoute '/_marketing/guides/': typeof MarketingGuidesIndexRoute + '/_auth/_onboarding/onboarding/thank-you': typeof AuthOnboardingOnboardingThankYouRoute + '/_auth/_onboarding/onboarding/': typeof AuthOnboardingOnboardingIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/blog' - | '/onboarding' | '/about' | '/privacy' | '/terms' @@ -210,14 +209,14 @@ export interface FileRouteTypes { | '/use-cases/kitchen' | '/use-cases/loft' | '/app/settings' - | '/onboarding/thank-you' | '/login/code' | '/guides/$slug' | '/api/auth/$' | '/app/' - | '/onboarding/' | '/login/' | '/guides/' + | '/onboarding/thank-you' + | '/onboarding/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -229,21 +228,21 @@ export interface FileRouteTypes { | '/use-cases/kitchen' | '/use-cases/loft' | '/app/settings' - | '/onboarding/thank-you' | '/login/code' | '/guides/$slug' | '/api/auth/$' | '/app' - | '/onboarding' | '/login' | '/guides' + | '/onboarding/thank-you' + | '/onboarding' id: | '__root__' | '/_auth' | '/_login' | '/_marketing' | '/blog' - | '/_auth/onboarding' + | '/_auth/_onboarding' | '/_marketing/about' | '/_marketing/privacy' | '/_marketing/terms' @@ -252,14 +251,14 @@ export interface FileRouteTypes { | '/use-cases/loft' | '/_marketing/' | '/_auth/app/settings' - | '/_auth/onboarding/thank-you' | '/_login/login/code' | '/_marketing/guides/$slug' | '/api/auth/$' | '/_auth/app/' - | '/_auth/onboarding/' | '/_login/login/' | '/_marketing/guides/' + | '/_auth/_onboarding/onboarding/thank-you' + | '/_auth/_onboarding/onboarding/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -352,10 +351,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingAboutRouteImport parentRoute: typeof MarketingRoute } - '/_auth/onboarding': { - id: '/_auth/onboarding' - path: '/onboarding' - fullPath: '/onboarding' + '/_auth/_onboarding': { + id: '/_auth/_onboarding' + path: '' + fullPath: '/' preLoaderRoute: typeof AuthOnboardingRouteImport parentRoute: typeof AuthRoute } @@ -373,13 +372,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLoginIndexRouteImport parentRoute: typeof LoginRoute } - '/_auth/onboarding/': { - id: '/_auth/onboarding/' - path: '/' - fullPath: '/onboarding/' - preLoaderRoute: typeof AuthOnboardingIndexRouteImport - parentRoute: typeof AuthOnboardingRoute - } '/_auth/app/': { id: '/_auth/app/' path: '/app' @@ -408,13 +400,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLoginCodeRouteImport parentRoute: typeof LoginRoute } - '/_auth/onboarding/thank-you': { - id: '/_auth/onboarding/thank-you' - path: '/thank-you' - fullPath: '/onboarding/thank-you' - preLoaderRoute: typeof AuthOnboardingThankYouRouteImport - parentRoute: typeof AuthOnboardingRoute - } '/_auth/app/settings': { id: '/_auth/app/settings' path: '/app/settings' @@ -422,17 +407,31 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAppSettingsRouteImport parentRoute: typeof AuthRoute } + '/_auth/_onboarding/onboarding/': { + id: '/_auth/_onboarding/onboarding/' + path: '/onboarding' + fullPath: '/onboarding/' + preLoaderRoute: typeof AuthOnboardingOnboardingIndexRouteImport + parentRoute: typeof AuthOnboardingRoute + } + '/_auth/_onboarding/onboarding/thank-you': { + id: '/_auth/_onboarding/onboarding/thank-you' + path: '/onboarding/thank-you' + fullPath: '/onboarding/thank-you' + preLoaderRoute: typeof AuthOnboardingOnboardingThankYouRouteImport + parentRoute: typeof AuthOnboardingRoute + } } } interface AuthOnboardingRouteChildren { - AuthOnboardingThankYouRoute: typeof AuthOnboardingThankYouRoute - AuthOnboardingIndexRoute: typeof AuthOnboardingIndexRoute + AuthOnboardingOnboardingThankYouRoute: typeof AuthOnboardingOnboardingThankYouRoute + AuthOnboardingOnboardingIndexRoute: typeof AuthOnboardingOnboardingIndexRoute } const AuthOnboardingRouteChildren: AuthOnboardingRouteChildren = { - AuthOnboardingThankYouRoute: AuthOnboardingThankYouRoute, - AuthOnboardingIndexRoute: AuthOnboardingIndexRoute, + AuthOnboardingOnboardingThankYouRoute: AuthOnboardingOnboardingThankYouRoute, + AuthOnboardingOnboardingIndexRoute: AuthOnboardingOnboardingIndexRoute, } const AuthOnboardingRouteWithChildren = AuthOnboardingRoute._addFileChildren( diff --git a/packages/web/src/routes/_auth.tsx b/packages/web/src/routes/_auth.tsx index c151c5c..b407424 100644 --- a/packages/web/src/routes/_auth.tsx +++ b/packages/web/src/routes/_auth.tsx @@ -19,13 +19,17 @@ export const Route = createFileRoute("/_auth")({ // Check if this is an onboarding route const isOnboardingRoute = location.pathname.startsWith("/onboarding"); - // Redirect to onboarding if no plan - if (!session.user.plan && !isOnboardingRoute) { + // Users without a plan OR on waitlist should be on onboarding + const needsOnboarding = + !session.user.plan || session.user.plan === "waitlist"; + + // Redirect to onboarding if needs onboarding + if (needsOnboarding && !isOnboardingRoute) { throw redirect({ to: "/onboarding" }); } - // Redirect to app if has plan and trying to access onboarding - if (session.user.plan && isOnboardingRoute) { + // Redirect to app if has real plan and trying to access onboarding + if (!needsOnboarding && isOnboardingRoute) { throw redirect({ to: "/app" }); } diff --git a/packages/web/src/routes/_auth/_onboarding.tsx b/packages/web/src/routes/_auth/_onboarding.tsx new file mode 100644 index 0000000..327f509 --- /dev/null +++ b/packages/web/src/routes/_auth/_onboarding.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { DiamondCorner } from "@/components/layout/DiamondCorner"; + +export const Route = createFileRoute("/_auth/_onboarding")({ + component: LayoutComponent, +}); + +/** + * Onboarding layout with square gutters along all edges. + * Uses CSS Grid to create a frame effect similar to ui.sh + * Grid structure: + * [padding] | 1px line | content | 1px line | [padding] + * with diamond corners at intersections + */ +function LayoutComponent() { + return ( +
+ {/* + Grid layout: 5 cols × 5 rows + - Column 1: left padding + - Column 2: left gutter line (1px) + - Column 3: main content + - Column 4: right gutter line (1px) + - Column 5: right padding + + Same pattern for rows (top padding, top line, content, bottom line, bottom padding) + */} +
+ {/* Top gutter line - spans all 5 columns */} +
+ + {/* Bottom gutter line - spans all 5 columns */} +
+ + {/* Left gutter line - spans all 5 rows */} +
+ + {/* Right gutter line - spans all 5 rows */} +
+ + {/* + Diamond corners at line intersections. + Each diamond is placed in a corner padding cell but positioned + at the inner corner (where the lines intersect). + Using OPPOSITE position values since we want the inner corners. + */} +
+ +
+
+ +
+
+ +
+
+ +
+ + {/* Main content area */} +
+ +
+
+
+ ); +} diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx new file mode 100644 index 0000000..347e9fb --- /dev/null +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx @@ -0,0 +1,173 @@ +import { createContactInLoops, MAILING_LISTS } from "@structa/core/marketing"; +import { UserService } from "@structa/core/user"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createServerFn, useServerFn } from "@tanstack/react-start"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { HomeIconLink } from "@/components/ui/link"; + +interface WaitlistInput { + email: string; + userId: string; + name?: string; + updateName: boolean; // Whether to update name in database +} + +// Server function to join waitlist - calls Loops directly from core package +// Also updates user's name and plan in database +export const joinWaitlistFn = createServerFn({ method: "POST" }) + .inputValidator((input: WaitlistInput) => input) + .handler(async ({ data }) => { + // Update user's name in database if they provided one + if (data.updateName && data.name) { + await UserService.updateNameFromId({ + id: data.userId, + name: data.name, + }); + } + + // Add to Loops waitlist + const nameParts = (data.name || "").trim().split(" "); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(" ") || undefined; + + const result = await createContactInLoops({ + email: data.email, + userId: data.email, // Use email as userId for waitlist signups + firstName, + lastName, + properties: { + source: "waitlist", + createdAt: new Date().toISOString(), + }, + mailingLists: { + [MAILING_LISTS.WAITLIST]: true, + [MAILING_LISTS.PRODUCT]: true, + [MAILING_LISTS.MARKETING]: true, + }, + }); + + if (!result.success) { + throw new Error(result.error); + } + + // Update user's plan to "waitlist" after successful Loops signup + await UserService.updatePlanFromId({ + id: data.userId, + plan: "waitlist", + }); + + return { success: true }; + }); + +export const Route = createFileRoute("/_auth/_onboarding/onboarding/")({ + component: OnboardingPage, +}); + +function OnboardingPage() { + // Get user from route context (populated by _auth.tsx beforeLoad) + const { user } = Route.useRouteContext(); + const navigate = useNavigate(); + + // Check if user is already on the waitlist + const isOnWaitlist = user.plan === "waitlist"; + + // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) + const userHasName = user.name != null && user.name.trim().length > 0; + const [name, setName] = useState(""); + + const joinWaitlistMutation = useMutation({ + mutationFn: useServerFn(joinWaitlistFn), + onSuccess: () => { + navigate({ to: "/onboarding/thank-you" }); + }, + onError: (error) => { + console.error("Waitlist mutation error:", error); + }, + }); + + const hasError = joinWaitlistMutation.isError; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!user.email || !user.id) { + return; + } + + // Only send name if we're collecting it (user doesn't have one already) + joinWaitlistMutation.mutate({ + data: { + email: user.email, + userId: user.id, + name: !userHasName ? name || undefined : user.name || undefined, + updateName: !userHasName && !!name, // Only update if user entered a new name + }, + }); + }; + + return ( +
+
+
+
+
+ +
+

+ Request early access. +

+

+ AI-powered workspace to renovate with confidence. +

+
+ +

+ We're building an AI assistant that understands your property and + guides your renovation.{" "} +

+ + {/* Name Input Form */} +
+ {/* Only show name field if user signed up with email (no OAuth name) */} + {!userHasName && !isOnWaitlist && ( + setName(e.target.value)} + disabled={joinWaitlistMutation.isPending} + /> + )} + + + + {hasError && ( +

+ Failed to join waitlist. Please try again. +

+ )} +
+
+
+
+ ); +} diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx new file mode 100644 index 0000000..1164033 --- /dev/null +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx @@ -0,0 +1,49 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { MoveLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { HomeIconLink } from '@/components/ui/link'; +import { useSession } from '@/lib/auth-client'; + +export const Route = createFileRoute('/_auth/_onboarding/onboarding/thank-you')( + { + component: RouteComponent, + } +); + +function RouteComponent() { + const { data: session } = useSession(); + const navigate = useNavigate(); + const name = session?.user?.name; + + return ( +
+
+
+
+
+ +
+

+ You're on the list! +

+

+ Thanks for signing up{' '} + {name} — + can't wait to show you what we've been working on. +

+
+ + +
+
+
+ ); +} diff --git a/packages/web/src/routes/_auth/onboarding.tsx b/packages/web/src/routes/_auth/onboarding.tsx deleted file mode 100644 index b287655..0000000 --- a/packages/web/src/routes/_auth/onboarding.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { DiagonalDivider } from "@/components/layout"; -import { Header } from "@/components/layout/Header"; - -export const Route = createFileRoute("/_auth/onboarding")({ - component: OnboardingLayout, -}); - -function OnboardingLayout() { - return ( -
-
- - {/* Main content wrapper */} -
- {/* Side gutters with noise texture */} -
-
-
-
-
-
-
- - -
-
-
- ); -} diff --git a/packages/web/src/routes/_auth/onboarding/index.tsx b/packages/web/src/routes/_auth/onboarding/index.tsx deleted file mode 100644 index e47aac2..0000000 --- a/packages/web/src/routes/_auth/onboarding/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; -import { useJoinWaitlistMutation } from "@/clients/waitlist"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { useSession } from "@/lib/auth-client"; - -export const Route = createFileRoute("/_auth/onboarding/")({ - component: OnboardingPage, -}); - -function OnboardingPage() { - const { data: session } = useSession(); - const joinWaitlist = useJoinWaitlistMutation(); - const navigate = useNavigate(); - - // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) - // Check for non-empty string (handles "", null, undefined, whitespace-only) - const userName = session?.user?.name; - const userHasName = userName != null && userName.trim().length > 0; - const [name, setName] = useState(""); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!session?.user?.email) { - return; - } - - // Only send name if we're collecting it (user doesn't have one already) - joinWaitlist.mutate( - { - email: session.user.email, - name: !userHasName ? name || undefined : session.user.name || undefined, - }, - { - onSuccess: () => { - navigate({ to: "/onboarding/thank-you" }); - }, - }, - ); - }; - - return ( -
-
- {/* Structa Introduction */} -
-

- Renovate with confidence. -
- - AI-powered workspace for UK homeowners. - -

-

- AI assistant that understands your property and guides your - renovation. Request early access below. -

-
- - {/* Name Input Form */} -
- {/* Only show name field if user signed up with email (no OAuth name) */} - {!userHasName && ( -
- setName(e.target.value)} - disabled={joinWaitlist.isPending} - /> -
- )} - - -
-
-
- ); -} diff --git a/packages/web/src/routes/_auth/onboarding/thank-you.tsx b/packages/web/src/routes/_auth/onboarding/thank-you.tsx deleted file mode 100644 index cd88d95..0000000 --- a/packages/web/src/routes/_auth/onboarding/thank-you.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Button } from "@/components/ui/button"; - -export const Route = createFileRoute("/_auth/onboarding/thank-you")({ - component: ThankYouPage, -}); - -function ThankYouPage() { - return ( -
-
-
-

- You're on the list! -

-

- Thanks for joining the waitlist. We'll be in touch soon with early - access to Structa. -

-
- -
-

- In the meantime, check out our renovation guides. -

- -
-
-
- ); -} diff --git a/packages/web/src/styles/app.css b/packages/web/src/styles/app.css index 82ac148..977455a 100644 --- a/packages/web/src/styles/app.css +++ b/packages/web/src/styles/app.css @@ -137,8 +137,10 @@ --color-accent: var(--ds-powder); --color-accent-foreground: var(--ds-carbon); + /* Lighter error color for dark mode visibility */ + --ds-error: 358.57deg 68.478% 55%; --color-destructive: var(--ds-error); - --color-destructive-foreground: var(--ds-paper); + --color-destructive-foreground: var(--ds-carbon); --color-border: var(--ds-mono-600); --color-input: var(--ds-mono-600); @@ -453,3 +455,28 @@ html { scroll-behavior: smooth; } + +/* Shake animation for error feedback */ +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-4px); + } + 20%, + 40%, + 60%, + 80% { + transform: translateX(4px); + } +} + +.animate-shake { + animation: shake 0.5s ease-in-out; +} From 4b1ede6d2196b08fd02060dede41fb8ebbb51e22 Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Fri, 27 Feb 2026 17:00:07 +0000 Subject: [PATCH 07/13] fix: prevent /app flash when navigating to homepage from onboarding Use reloadDocument on HomeIconLink when navigating from authenticated routes to avoid client-side router route tree conflicts. --- packages/web/src/components/ui/link.tsx | 7 + .../_auth/_onboarding/onboarding/index.tsx | 331 +++++++++--------- 2 files changed, 175 insertions(+), 163 deletions(-) diff --git a/packages/web/src/components/ui/link.tsx b/packages/web/src/components/ui/link.tsx index 2f5219e..2a10c8b 100644 --- a/packages/web/src/components/ui/link.tsx +++ b/packages/web/src/components/ui/link.tsx @@ -128,9 +128,16 @@ export const HomeIconLink = React.forwardRef< onClick?.(e); }; + // Use reloadDocument when navigating from authenticated routes to avoid + // route tree conflicts that can cause brief flashes of /app + const isAuthRoute = + location.pathname.startsWith("/app") || + location.pathname.startsWith("/onboarding"); + return ( input) - .handler(async ({ data }) => { - // Update user's name in database if they provided one - if (data.updateName && data.name) { - await UserService.updateNameFromId({ - id: data.userId, - name: data.name, - }); - } - - // Add to Loops waitlist - const nameParts = (data.name || "").trim().split(" "); - const firstName = nameParts[0] || undefined; - const lastName = nameParts.slice(1).join(" ") || undefined; - - const result = await createContactInLoops({ - email: data.email, - userId: data.email, // Use email as userId for waitlist signups - firstName, - lastName, - properties: { - source: "waitlist", - createdAt: new Date().toISOString(), - }, - mailingLists: { - [MAILING_LISTS.WAITLIST]: true, - [MAILING_LISTS.PRODUCT]: true, - [MAILING_LISTS.MARKETING]: true, - }, - }); - - if (!result.success) { - throw new Error(result.error); - } - - // Update user's plan to "waitlist" after successful Loops signup - await UserService.updatePlanFromId({ - id: data.userId, - plan: "waitlist", - }); - - return { success: true }; - }); - -export const Route = createFileRoute("/_auth/_onboarding/onboarding/")({ - component: OnboardingPage, +export const joinWaitlistFn = createServerFn({ method: 'POST' }) + .inputValidator((input: WaitlistInput) => input) + .handler(async ({ data }) => { + // Update user's name in database if they provided one + if (data.updateName && data.name) { + await UserService.updateNameFromId({ + id: data.userId, + name: data.name, + }); + } + + // Add to Loops waitlist + const nameParts = (data.name || '').trim().split(' '); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(' ') || undefined; + + const result = await createContactInLoops({ + email: data.email, + userId: data.email, // Use email as userId for waitlist signups + firstName, + lastName, + properties: { + source: 'waitlist', + createdAt: new Date().toISOString(), + }, + mailingLists: { + [MAILING_LISTS.WAITLIST]: true, + [MAILING_LISTS.PRODUCT]: true, + [MAILING_LISTS.MARKETING]: true, + }, + }); + + if (!result.success) { + throw new Error(result.error); + } + + // Update user's plan to "waitlist" after successful Loops signup + await UserService.updatePlanFromId({ + id: data.userId, + plan: 'waitlist', + }); + + return { success: false }; + }); + +export const Route = createFileRoute('/_auth/_onboarding/onboarding/')({ + component: OnboardingPage, }); function OnboardingPage() { - // Get user from route context (populated by _auth.tsx beforeLoad) - const { user } = Route.useRouteContext(); - const navigate = useNavigate(); - - // Check if user is already on the waitlist - const isOnWaitlist = user.plan === "waitlist"; - - // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) - const userHasName = user.name != null && user.name.trim().length > 0; - const [name, setName] = useState(""); - - const joinWaitlistMutation = useMutation({ - mutationFn: useServerFn(joinWaitlistFn), - onSuccess: () => { - navigate({ to: "/onboarding/thank-you" }); - }, - onError: (error) => { - console.error("Waitlist mutation error:", error); - }, - }); - - const hasError = joinWaitlistMutation.isError; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!user.email || !user.id) { - return; - } - - // Only send name if we're collecting it (user doesn't have one already) - joinWaitlistMutation.mutate({ - data: { - email: user.email, - userId: user.id, - name: !userHasName ? name || undefined : user.name || undefined, - updateName: !userHasName && !!name, // Only update if user entered a new name - }, - }); - }; - - return ( -
-
-
-
-
- -
-

- Request early access. -

-

- AI-powered workspace to renovate with confidence. -

-
- -

- We're building an AI assistant that understands your property and - guides your renovation.{" "} -

- - {/* Name Input Form */} -
- {/* Only show name field if user signed up with email (no OAuth name) */} - {!userHasName && !isOnWaitlist && ( - setName(e.target.value)} - disabled={joinWaitlistMutation.isPending} - /> - )} - - - - {hasError && ( -

- Failed to join waitlist. Please try again. -

- )} -
-
-
-
- ); + // Get user from route context (populated by _auth.tsx beforeLoad) + const { user } = Route.useRouteContext(); + const navigate = useNavigate(); + + // Check if user is already on the waitlist + const isOnWaitlist = user.plan === 'waitlist'; + + // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) + const userHasName = user.name != null && user.name.trim().length > 0; + const [name, setName] = useState(''); + + const joinWaitlistMutation = useMutation({ + mutationFn: useServerFn(joinWaitlistFn), + onSuccess: () => { + navigate({ to: '/onboarding/thank-you' }); + }, + onError: error => { + console.error('Waitlist mutation error:', error); + }, + }); + + const hasError = joinWaitlistMutation.isError; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!user.email || !user.id) { + return; + } + + // Only send name if we're collecting it (user doesn't have one already) + joinWaitlistMutation.mutate({ + data: { + email: user.email, + userId: user.id, + name: !userHasName ? name || undefined : user.name || undefined, + updateName: !userHasName && !!name, // Only update if user entered a new name + }, + }); + }; + + return ( +
+
+
+
+
+ +
+

+ Request early access. +

+

+ AI-powered workspace to renovate with confidence. +

+
+ +

+ We're building an AI assistant that understands your + property and guides your renovation.{' '} +

+ + {/* Name Input Form */} +
+ {/* Only show name field if user signed up with email (no OAuth name) */} + {!userHasName && !isOnWaitlist && ( + setName(e.target.value)} + disabled={joinWaitlistMutation.isPending} + /> + )} + + + + {hasError && ( +

+ Failed to join waitlist. Please try again. +

+ )} +
+
+
+
+ ); } From 5e326b2bf26924d48a284f137ee84cfe765e1ae6 Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Fri, 27 Feb 2026 17:44:09 +0000 Subject: [PATCH 08/13] fix: prevent /app flash when navigating to homepage from onboarding Use reloadDocument on HomeIconLink when navigating from authenticated routes to avoid client-side router route tree conflicts. --- packages/web/src/middleware/auth.ts | 88 +++++++++---------- .../_auth/_onboarding/onboarding/index.tsx | 4 +- .../_onboarding/onboarding/thank-you.tsx | 2 +- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/web/src/middleware/auth.ts b/packages/web/src/middleware/auth.ts index 83e0c30..4af7943 100644 --- a/packages/web/src/middleware/auth.ts +++ b/packages/web/src/middleware/auth.ts @@ -1,53 +1,53 @@ -import { redirect } from '@tanstack/react-router'; -import { createMiddleware } from '@tanstack/react-start'; -import { auth } from '@/lib/auth'; +import { redirect } from "@tanstack/react-router"; +import { createMiddleware } from "@tanstack/react-start"; +import { auth } from "@/lib/auth"; export const authMiddleware = createMiddleware().server( - async ({ next, request }) => { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session) { - throw redirect({ to: '/login' as any }); - } - - return await next({ - context: { session: session.session, user: session.user }, - }); - } + async ({ next, request }) => { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + throw redirect({ to: "/login" as any }); + } + + return await next({ + context: { session: session.session, user: session.user }, + }); + }, ); export const publicAuthMiddleware = createMiddleware().server( - async ({ next, request }) => { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session) { - return await next({ - context: { session: null, user: null } as any, - }); - } - - return await next({ - context: { session: session.session, user: session.user } as any, - }); - } + async ({ next, request }) => { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session) { + return await next({ + context: { session: null, user: null } as any, + }); + } + + return await next({ + context: { session: session.session, user: session.user } as any, + }); + }, ); export const loginMiddleware = createMiddleware().server( - async ({ next, request }) => { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (session) { - throw redirect({ to: '/app' as any }); - } - - return await next({ - context: { session: null, user: null }, - }); - } + async ({ next, request }) => { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (session) { + throw redirect({ to: "/app" as any }); + } + + return await next({ + context: { session: null, user: null }, + }); + }, ); diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx index 2e65fea..f6ec51a 100644 --- a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx @@ -59,7 +59,7 @@ export const joinWaitlistFn = createServerFn({ method: 'POST' }) plan: 'waitlist', }); - return { success: false }; + return { success: true }; }); export const Route = createFileRoute('/_auth/_onboarding/onboarding/')({ @@ -166,7 +166,7 @@ function OnboardingPage() { {hasError && ( -

+

Failed to join waitlist. Please try again.

)} diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx index 1164033..c04cf32 100644 --- a/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/thank-you.tsx @@ -26,7 +26,7 @@ function RouteComponent() {

You're on the list!

-

+

Thanks for signing up{' '} {name} — can't wait to show you what we've been working on. From 1dafbccb7cdc8f96f3d96e83bcb2fd8a85694c17 Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Fri, 27 Feb 2026 18:21:51 +0000 Subject: [PATCH 09/13] fix: properly handle waitlist users in login middleware - Only redirect to /app if user has a real plan (not waitlist) - Waitlist users can access login and marketing routes - This prevents the /app flash when navigating from onboarding to homepage --- .../web/src/components/ui/avatar-stack.tsx | 56 +++ packages/web/src/middleware/auth.ts | 7 +- .../_auth/_onboarding/onboarding/index.tsx | 371 ++++++++++-------- 3 files changed, 265 insertions(+), 169 deletions(-) create mode 100644 packages/web/src/components/ui/avatar-stack.tsx diff --git a/packages/web/src/components/ui/avatar-stack.tsx b/packages/web/src/components/ui/avatar-stack.tsx new file mode 100644 index 0000000..319cc35 --- /dev/null +++ b/packages/web/src/components/ui/avatar-stack.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +interface AvatarItem { + src: string; + alt: string; + fallback?: string; +} + +interface AvatarStackProps { + avatars: AvatarItem[]; + className?: string; + maxDisplay?: number; +} + +/** + * AvatarStack - Displays overlapping avatars in a stack layout + * Inspired by ui.sh creator avatars + */ +const AvatarStack = React.forwardRef( + ({ avatars, className, maxDisplay = 3 }, ref) => { + const displayAvatars = avatars.slice(0, maxDisplay); + const remainingCount = avatars.length - maxDisplay; + + return ( +
+ {displayAvatars.map((avatar) => ( + 0 && "-ml-3", // Overlap avatars + )} + > + + + {avatar.fallback || avatar.alt.charAt(0).toUpperCase()} + + + ))} + {remainingCount > 0 && ( + + + +{remainingCount} + + + )} +
+ ); + }, +); +AvatarStack.displayName = "AvatarStack"; + +export { AvatarStack }; +export type { AvatarStackProps, AvatarItem }; diff --git a/packages/web/src/middleware/auth.ts b/packages/web/src/middleware/auth.ts index 4af7943..2816ca8 100644 --- a/packages/web/src/middleware/auth.ts +++ b/packages/web/src/middleware/auth.ts @@ -43,7 +43,12 @@ export const loginMiddleware = createMiddleware().server( }); if (session) { - throw redirect({ to: "/app" as any }); + // Only redirect to /app if user has a real plan (not waitlist) + const hasRealPlan = session.user.plan && session.user.plan !== "waitlist"; + + if (hasRealPlan) { + throw redirect({ to: "/app" as any }); + } } return await next({ diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx index f6ec51a..06da6bf 100644 --- a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx @@ -1,178 +1,213 @@ -import { createContactInLoops, MAILING_LISTS } from '@structa/core/marketing'; -import { UserService } from '@structa/core/user'; -import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { createServerFn, useServerFn } from '@tanstack/react-start'; -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { HomeIconLink } from '@/components/ui/link'; +import { createContactInLoops, MAILING_LISTS } from "@structa/core/marketing"; +import { UserService } from "@structa/core/user"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createServerFn, useServerFn } from "@tanstack/react-start"; +import { useState } from "react"; +import { AvatarStack } from "@/components/ui/avatar-stack"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { HomeIconLink } from "@/components/ui/link"; interface WaitlistInput { - email: string; - userId: string; - name?: string; - updateName: boolean; // Whether to update name in database + email: string; + userId: string; + name?: string; + updateName: boolean; // Whether to update name in database } // Server function to join waitlist - calls Loops directly from core package // Also updates user's name and plan in database -export const joinWaitlistFn = createServerFn({ method: 'POST' }) - .inputValidator((input: WaitlistInput) => input) - .handler(async ({ data }) => { - // Update user's name in database if they provided one - if (data.updateName && data.name) { - await UserService.updateNameFromId({ - id: data.userId, - name: data.name, - }); - } - - // Add to Loops waitlist - const nameParts = (data.name || '').trim().split(' '); - const firstName = nameParts[0] || undefined; - const lastName = nameParts.slice(1).join(' ') || undefined; - - const result = await createContactInLoops({ - email: data.email, - userId: data.email, // Use email as userId for waitlist signups - firstName, - lastName, - properties: { - source: 'waitlist', - createdAt: new Date().toISOString(), - }, - mailingLists: { - [MAILING_LISTS.WAITLIST]: true, - [MAILING_LISTS.PRODUCT]: true, - [MAILING_LISTS.MARKETING]: true, - }, - }); - - if (!result.success) { - throw new Error(result.error); - } - - // Update user's plan to "waitlist" after successful Loops signup - await UserService.updatePlanFromId({ - id: data.userId, - plan: 'waitlist', - }); - - return { success: true }; - }); - -export const Route = createFileRoute('/_auth/_onboarding/onboarding/')({ - component: OnboardingPage, +export const joinWaitlistFn = createServerFn({ method: "POST" }) + .inputValidator((input: WaitlistInput) => input) + .handler(async ({ data }) => { + // Update user's name in database if they provided one + if (data.updateName && data.name) { + await UserService.updateNameFromId({ + id: data.userId, + name: data.name, + }); + } + + // Add to Loops waitlist + const nameParts = (data.name || "").trim().split(" "); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(" ") || undefined; + + const result = await createContactInLoops({ + email: data.email, + userId: data.email, // Use email as userId for waitlist signups + firstName, + lastName, + properties: { + source: "waitlist", + createdAt: new Date().toISOString(), + }, + mailingLists: { + [MAILING_LISTS.WAITLIST]: true, + [MAILING_LISTS.PRODUCT]: true, + [MAILING_LISTS.MARKETING]: true, + }, + }); + + if (!result.success) { + throw new Error(result.error); + } + + // Update user's plan to "waitlist" after successful Loops signup + await UserService.updatePlanFromId({ + id: data.userId, + plan: "waitlist", + }); + + return { success: true }; + }); + +export const Route = createFileRoute("/_auth/_onboarding/onboarding/")({ + component: OnboardingPage, }); function OnboardingPage() { - // Get user from route context (populated by _auth.tsx beforeLoad) - const { user } = Route.useRouteContext(); - const navigate = useNavigate(); - - // Check if user is already on the waitlist - const isOnWaitlist = user.plan === 'waitlist'; - - // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) - const userHasName = user.name != null && user.name.trim().length > 0; - const [name, setName] = useState(''); - - const joinWaitlistMutation = useMutation({ - mutationFn: useServerFn(joinWaitlistFn), - onSuccess: () => { - navigate({ to: '/onboarding/thank-you' }); - }, - onError: error => { - console.error('Waitlist mutation error:', error); - }, - }); - - const hasError = joinWaitlistMutation.isError; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!user.email || !user.id) { - return; - } - - // Only send name if we're collecting it (user doesn't have one already) - joinWaitlistMutation.mutate({ - data: { - email: user.email, - userId: user.id, - name: !userHasName ? name || undefined : user.name || undefined, - updateName: !userHasName && !!name, // Only update if user entered a new name - }, - }); - }; - - return ( -
-
-
-
-
- -
-

- Request early access. -

-

- AI-powered workspace to renovate with confidence. -

-
- -

- We're building an AI assistant that understands your - property and guides your renovation.{' '} -

- - {/* Name Input Form */} -
- {/* Only show name field if user signed up with email (no OAuth name) */} - {!userHasName && !isOnWaitlist && ( - setName(e.target.value)} - disabled={joinWaitlistMutation.isPending} - /> - )} - - - - {hasError && ( -

- Failed to join waitlist. Please try again. -

- )} -
-
-
-
- ); + // Get user from route context (populated by _auth.tsx beforeLoad) + const { user } = Route.useRouteContext(); + const navigate = useNavigate(); + + // Check if user is already on the waitlist + const isOnWaitlist = user.plan === "waitlist"; + + // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) + const userHasName = user.name != null && user.name.trim().length > 0; + const [name, setName] = useState(""); + + const joinWaitlistMutation = useMutation({ + mutationFn: useServerFn(joinWaitlistFn), + onSuccess: () => { + navigate({ to: "/onboarding/thank-you" }); + }, + onError: (error) => { + console.error("Waitlist mutation error:", error); + }, + }); + + const hasError = joinWaitlistMutation.isError; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!user.email || !user.id) { + return; + } + + // Only send name if we're collecting it (user doesn't have one already) + joinWaitlistMutation.mutate({ + data: { + email: user.email, + userId: user.id, + name: !userHasName ? name || undefined : user.name || undefined, + updateName: !userHasName && !!name, // Only update if user entered a new name + }, + }); + }; + + return ( +
+
+
+
+
+ +
+

+ Request early access. +

+

+ AI-powered workspace to renovate with confidence. +

+
+ +

+ We're building an AI assistant that understands your property and + guides your renovation.{" "} +

+ + {/* Name Input Form */} +
+ {/* Only show name field if user signed up with email (no OAuth name) */} + {!userHasName && !isOnWaitlist && ( + setName(e.target.value)} + disabled={joinWaitlistMutation.isPending} + /> + )} + + + + {hasError && ( +

+ Failed to join waitlist. Please try again. +

+ )} +
+
+
+ + {/* Avatar stack - fixed to bottom right */} +
+

+ Built by the team behind +
+ + Tailwind CSS + {" "} + &{" "} + + Refactoring UI + +

+ +
+
+ ); } From aabd0edc69e94f3cf66e63cdb745ba4d3b60997a Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Fri, 27 Feb 2026 18:23:29 +0000 Subject: [PATCH 10/13] refactor: remove reloadDocument workaround from HomeIconLink Now that login middleware properly handles waitlist users --- packages/web/src/components/ui/link.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/web/src/components/ui/link.tsx b/packages/web/src/components/ui/link.tsx index 2a10c8b..2f5219e 100644 --- a/packages/web/src/components/ui/link.tsx +++ b/packages/web/src/components/ui/link.tsx @@ -128,16 +128,9 @@ export const HomeIconLink = React.forwardRef< onClick?.(e); }; - // Use reloadDocument when navigating from authenticated routes to avoid - // route tree conflicts that can cause brief flashes of /app - const isAuthRoute = - location.pathname.startsWith("/app") || - location.pathname.startsWith("/onboarding"); - return ( Date: Fri, 27 Feb 2026 18:26:07 +0000 Subject: [PATCH 11/13] fix: add index to avatar map to fix overlap logic --- packages/web/src/components/ui/avatar-stack.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/ui/avatar-stack.tsx b/packages/web/src/components/ui/avatar-stack.tsx index 319cc35..785d479 100644 --- a/packages/web/src/components/ui/avatar-stack.tsx +++ b/packages/web/src/components/ui/avatar-stack.tsx @@ -25,7 +25,7 @@ const AvatarStack = React.forwardRef( return (
- {displayAvatars.map((avatar) => ( + {displayAvatars.map((avatar, index) => ( Date: Fri, 27 Feb 2026 18:40:51 +0000 Subject: [PATCH 12/13] fix: redirect logged-in users from login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Users with a real plan → /app - Users on waitlist or no plan → /onboarding - Previously, waitlist users stayed on login page --- packages/web/src/middleware/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/src/middleware/auth.ts b/packages/web/src/middleware/auth.ts index 2816ca8..8c78818 100644 --- a/packages/web/src/middleware/auth.ts +++ b/packages/web/src/middleware/auth.ts @@ -43,12 +43,15 @@ export const loginMiddleware = createMiddleware().server( }); if (session) { - // Only redirect to /app if user has a real plan (not waitlist) + // Redirect to /app if user has a real plan (not waitlist) const hasRealPlan = session.user.plan && session.user.plan !== "waitlist"; if (hasRealPlan) { throw redirect({ to: "/app" as any }); } + + // Redirect to /onboarding if user is on waitlist or has no plan + throw redirect({ to: "/onboarding" as any }); } return await next({ From 05aa4434581df32b82db21bb2275d5f07e5ab8f6 Mon Sep 17 00:00:00 2001 From: imharrisonking Date: Fri, 27 Feb 2026 19:18:52 +0000 Subject: [PATCH 13/13] Adds avatar stack to onboarding page --- .../web/src/components/ui/avatar-stack.tsx | 83 ++-- packages/web/src/routes/_auth/_onboarding.tsx | 104 +++-- .../_auth/_onboarding/onboarding/index.tsx | 371 ++++++++---------- 3 files changed, 280 insertions(+), 278 deletions(-) diff --git a/packages/web/src/components/ui/avatar-stack.tsx b/packages/web/src/components/ui/avatar-stack.tsx index 785d479..093fe2c 100644 --- a/packages/web/src/components/ui/avatar-stack.tsx +++ b/packages/web/src/components/ui/avatar-stack.tsx @@ -1,56 +1,61 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; -import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from './avatar'; interface AvatarItem { - src: string; - alt: string; - fallback?: string; + src: string; + alt: string; + fallback?: string; } interface AvatarStackProps { - avatars: AvatarItem[]; - className?: string; - maxDisplay?: number; + avatars: AvatarItem[]; + className?: string; + maxDisplay?: number; } /** * AvatarStack - Displays overlapping avatars in a stack layout * Inspired by ui.sh creator avatars + * Leftmost avatar appears on top (higher z-index) */ const AvatarStack = React.forwardRef( - ({ avatars, className, maxDisplay = 3 }, ref) => { - const displayAvatars = avatars.slice(0, maxDisplay); - const remainingCount = avatars.length - maxDisplay; + ({ avatars, className, maxDisplay = 3 }, ref) => { + const displayAvatars = avatars.slice(0, maxDisplay); + const remainingCount = avatars.length - maxDisplay; + const lastIndex = displayAvatars.length - 1; - return ( -
- {displayAvatars.map((avatar, index) => ( - 0 && "-ml-3", // Overlap avatars - )} - > - - - {avatar.fallback || avatar.alt.charAt(0).toUpperCase()} - - - ))} - {remainingCount > 0 && ( - - - +{remainingCount} - - - )} -
- ); - }, + return ( +
+ {displayAvatars.map((avatar, index) => ( + 0 && '-ml-3', // Overlap avatars + 'relative' // Enable z-index stacking + )} + style={{ zIndex: lastIndex - index }} // Leftmost has highest z-index + > + + + {avatar.fallback || + avatar.alt.charAt(0).toUpperCase()} + + + ))} + {remainingCount > 0 && ( + + + +{remainingCount} + + + )} +
+ ); + } ); -AvatarStack.displayName = "AvatarStack"; +AvatarStack.displayName = 'AvatarStack'; export { AvatarStack }; export type { AvatarStackProps, AvatarItem }; diff --git a/packages/web/src/routes/_auth/_onboarding.tsx b/packages/web/src/routes/_auth/_onboarding.tsx index 327f509..001a0c3 100644 --- a/packages/web/src/routes/_auth/_onboarding.tsx +++ b/packages/web/src/routes/_auth/_onboarding.tsx @@ -1,8 +1,9 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { DiamondCorner } from "@/components/layout/DiamondCorner"; +import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { DiamondCorner } from '@/components/layout/DiamondCorner'; +import { AvatarStack } from '@/components/ui/avatar-stack'; -export const Route = createFileRoute("/_auth/_onboarding")({ - component: LayoutComponent, +export const Route = createFileRoute('/_auth/_onboarding')({ + component: LayoutComponent, }); /** @@ -13,9 +14,9 @@ export const Route = createFileRoute("/_auth/_onboarding")({ * with diamond corners at intersections */ function LayoutComponent() { - return ( -
- {/* + return ( +
+ {/* Grid layout: 5 cols × 5 rows - Column 1: left padding - Column 2: left gutter line (1px) @@ -25,43 +26,74 @@ function LayoutComponent() { Same pattern for rows (top padding, top line, content, bottom line, bottom padding) */} -
- {/* Top gutter line - spans all 5 columns */} -
+
+ {/* Top gutter line - spans all 5 columns */} +
- {/* Bottom gutter line - spans all 5 columns */} -
+ {/* Bottom gutter line - spans all 5 columns */} +
- {/* Left gutter line - spans all 5 rows */} -
+ {/* Left gutter line - spans all 5 rows */} +
- {/* Right gutter line - spans all 5 rows */} -
+ {/* Right gutter line - spans all 5 rows */} +
- {/* + {/* Diamond corners at line intersections. Each diamond is placed in a corner padding cell but positioned at the inner corner (where the lines intersect). Using OPPOSITE position values since we want the inner corners. */} -
- -
-
- -
-
- -
-
- -
+
+ +
+
+ +
+
+ +
+
+ +
- {/* Main content area */} -
- -
-
-
- ); + {/* Main content area */} +
+ +
+ + {/* Avatar stack - positioned inside the gutters, bottom-right */} +
+

+ Built by Hester & Harrison +
+ from{' '} + + @lifewithcharacter + +

+ +
+
+
+ ); } diff --git a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx index 06da6bf..6c7a521 100644 --- a/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx +++ b/packages/web/src/routes/_auth/_onboarding/onboarding/index.tsx @@ -1,213 +1,178 @@ -import { createContactInLoops, MAILING_LISTS } from "@structa/core/marketing"; -import { UserService } from "@structa/core/user"; -import { useMutation } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { createServerFn, useServerFn } from "@tanstack/react-start"; -import { useState } from "react"; -import { AvatarStack } from "@/components/ui/avatar-stack"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { HomeIconLink } from "@/components/ui/link"; +import { createContactInLoops, MAILING_LISTS } from '@structa/core/marketing'; +import { UserService } from '@structa/core/user'; +import { useMutation } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { createServerFn, useServerFn } from '@tanstack/react-start'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { HomeIconLink } from '@/components/ui/link'; interface WaitlistInput { - email: string; - userId: string; - name?: string; - updateName: boolean; // Whether to update name in database + email: string; + userId: string; + name?: string; + updateName: boolean; // Whether to update name in database } // Server function to join waitlist - calls Loops directly from core package // Also updates user's name and plan in database -export const joinWaitlistFn = createServerFn({ method: "POST" }) - .inputValidator((input: WaitlistInput) => input) - .handler(async ({ data }) => { - // Update user's name in database if they provided one - if (data.updateName && data.name) { - await UserService.updateNameFromId({ - id: data.userId, - name: data.name, - }); - } - - // Add to Loops waitlist - const nameParts = (data.name || "").trim().split(" "); - const firstName = nameParts[0] || undefined; - const lastName = nameParts.slice(1).join(" ") || undefined; - - const result = await createContactInLoops({ - email: data.email, - userId: data.email, // Use email as userId for waitlist signups - firstName, - lastName, - properties: { - source: "waitlist", - createdAt: new Date().toISOString(), - }, - mailingLists: { - [MAILING_LISTS.WAITLIST]: true, - [MAILING_LISTS.PRODUCT]: true, - [MAILING_LISTS.MARKETING]: true, - }, - }); - - if (!result.success) { - throw new Error(result.error); - } - - // Update user's plan to "waitlist" after successful Loops signup - await UserService.updatePlanFromId({ - id: data.userId, - plan: "waitlist", - }); - - return { success: true }; - }); - -export const Route = createFileRoute("/_auth/_onboarding/onboarding/")({ - component: OnboardingPage, +export const joinWaitlistFn = createServerFn({ method: 'POST' }) + .inputValidator((input: WaitlistInput) => input) + .handler(async ({ data }) => { + // Update user's name in database if they provided one + if (data.updateName && data.name) { + await UserService.updateNameFromId({ + id: data.userId, + name: data.name, + }); + } + + // Add to Loops waitlist + const nameParts = (data.name || '').trim().split(' '); + const firstName = nameParts[0] || undefined; + const lastName = nameParts.slice(1).join(' ') || undefined; + + const result = await createContactInLoops({ + email: data.email, + userId: data.email, // Use email as userId for waitlist signups + firstName, + lastName, + properties: { + source: 'waitlist', + createdAt: new Date().toISOString(), + }, + mailingLists: { + [MAILING_LISTS.WAITLIST]: true, + [MAILING_LISTS.PRODUCT]: true, + [MAILING_LISTS.MARKETING]: true, + }, + }); + + if (!result.success) { + throw new Error(result.error); + } + + // Update user's plan to "waitlist" after successful Loops signup + await UserService.updatePlanFromId({ + id: data.userId, + plan: 'waitlist', + }); + + return { success: true }; + }); + +export const Route = createFileRoute('/_auth/_onboarding/onboarding/')({ + component: OnboardingPage, }); function OnboardingPage() { - // Get user from route context (populated by _auth.tsx beforeLoad) - const { user } = Route.useRouteContext(); - const navigate = useNavigate(); - - // Check if user is already on the waitlist - const isOnWaitlist = user.plan === "waitlist"; - - // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) - const userHasName = user.name != null && user.name.trim().length > 0; - const [name, setName] = useState(""); - - const joinWaitlistMutation = useMutation({ - mutationFn: useServerFn(joinWaitlistFn), - onSuccess: () => { - navigate({ to: "/onboarding/thank-you" }); - }, - onError: (error) => { - console.error("Waitlist mutation error:", error); - }, - }); - - const hasError = joinWaitlistMutation.isError; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!user.email || !user.id) { - return; - } - - // Only send name if we're collecting it (user doesn't have one already) - joinWaitlistMutation.mutate({ - data: { - email: user.email, - userId: user.id, - name: !userHasName ? name || undefined : user.name || undefined, - updateName: !userHasName && !!name, // Only update if user entered a new name - }, - }); - }; - - return ( -
-
-
-
-
- -
-

- Request early access. -

-

- AI-powered workspace to renovate with confidence. -

-
- -

- We're building an AI assistant that understands your property and - guides your renovation.{" "} -

- - {/* Name Input Form */} -
- {/* Only show name field if user signed up with email (no OAuth name) */} - {!userHasName && !isOnWaitlist && ( - setName(e.target.value)} - disabled={joinWaitlistMutation.isPending} - /> - )} - - - - {hasError && ( -

- Failed to join waitlist. Please try again. -

- )} -
-
-
- - {/* Avatar stack - fixed to bottom right */} -
-

- Built by the team behind -
- - Tailwind CSS - {" "} - &{" "} - - Refactoring UI - -

- -
-
- ); + // Get user from route context (populated by _auth.tsx beforeLoad) + const { user } = Route.useRouteContext(); + const navigate = useNavigate(); + + // Check if user is already on the waitlist + const isOnWaitlist = user.plan === 'waitlist'; + + // Only show name field if user doesn't have a name (i.e., signed up with email OTP, not OAuth) + const userHasName = user.name != null && user.name.trim().length > 0; + const [name, setName] = useState(''); + + const joinWaitlistMutation = useMutation({ + mutationFn: useServerFn(joinWaitlistFn), + onSuccess: () => { + navigate({ to: '/onboarding/thank-you' }); + }, + onError: error => { + console.error('Waitlist mutation error:', error); + }, + }); + + const hasError = joinWaitlistMutation.isError; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!user.email || !user.id) { + return; + } + + // Only send name if we're collecting it (user doesn't have one already) + joinWaitlistMutation.mutate({ + data: { + email: user.email, + userId: user.id, + name: !userHasName ? name || undefined : user.name || undefined, + updateName: !userHasName && !!name, // Only update if user entered a new name + }, + }); + }; + + return ( +
+
+
+
+
+ +
+

+ Request early access. +

+

+ AI-powered workspace to renovate with confidence. +

+
+ +

+ We're building an AI assistant that understands your + property and guides your renovation.{' '} +

+ + {/* Name Input Form */} +
+ {/* Only show name field if user signed up with email (no OAuth name) */} + {!userHasName && !isOnWaitlist && ( + setName(e.target.value)} + disabled={joinWaitlistMutation.isPending} + /> + )} + + + + {hasError && ( +

+ Failed to join waitlist. Please try again. +

+ )} +
+
+
+
+ ); }