From 7e80be888b1c5df238f2c966b1a91533cf2e5c23 Mon Sep 17 00:00:00 2001 From: juancwu Date: Mon, 9 Feb 2026 13:57:35 +0000 Subject: [PATCH] feat: extend onboarding --- internal/handler/auth.go | 64 ++++++++-- internal/routes/routes.go | 2 +- internal/service/auth.go | 6 - internal/ui/pages/onboarding.templ | 183 +++++++++++++++++++++++++++-- 4 files changed, 225 insertions(+), 30 deletions(-) diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 584ae0f..1501d84 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -18,10 +18,11 @@ import ( type authHandler struct { authService *service.AuthService inviteService *service.InviteService + spaceService *service.SpaceService } -func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService) *authHandler { - return &authHandler{authService: authService, inviteService: inviteService} +func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService, spaceService *service.SpaceService) *authHandler { + return &authHandler{authService: authService, inviteService: inviteService, spaceService: spaceService} } func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) { @@ -192,7 +193,13 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) { } func (h *authHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) { - ui.Render(w, r, pages.Onboarding("")) + step := r.URL.Query().Get("step") + switch step { + case "2": + ui.Render(w, r, pages.OnboardingName("")) + default: + ui.Render(w, r, pages.OnboardingWelcome()) + } } func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) { @@ -202,15 +209,48 @@ func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) return } - name := strings.TrimSpace(r.FormValue("name")) + step := r.FormValue("step") - err := h.authService.CompleteOnboarding(user.ID, name) - if err != nil { - slog.Error("onboarding failed", "error", err, "user_id", user.ID) - ui.Render(w, r, pages.Onboarding("Please enter your name")) - return + switch step { + case "2": + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + ui.Render(w, r, pages.OnboardingName("Please enter your name")) + return + } + ui.Render(w, r, pages.OnboardingSpace(name, "")) + + case "3": + name := strings.TrimSpace(r.FormValue("name")) + spaceName := strings.TrimSpace(r.FormValue("space_name")) + + if name == "" { + ui.Render(w, r, pages.OnboardingName("Please enter your name")) + return + } + if spaceName == "" { + ui.Render(w, r, pages.OnboardingSpace(name, "Please enter a space name")) + return + } + + err := h.authService.CompleteOnboarding(user.ID, name) + if err != nil { + slog.Error("onboarding failed", "error", err, "user_id", user.ID) + ui.Render(w, r, pages.OnboardingName("Please enter a valid name")) + return + } + + _, err = h.spaceService.CreateSpace(spaceName, user.ID) + if err != nil { + slog.Error("failed to create space during onboarding", "error", err, "user_id", user.ID) + ui.Render(w, r, pages.OnboardingSpace(name, "Failed to create space. Please try again.")) + return + } + + slog.Info("onboarding completed", "user_id", user.ID, "name", name, "space", spaceName) + http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) + + default: + ui.Render(w, r, pages.OnboardingWelcome()) } - - slog.Info("onboarding completed", "user_id", user.ID, "name", name) - http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 24fdb2c..ae4cd3b 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -11,7 +11,7 @@ import ( ) func SetupRoutes(a *app.App) http.Handler { - auth := handler.NewAuthHandler(a.AuthService, a.InviteService) + auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) home := handler.NewHomeHandler() dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService) settings := handler.NewSettingsHandler(a.AuthService, a.UserService) diff --git a/internal/service/auth.go b/internal/service/auth.go index 01c8b78..c76c393 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -261,12 +261,6 @@ func (s *AuthService) SendMagicLink(email string) error { return fmt.Errorf("failed to create profile: %w", err) } - _, err = s.spaceService.EnsurePersonalSpace(user) - if err != nil { - // Log the error but don't fail the whole auth flow - slog.Error("failed to create personal space for new user", "error", err, "user_id", user.ID) - } - slog.Info("new passwordless user created", "email", email, "user_id", user.ID) } else { // user look up unexpected error diff --git a/internal/ui/pages/onboarding.templ b/internal/ui/pages/onboarding.templ index 2689c90..8048421 100644 --- a/internal/ui/pages/onboarding.templ +++ b/internal/ui/pages/onboarding.templ @@ -1,6 +1,7 @@ package pages import ( + "fmt" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" @@ -11,11 +12,78 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" ) -templ Onboarding(errorMsg string) { +templ stepIndicator(current int) { +
+ for i := 1; i <= 3; i++ { + if i == current { +
+ } else if i < current { +
+ } else { +
+ } + } + { fmt.Sprintf("%d/3", current) } +
+} + +templ OnboardingWelcome() { + {{ cfg := ctxkeys.Config(ctx) }} + @layouts.Auth(layouts.SEOProps{ + Title: "Welcome", + Description: "Get started with " + cfg.AppName, + Path: ctxkeys.URLPath(ctx), + }) { +
+
+
+
+ @button.Button(button.Props{ + Variant: button.VariantSecondary, + Size: button.SizeLg, + Href: "/", + }) { + @icon.Layers() + { cfg.AppName } + } +
+ @stepIndicator(1) +

Welcome!

+

+ { cfg.AppName } helps you manage shared expenses and shopping lists with your household. +

+

+ Let's get you set up in just a couple of steps. +

+ @button.Button(button.Props{ + Href: "/auth/onboarding?step=2", + FullWidth: true, + }) { + Get Started + @icon.ArrowRight() + } +
+
+ @csrf.Token() + Not you? + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantLink, + Class: "p-0 h-auto text-sm", + }) { + Sign out + } +
+
+
+ } +} + +templ OnboardingName(errorMsg string) { {{ cfg := ctxkeys.Config(ctx) }} {{ user := ctxkeys.User(ctx) }} @layouts.Auth(layouts.SEOProps{ - Title: "Complete Your Profile", + Title: "Your Name", Description: "Tell us your name", Path: ctxkeys.URLPath(ctx), }) { @@ -32,15 +100,15 @@ templ Onboarding(errorMsg string) { { cfg.AppName } } -

Welcome!

+ @stepIndicator(2) +

What's your name?

if user != nil {

Continue as { user.Email }

- } else { -

What should we call you?

}
@csrf.Token() + @form.Item() { @label.Label(label.Props{ For: "name", @@ -62,12 +130,105 @@ templ Onboarding(errorMsg string) { } } } - @button.Button(button.Props{ - Type: button.TypeSubmit, - FullWidth: true, - }) { - Continue - } +
+ @button.Button(button.Props{ + Variant: button.VariantOutline, + Href: "/auth/onboarding", + }) { + @icon.ArrowLeft() + Back + } + @button.Button(button.Props{ + Type: button.TypeSubmit, + FullWidth: true, + }) { + Continue + @icon.ArrowRight() + } +
+
+
+ @csrf.Token() + Not you? + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantLink, + Class: "p-0 h-auto text-sm", + }) { + Sign out + } +
+ + + } +} + +templ OnboardingSpace(name string, errorMsg string) { + {{ cfg := ctxkeys.Config(ctx) }} + @layouts.Auth(layouts.SEOProps{ + Title: "Create Your Space", + Description: "Create your first space", + Path: ctxkeys.URLPath(ctx), + }) { +
+
+
+
+ @button.Button(button.Props{ + Variant: button.VariantSecondary, + Size: button.SizeLg, + Href: "/", + }) { + @icon.Layers() + { cfg.AppName } + } +
+ @stepIndicator(3) +

Create your space

+

+ A space is where you organize expenses and lists. You can invite others later. +

+
+
+ @csrf.Token() + + + @form.Item() { + @label.Label(label.Props{ + For: "space_name", + Class: "block mb-2", + }) { + Space Name + } + @input.Input(input.Props{ + ID: "space_name", + Name: "space_name", + Type: input.TypeText, + Placeholder: "My Household", + HasError: errorMsg != "", + Attributes: templ.Attributes{"autofocus": ""}, + }) + if errorMsg != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { errorMsg } + } + } + } +
+ @button.Button(button.Props{ + Variant: button.VariantOutline, + Href: "/auth/onboarding?step=2", + }) { + @icon.ArrowLeft() + Back + } + @button.Button(button.Props{ + Type: button.TypeSubmit, + FullWidth: true, + }) { + Create Space + } +
@csrf.Token()