From 6559c4dd8faf42da770daf2563249e83893a137f Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Sun, 12 Apr 2026 12:11:04 +0200 Subject: [PATCH] fix and feature: made checkout page cleaner, and added card and address modification to account page the checkout flow was a bit wonky because it would display the same info twice which looked weird and confusing. this is now changed to only show once and update one of the pages independantly. Now added the possibility to modify card and address list in the account page and not in the checkout flow --- pkg/models/coffee.go | 2 + pkg/tui/account.go | 194 +++++++++++++++++++++++++++++++++------- pkg/tui/confirmation.go | 34 +++++-- pkg/tui/model.go | 17 +++- pkg/tui/payment_form.go | 4 +- 5 files changed, 208 insertions(+), 43 deletions(-) diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go index eb9550c..e1cd904 100644 --- a/pkg/models/coffee.go +++ b/pkg/models/coffee.go @@ -29,6 +29,8 @@ func (Coffee) TableName() string { // AccountMenuItems var AccountMenuItems = []string{ "order history", + "addresses", + "cards", "faq", "about", } diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 5a92bfa..f6e4f23 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -84,6 +84,58 @@ func (m Model) BuildAccountView(availableHeight int) string { } detailView = lines } + + case "addresses": + lines := titleStyle.Render("Address") + "\n\n" + if len(m.SavedAddresses) == 0 { + lines += contentStyle.Render("No saved addresses") + } else { + for i, addr := range m.SavedAddresses { + isSelected := m.AddressListFocused && i == m.AccountAddressCursor + label := addr.Name + " " + addr.Street + ", " + addr.City + if len(label) > detailContentWidth-4 { + label = label[:detailContentWidth-4] + } + if m.AccountAddressDeleting != nil && *m.AccountAddressDeleting == i { + lines += m.theme.TextError().Bold(true).Render(" deletes? (y/n)") + "\n" + } else if isSelected { + lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n" + } else { + lines += contentStyle.Render(" "+label) + "\n" + } + } + } + if m.AddressListFocused { + lines += "\n" + m.theme.TextDim().Render("x: delete esc: back") + } else { + lines += "\n" + m.theme.TextDim().Render("enter: manage") + } + detailView = lines + + case "cards": + lines := titleStyle.Render("Cards") + "\n\n" + if len(m.SavedCards) == 0 { + lines += contentStyle.Render("No saved cards.") + } else { + for i, card := range m.SavedCards { + isSelected := m.CardListFocused && i == m.AccountCardCursor + label := fmt.Sprintf("**** **** **** %s %s exp %02d/%02d", + card.Last4, card.Brand, card.ExpMonth, card.ExpYear%100) + if m.AccountCardDeleting != nil && *m.AccountCardDeleting == i { + lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n" + } else if isSelected { + lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n" + } else { + lines += contentStyle.Render(" "+label) + "\n" + } + } + } + if m.CardListFocused { + lines += "\n" + m.theme.TextDim().Render("x: delete esc: back") + } else { + lines += "\n" + m.theme.TextDim().Render("enter: manage") + } + detailView = lines case "faq": questionStyle := m.theme.TextHighlight().Bold(true) @@ -380,9 +432,23 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { if !ok { return m, nil } + + selectedItem := "" + if m.AccountCursor < len(models.AccountMenuItems) { + selectedItem = models.AccountMenuItems[m.AccountCursor] + } + switch keyMsg.String() { case "up", "k": - if m.FaqFocused { + if m.AddressListFocused && m.AccountAddressDeleting == nil { + if m.AccountAddressCursor > 0 { + m.AccountAddressCursor-- + } + } else if m.CardListFocused && m.AccountCardDeleting == nil { + if m.AccountCardCursor > 0 { + m.AccountCardCursor-- + } + } else if m.FaqFocused { m.ScrollOffset -= 3 if m.ScrollOffset < 0 { m.ScrollOffset = 0 @@ -401,42 +467,45 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.AccountCursor-- m.ScrollOffset = 0 } + case "down", "j": - if m.FaqFocused { - m.ScrollOffset += 3 - maxScroll := m.computeFaqScrollMax() - if m.ScrollOffset > maxScroll { - m.ScrollOffset = maxScroll + if m.AddressListFocused && m.AccountAddressDeleting == nil { + if m.AccountAddressCursor < len(m.SavedAddresses)-1 { + m.AccountAddressCursor++ } - } else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 { - m.OrderCursor++ + } else if m.CardListFocused && m.AccountCardDeleting == nil { + if m.AccountCardCursor < len(m.SavedCards)-1 { + m.AccountCardCursor++ + } + } else if m.FaqFocused { + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + } else if m.OrderViewState == 1 && m.OrderCursor > 0 { + m.OrderCursor-- cardHeight := 5 if m.heightContainer >= 25 { cardHeight = 7 } - viewportHeight := m.heightContainer - 7 - if m.size < large { - viewportHeight -= len(models.AccountMenuItems) + 1 + targetTop := 3 + m.OrderCursor*cardHeight + if targetTop < m.ScrollOffset { + m.ScrollOffset = targetTop } - if viewportHeight < 5 { - viewportHeight = 5 - } - targetBottom := 3 + (m.OrderCursor+1)*cardHeight - if targetBottom > m.ScrollOffset+viewportHeight { - m.ScrollOffset = targetBottom - viewportHeight - } - } else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 { - m.AccountCursor++ + } else if m.OrderViewState == 0 && m.AccountCursor > 0 { + m.AccountCursor-- m.ScrollOffset = 0 } - case "pgup", "ctrl+u": + + case "pgup", "ctrl-u": if m.FaqFocused || m.OrderViewState >= 2 { m.ScrollOffset -= 3 if m.ScrollOffset < 0 { m.ScrollOffset = 0 } } - case "pgdown", "ctrl+d": + + case "pgdown", "ctrl-d": if m.FaqFocused || m.OrderViewState >= 2 { m.ScrollOffset += 3 maxScroll := m.computeFaqScrollMax() @@ -444,22 +513,81 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.ScrollOffset = maxScroll } } + + case "x", "d": + if m.AddressListFocused && m.AccountAddressDeleting == nil && m.AccountAddressCursor < len(m.SavedAddresses) { + m.AccountAddressDeleting = &m.AccountAddressCursor + } else if m.CardListFocused && m.AccountCardDeleting == nil && m.AccountCardCursor < len(m.SavedCards) { + m.AccountCardDeleting = &m.AccountCardCursor + } + + case "y": + if m.AccountAddressDeleting != nil { + cursor := *m.AccountAddressDeleting + addr := m.SavedAddresses[cursor] + m.AccountAddressDeleting = nil + m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...) + if m.AccountAddressCursor >= len(m.SavedAddresses) && m.AccountAddressCursor > 0 { + m.AccountAddressCursor-- + } + return m, m.deleteAddressCmd(addr.ID) + } else if m.AccountCardDeleting != nil { + cursor := *m.AccountCardDeleting + card := m.SavedCards[cursor] + m.AccountCardDeleting = nil + m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...) + if m.AccountCardCursor >= len(m.SavedCards) && m.AccountCardCursor > 0 { + m.AccountCardCursor-- + } + return m, m.deleteAddressCmd(card.ID) + } + + case "n": + m.AccountAddressDeleting = nil + m.AccountCardDeleting = nil + case "p", "enter": - if m.AccountCursor == 0 && len(m.Orders) > 0 { - if m.OrderViewState == 0 { - m.OrderViewState = 1 - m.OrderCursor = 0 - m.ScrollOffset = 0 - } else if m.OrderViewState == 1 { - m.OrderViewState = 2 + switch selectedItem { + case "order history": + if len(m.Orders) > 0 { + if m.OrderViewState == 0 { + m.OrderViewState = 1 + m.OrderCursor = 0 + m.ScrollOffset = 0 + } else if m.OrderViewState == 1 { + m.OrderViewState = 2 + m.ScrollOffset = 0 + } + } + case "addresses": + if !m.AddressListFocused { + m.AddressListFocused = true + m.AccountAddressCursor = 0 + } + case "cards": + if !m.CardListFocused { + m.CardListFocused = true + m.AccountCardCursor = 0 + } + case "faq": + if !m.FaqFocused { + m.FaqFocused = true m.ScrollOffset = 0 } - } else if m.AccountCursor == 1 && !m.FaqFocused { - m.FaqFocused = true - m.ScrollOffset = 0 } + case "esc": - if m.FaqFocused { + if m.AccountAddressDeleting != nil { + m.AccountAddressDeleting = nil + } else if m.AddressListFocused { + m.AddressListFocused = false + m.AccountAddressCursor = 0 + } else if m.AccountCardDeleting != nil { + m.AccountCardDeleting = nil + } else if m.CardListFocused { + m.CardListFocused = false + m.AccountCardCursor = 0 + } else if m.FaqFocused { m.FaqFocused = false m.ScrollOffset = 0 } else if m.OrderViewState > 0 { diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 1452553..9e78fa1 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -132,6 +132,10 @@ func formatUSD(cents int) string { func (m Model) generateReviewContent() string { view := strings.Builder{} + if m.CardJustAdded { + view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n") + } + if m.ShippingInfo != nil { view.WriteString(m.ShippingInfo.Name + "\n") view.WriteString(m.ShippingInfo.Street + "\n") @@ -151,7 +155,7 @@ func (m Model) generateReviewContent() string { for _, item := range m.GetCartItemsSlice() { subtotal += item.Coffee.Price * item.Quantity } - view.WriteString(fmt.Sprintf("subtotal: %\n", formatUSD(subtotal))) + view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal))) view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n") view.WriteString("\n") view.WriteString(m.theme.TextDim().Render("press enter to confirm") + "\n") @@ -163,6 +167,15 @@ func (m Model) ReviewView() string { if m.CheckingOut { return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") } + if m.ReviewSuccess { + content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") + return lipgloss.Place( + m.widthContainer, + lipgloss.Height(content), + lipgloss.Center, lipgloss.Center, + content, + ) + } content := m.generateReviewContent() return lipgloss.Place( m.widthContainer, @@ -183,22 +196,29 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { m.PaymentForm = nil return m, nil } - m.ConfirmTotal = msg.Total - for _, item := range m.GetCartItemsSlice() { - m.ConfirmItems = append(m.ConfirmItems, *item) - } - m.ConfirmShipping = m.ShippingInfo m.Cart = make(map[uint]*models.CartItem) m.CartCursor = 0 m.ShippingInfo = nil m.OrdersLoaded = false - m = m.SwitchPage(confirmPage) + m.ReviewSuccess = true return m, nil case tea.KeyMsg: if m.CheckingOut { return m, nil } + if m.ReviewSuccess { + m = m.SwitchPage(shopPage) + m.ReviewSuccess = false + m.CardJustAdded = false + m = m.resetPageState() + return m, func() tea.Msg { + if m.APIClient != nil { + _ = m.APIClient.ClearCart() + } + return nil + } + } switch msg.String() { case "esc": m = m.SwitchPage(paymentPage) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index af601fa..dc7147e 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -132,12 +132,22 @@ type Model struct { FAQs []FAQ // loaded from embedded faq.json FaqFocused bool // true when FAQ detail is focused + // Account manegement state + AddressListFocused bool // true when browsing addresses in account + CardListFocused bool // true when browsing cards in account + AccountAddressCursor int // cursor within address list in AccountAddressCursor + AccountCardCursor int // cursor within card list in account + AccountAddressDeleting *int // index of address pending deletion + AccountCardDeleting *int // index of card pending deletion + // Splash screen splashDataReady bool // true when products have loaded splashDelayDone bool // true when minimum display time has elapsed splashCursor bool // toggles for blinking cursor animation - // Confirmation screen — snapshot populated on checkout, cleared when leaving confirm page + // Review / confirmation state + CardJustAdded bool // true when arriving at review page after adding a new card + ReviewSuccess bool // true after a successful order, shows success msg on review page ConfirmTotal int // server-confirmed order total in cents ConfirmItems []models.CartItem // cart items at time of purchase ConfirmShipping *models.Address // shipping address at time of purchase @@ -271,6 +281,10 @@ func (m Model) resetPageState() Model { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false + m.AddressListFocused = false + m.CardListFocused = false + m.AccountAddressDeleting = nil + m.AccountCardDeleting = nil return m } @@ -606,7 +620,6 @@ func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd { } } - // loadCartFromAPI populates the in-memory cart map from an API CartData response // It matches cart items to local Coffees by ID so the Coffee struct is populated func (m *Model) loadCartFromAPI(cartData *api.CartData) { diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index cd78a40..de65aec 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -431,7 +431,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.PaymentForm.submitting = false } if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to save card: %w", msg.Err) + m.ErrorMsg = fmt.Sprintf("failed to save card: %v", msg.Err) m.PaymentView = 1 m.PaymentForm = m.InitPaymentForm() return m, m.PaymentForm.form.Init() @@ -440,6 +440,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { selected := msg.Card m.SelectedCard = &selected m.PaymentForm = nil + m.CardJustAdded = true m = m.SwitchPage(reviewPage) return m, nil @@ -463,6 +464,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.CollectURL = nil selected := m.SavedCards[len(m.SavedCards)-1] m.SelectedCard = &selected + m.CardJustAdded = true m = m.SwitchPage(reviewPage) return m, nil }