mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user