Merge branch 'fix/onboarding'

This commit is contained in:
juancwu 2026-02-09 13:57:44 +00:00
commit e97e154b76
4 changed files with 225 additions and 30 deletions

View file

@ -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
}
step := r.FormValue("step")
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.Onboarding("Please enter your name"))
ui.Render(w, r, pages.OnboardingName("Please enter a valid name"))
return
}
slog.Info("onboarding completed", "user_id", user.ID, "name", name)
_, 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())
}
}

View file

@ -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)

View file

@ -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

View file

@ -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) {
<div class="flex items-center justify-center gap-2 mb-8">
for i := 1; i <= 3; i++ {
if i == current {
<div class="w-2.5 h-2.5 rounded-full bg-primary"></div>
} else if i < current {
<div class="w-2.5 h-2.5 rounded-full bg-primary/50"></div>
} else {
<div class="w-2.5 h-2.5 rounded-full bg-muted"></div>
}
}
<span class="text-xs text-muted-foreground ml-2">{ fmt.Sprintf("%d/3", current) }</span>
</div>
}
templ OnboardingWelcome() {
{{ cfg := ctxkeys.Config(ctx) }}
@layouts.Auth(layouts.SEOProps{
Title: "Welcome",
Description: "Get started with " + cfg.AppName,
Path: ctxkeys.URLPath(ctx),
}) {
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center">
<div class="mb-8">
@button.Button(button.Props{
Variant: button.VariantSecondary,
Size: button.SizeLg,
Href: "/",
}) {
@icon.Layers()
{ cfg.AppName }
}
</div>
@stepIndicator(1)
<h2 class="text-3xl font-bold">Welcome!</h2>
<p class="text-muted-foreground mt-2 mb-2">
{ cfg.AppName } helps you manage shared expenses and shopping lists with your household.
</p>
<p class="text-muted-foreground mb-8">
Let's get you set up in just a couple of steps.
</p>
@button.Button(button.Props{
Href: "/auth/onboarding?step=2",
FullWidth: true,
}) {
Get Started
@icon.ArrowRight()
}
</div>
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {
Sign out
}
</form>
</div>
</div>
}
}
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 }
}
</div>
<h2 class="text-3xl font-bold">Welcome!</h2>
@stepIndicator(2)
<h2 class="text-3xl font-bold">What's your name?</h2>
if user != nil {
<p class="text-muted-foreground mt-2">Continue as <strong>{ user.Email }</strong></p>
} else {
<p class="text-muted-foreground mt-2">What should we call you?</p>
}
</div>
<form action="/auth/onboarding" method="POST" class="space-y-6">
@csrf.Token()
<input type="hidden" name="step" value="2"/>
@form.Item() {
@label.Label(label.Props{
For: "name",
@ -62,12 +130,105 @@ templ Onboarding(errorMsg string) {
}
}
}
<div class="flex gap-3">
@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()
}
</div>
</form>
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {
Sign out
}
</form>
</div>
</div>
}
}
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),
}) {
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="mb-8">
@button.Button(button.Props{
Variant: button.VariantSecondary,
Size: button.SizeLg,
Href: "/",
}) {
@icon.Layers()
{ cfg.AppName }
}
</div>
@stepIndicator(3)
<h2 class="text-3xl font-bold">Create your space</h2>
<p class="text-muted-foreground mt-2">
A space is where you organize expenses and lists. You can invite others later.
</p>
</div>
<form action="/auth/onboarding" method="POST" class="space-y-6">
@csrf.Token()
<input type="hidden" name="step" value="3"/>
<input type="hidden" name="name" value={ name }/>
@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 }
}
}
}
<div class="flex gap-3">
@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
}
</div>
</form>
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()