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 }