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
This commit is contained in:
2026-04-12 12:11:04 +02:00
parent 157a752540
commit 6559c4dd8f
5 changed files with 208 additions and 43 deletions

View File

@@ -29,6 +29,8 @@ func (Coffee) TableName() string {
// AccountMenuItems
var AccountMenuItems = []string{
"order history",
"addresses",
"cards",
"faq",
"about",
}

View File

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

View File

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

View File

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

View File

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