refactor: update the flat model struct with nested structs
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
150
REFACTOR_PLAN.md
Normal file
150
REFACTOR_PLAN.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
188
pkg/tui/model.go
188
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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = " "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user