Compare commits

...

4 Commits

Author SHA1 Message Date
abe11853f6 refactor: add helper functions for subtotal, cart empty, and cart item count
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 10:29:15 +02:00
280585c2d0 sdfsdf
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 10:17:46 +02:00
8a86965981 update .gitignore
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 10:07:03 +02:00
edc032d6d4 bug fixes: various small bug fixes and typos
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 10:04:31 +02:00
14 changed files with 152 additions and 181 deletions

1
.gitignore vendored
View File

@@ -78,6 +78,7 @@ REFACTORING.md
TODO.md TODO.md
NEXT.md NEXT.md
test_stripe.go test_stripe.go
REFACTOR_PLAN.md
codebase_graph.html codebase_graph.html

View File

@@ -1,150 +0,0 @@
# 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

105
REFERENCE_PATTERNS.md Normal file
View File

@@ -0,0 +1,105 @@
# Reference Patterns to Adopt
Patterns from `terminal-shop-source` that we're copying into this project.
---
## 1. Footer: Command Array Pattern
### Structs (in model.go or footer.go)
```go
type footerState struct {
commands []footerCommand
}
type footerCommand struct {
key string
value string
}
```
### Setting commands (in each *Switch method)
```go
func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage)
m.footer.commands = []footerCommand{
{key: "esc", value: "back"},
{key: "↑/↓", value: "items"},
{key: "+/-", value: "qty"},
{key: "c", value: "checkout"},
}
m = m.updateCartViewport()
return m, nil
}
```
### Rendering (footer.go)
```go
// Iterate commands instead of if/else chain
commands := []string{}
for _, cmd := range m.footer.commands {
commands = append(commands, bold(" "+cmd.key+" ")+base(cmd.value+" "))
}
// Error display replaces default content when m.ErrorMsg != ""
```
### Small screen: only show "m menu"
```go
if m.size == small {
return table.Render(bold("m") + base(" menu"))
}
```
---
## 2. Helper Methods
### Cart helpers (add to model.go or cart.go)
```go
func (m Model) IsCartEmpty() bool {
return len(m.Cart) == 0
}
func (m Model) CartItemCount() int {
return len(m.Cart)
}
func (m Model) CalculateSubtotal() int {
subtotal := 0
for _, item := range m.Cart {
subtotal += item.Coffee.Price * item.Quantity
}
return subtotal
}
```
### Used in reference for: header cart badge, footer conditional commands, checkout validation, review page totals.
---
## 3. Account View Split
### Reference splits into separate files per sub-view:
- `account.go` — parent: menu + delegation (~80 lines)
- `orders.go` — OrdersView() + OrdersUpdate()
- `subscriptions.go` — SubscriptionsView() + SubscriptionsUpdate()
- `tokens.go` — TokensView() + TokensUpdate()
- `apps.go` — AppsView() + AppsUpdate()
- `faq.go` — FaqView()
- `about.go` — AboutView() (in account.go, inline)
### Parent dispatches via:
```go
func (m model) GetAccountPageContent(accountPage page, width int) string {
switch accountPage {
case ordersPage:
return m.OrdersView(width, m.state.account.focused)
case addressesPage:
return m.AddressesView(width, m.state.account.focused)
// ...
}
}
```
### Each sub-view owns its own state, cursor, and deletion logic.

View File

@@ -31,7 +31,7 @@ var (
) )
func storeRedirect(stripeURL string) string { func storeRedirect(stripeURL string) string {
b := make([]byte, 4) b := make([]byte, 16)
rand.Read(b) rand.Read(b)
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
payRedirectsMu.Lock() payRedirectsMu.Lock()

View File

@@ -4,6 +4,8 @@ import (
"net/http" "net/http"
"time" "time"
"strconv"
"github.com/go-chi/httprate" "github.com/go-chi/httprate"
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
) )
@@ -29,7 +31,7 @@ func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Ha
// Fall back to IP if no user context (shouldn't happen on protected routes). // Fall back to IP if no user context (shouldn't happen on protected routes).
return httprate.KeyByIP(r) return httprate.KeyByIP(r)
} }
return http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), nil return "user:" + strconv.FormatUint(uint64(userID), 10), nil
}), }),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil) utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)

View File

@@ -594,7 +594,7 @@ func (c *Client) CollectCard() (string, error) {
var collectResp CollectCardResponse var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
return "", fmt.Errorf("failed to devode collect response: %w", err) return "", fmt.Errorf("failed to decode collect response: %w", err)
} }
if !collectResp.Success { if !collectResp.Success {

View File

@@ -28,7 +28,7 @@ func (m Model) updateCartViewport() Model {
} }
func (m Model) generateCartContent() string { func (m Model) generateCartContent() string {
if len(m.Cart) == 0 { if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim(). emptyStyle := m.theme.TextDim().
Align(lipgloss.Center). Align(lipgloss.Center).
Width(m.widthContent). Width(m.widthContent).
@@ -179,7 +179,7 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, m.syncCartItemCmd(coffeeID, newQty) return m, m.syncCartItemCmd(coffeeID, newQty)
} }
case "p", "enter": case "p", "enter":
if len(m.Cart) > 0 { if !m.IsCartEmpty() {
return m.ShippingSwitch() return m.ShippingSwitch()
} }
} }

