feat: extend onboarding
This commit is contained in:
parent
567a9c086e
commit
7e80be888b
4 changed files with 225 additions and 30 deletions
|
|
@ -18,10 +18,11 @@ import (
|
||||||
type authHandler struct {
|
type authHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
inviteService *service.InviteService
|
inviteService *service.InviteService
|
||||||
|
spaceService *service.SpaceService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService) *authHandler {
|
func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService, spaceService *service.SpaceService) *authHandler {
|
||||||
return &authHandler{authService: authService, inviteService: inviteService}
|
return &authHandler{authService: authService, inviteService: inviteService, spaceService: spaceService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) {
|
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) {
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
step := r.FormValue("step")
|
||||||
|
|
||||||
err := h.authService.CompleteOnboarding(user.ID, name)
|
switch step {
|
||||||
if err != nil {
|
case "2":
|
||||||
slog.Error("onboarding failed", "error", err, "user_id", user.ID)
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
ui.Render(w, r, pages.Onboarding("Please enter your name"))
|
if name == "" {
|
||||||
return
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(a *app.App) http.Handler {
|
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()
|
home := handler.NewHomeHandler()
|
||||||
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
|
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
|
||||||
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
||||||
|
|
|
||||||
|
|
@ -261,12 +261,6 @@ func (s *AuthService) SendMagicLink(email string) error {
|
||||||
return fmt.Errorf("failed to create profile: %w", err)
|
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)
|
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
|
||||||
} else {
|
} else {
|
||||||
// user look up unexpected error
|
// user look up unexpected error
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
|
|
@ -11,11 +12,78 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
"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) }}
|
{{ cfg := ctxkeys.Config(ctx) }}
|
||||||
{{ user := ctxkeys.User(ctx) }}
|
{{ user := ctxkeys.User(ctx) }}
|
||||||
@layouts.Auth(layouts.SEOProps{
|
@layouts.Auth(layouts.SEOProps{
|
||||||
Title: "Complete Your Profile",
|
Title: "Your Name",
|
||||||
Description: "Tell us your name",
|
Description: "Tell us your name",
|
||||||
Path: ctxkeys.URLPath(ctx),
|
Path: ctxkeys.URLPath(ctx),
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -32,15 +100,15 @@ templ Onboarding(errorMsg string) {
|
||||||
{ cfg.AppName }
|
{ cfg.AppName }
|
||||||
}
|
}
|
||||||
</div>
|
</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 {
|
if user != nil {
|
||||||
<p class="text-muted-foreground mt-2">Continue as <strong>{ user.Email }</strong></p>
|
<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>
|
</div>
|
||||||
<form action="/auth/onboarding" method="POST" class="space-y-6">
|
<form action="/auth/onboarding" method="POST" class="space-y-6">
|
||||||
@csrf.Token()
|
@csrf.Token()
|
||||||
|
<input type="hidden" name="step" value="2"/>
|
||||||
@form.Item() {
|
@form.Item() {
|
||||||
@label.Label(label.Props{
|
@label.Label(label.Props{
|
||||||
For: "name",
|
For: "name",
|
||||||
|
|
@ -62,12 +130,105 @@ templ Onboarding(errorMsg string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@button.Button(button.Props{
|
<div class="flex gap-3">
|
||||||
Type: button.TypeSubmit,
|
@button.Button(button.Props{
|
||||||
FullWidth: true,
|
Variant: button.VariantOutline,
|
||||||
}) {
|
Href: "/auth/onboarding",
|
||||||
Continue
|
}) {
|
||||||
}
|
@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>
|
||||||
<form action="/auth/logout" method="POST" class="text-center mt-6">
|
<form action="/auth/logout" method="POST" class="text-center mt-6">
|
||||||
@csrf.Token()
|
@csrf.Token()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue