From 0baacb4b280859e39edbbae7938410db2dd6303c Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 3 May 2026 18:14:28 +0000 Subject: [PATCH] feat: create accounts --- internal/handler/space.go | 66 +++++++++++++++++++ internal/routes/routes.go | 2 + internal/ui/forms/create_account.templ | 68 ++++++++++++++++++++ internal/ui/pages/space_create_account.templ | 24 +++++++ internal/ui/pages/space_overview.templ | 15 ++++- 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 internal/ui/forms/create_account.templ create mode 100644 internal/ui/pages/space_create_account.templ diff --git a/internal/handler/space.go b/internal/handler/space.go index c81a196..f6b47f5 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -178,6 +178,72 @@ func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request) })) } +func (h *spaceHandler) SpaceCreateAccountPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to fetch space", "error", err, "space_id", spaceID) + ui.Render(w, r, pages.NotFound()) + return + } + + ui.Render(w, r, pages.SpaceCreateAccountPage(pages.SpaceCreateAccountPageProps{ + SpaceID: space.ID, + SpaceName: space.Name, + Form: forms.CreateAccountProps{ + SpaceID: space.ID, + }, + })) +} + +func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + nameInput := strings.TrimSpace(r.FormValue("name")) + + formProps := forms.CreateAccountProps{ + SpaceID: spaceID, + Name: nameInput, + } + + if nameInput == "" { + formProps.NameErr = "Account name is required." + ui.Render(w, r, forms.CreateAccount(formProps)) + return + } + + existing, err := h.accountService.GetAccountsForSpace(spaceID) + if err != nil { + slog.Error("failed to load accounts", "error", err, "space_id", spaceID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.CreateAccount(formProps)) + return + } + for _, a := range existing { + if strings.EqualFold(strings.TrimSpace(a.Name), nameInput) { + formProps.NameErr = "An account with this name already exists in this space." + ui.Render(w, r, forms.CreateAccount(formProps)) + return + } + } + + account, err := h.accountService.CreateAccount(spaceID, nameInput) + if err != nil { + slog.Error("failed to create account", "error", err, "space_id", spaceID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.CreateAccount(formProps)) + return + } + + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.overview", + "spaceID", spaceID, + "accountID", account.ID, + ) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) +} + func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 8c36e82..8f70af4 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -92,6 +92,8 @@ func SetupRoutes(a *app.App) http.Handler { spaceAccessMw := middleware.RequireSpaceAccess(a.SpaceService) g.Use(spaceAccessMw) g.Get("/overview", spaceH.SpaceOverviewPage).Name("page.app.spaces.space.overview") + g.Get("/accounts/create", spaceH.SpaceCreateAccountPage).Name("page.app.spaces.space.accounts.create") + g.Post("/accounts/create", spaceH.HandleCreateAccount).Name("action.app.spaces.space.accounts.create") g.SubGroup("/accounts/{accountID}", func(g *router.Group) { g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview") diff --git a/internal/ui/forms/create_account.templ b/internal/ui/forms/create_account.templ new file mode 100644 index 0000000..6216e2c --- /dev/null +++ b/internal/ui/forms/create_account.templ @@ -0,0 +1,68 @@ +package forms + +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + +type CreateAccountProps struct { + SpaceID string + + Name string + + NameErr string + GeneralErr string +} + +templ CreateAccount(props CreateAccountProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-4 space-y-4"}) { + if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "name"}) { + Account Name + } + @input.Input(input.Props{ + ID: "name", + Name: "name", + Type: input.TypeText, + Placeholder: "e.g. Checking", + Class: "rounded-sm", + Value: props.Name, + HasError: props.NameErr != "", + Required: true, + Attributes: templ.Attributes{ + "autocomplete": "off", + "autofocus": "", + }, + }) + if props.NameErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.NameErr } + } + } + @form.Description() { + You can rename the account later. Starts with a $0.00 balance. + } + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Href: routeurl.URL("page.app.spaces.space.overview", "spaceID", props.SpaceID), + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Create Account + } + } + } +
+} diff --git a/internal/ui/pages/space_create_account.templ b/internal/ui/pages/space_create_account.templ new file mode 100644 index 0000000..1f99537 --- /dev/null +++ b/internal/ui/pages/space_create_account.templ @@ -0,0 +1,24 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceCreateAccountPageProps struct { + SpaceID string + SpaceName string + Form forms.CreateAccountProps +} + +templ SpaceCreateAccountPage(props SpaceCreateAccountPageProps) { + @layouts.App("Create Account", spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID)) { +
+
+

Create Account

+

+ Add a new account to { props.SpaceName }. +

+
+ @forms.CreateAccount(props.Form) +
+ } +} diff --git a/internal/ui/pages/space_overview.templ b/internal/ui/pages/space_overview.templ index d7d2072..5c3e761 100644 --- a/internal/ui/pages/space_overview.templ +++ b/internal/ui/pages/space_overview.templ @@ -2,6 +2,7 @@ package pages import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar" import "git.juancwu.dev/juancwu/budgit/internal/routeurl" @@ -22,9 +23,19 @@ templ SpaceOverview(props SpaceOverviewProps) {

Space overview

-

Accounts

+
+

Accounts

+ @button.Button(button.Props{ + Variant: button.VariantDefault, + Href: routeurl.URL("page.app.spaces.space.accounts.create", "spaceID", props.SpaceID), + Class: "flex gap-2 items-center", + }) { + @icon.Plus() + New Account + } +
if len(props.Accounts) == 0 { -

No accounts yet.

+

No accounts yet. Create one to get started.

} else {
for _, account := range props.Accounts {