diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..c123067 --- /dev/null +++ b/REFACTOR_PLAN.md @@ -0,0 +1,150 @@ +# Refactor: Extract Flat Model State into Nested Page Structs + +## Context + +The TUI's `Model` struct has 40+ flat fields for page-specific state (cursors, viewports, form pointers, booleans). The reference implementation (`terminal-shop-source`) uses nested per-page state structs, which keeps related state grouped, simplifies page transitions, and makes it easier to add new pages. The `shopState` struct already follows this pattern — this refactor extends it to all other pages. + +This is a **pure refactoring** — no new features, no behavior changes. It unblocks future work (product variants, profile editing) by making the Model manageable. + +--- + +## New Struct Definitions (all in `model.go`) + +### `splashState` (3 fields, from Model) +| Old Field | New Field | +|-----------|-----------| +| `splashDataReady` | `splash.dataReady` | +| `splashDelayDone` | `splash.delayDone` | +| `splashCursor` | `splash.cursor` | + +### `cartState` (3 fields, new struct) +| Old Field | New Field | +|-----------|-----------| +| `CartCursor` | `cart.cursor` | +| `cartVP` | `cart.viewport` | +| `cartVPReady` | `cart.viewportReady` | + +### `shippingState` (5 fields, new struct) +| Old Field | New Field | +|-----------|-----------| +| `ShippingForm` | `shipping.form` | +| `ShippingView` | `shipping.view` | +| `AddressCursor` | `shipping.addressCursor` | +| `shippingVP` | `shipping.viewport` | +| `shippingVPReady` | `shipping.viewportReady` | + +### `paymentState` (7 fields, new struct) +| Old Field | New Field | +|-----------|-----------| +| `PaymentForm` | `payment.form` | +| `PaymentView` | `payment.view` | +| `CardCursor` | `payment.cardCursor` | +| `CollectURL` | `payment.collectURL` | +| `CollectCardCount` | `payment.collectCardCount` | +| `paymentVP` | `payment.viewport` | +| `paymentVPReady` | `payment.viewportReady` | + +### `accountState` (14 fields, new struct) +| Old Field | New Field | +|-----------|-----------| +| `AccountCursor` | `account.cursor` | +| `ScrollOffset` | `account.scrollOffset` | +| `OrderViewState` | `account.orderViewState` | +| `OrderCursor` | `account.orderCursor` | +| `FaqFocused` | `account.faqFocused` | +| `AddressListFocused` | `account.addressListFocused` | +| `CardListFocused` | `account.cardListFocused` | +| `AccountAddressCursor` | `account.addressCursor` | +| `AccountCardCursor` | `account.cardCursor` | +| `AccountAddressDeleting` | `account.addressDeleting` | +| `AccountCardDeleting` | `account.cardDeleting` | +| `SSHKeyListFocused` | `account.sshKeyListFocused` | +| `AccountSSHKeyCursor` | `account.sshKeyCursor` | +| `AccountSSHKeyDeleting` | `account.sshKeyDeleting` | + +### `reviewState` (2 fields, new struct) +| Old Field | New Field | +|-----------|-----------| +| `CardJustAdded` | `review.cardJustAdded` | +| `ReviewSuccess` | `review.success` | + +### `confirmState` (5 fields, new struct) +| Old Field | New Field | +|-----------|-----------| +| `ConfirmTotal` | `confirm.total` | +| `ConfirmItems` | `confirm.items` | +| `ConfirmShipping` | `confirm.shipping` | +| `confirmVP` | `confirm.viewport` | +| `confirmVPReady` | `confirm.viewportReady` | + +### Fields that STAY on Model (shared across pages) +`Cart`, `Coffees`, `SavedAddresses`, `SavedCards`, `SSHKeys`, `Orders`, `OrdersLoaded`, `SSHKeysLoaded`, `FAQs`, `ShippingInfo`, `SelectedCard`, `CheckingOut`, `lastCartUpdateID`, `User`, `AccessToken`, `APIClient`, `ErrorMsg`, `Loading`, all layout/resize/auth/rendering fields. + +--- + +## Execution Order (one struct at a time, compile+test after each) + +### Phase 1: `splashState` (simplest — 3 fields, 3 files) +- Files: `model.go`, `update.go`, `splash.go` + +### Phase 2: `cartState` (3 fields, 3 files) +- Files: `model.go`, `cart.go`, `confirmation.go` (resets cart cursor after checkout) + +### Phase 3: `confirmState` (5 fields, 1 file) +- Files: `model.go`, `confirmation.go` + +### Phase 4: `reviewState` (2 fields, 2 files) +- Files: `model.go`, `confirmation.go`, `payment_form.go` + +### Phase 5: `shippingState` (5 fields, 4 files) +- Files: `model.go`, `shipping_form.go`, `update.go`, `footer.go` + +### Phase 6: `paymentState` (7 fields, 4 files) +- Files: `model.go`, `payment_form.go`, `update.go`, `footer.go` + +### Phase 7: `accountState` (14 fields, 5 files — largest change) +- Files: `model.go`, `account.go`, `views.go`, `footer.go`, `update.go` +- Note: `ScrollOffset` moves here. All other pages use viewports, so the generic scroll block in `views.go` becomes account-only. + +### Phase 8: Add `*Switch()` methods + remove `resetPageState()` +- Add `ShopSwitch()`, `CartSwitch()`, `AccountSwitch()`, `ShippingSwitch()`, `PaymentSwitch()`, `ReviewSwitch()` to `model.go` +- Each calls `SwitchPage()`, zero-initializes its page state (`m.cart = cartState{}`), updates viewport +- Replace `SwitchPage(X).resetPageState()` calls in `update.go`, `menu.go`, `confirmation.go` +- Delete `resetPageState()` + +--- + +## Files Modified (summary) + +| File | Scope of Changes | +|------|-----------------| +| `pkg/tui/model.go` | Define structs, remove flat fields, add Switch methods, delete `resetPageState()` | +| `pkg/tui/update.go` | Rename all field accesses, replace `SwitchPage().resetPageState()` with `*Switch()` | +| `pkg/tui/cart.go` | `m.CartCursor` -> `m.cart.cursor`, viewport fields | +| `pkg/tui/account.go` | 14 field renames to `m.account.*` | +| `pkg/tui/shipping_form.go` | `m.ShippingForm` -> `m.shipping.form`, viewport/cursor fields | +| `pkg/tui/payment_form.go` | `m.PaymentForm` -> `m.payment.form`, viewport/cursor fields | +| `pkg/tui/confirmation.go` | confirm + review + cart cursor field renames | +| `pkg/tui/views.go` | `m.ScrollOffset` -> `m.account.scrollOffset`, account field refs | +| `pkg/tui/footer.go` | Multiple field renames for page-state reads | +| `pkg/tui/menu.go` | Replace `SwitchPage().resetPageState()` with `*Switch()` | +| `pkg/tui/splash.go` | `m.splashCursor` -> `m.splash.cursor` | +| `pkg/tui/header.go` | No changes | +| `pkg/tui/breadcrumbs.go` | No changes | +| `pkg/tui/help.go` | No changes | +| `pkg/tui/faq.go` | No changes | + +--- + +## Verification + +After each phase: +1. `go build ./...` — must compile +2. `go test ./pkg/tui/...` — existing tests pass +3. After all phases complete, manual test: + - SSH in, verify splash -> shop transition + - Browse shop, add items to cart + - Full checkout: shipping -> payment -> review -> confirm + - Account page: orders, addresses, cards, SSH keys, FAQ tabs + - Menu (m) and help (?) modals + - Terminal resize during each page diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 1a3dfb7..3a5a2fe 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -17,7 +17,7 @@ func (m Model) BuildAccountView(availableHeight int) string { // Build the left panel (menu items) leftPanel := "" for i, item := range models.AccountMenuItems { - if m.AccountCursor == i { + if m.account.cursor == i { style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left) leftPanel += style.Render(item) + "\n" } else { @@ -31,7 +31,7 @@ func (m Model) BuildAccountView(availableHeight int) string { // Build the detail view (right panel) based on cursor position detailView := "" - if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) { + if m.account.cursor >= 0 && m.account.cursor < len(models.AccountMenuItems) { titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2) // On small terminals, use full content width @@ -42,16 +42,16 @@ func (m Model) BuildAccountView(availableHeight int) string { contentStyle := m.theme.TextBody().Width(detailContentWidth) - selectedItem := models.AccountMenuItems[m.AccountCursor] + selectedItem := models.AccountMenuItems[m.account.cursor] switch selectedItem { case "order history": if !m.OrdersLoaded { detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...") } else if len(m.Orders) == 0 { detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.") - } else if m.OrderViewState == 2 { + } else if m.account.orderViewState == 2 { // State 2: full detail for the selected order - detailView = m.buildOrderDetailView(m.Orders[m.OrderCursor], detailContentWidth) + detailView = m.buildOrderDetailView(m.Orders[m.account.orderCursor], detailContentWidth) } else { // State 0 (preview) or State 1 (browsing with cursor) boxWidth := detailContentWidth - 4 @@ -62,14 +62,14 @@ func (m Model) BuildAccountView(availableHeight int) string { lines := titleStyle.Render("Order History") + "\n\n" for i, order := range m.Orders { - isSelected := m.OrderViewState == 1 && i == m.OrderCursor + isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, detailContentWidth) if i < len(m.Orders)-1 { lines += "\n" } } - if m.OrderViewState == 0 { + if m.account.orderViewState == 0 { hintStyle := m.theme.TextDim() lines += "\n" + hintStyle.Render("enter: browse orders") } @@ -82,12 +82,12 @@ func (m Model) BuildAccountView(availableHeight int) string { lines += contentStyle.Render("No saved addresses") } else { for i, addr := range m.SavedAddresses { - isSelected := m.AddressListFocused && i == m.AccountAddressCursor + isSelected := m.account.addressListFocused && i == m.account.addressCursor label := addr.Name + " " + addr.Street + ", " + addr.City if len(label) > detailContentWidth-4 { label = label[:detailContentWidth-4] } - if m.AccountAddressDeleting != nil && *m.AccountAddressDeleting == i { + if m.account.addressDeleting != nil && *m.account.addressDeleting == i { lines += m.theme.TextError().Bold(true).Render(" deletes? (y/n)") + "\n" } else if isSelected { lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n" @@ -96,7 +96,7 @@ func (m Model) BuildAccountView(availableHeight int) string { } } } - if m.AddressListFocused { + if m.account.addressListFocused { lines += "\n" + m.theme.TextDim().Render("x: delete esc: back") } else { lines += "\n" + m.theme.TextDim().Render("enter: manage") @@ -109,10 +109,10 @@ func (m Model) BuildAccountView(availableHeight int) string { lines += contentStyle.Render("No saved cards.") } else { for i, card := range m.SavedCards { - isSelected := m.CardListFocused && i == m.AccountCardCursor + isSelected := m.account.cardListFocused && i == m.account.cardCursor label := fmt.Sprintf("**** **** **** %s %s exp %02d/%02d", card.Last4, card.Brand, card.ExpMonth, card.ExpYear%100) - if m.AccountCardDeleting != nil && *m.AccountCardDeleting == i { + if m.account.cardDeleting != nil && *m.account.cardDeleting == i { lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n" } else if isSelected { lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n" @@ -121,7 +121,7 @@ func (m Model) BuildAccountView(availableHeight int) string { } } } - if m.CardListFocused { + if m.account.cardListFocused { lines += "\n" + m.theme.TextDim().Render("x: delete esc: back") } else { lines += "\n" + m.theme.TextDim().Render("enter: manage") @@ -136,7 +136,7 @@ func (m Model) BuildAccountView(availableHeight int) string { lines += contentStyle.Render("No saved ssh keys.") } else { for i, key := range m.SSHKeys { - isSelected := m.SSHKeyListFocused && i == m.AccountSSHKeyCursor + isSelected := m.account.sshKeyListFocused && i == m.account.sshKeyCursor label := key.Fingerprint if key.Comment != "" { label += " " + key.Comment @@ -144,7 +144,7 @@ func (m Model) BuildAccountView(availableHeight int) string { if len(label) > detailContentWidth-4 { label = label[:detailContentWidth-4] } - if m.AccountSSHKeyDeleting != nil && *m.AccountSSHKeyDeleting == i { + if m.account.sshKeyDeleting != nil && *m.account.sshKeyDeleting == i { lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n" } else if isSelected { lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n" @@ -153,7 +153,7 @@ func (m Model) BuildAccountView(availableHeight int) string { } } } - if m.SSHKeyListFocused { + if m.account.sshKeyListFocused { lines += "\n" + m.theme.TextDim().Render("x: delete esc: back") } else { lines += "\n" + m.theme.TextDim().Render("enter: manage") @@ -172,7 +172,7 @@ func (m Model) BuildAccountView(availableHeight int) string { } } detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent - if !m.FaqFocused { + if !m.account.faqFocused { hintStyle := m.theme.TextDim() detailView += "\n\n" + hintStyle.Render("enter: scroll faq") } @@ -202,7 +202,7 @@ func (m Model) BuildAccountView(availableHeight int) string { // Apply internal scrolling to just the detail view // Scrolling is needed for: order detail/list and FAQ when focused - if m.OrderViewState >= 1 || m.FaqFocused { + if m.account.orderViewState >= 1 || m.account.faqFocused { detailLines := strings.Split(detailView, "\n") totalLines := len(detailLines) @@ -217,7 +217,7 @@ func (m Model) BuildAccountView(availableHeight int) string { } // Clamp scroll offset locally (don't mutate m since value receiver) - offset := m.ScrollOffset + offset := m.account.scrollOffset maxScroll := totalLines - scrollHeight if maxScroll < 0 { maxScroll = 0 @@ -458,65 +458,65 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { } selectedItem := "" - if m.AccountCursor < len(models.AccountMenuItems) { - selectedItem = models.AccountMenuItems[m.AccountCursor] + if m.account.cursor < len(models.AccountMenuItems) { + selectedItem = models.AccountMenuItems[m.account.cursor] } switch keyMsg.String() { case "up", "k": - if m.AddressListFocused && m.AccountAddressDeleting == nil { - if m.AccountAddressCursor > 0 { - m.AccountAddressCursor-- + if m.account.addressListFocused && m.account.addressDeleting == nil { + if m.account.addressCursor > 0 { + m.account.addressCursor-- } - } else if m.CardListFocused && m.AccountCardDeleting == nil { - if m.AccountCardCursor > 0 { - m.AccountCardCursor-- + } else if m.account.cardListFocused && m.account.cardDeleting == nil { + if m.account.cardCursor > 0 { + m.account.cardCursor-- } - } else if m.SSHKeyListFocused && m.AccountSSHKeyDeleting == nil { - if m.AccountSSHKeyCursor > 0 { - m.AccountSSHKeyCursor-- + } else if m.account.sshKeyListFocused && m.account.sshKeyDeleting == nil { + if m.account.sshKeyCursor > 0 { + m.account.sshKeyCursor-- } - } else if m.FaqFocused { - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 + } else if m.account.faqFocused { + m.account.scrollOffset -= 3 + if m.account.scrollOffset < 0 { + m.account.scrollOffset = 0 } - } else if m.OrderViewState == 1 && m.OrderCursor > 0 { - m.OrderCursor-- + } else if m.account.orderViewState == 1 && m.account.orderCursor > 0 { + m.account.orderCursor-- cardHeight := 5 if m.heightContainer >= 25 { cardHeight = 7 } - targetTop := 3 + m.OrderCursor*cardHeight - if targetTop < m.ScrollOffset { - m.ScrollOffset = targetTop + targetTop := 3 + m.account.orderCursor*cardHeight + if targetTop < m.account.scrollOffset { + m.account.scrollOffset = targetTop } - } else if m.OrderViewState == 0 && m.AccountCursor > 0 { - m.AccountCursor-- - m.ScrollOffset = 0 + } else if m.account.orderViewState == 0 && m.account.cursor > 0 { + m.account.cursor-- + m.account.scrollOffset = 0 } case "down", "j": - if m.AddressListFocused && m.AccountAddressDeleting == nil { - if m.AccountAddressCursor < len(m.SavedAddresses)-1 { - m.AccountAddressCursor++ + if m.account.addressListFocused && m.account.addressDeleting == nil { + if m.account.addressCursor < len(m.SavedAddresses)-1 { + m.account.addressCursor++ } - } else if m.CardListFocused && m.AccountCardDeleting == nil { - if m.AccountCardCursor < len(m.SavedCards)-1 { - m.AccountCardCursor++ + } else if m.account.cardListFocused && m.account.cardDeleting == nil { + if m.account.cardCursor < len(m.SavedCards)-1 { + m.account.cardCursor++ } - } else if m.SSHKeyListFocused && m.AccountSSHKeyDeleting == nil { - if m.AccountSSHKeyCursor < len(m.SSHKeys)-1 { - m.AccountSSHKeyCursor++ + } else if m.account.sshKeyListFocused && m.account.sshKeyDeleting == nil { + if m.account.sshKeyCursor < len(m.SSHKeys)-1 { + m.account.sshKeyCursor++ } - } else if m.FaqFocused { - m.ScrollOffset += 3 + } else if m.account.faqFocused { + m.account.scrollOffset += 3 maxScroll := m.computeFaqScrollMax() - if m.ScrollOffset > maxScroll { - m.ScrollOffset = maxScroll + if m.account.scrollOffset > maxScroll { + m.account.scrollOffset = maxScroll } - } else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 { - m.OrderCursor++ + } else if m.account.orderViewState == 1 && m.account.orderCursor < len(m.Orders)-1 { + m.account.orderCursor++ cardHeight := 5 if m.heightContainer >= 25 { cardHeight = 7 @@ -528,133 +528,133 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { if viewportHeight < 5 { viewportHeight = 5 } - targetBottom := 3 + (m.OrderCursor+1)*cardHeight - if targetBottom > m.ScrollOffset+viewportHeight { - m.ScrollOffset = targetBottom - viewportHeight + targetBottom := 3 + (m.account.orderCursor+1)*cardHeight + if targetBottom > m.account.scrollOffset+viewportHeight { + m.account.scrollOffset = targetBottom - viewportHeight } - } else if m.OrderViewState == 0 && !m.AddressListFocused && !m.CardListFocused && !m.SSHKeyListFocused && m.AccountCursor < len(models.AccountMenuItems)-1 { - m.AccountCursor++ - m.ScrollOffset = 0 + } else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && !m.account.sshKeyListFocused && m.account.cursor < len(models.AccountMenuItems)-1 { + m.account.cursor++ + m.account.scrollOffset = 0 } case "pgup", "ctrl+u": - if m.FaqFocused || m.OrderViewState >= 2 { - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 + if m.account.faqFocused || m.account.orderViewState >= 2 { + m.account.scrollOffset -= 3 + if m.account.scrollOffset < 0 { + m.account.scrollOffset = 0 } } case "pgdown", "ctrl+d": - if m.FaqFocused || m.OrderViewState >= 2 { - m.ScrollOffset += 3 + if m.account.faqFocused || m.account.orderViewState >= 2 { + m.account.scrollOffset += 3 maxScroll := m.computeFaqScrollMax() - if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll { - m.ScrollOffset = maxScroll + if m.account.faqFocused && maxScroll >= 0 && m.account.scrollOffset > maxScroll { + m.account.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 - } else if m.SSHKeyListFocused && m.AccountSSHKeyDeleting == nil && m.AccountSSHKeyCursor < len(m.SSHKeys) { - m.AccountSSHKeyDeleting = &m.AccountSSHKeyCursor + if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) { + m.account.addressDeleting = &m.account.addressCursor + } else if m.account.cardListFocused && m.account.cardDeleting == nil && m.account.cardCursor < len(m.SavedCards) { + m.account.cardDeleting = &m.account.cardCursor + } else if m.account.sshKeyListFocused && m.account.sshKeyDeleting == nil && m.account.sshKeyCursor < len(m.SSHKeys) { + m.account.sshKeyDeleting = &m.account.sshKeyCursor } case "y": - if m.AccountAddressDeleting != nil { - cursor := *m.AccountAddressDeleting + if m.account.addressDeleting != nil { + cursor := *m.account.addressDeleting addr := m.SavedAddresses[cursor] - m.AccountAddressDeleting = nil + m.account.addressDeleting = nil m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...) - if m.AccountAddressCursor >= len(m.SavedAddresses) && m.AccountAddressCursor > 0 { - m.AccountAddressCursor-- + if m.account.addressCursor >= len(m.SavedAddresses) && m.account.addressCursor > 0 { + m.account.addressCursor-- } return m, m.deleteAddressCmd(addr.ID) - } else if m.AccountCardDeleting != nil { - cursor := *m.AccountCardDeleting + } else if m.account.cardDeleting != nil { + cursor := *m.account.cardDeleting card := m.SavedCards[cursor] - m.AccountCardDeleting = nil + m.account.cardDeleting = nil m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...) - if m.AccountCardCursor >= len(m.SavedCards) && m.AccountCardCursor > 0 { - m.AccountCardCursor-- + if m.account.cardCursor >= len(m.SavedCards) && m.account.cardCursor > 0 { + m.account.cardCursor-- } return m, m.deleteCardCmd(card.ID) - } else if m.AccountSSHKeyDeleting != nil { - cursor := *m.AccountSSHKeyDeleting + } else if m.account.sshKeyDeleting != nil { + cursor := *m.account.sshKeyDeleting key := m.SSHKeys[cursor] - m.AccountSSHKeyDeleting = nil + m.account.sshKeyDeleting = nil m.SSHKeys = append(m.SSHKeys[:cursor], m.SSHKeys[cursor+1:]...) - if m.AccountSSHKeyCursor >= len(m.SSHKeys) && m.AccountSSHKeyCursor > 0 { - m.AccountSSHKeyCursor-- + if m.account.sshKeyCursor >= len(m.SSHKeys) && m.account.sshKeyCursor > 0 { + m.account.sshKeyCursor-- } return m, m.deleteSSHKeyCmd(key.ID) } case "n": - m.AccountAddressDeleting = nil - m.AccountCardDeleting = nil - m.AccountSSHKeyDeleting = nil + m.account.addressDeleting = nil + m.account.cardDeleting = nil + m.account.sshKeyDeleting = nil case "p", "enter": 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 + if m.account.orderViewState == 0 { + m.account.orderViewState = 1 + m.account.orderCursor = 0 + m.account.scrollOffset = 0 + } else if m.account.orderViewState == 1 { + m.account.orderViewState = 2 + m.account.scrollOffset = 0 } } case "ssh keys": - if !m.SSHKeyListFocused { - m.SSHKeyListFocused = true - m.AccountSSHKeyCursor = 0 + if !m.account.sshKeyListFocused { + m.account.sshKeyListFocused = true + m.account.sshKeyCursor = 0 } case "addresses": - if !m.AddressListFocused { - m.AddressListFocused = true - m.AccountAddressCursor = 0 + if !m.account.addressListFocused { + m.account.addressListFocused = true + m.account.addressCursor = 0 } case "cards": - if !m.CardListFocused { - m.CardListFocused = true - m.AccountCardCursor = 0 + if !m.account.cardListFocused { + m.account.cardListFocused = true + m.account.cardCursor = 0 } case "faq": - if !m.FaqFocused { - m.FaqFocused = true - m.ScrollOffset = 0 + if !m.account.faqFocused { + m.account.faqFocused = true + m.account.scrollOffset = 0 } } case "esc": - 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.AccountSSHKeyDeleting != nil { - m.AccountSSHKeyDeleting = nil - } else if m.SSHKeyListFocused { - m.SSHKeyListFocused = false - m.AccountSSHKeyCursor = 0 - } else if m.FaqFocused { - m.FaqFocused = false - m.ScrollOffset = 0 - } else if m.OrderViewState > 0 { - m.OrderViewState-- - m.ScrollOffset = 0 + if m.account.addressDeleting != nil { + m.account.addressDeleting = nil + } else if m.account.addressListFocused { + m.account.addressListFocused = false + m.account.addressCursor = 0 + } else if m.account.cardDeleting != nil { + m.account.cardDeleting = nil + } else if m.account.cardListFocused { + m.account.cardListFocused = false + m.account.cardCursor = 0 + } else if m.account.sshKeyDeleting != nil { + m.account.sshKeyDeleting = nil + } else if m.account.sshKeyListFocused { + m.account.sshKeyListFocused = false + m.account.sshKeyCursor = 0 + } else if m.account.faqFocused { + m.account.faqFocused = false + m.account.scrollOffset = 0 + } else if m.account.orderViewState > 0 { + m.account.orderViewState-- + m.account.scrollOffset = 0 } } return m, nil diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index d886f83..1bc6c6b 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -16,13 +16,13 @@ func (m Model) updateCartViewport() Model { if availH < 1 { availH = 1 } - if !m.cartVPReady { - m.cartVP = viewport.New(m.widthContent, availH) - m.cartVP.KeyMap = viewport.KeyMap{} - m.cartVPReady = true + if !m.cart.viewportReady { + m.cart.viewport = viewport.New(m.widthContent, availH) + m.cart.viewport.KeyMap = viewport.KeyMap{} + m.cart.viewportReady = true } else { - m.cartVP.Width = m.widthContent - m.cartVP.Height = availH + m.cart.viewport.Width = m.widthContent + m.cart.viewport.Height = availH } return m } @@ -87,7 +87,7 @@ func (m Model) generateCartContent() string { boxContent := contentLine1 + "\n" + contentLine2 var itemBox lipgloss.Style - if idx == m.CartCursor { + if idx == m.cart.cursor { itemBox = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), true). BorderForeground(m.theme.Accent()). @@ -118,26 +118,26 @@ func (m Model) generateCartContent() string { } func (m Model) CartView() string { - if !m.cartVPReady { + if !m.cart.viewportReady { m = m.updateCartViewport() } content := m.generateCartContent() - m.cartVP.SetContent(content) + m.cart.viewport.SetContent(content) if len(m.Cart) > 0 { itemHeight := 5 - targetY := m.CartCursor * itemHeight - if targetY < m.cartVP.YOffset { - m.cartVP.SetYOffset(targetY) + targetY := m.cart.cursor * itemHeight + if targetY < m.cart.viewport.YOffset { + m.cart.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.cartVP.YOffset+m.cartVP.Height { - m.cartVP.SetYOffset(targetY - m.cartVP.Height + itemHeight) + if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height { + m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight) } } return lipgloss.Place( m.widthContainer, - lipgloss.Height(m.cartVP.View()), + lipgloss.Height(m.cart.viewport.View()), lipgloss.Center, lipgloss.Center, - m.cartVP.View(), + m.cart.viewport.View(), ) } @@ -148,31 +148,31 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { } switch keyMsg.String() { case "up", "k": - if m.CartCursor > 0 { - m.CartCursor-- + if m.cart.cursor > 0 { + m.cart.cursor-- } case "down", "j": - if m.CartCursor < len(m.Cart)-1 { - m.CartCursor++ + if m.cart.cursor < len(m.Cart)-1 { + m.cart.cursor++ } case "+", "=": cartItems := m.GetCartItemsSlice() - if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { - cartItems[m.CartCursor].Quantity++ - return m, m.syncCartItemCmd(cartItems[m.CartCursor].CoffeeID, cartItems[m.CartCursor].Quantity) + if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) { + cartItems[m.cart.cursor].Quantity++ + return m, m.syncCartItemCmd(cartItems[m.cart.cursor].CoffeeID, cartItems[m.cart.cursor].Quantity) } case "-", "_": cartItems := m.GetCartItemsSlice() - if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { - coffeeID := cartItems[m.CartCursor].CoffeeID - cartItems[m.CartCursor].Quantity-- - newQty := cartItems[m.CartCursor].Quantity - if cartItems[m.CartCursor].Quantity <= 0 { + if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) { + coffeeID := cartItems[m.cart.cursor].CoffeeID + cartItems[m.cart.cursor].Quantity-- + newQty := cartItems[m.cart.cursor].Quantity + if cartItems[m.cart.cursor].Quantity <= 0 { delete(m.Cart, coffeeID) if len(m.Cart) == 0 { - m.CartCursor = 0 - } else if m.CartCursor >= len(m.Cart) { - m.CartCursor = len(m.Cart) - 1 + m.cart.cursor = 0 + } else if m.cart.cursor >= len(m.Cart) { + m.cart.cursor = len(m.Cart) - 1 } newQty = 0 } diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 9e78fa1..045a657 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -26,20 +26,20 @@ func (m Model) RenderConfirmation() string { // Shipping summary shippingSection := "" - if m.ConfirmShipping != nil { + if m.confirm.shipping != nil { shippingSection = sectionStyle.Render( labelStyle.Render("Ship to: ") + - valueStyle.Render(m.ConfirmShipping.Name) + "\n" + + valueStyle.Render(m.confirm.shipping.Name) + "\n" + labelStyle.Render(" ") + - valueStyle.Render(m.ConfirmShipping.Street) + "\n" + + valueStyle.Render(m.confirm.shipping.Street) + "\n" + labelStyle.Render(" ") + - valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.Country)), + valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.confirm.shipping.City, m.confirm.shipping.State, m.confirm.shipping.Zip, m.confirm.shipping.Country)), ) } // Items summary cartSection := "" - for _, item := range m.ConfirmItems { + for _, item := range m.confirm.items { line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100) cartSection += sectionStyle.Render(line) + "\n" } @@ -47,7 +47,7 @@ func (m Model) RenderConfirmation() string { labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"), ) + "\n" cartSection += sectionStyle.Render( - labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)), + labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.confirm.total)/100)), ) success := successStyle.Render("Order placed successfully!") @@ -72,34 +72,34 @@ func (m Model) updateConfirmViewport() Model { if availH < 1 { availH = 1 } - if !m.confirmVPReady { - m.confirmVP = viewport.New(m.widthContent, availH) - m.confirmVP.KeyMap = viewport.DefaultKeyMap() - m.confirmVPReady = true + if !m.confirm.viewportReady { + m.confirm.viewport = viewport.New(m.widthContent, availH) + m.confirm.viewport.KeyMap = viewport.DefaultKeyMap() + m.confirm.viewportReady = true } else { - m.confirmVP.Width = m.widthContent - m.confirmVP.Height = availH + m.confirm.viewport.Width = m.widthContent + m.confirm.viewport.Height = availH } return m } func (m Model) ConfirmView() string { - if !m.confirmVPReady { + if !m.confirm.viewportReady { m = m.updateConfirmViewport() } - m.confirmVP.SetContent(m.RenderConfirmation()) + m.confirm.viewport.SetContent(m.RenderConfirmation()) return lipgloss.Place( m.widthContainer, - lipgloss.Height(m.confirmVP.View()), + lipgloss.Height(m.confirm.viewport.View()), lipgloss.Center, lipgloss.Center, - m.confirmVP.View(), + m.confirm.viewport.View(), ) } func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd - if m.confirmVPReady { - m.confirmVP, cmd = m.confirmVP.Update(msg) + if m.confirm.viewportReady { + m.confirm.viewport, cmd = m.confirm.viewport.Update(msg) } keyMsg, ok := msg.(tea.KeyMsg) @@ -113,9 +113,9 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) { m = m.SwitchPage(shopPage) m.CheckingOut = false - m.ConfirmTotal = 0 - m.ConfirmItems = nil - m.ConfirmShipping = nil + m.confirm.total = 0 + m.confirm.items = nil + m.confirm.shipping = nil m = m.resetPageState() return m, func() tea.Msg { if m.APIClient != nil { @@ -132,7 +132,7 @@ func formatUSD(cents int) string { func (m Model) generateReviewContent() string { view := strings.Builder{} - if m.CardJustAdded { + if m.review.cardJustAdded { view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n") } @@ -167,7 +167,7 @@ func (m Model) ReviewView() string { if m.CheckingOut { return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") } - if m.ReviewSuccess { + if m.review.success { content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") return lipgloss.Place( m.widthContainer, @@ -192,25 +192,25 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { if msg.Err != nil { m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) m = m.SwitchPage(paymentPage) - m.PaymentView = 0 - m.PaymentForm = nil + m.payment.view = 0 + m.payment.form = nil return m, nil } m.Cart = make(map[uint]*models.CartItem) - m.CartCursor = 0 + m.cart.cursor = 0 m.ShippingInfo = nil m.OrdersLoaded = false - m.ReviewSuccess = true + m.review.success = true return m, nil case tea.KeyMsg: if m.CheckingOut { return m, nil } - if m.ReviewSuccess { + if m.review.success { m = m.SwitchPage(shopPage) - m.ReviewSuccess = false - m.CardJustAdded = false + m.review.success = false + m.review.cardJustAdded = false m = m.resetPageState() return m, func() tea.Msg { if m.APIClient != nil { diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go index 67a2982..b731b75 100644 --- a/pkg/tui/footer.go +++ b/pkg/tui/footer.go @@ -20,7 +20,7 @@ func (m Model) BuildFooter() string { } var footerText string - if m.FaqFocused { + if m.account.faqFocused { footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", keybindStyle.Render("j/k"), descStyle.Render("scroll"), @@ -34,7 +34,7 @@ func (m Model) BuildFooter() string { descStyle.Render("quit"), ) } else if m.currentPage == accountPage { - switch m.OrderViewState { + switch m.account.orderViewState { case 2: // Viewing single order detail footerText = fmt.Sprintf("%s %s %s %s %s %s", @@ -83,7 +83,7 @@ func (m Model) BuildFooter() string { descStyle.Render("quit"), ) } else if m.currentPage == shippingPage { - if m.ShippingView == 0 && m.ShippingForm == nil { + if m.shipping.view == 0 && m.shipping.form == nil { // Address list footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", keybindStyle.Render("j/k"), @@ -107,7 +107,7 @@ func (m Model) BuildFooter() string { ) } } else if m.currentPage == paymentPage { - if m.PaymentView == 0 && m.PaymentForm == nil { + if m.payment.view == 0 && m.payment.form == nil { // Card list footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", keybindStyle.Render("j/k"), diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 4b9873f..bf96ebf 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -62,26 +62,21 @@ type Model struct { AuthFingerprintKey string // shared secret for /auth/token refresh StripePublicKey string - // Shop state - Username string - Coffees []models.Coffee - shop shopState - cartVP viewport.Model - cartVPReady bool - shippingVP viewport.Model - shippingVPReady bool - paymentVP viewport.Model - paymentVPReady bool - confirmVP viewport.Model - confirmVPReady bool - Cart map[uint]*models.CartItem - CartCursor int // cursor position in cart view - AccountCursor int - currentPage page // currently active screen - switched bool // true when the page just changed (for state resets) - ScrollOffset int // scroll position for content - CheckingOut bool // true while saveCardAndConvert is running - lastCartUpdateID int64 // debounce ID for optimistic cart sync + // Shared data + Username string + Coffees []models.Coffee + Cart map[uint]*models.CartItem + SavedAddresses []models.Address + SavedCards []models.Card + Orders []models.Order + OrdersLoaded bool + SSHKeys []models.SSHKey + SSHKeysLoaded bool + FAQs []FAQ + + // Page navigation + currentPage page // currently active screen + switched bool // true when the page just changed (for state resets) // Menu modal state ShowingMenu bool // true when full-screen menu is showing @@ -109,57 +104,21 @@ type Model struct { renderer *lipgloss.Renderer theme theme.Theme - // Checkout state - ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step) - PaymentForm *PaymentFormState // Payment form state (nil when not in payment step) - ShippingInfo *models.Address // Selected shipping address for current order - SavedAddresses []models.Address // Saved addresses from database - ShippingView int - AddressCursor int - SavedCards []models.Card // Saved cards from database - SelectedCard *models.Card // Card selected from saved list (nil = new card) - CardCursor int // cursor position in the card list - PaymentView int // 0=card list, 1=new card form, 2 = browser payment - CollectURL *string // URL for browser-based card entry - CollectCardCount int // card count when browser polling started - - // Order history - Orders []models.Order - OrdersLoaded bool - OrderCursor int // which order is selected in the list - OrderViewState int // 0=preview, 1=browsing list, 2=viewing detail - - // Static content - 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 - - // Account SSH key state - SSHKeys []models.SSHKey // list of keys - SSHKeysLoaded bool // true when ssh keys are loaded - SSHKeyListFocused bool // true when browsing ssh keys in account - AccountSSHKeyCursor int // cursor within ssh key list in account - AccountSSHKeyDeleting *int // index of ssh key 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 - - // 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 + // Checkout flow state (spans multiple pages) + CheckingOut bool + ShippingInfo *models.Address + SelectedCard *models.Card + lastCartUpdateID int64 + // Per-page State + splash splashState + shop shopState + cart cartState + shipping shippingState + payment paymentState + account accountState + review reviewState + confirm confirmState } type shopState struct { @@ -169,6 +128,66 @@ type shopState struct { viewportsReady bool } +type splashState struct { + dataReady bool + delayDone bool + cursor bool +} + +type cartState struct { + cursor int + viewport viewport.Model + viewportReady bool +} + +type shippingState struct { + form *ShippingFormState + view int + addressCursor int + viewport viewport.Model + viewportReady bool +} + +type paymentState struct { + form *PaymentFormState + view int + cardCursor int + collectURL *string + collectCardCount int + viewport viewport.Model + viewportReady bool +} + +type accountState struct { + cursor int + scrollOffset int + orderViewState int + orderCursor int + faqFocused bool + addressListFocused bool + cardListFocused bool + addressCursor int + cardCursor int + addressDeleting *int + cardDeleting *int + sshKeyListFocused bool + sshKeyCursor int + sshKeyDeleting *int +} + +type reviewState struct { + cardJustAdded bool + success bool +} + +type confirmState struct { + total int + items []models.CartItem + shipping *models.Address + viewport viewport.Model + viewportReady bool +} + var modifiedKeyMap = viewport.KeyMap{ PageDown: key.NewBinding(key.WithKeys("pgdown")), PageUp: key.NewBinding(key.WithKeys("pgup")), @@ -284,17 +303,10 @@ func (m Model) Init() tea.Cmd { } func (m Model) resetPageState() Model { - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - m.AddressListFocused = false - m.CardListFocused = false - m.AccountAddressDeleting = nil - m.AccountCardDeleting = nil - m.SSHKeyListFocused = false - m.AccountSSHKeyDeleting = nil + m.shipping = shippingState{} + m.payment = paymentState{} + m.account = accountState{} + m.review = reviewState{} return m } @@ -677,8 +689,8 @@ func (m *Model) loadCartFromAPI(cartData *api.CartData) { } } - if m.CartCursor >= len(m.Cart) { - m.CartCursor = 0 + if m.cart.cursor >= len(m.Cart) { + m.cart.cursor = 0 } } @@ -691,7 +703,7 @@ func (m Model) GetCartItemsSlice() []*models.CartItem { } // Sort keys to ensure consistent order - sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) // Build items slice in sorted order items := make([]*models.CartItem, 0, len(m.Cart)) @@ -769,12 +781,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model { Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", }, }, - Cart: make(map[uint]*models.CartItem), - CartCursor: 0, - AccountCursor: 0, - OrderCursor: 0, - OrderViewState: 0, - Loading: true, + Cart: make(map[uint]*models.CartItem), + Loading: true, APIClient: api.NewClient("http://localhost:8080", ""), FAQs: LoadFaqs(), } diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index ef52a8f..2d6d12e 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -272,61 +272,61 @@ func (m Model) updatePaymentViewport() Model { if availH < 1 { availH = 1 } - if !m.paymentVPReady { - m.paymentVP = viewport.New(m.widthContent, availH) - m.paymentVP.KeyMap = viewport.KeyMap{} - m.paymentVPReady = true + if !m.payment.viewportReady { + m.payment.viewport = viewport.New(m.widthContent, availH) + m.payment.viewport.KeyMap = viewport.KeyMap{} + m.payment.viewportReady = true } else { - m.paymentVP.Width = m.widthContent - m.paymentVP.Height = availH + m.payment.viewport.Width = m.widthContent + m.payment.viewport.Height = availH } return m } func (m Model) PaymentPageView() string { - if !m.paymentVPReady { + if !m.payment.viewportReady { m = m.updatePaymentViewport() } var content string if m.CheckingOut { content = " submitting order..." - } else if m.PaymentView == 2 { + } else if m.payment.view == 2 { // Bypass viewport — it strips the raw ANSI codes qrfefe emits. return lipgloss.Place( m.widthContainer, - m.paymentVP.Height, + m.payment.viewport.Height, lipgloss.Center, lipgloss.Center, m.renderPaymentHttpsView(), ) - } else if m.PaymentView == 0 && m.PaymentForm == nil { + } else if m.payment.view == 0 && m.payment.form == nil { content = m.RenderCardList() - m.paymentVP.SetContent(content) + m.payment.viewport.SetContent(content) itemHeight := 3 - targetY := m.CardCursor * itemHeight - if targetY < m.paymentVP.YOffset { - m.paymentVP.SetYOffset(targetY) + targetY := m.payment.cardCursor * itemHeight + if targetY < m.payment.viewport.YOffset { + m.payment.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height { - m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1) + if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height { + m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1) } - if m.CardCursor == len(m.SavedCards) { - m.paymentVP.GotoBottom() + if m.payment.cardCursor == len(m.SavedCards) { + m.payment.viewport.GotoBottom() } return lipgloss.Place( m.widthContainer, - lipgloss.Height(m.paymentVP.View()), + lipgloss.Height(m.payment.viewport.View()), lipgloss.Center, lipgloss.Center, - m.paymentVP.View(), + m.payment.viewport.View(), ) - } else if m.PaymentForm != nil { - content = m.RenderPaymentForm(m.PaymentForm) + } else if m.payment.form != nil { + content = m.RenderPaymentForm(m.payment.form) } - m.paymentVP.SetContent(content) + m.payment.viewport.SetContent(content) return lipgloss.Place( m.widthContainer, - lipgloss.Height(m.paymentVP.View()), + lipgloss.Height(m.payment.viewport.View()), lipgloss.Center, lipgloss.Center, - m.paymentVP.View(), + m.payment.viewport.View(), ) } @@ -342,7 +342,7 @@ func (m Model) RenderCardList() string { for i, card := range m.SavedCards { cursor := " " style := inactiveStyle - if i == m.CardCursor { + if i == m.payment.cardCursor { cursor = "> " style = activeStyle } @@ -358,7 +358,7 @@ func (m Model) RenderCardList() string { sshCursor := " " sshStyle := inactiveStyle - if m.CardCursor == len(m.SavedCards) { + if m.payment.cardCursor == len(m.SavedCards) { sshCursor = "> " sshStyle = activeStyle } @@ -366,7 +366,7 @@ func (m Model) RenderCardList() string { httpsCursor := " " httpsStyle := inactiveStyle - if m.CardCursor == len(m.SavedCards)+1 { + if m.payment.cardCursor == len(m.SavedCards)+1 { httpsCursor = "> " httpsStyle = activeStyle } @@ -382,22 +382,22 @@ func (m Model) renderPaymentHttpsView() string { baseStyle := m.theme.TextLabel() accentStyle := m.theme.TextHighlight() - if m.CollectURL == nil { + if m.payment.collectURL == nil { return baseStyle.Render(" generating payment link...") } - qr, qrSize, err := qrfefe.Generate(0, *m.CollectURL) + qr, qrSize, err := qrfefe.Generate(0, *m.payment.collectURL) if err != nil || qrSize > m.widthContent { // QR too wide for the terminal — just show the URL. return baseStyle.Render("open in browser to add payment information:") + "\n\n" + - accentStyle.Render(*m.CollectURL) + "\n" + accentStyle.Render(*m.payment.collectURL) + "\n" } // Concatenate manually — lipgloss layout functions (JoinVertical with Center) // measure string widths and add padding that corrupts qrfefe's raw ANSI codes. return qr + baseStyle.Render("scan or visit to add payment information") + "\n" + - accentStyle.Render(*m.CollectURL) + "\n" + accentStyle.Render(*m.payment.collectURL) + "\n" } // PaymentUpdate handles all messages while on the payment page. @@ -410,14 +410,14 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { } else { m.SavedCards = msg.Cards } - m.PaymentView = 0 - m.CardCursor = 0 - m.PaymentForm = nil + m.payment.view = 0 + m.payment.cardCursor = 0 + m.payment.form = nil return m, nil case PaymentFormCompleteMsg: - if m.PaymentForm != nil && !m.PaymentForm.submitting { - m.PaymentForm.submitting = true + if m.payment.form != nil && !m.payment.form.submitting { + m.payment.form.submitting = true return m, m.saveCardOnlyCmd(msg) } return m, nil @@ -427,50 +427,50 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, nil case CardSavedForReviewMsg: - if m.PaymentForm != nil { - m.PaymentForm.submitting = false + if m.payment.form != nil { + m.payment.form.submitting = false } if msg.Err != nil { m.ErrorMsg = fmt.Sprintf("failed to save card: %v", msg.Err) - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() + m.payment.view = 1 + m.payment.form = m.InitPaymentForm() + return m, m.payment.form.form.Init() } m.SavedCards = append(m.SavedCards, msg.Card) selected := msg.Card m.SelectedCard = &selected - m.PaymentForm = nil - m.CardJustAdded = true + m.payment.form = nil + m.review.cardJustAdded = true m = m.SwitchPage(reviewPage) return m, nil case PollPaymentInitMsg: url := msg.URL - m.CollectURL = &url - return m, m.pollCardsCmd(m.CollectCardCount) + m.payment.collectURL = &url + return m, m.pollCardsCmd(m.payment.collectCardCount) case PollPaymentStatusMsg: - if m.PaymentView != 2 { + if m.payment.view != 2 { return m, nil // user navigated away, stop polling } return m, m.pollCardsCmd(msg.CardCount) case PollPaymentCompleteMsg: - if m.PaymentView != 2 { + if m.payment.view != 2 { return m, nil } m.SavedCards = msg.Cards - m.PaymentView = 0 - m.CollectURL = nil + m.payment.view = 0 + m.payment.collectURL = nil selected := m.SavedCards[len(m.SavedCards)-1] m.SelectedCard = &selected - m.CardJustAdded = true + m.review.cardJustAdded = true m = m.SwitchPage(reviewPage) return m, nil } // Card list navigation - if m.PaymentView == 0 && m.PaymentForm == nil { + if m.payment.view == 0 && m.payment.form == nil { keyMsg, ok := msg.(tea.KeyMsg) if !ok || m.CheckingOut { return m, nil @@ -481,43 +481,43 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m = m.SwitchPage(shippingPage) return m, m.fetchAddressesCmd() case "up", "k": - if m.CardCursor > 0 { - m.CardCursor-- + if m.payment.cardCursor > 0 { + m.payment.cardCursor-- } case "down", "j": - if m.CardCursor < len(m.SavedCards)+1 { - m.CardCursor++ + if m.payment.cardCursor < len(m.SavedCards)+1 { + m.payment.cardCursor++ } case "enter": - if m.CardCursor < len(m.SavedCards) { - selected := m.SavedCards[m.CardCursor] + if m.payment.cardCursor < len(m.SavedCards) { + selected := m.SavedCards[m.payment.cardCursor] m.SelectedCard = &selected m = m.SwitchPage(reviewPage) return m, nil } - if m.CardCursor == len(m.SavedCards) { - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() + if m.payment.cardCursor == len(m.SavedCards) { + m.payment.view = 1 + m.payment.form = m.InitPaymentForm() + return m, m.payment.form.form.Init() } // Browser payment - m.PaymentView = 2 - m.CollectURL = nil - m.CollectCardCount = len(m.SavedCards) + m.payment.view = 2 + m.payment.collectURL = nil + m.payment.collectCardCount = len(m.SavedCards) return m, m.collectCardCmd() case "d", "x": - if m.CardCursor < len(m.SavedCards) { - card := m.SavedCards[m.CardCursor] - m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...) - if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 { - m.CardCursor-- + if m.payment.cardCursor < len(m.SavedCards) { + card := m.SavedCards[m.payment.cardCursor] + m.SavedCards = append(m.SavedCards[:m.payment.cardCursor], m.SavedCards[m.payment.cardCursor+1:]...) + if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 { + m.payment.cardCursor-- } if len(m.SavedCards) == 0 { - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID)) + m.payment.view = 1 + m.payment.form = m.InitPaymentForm() + return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID)) } return m, m.deleteCardCmd(card.ID) } @@ -526,33 +526,33 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { } // HTTPS/browser view avigation - if m.PaymentView == 2 { + if m.payment.view == 2 { if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { - m.PaymentView = 0 - m.CollectURL = nil + m.payment.view = 0 + m.payment.collectURL = nil return m, nil } return m, nil } // Payment form navigation - if m.PaymentForm != nil { + if m.payment.form != nil { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" { m.ErrorMsg = "" if len(m.SavedCards) > 0 { - m.PaymentView = 0 - m.PaymentForm = nil + m.payment.view = 0 + m.payment.form = nil return m, nil } - m.PaymentForm = nil + m.payment.form = nil m = m.SwitchPage(shippingPage) return m, m.fetchAddressesCmd() } // Clear error only when user starts typing, not on internal huh messages m.ErrorMsg = "" } - return m, m.UpdatePaymentForm(msg, m.PaymentForm) + return m, m.UpdatePaymentForm(msg, m.payment.form) } return m, nil } diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index b19012f..e456fe9 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -262,43 +262,43 @@ func (m Model) updateShippingViewport() Model { if availH < 1 { availH = 1 } - if !m.shippingVPReady { - m.shippingVP = viewport.New(m.widthContent, availH) - m.shippingVP.KeyMap = viewport.KeyMap{} - m.shippingVPReady = true + if !m.shipping.viewportReady { + m.shipping.viewport = viewport.New(m.widthContent, availH) + m.shipping.viewport.KeyMap = viewport.KeyMap{} + m.shipping.viewportReady = true } else { - m.shippingVP.Width = m.widthContent - m.shippingVP.Height = availH + m.shipping.viewport.Width = m.widthContent + m.shipping.viewport.Height = availH } return m } func (m Model) ShippingPageView() string { - if !m.shippingVPReady { + if !m.shipping.viewportReady { m = m.updateShippingViewport() } - if m.ShippingView == 0 && m.ShippingForm == nil { + if m.shipping.view == 0 && m.shipping.form == nil { content := m.RenderAddressList() - m.shippingVP.SetContent(content) + m.shipping.viewport.SetContent(content) itemHeight := 4 - targetY := m.AddressCursor * itemHeight - if targetY < m.shippingVP.YOffset { - m.shippingVP.SetYOffset(targetY) + targetY := m.shipping.addressCursor * itemHeight + if targetY < m.shipping.viewport.YOffset { + m.shipping.viewport.SetYOffset(targetY) } - if targetY+itemHeight > m.shippingVP.YOffset+m.shippingVP.Height { - m.shippingVP.SetYOffset(targetY - m.shippingVP.Height + itemHeight + 1) + if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height { + m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1) } - if m.AddressCursor == len(m.SavedAddresses) { - m.shippingVP.GotoBottom() + if m.shipping.addressCursor == len(m.SavedAddresses) { + m.shipping.viewport.GotoBottom() } - } else if m.ShippingForm != nil { - m.shippingVP.SetContent(m.RenderShippingForm(m.ShippingForm)) + } else if m.shipping.form != nil { + m.shipping.viewport.SetContent(m.RenderShippingForm(m.shipping.form)) } return lipgloss.Place( m.widthContainer, - lipgloss.Height(m.shippingVP.View()), + lipgloss.Height(m.shipping.viewport.View()), lipgloss.Center, lipgloss.Center, - m.shippingVP.View(), + m.shipping.viewport.View(), ) } @@ -314,7 +314,7 @@ func (m Model) RenderAddressList() string { for i, addr := range m.SavedAddresses { cursor := " " style := inactiveStyle - if i == m.AddressCursor { + if i == m.shipping.addressCursor { cursor = "> " style = activeStyle } @@ -334,7 +334,7 @@ func (m Model) RenderAddressList() string { addCursor := " " addStyle := inactiveStyle - if m.AddressCursor == len(m.SavedAddresses) { + if m.shipping.addressCursor == len(m.SavedAddresses) { addCursor = "> " addStyle = activeStyle } @@ -360,27 +360,27 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { case AddressesMsg: if msg.Err != nil || len(msg.Addresses) == 0 { m.SavedAddresses = nil - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, m.ShippingForm.form.Init() + m.shipping.view = 1 + m.shipping.form = m.InitShippingForm() + return m, m.shipping.form.form.Init() } m.SavedAddresses = msg.Addresses - m.ShippingView = 0 - m.AddressCursor = 0 - m.ShippingForm = nil + m.shipping.view = 0 + m.shipping.addressCursor = 0 + m.shipping.form = nil return m, nil case AddressSavedMsg: if msg.Err != nil { - if m.ShippingForm != nil { - m.ShippingForm.submitting = false - m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) + if m.shipping.form != nil { + 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." - return m, m.ShippingForm.form.Init() + return m, m.shipping.form.form.Init() } m.ShippingInfo = &msg.Address - m.ShippingForm = nil + m.shipping.form = nil m = m.SwitchPage(paymentPage) m.SelectedCard = nil return m, m.fetchCardsCmd() @@ -391,7 +391,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { } // Address list navigation - if m.ShippingView == 0 && m.ShippingForm == nil { + if m.shipping.view == 0 && m.shipping.form == nil { keyMsg, ok := msg.(tea.KeyMsg) if !ok { return m, nil @@ -402,35 +402,35 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { m = m.SwitchPage(cartPage) return m, nil case "up", "k": - if m.AddressCursor > 0 { - m.AddressCursor-- + if m.shipping.addressCursor > 0 { + m.shipping.addressCursor-- } case "down", "j": - if m.AddressCursor < len(m.SavedAddresses) { - m.AddressCursor++ + if m.shipping.addressCursor < len(m.SavedAddresses) { + m.shipping.addressCursor++ } case "enter": - if m.AddressCursor < len(m.SavedAddresses) { - selected := m.SavedAddresses[m.AddressCursor] + if m.shipping.addressCursor < len(m.SavedAddresses) { + selected := m.SavedAddresses[m.shipping.addressCursor] m.ShippingInfo = &selected m = m.SwitchPage(paymentPage) m.SelectedCard = nil return m, m.fetchCardsCmd() } - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, m.ShippingForm.form.Init() + m.shipping.view = 1 + m.shipping.form = m.InitShippingForm() + return m, m.shipping.form.form.Init() case "d", "x": - if m.AddressCursor < len(m.SavedAddresses) { - addr := m.SavedAddresses[m.AddressCursor] - m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor+1:]...) - if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 { - m.AddressCursor-- + if m.shipping.addressCursor < len(m.SavedAddresses) { + addr := m.SavedAddresses[m.shipping.addressCursor] + m.SavedAddresses = append(m.SavedAddresses[:m.shipping.addressCursor], m.SavedAddresses[m.shipping.addressCursor+1:]...) + if m.shipping.addressCursor >= len(m.SavedAddresses) && m.shipping.addressCursor > 0 { + m.shipping.addressCursor-- } if len(m.SavedAddresses) == 0 { - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID)) + m.shipping.view = 1 + m.shipping.form = m.InitShippingForm() + return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID)) } return m, m.deleteAddressCmd(addr.ID) } @@ -439,23 +439,23 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { } // Form navigation - if m.ShippingForm != nil { + if m.shipping.form != nil { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" { m.ErrorMsg = "" if len(m.SavedAddresses) > 0 { - m.ShippingView = 0 - m.ShippingForm = nil + m.shipping.view = 0 + m.shipping.form = nil return m, nil } - m.ShippingForm = nil + m.shipping.form = nil m = m.SwitchPage(cartPage) return m, nil } // Clear error only when user starts typing, not on internal huh messages m.ErrorMsg = "" } - return m, m.UpdateShippingForm(msg, m.ShippingForm) + return m, m.UpdateShippingForm(msg, m.shipping.form) } return m, nil } diff --git a/pkg/tui/splash.go b/pkg/tui/splash.go index 7550084..027cd5e 100644 --- a/pkg/tui/splash.go +++ b/pkg/tui/splash.go @@ -48,7 +48,7 @@ func (m Model) SplashView() string { accent := m.theme.TextBody().Bold(true) var cursor string - if m.splashCursor { + if m.splash.cursor { cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ") } else { cursor = " " diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 08fb343..eee53f4 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -28,11 +28,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case reviewPage: // no viewport on review page } - if m.ShippingForm != nil { - m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) + if m.shipping.form != nil { + m.shipping.form.form = m.buildShippingForm(m.shipping.form) } - if m.PaymentForm != nil { - m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) + if m.payment.form != nil { + m.payment.form.form = m.buildPaymentForm(m.payment.form) } } return m, nil @@ -93,8 +93,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SSHKeysLoaded = true m.Orders = msg.Data.Orders m.OrdersLoaded = true - m.splashDataReady = true - if m.splashDelayDone { + m.splash.dataReady = true + if m.splash.delayDone { m = m.SwitchPage(shopPage) m = m.updateShopViewports() return m, nil @@ -147,21 +147,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case DelayCompleteMsg: - m.splashDelayDone = true - if m.currentPage == splashPage && m.splashDataReady { + m.splash.delayDone = true + if m.currentPage == splashPage && m.splash.dataReady { m = m.SwitchPage(shopPage) m = m.updateShopViewports() return m, nil } return m, nil case splashCursorTickMsg: - m.splashCursor = !m.splashCursor + m.splash.cursor = !m.splash.cursor return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg { return splashCursorTickMsg{} }) case tea.KeyMsg: // If a form is active, skip global keys — let the page handler own input - if m.ShippingForm != nil || m.PaymentForm != nil { + if m.shipping.form != nil || m.payment.form != nil { break } // Global navigation diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 4723a0a..2876066 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -89,23 +89,23 @@ func (m Model) View() string { // Skip global scroll when account view handles its own scrolling // (order list/detail and FAQ focused use internal scrolling in BuildAccountView) - accountHandlesScroll := m.currentPage == accountPage && (m.OrderViewState >= 1 || m.FaqFocused) + accountHandlesScroll := m.currentPage == accountPage && (m.account.orderViewState >= 1 || m.account.faqFocused) if !accountHandlesScroll { // Ensure scroll offset is within bounds - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 + if m.account.scrollOffset < 0 { + m.account.scrollOffset = 0 } maxScroll := totalLines - availableContentHeight if maxScroll < 0 { maxScroll = 0 } - if m.ScrollOffset > maxScroll { - m.ScrollOffset = maxScroll + if m.account.scrollOffset > maxScroll { + m.account.scrollOffset = maxScroll } if totalLines > availableContentHeight { - start := m.ScrollOffset + start := m.account.scrollOffset end := start + availableContentHeight if end > totalLines { end = totalLines