View File

@@ -150,10 +150,8 @@ func (m Model) generateReviewContent() string {
} }
view.WriteString("\n") view.WriteString("\n")
subtotal := 0 subtotal := m.CalculateSubtotal()
for _, item := range m.GetCartItemsSlice() {
subtotal += item.Coffee.Price * item.Quantity
}
view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal))) view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal)))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n") view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n") view.WriteString("\n")

View File

@@ -135,7 +135,7 @@ func (m Model) BuildFooter() string {
} }
} else if m.currentPage == cartPage { } else if m.currentPage == cartPage {
// In cart view, show proceed option // In cart view, show proceed option
if len(m.Cart) > 0 { if !m.IsCartEmpty() {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s", footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"), keybindStyle.Render("j/k"),
descStyle.Render("items"), descStyle.Render("items"),

View File

@@ -8,12 +8,8 @@ import (
func (m Model) BuildHeader() string { func (m Model) BuildHeader() string {
// Calculate cart total and item count // Calculate cart total and item count
total := 0 total := m.CalculateSubtotal()
itemCount := 0 itemCount := m.CartItemCount()
for _, item := range m.Cart {
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
// Define colors // Define colors
grayStyle := m.theme.TextBody() grayStyle := m.theme.TextBody()

View File

@@ -32,12 +32,8 @@ func (m Model) BuildMenuView() string {
hint := base.Render("press esc to close") hint := base.Render("press esc to close")
itemCount := 0 total := m.CalculateSubtotal()
total := 0 itemCount := m.CartItemCount()
for _, item := range m.Cart {
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount)) cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount))

View File

@@ -29,7 +29,7 @@ const (
large // viewportWidth >= 80 large // viewportWidth >= 80
) )
// page represetnts the currently active TUI screen // page represents the currently active TUI screen
type page int type page int
const ( const (
@@ -62,7 +62,7 @@ type Model struct {
StripePublicKey string StripePublicKey string
// One-shop auth command - capture pubKeyStr in closure, not stored on Model // One-shop auth command - capture pubKeyStr in closure, not stored on Model
autchCmd tea.Cmd authCmd tea.Cmd
// Shared data // Shared data
Username string Username string
@@ -342,7 +342,7 @@ func (m Model) fetchProductsCmd() tea.Msg {
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.autchCmd, m.authCmd,
tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
return DelayCompleteMsg{} return DelayCompleteMsg{}
}), }),
@@ -755,6 +755,29 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
return items return items
} }
// IsCartEmpty returns true when the cart has no items
func (m Model) IsCartEmpty() bool {
return len(m.Cart) == 0
}
// CartItemCount returns the total number of items (sum of quanities) in the cart
func (m Model) CartItemCount() int {
count := 0
for _, item := range m.Cart {
count += item.Quantity
}
return count
}
// CalculateSubtotal returns the cart subtotal in cents
func (m Model) CalculateSubtotal() int {
subtotal := 0
for _, item := range m.Cart {
subtotal += item.Coffee.Price * item.Quantity
}
return subtotal
}
// NewModel creates a new model using the default renderer. // NewModel creates a new model using the default renderer.
func NewModel(username string) Model { func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer()) return newModelWithRenderer(username, lipgloss.DefaultRenderer())
@@ -845,7 +868,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
m.APIClient = api.NewClient("http://localhost:8000", "") m.APIClient = api.NewClient("http://localhost:8000", "")
} }
m.autchCmd = func() tea.Msg { m.authCmd = func() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken( token, user, err := m.APIClient.GetOrCreateToken(
fingerprint, fingerprint,
pubKeyStr, pubKeyStr,

View File

@@ -42,7 +42,7 @@ func (m Model) SplashView() string {
cursor = " " cursor = " "
} }
logo := accent.Render("terminal coffe") + cursor logo := accent.Render("terminal coffee") + cursor
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Center, lipgloss.Center,

View File

@@ -129,11 +129,11 @@ func (m Model) View() string {
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer) child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
} }
} else if breadcrumbs != "" { } else if breadcrumbs != "" {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content) contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, ccontentWithPadding, footer) child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
} else { } else {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content) contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, ccontentWithPadding, footer) child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
} }
// Constrain the assembled layout to the container dimensions // Constrain the assembled layout to the container dimensions