refactor: update the flat model struct with nested structs

This commit is contained in:
2026-04-24 08:57:04 +02:00
parent bef7f85bda
commit 1c3ebf789c
11 changed files with 602 additions and 444 deletions

150
REFACTOR_PLAN.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
}

View File

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

View File

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

View File

@@ -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 = " "

View File

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

View File

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