From f47c10ed8a9fd8d60622368e4a0e516c314c3cb4 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Mon, 27 Apr 2026 20:52:52 +0200 Subject: [PATCH] refactor: structured error handling + CreateBox/formatListItem in checkout Replace ErrorMsg string with typed VisibleError struct. Add catch-all error handler at top of Update() that auto-recovers cart on shop/cart errors. ESC now dismisses error banners. Refactor shipping address and payment card checkout lists to use CreateBox + formatListItem for consistent bordered cards with radio indicators. Written with Claude Code (claude.ai/code) Co-Authored-By: Claude Opus 4.6 --- pkg/tui/confirmation.go | 2 +- pkg/tui/model.go | 11 ++++++-- pkg/tui/payment_form.go | 56 ++++++++++++++-------------------------- pkg/tui/shipping_form.go | 49 ++++++++++++----------------------- pkg/tui/update.go | 20 +++++++++++--- pkg/tui/views.go | 4 +-- 6 files changed, 66 insertions(+), 76 deletions(-) diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 7c44f92..4151ec3 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -188,7 +188,7 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.CheckingOut = false if msg.Err != nil { m = m.SwitchPage(paymentPage) - m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) + m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)} m.review.cardJustAdded = false m.payment.view = 0 m.payment.form = nil diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 8d2aab0..42dc861 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -58,6 +58,13 @@ type footerCommand struct { value string } +// VisibleError is a typed error message displayed to the user as a banner. +// Using a struct instead of a plain string lets the Update loop catch raw +// errors and convert them, and lets ESC dismiss the banner. +type VisibleError struct { + message string +} + type Model struct { // User authentication User *models.User @@ -102,7 +109,7 @@ type Model struct { pendingHeight int // buffered height from latest resize Loading bool // true when fetching data from API - ErrorMsg string // error message if API fetch fails + error *VisibleError APIClient *api.Client // Rendering @@ -206,7 +213,7 @@ var modifiedKeyMap = viewport.KeyMap{ func (m Model) SwitchPage(p page) Model { m.currentPage = p m.switched = true - m.ErrorMsg = "" + m.error = nil return m } diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index b0e15f7..d96527e 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -332,48 +332,32 @@ func (m Model) PaymentPageView() string { func (m Model) RenderCardList() string { titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0) - activeStyle := m.theme.TextAccent().Bold(true) - inactiveStyle := m.theme.TextDim() - labelStyle := m.theme.TextLabel() title := titleStyle.Render("Select Payment Method") - var lines []string + var boxes []string for i, card := range m.SavedCards { - cursor := " " - style := inactiveStyle - if i == m.payment.cardCursor { - cursor = "> " - style = activeStyle - } + focused := i == m.payment.cardCursor - brand := style.Render(strings.ToUpper(card.Brand[:1]) + card.Brand[1:]) - last4 := labelStyle.Render(fmt.Sprintf("**** **** **** %s", card.Last4)) - exp := labelStyle.Render(fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100)) + brand := strings.ToUpper(card.Brand[:1]) + card.Brand[1:] + last4 := fmt.Sprintf("**** **** **** %s", card.Last4) + exp := fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100) + content := brand + " " + last4 + "\n" + exp - lines = append(lines, cursor+brand+" "+last4) - lines = append(lines, " "+exp) - lines = append(lines, "") + box := m.CreateBox(m.formatListItem(content, focused), focused) + boxes = append(boxes, box) } - sshCursor := " " - sshStyle := inactiveStyle - if m.payment.cardCursor == len(m.SavedCards) { - sshCursor = "> " - sshStyle = activeStyle - } - lines = append(lines, sshCursor+sshStyle.Render("+ add card via ssh")) + sshFocused := m.payment.cardCursor == len(m.SavedCards) + sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused) + boxes = append(boxes, sshBox) - httpsCursor := " " - httpsStyle := inactiveStyle - if m.payment.cardCursor == len(m.SavedCards)+1 { - httpsCursor = "> " - httpsStyle = activeStyle - } - lines = append(lines, httpsCursor+httpsStyle.Render("+ add payment via browser")) + httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1 + httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused) + boxes = append(boxes, httpsBox) parts := []string{title} - parts = append(parts, strings.Join(lines, "\n")) + parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...)) return lipgloss.JoinVertical(lipgloss.Left, parts...) } @@ -423,7 +407,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, nil case PaymentFormErrorMsg: - m.ErrorMsg = msg.Message + m.error = &VisibleError{message: msg.Message} return m, nil case CardSavedForReviewMsg: @@ -431,7 +415,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.payment.form.submitting = false } if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to save card: %v", msg.Err) + m.error = &VisibleError{message: fmt.Sprintf("failed to save card: %v", msg.Err)} m.payment.view = 1 m.payment.form = m.InitPaymentForm() m.footer = []footerCommand{ @@ -478,7 +462,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { if !ok || m.CheckingOut { return m, nil } - m.ErrorMsg = "" + m.error = nil switch keyMsg.String() { case "esc": return m.ShippingSwitch() @@ -559,7 +543,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { if m.payment.form != nil { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" { - m.ErrorMsg = "" + m.error = nil if len(m.SavedCards) > 0 { m.payment.view = 0 m.payment.form = nil @@ -576,7 +560,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, m.fetchAddressesCmd() } // Clear error only when user starts typing, not on internal huh messages - m.ErrorMsg = "" + m.error = nil } return m, m.UpdatePaymentForm(msg, m.payment.form) } diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index 2117f48..45d0745 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -304,45 +304,30 @@ func (m Model) ShippingPageView() string { func (m Model) RenderAddressList() string { titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0) - activeStyle := m.theme.TextAccent().Bold(true) - inactiveStyle := m.theme.TextDim() - labelStyle := m.theme.TextLabel() title := titleStyle.Render("Selected Shipping Address") - var lines []string + var boxes []string for i, addr := range m.SavedAddresses { - cursor := " " - style := inactiveStyle - if i == m.shipping.addressCursor { - cursor = "> " - style = activeStyle - } + focused := i == m.shipping.addressCursor - name := style.Render(addr.Name) - street := labelStyle.Render(addr.Street) + street := addr.Street if addr.Street2 != "" { - street += ", " + labelStyle.Render(addr.Street2) + street += ", " + addr.Street2 } - city := labelStyle.Render(fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip)) + city := fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip) + content := addr.Name + "\n" + street + "\n" + city - lines = append(lines, cursor+name) - lines = append(lines, " "+street) - lines = append(lines, " "+city) - lines = append(lines, "") + box := m.CreateBox(m.formatListItem(content, focused), focused) + boxes = append(boxes, box) } - addCursor := " " - addStyle := inactiveStyle - if m.shipping.addressCursor == len(m.SavedAddresses) { - addCursor = "> " - addStyle = activeStyle - } - - lines = append(lines, addCursor+addStyle.Render("+ Add new address")) + addFocused := m.shipping.addressCursor == len(m.SavedAddresses) + addBox := m.CreateBox(m.formatListItem("+ Add new address", addFocused), addFocused) + boxes = append(boxes, addBox) parts := []string{title} - parts = append(parts, strings.Join(lines, "\n")) + parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...)) return lipgloss.JoinVertical(lipgloss.Left, parts...) } @@ -381,7 +366,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { m.shipping.form.submitting = false m.shipping.form.form = m.buildShippingForm(m.shipping.form) } - m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." + m.error = &VisibleError{message: "Invalid address. Currently only US and Norwegian addresses are supported."} return m, m.shipping.form.form.Init() } saved := msg.Address @@ -390,7 +375,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { return m.PaymentSwitch() case ShippingFormErrorMsg: - m.ErrorMsg = msg.Message + m.error = &VisibleError{message: msg.Message} return m, nil } @@ -400,7 +385,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { if !ok { return m, nil } - m.ErrorMsg = "" + m.error = nil switch keyMsg.String() { case "esc": return m.CartSwitch() @@ -453,7 +438,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { if m.shipping.form != nil { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" { - m.ErrorMsg = "" + m.error = nil if len(m.SavedAddresses) > 0 { m.shipping.view = 0 m.shipping.form = nil @@ -470,7 +455,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, cmd } // Clear error only when user starts typing, not on internal huh messages - m.ErrorMsg = "" + m.error = nil } return m, m.UpdateShippingForm(msg, m.shipping.form) } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 6e837be..14a4cbd 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -49,6 +49,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case VisibleError: + m.error = &msg + return m, nil + case error: + m.error = &VisibleError{message: msg.Error()} + if m.currentPage == shopPage || m.currentPage == cartPage { + return m, m.fetchCartCmd + } + return m, nil case tea.WindowSizeMsg: m.pendingWidth = msg.Width m.pendingHeight = msg.Height @@ -59,7 +68,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) case SplashAuthMsg: if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("authentication failed: %v", msg.Err) + m.error = &VisibleError{message: fmt.Sprintf("authentication failed: %v", msg.Err)} // Still attempt to load view data (products may not require auth) // so the splash screen doesn't get permanently stuck. return m, m.splashViewInitCmd @@ -125,12 +134,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case AddressDeletedMsg: if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to delete address: %v", msg.Err) + m.error = &VisibleError{message: fmt.Sprintf("failed to delete address: %v", msg.Err)} } return m, nil case CardDeletedMsg: if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err) + m.error = &VisibleError{message: fmt.Sprintf("failed to delete card: %v", msg.Err)} } return m, nil case DelayCompleteMsg: @@ -149,6 +158,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.shipping.form != nil || m.payment.form != nil { break } + // Dismiss error banner with ESC + if msg.String() == "esc" && m.error != nil { + m.error = nil + return m, nil + } // Global navigation switch msg.String() { case "ctrl+c", "q": diff --git a/pkg/tui/views.go b/pkg/tui/views.go index cb9ec6b..64630d5 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -73,9 +73,9 @@ func (m Model) View() string { if m.Loading { loadingStyle := m.theme.TextLoading().Padding(2, 4) content = loadingStyle.Render("Loading products from API...") - } else if m.ErrorMsg != "" { + } else if m.error != nil { errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1) - errorBanner := errorStyle.Render(m.ErrorMsg) + errorBanner := errorStyle.Render(m.error.message) content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) } else { content = m.buildPageContent(availableContentHeight)