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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 20:52:52 +02:00
parent b35103657d
commit f47c10ed8a
6 changed files with 66 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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