Compare commits

...

6 Commits

Author SHA1 Message Date
d66b41eed3 refactor: refactor the footer handling
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 12:13:26 +02:00
6db7748d9f change the BuildAccountView inline sub-views to use 4 different files to make it better
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 11:37:42 +02:00
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
21 changed files with 669 additions and 632 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

@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -45,124 +44,15 @@ func (m Model) BuildAccountView(availableHeight int) string {
selectedItem := models.AccountMenuItems[m.account.cursor] selectedItem := models.AccountMenuItems[m.account.cursor]
switch selectedItem { switch selectedItem {
case "order history": case "order history":
if !m.OrdersLoaded { detailView = m.OrdersView(detailContentWidth)
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.account.orderViewState == 2 {
// State 2: full detail for the selected order
detailView = m.buildOrderDetailView(m.Orders[m.account.orderCursor], detailContentWidth)
} else {
// State 0 (preview) or State 1 (browsing with cursor)
boxWidth := detailContentWidth - 4
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
lines := titleStyle.Render("Order History") + "\n\n"
for i, order := range m.Orders {
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.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders")
}
detailView = lines
}
case "addresses": case "addresses":
lines := titleStyle.Render("Address") + "\n\n" detailView = m.AddressesView(detailContentWidth)
if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses")
} else {
for i, addr := range m.SavedAddresses {
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.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"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.addressListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
detailView = lines
case "cards": case "cards":
lines := titleStyle.Render("Cards") + "\n\n" detailView = m.CardsView(detailContentWidth)
if len(m.SavedCards) == 0 {
lines += contentStyle.Render("No saved cards.")
} else {
for i, card := range m.SavedCards {
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.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"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.cardListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
detailView = lines
case "ssh keys": case "ssh keys":
lines := titleStyle.Render("SSH keys") + "\n\n" detailView = m.SSHKeysView(detailContentWidth)
if !m.SSHKeysLoaded {
lines += contentStyle.Render("Loading ssh keys...")
} else if len(m.SSHKeys) == 0 {
lines += contentStyle.Render("No saved ssh keys.")
} else {
for i, key := range m.SSHKeys {
isSelected := m.account.sshKeyListFocused && i == m.account.sshKeyCursor
label := key.Fingerprint
if key.Comment != "" {
label += " " + key.Comment
}
if len(label) > detailContentWidth-4 {
label = label[:detailContentWidth-4]
}
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"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.sshKeyListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
detailView = lines
case "faq": case "faq":
questionStyle := m.theme.TextHighlight().Bold(true) questionStyle := m.theme.TextHighlight().Bold(true)
faqContent := "" faqContent := ""
for i, faq := range m.FAQs { for i, faq := range m.FAQs {
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n" faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
@@ -177,26 +67,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
detailView += "\n\n" + hintStyle.Render("enter: scroll faq") detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
} }
case "about": case "about":
accentStyle := m.theme.TextHighlight().Bold(true) detailView = m.AboutView(detailContentWidth)
userStyle := m.theme.TextAccent().Bold(true)
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
detailContentWidth,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += contentStyle.Render(wordWrap("This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
detailContentWidth)) + "\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
detailView = titleStyle.Render("About") + "\n\n" + aboutContent
} }
} }
@@ -260,152 +131,31 @@ func (m Model) BuildAccountView(availableHeight int) string {
) )
} }
// buildOrderCard renders a single order as a bordered box for the order list. func (m Model) AboutView(width int) string {
// Shows order number, date, status, total, and the first 2 items as a preview. titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string { contentStyle := m.theme.TextBody().Width(width)
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right accentStyle := m.theme.TextHighlight().Bold(true)
nameStyle := m.theme.TextAccent().Bold(true) userStyle := m.theme.TextAccent().Bold(true)
dimStyle := m.theme.TextDim() aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
width,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += contentStyle.Render(wordWrap(
"This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
width,
)) + "\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
statusStyle := m.theme.TextHighlight().Bold(true) return titleStyle.Render("About") + "\n\n" + aboutContent
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
rightSide := date + " " + status + " " + total
rightWidth := lipgloss.Width(rightSide)
leftWidth := lipgloss.Width(orderLabel)
spacing := boxWidth - leftWidth - rightWidth - 4 // 4 for padding
if spacing < 1 {
spacing = 1
}
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + rightSide
// Item preview lines (show first 2 items)
var lines []string
lines = append(lines, line1)
previewCount := 2
if len(order.Items) < previewCount {
previewCount = len(order.Items)
}
for i := 0; i < previewCount; i++ {
item := order.Items[i]
itemLine := dimStyle.Render(fmt.Sprintf("%dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0))
lines = append(lines, itemLine)
}
if len(order.Items) > 2 {
extra := len(order.Items) - 2
moreText := "item"
if extra > 1 {
moreText = "items"
}
lines = append(lines, dimStyle.Render(fmt.Sprintf("+ %d more %s", extra, moreText)))
}
boxContent := strings.Join(lines, "\n")
// Create bordered box — highlight if selected
var itemBox lipgloss.Style
if isSelected {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth)
}
centered := lipgloss.NewStyle().
Width(containerWidth).
Align(lipgloss.Center)
return centered.Render(itemBox.Render(boxContent))
}
// buildOrderDetailView renders the full detail view for a single order.
func (m Model) buildOrderDetailView(order models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
labelStyle := m.theme.TextHighlight().Bold(true)
valueStyle := m.theme.TextBody()
dimStyle := m.theme.TextDim()
var b strings.Builder
// Header
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
b.WriteString("\n\n")
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
b.WriteString("\n")
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
b.WriteString("\n\n")
// Items
b.WriteString(labelStyle.Render("Items"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
for _, item := range order.Items {
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
b.WriteString(valueStyle.Render(line))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
// Totals
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingName))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
b.WriteString("\n")
if order.ShippingStreet2 != "" {
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
b.WriteString("\n")
}
cityLine := order.ShippingCity
if order.ShippingState != "" {
cityLine += ", " + order.ShippingState
}
cityLine += " " + order.ShippingZip
b.WriteString(valueStyle.Render(" " + cityLine))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
b.WriteString("\n\n")
// Footer hint
b.WriteString(dimStyle.Render("esc: back to orders"))
return b.String()
} }
// computeFaqScrollMax calculates the maximum scroll offset for the FAQ content. // computeFaqScrollMax calculates the maximum scroll offset for the FAQ content.
@@ -606,55 +356,110 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.account.orderViewState = 1 m.account.orderViewState = 1
m.account.orderCursor = 0 m.account.orderCursor = 0
m.account.scrollOffset = 0 m.account.scrollOffset = 0
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else if m.account.orderViewState == 1 { } else if m.account.orderViewState == 1 {
m.account.orderViewState = 2 m.account.orderViewState = 2
m.account.scrollOffset = 0 m.account.scrollOffset = 0
m.footer = []footerCommand{
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "q", value: "quit"},
}
} }
} }
case "ssh keys": case "ssh keys":
if !m.account.sshKeyListFocused { if !m.account.sshKeyListFocused {
m.account.sshKeyListFocused = true m.account.sshKeyListFocused = true
m.account.sshKeyCursor = 0 m.account.sshKeyCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "keys"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
} }
case "addresses": case "addresses":
if !m.account.addressListFocused { if !m.account.addressListFocused {
m.account.addressListFocused = true m.account.addressListFocused = true
m.account.addressCursor = 0 m.account.addressCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
} }
case "cards": case "cards":
if !m.account.cardListFocused { if !m.account.cardListFocused {
m.account.cardListFocused = true m.account.cardListFocused = true
m.account.cardCursor = 0 m.account.cardCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
} }
case "faq": case "faq":
if !m.account.faqFocused { if !m.account.faqFocused {
m.account.faqFocused = true m.account.faqFocused = true
m.account.scrollOffset = 0 m.account.scrollOffset = 0
m.footer = []footerCommand{
{key: "j/k", value: "scroll"},
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "q", value: "quit"},
}
} }
} }
case "esc": case "esc":
accountDefaultFooter := []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
if m.account.addressDeleting != nil { if m.account.addressDeleting != nil {
m.account.addressDeleting = nil m.account.addressDeleting = nil
} else if m.account.addressListFocused { } else if m.account.addressListFocused {
m.account.addressListFocused = false m.account.addressListFocused = false
m.account.addressCursor = 0 m.account.addressCursor = 0
m.footer = accountDefaultFooter
} else if m.account.cardDeleting != nil { } else if m.account.cardDeleting != nil {
m.account.cardDeleting = nil m.account.cardDeleting = nil
} else if m.account.cardListFocused { } else if m.account.cardListFocused {
m.account.cardListFocused = false m.account.cardListFocused = false
m.account.cardCursor = 0 m.account.cardCursor = 0
m.footer = accountDefaultFooter
} else if m.account.sshKeyDeleting != nil { } else if m.account.sshKeyDeleting != nil {
m.account.sshKeyDeleting = nil m.account.sshKeyDeleting = nil
} else if m.account.sshKeyListFocused { } else if m.account.sshKeyListFocused {
m.account.sshKeyListFocused = false m.account.sshKeyListFocused = false
m.account.sshKeyCursor = 0 m.account.sshKeyCursor = 0
m.footer = accountDefaultFooter
} else if m.account.faqFocused { } else if m.account.faqFocused {
m.account.faqFocused = false m.account.faqFocused = false
m.account.scrollOffset = 0 m.account.scrollOffset = 0
m.footer = accountDefaultFooter
} else if m.account.orderViewState > 0 { } else if m.account.orderViewState > 0 {
m.account.orderViewState-- m.account.orderViewState--
m.account.scrollOffset = 0 m.account.scrollOffset = 0
if m.account.orderViewState == 1 {
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else {
m.footer = accountDefaultFooter
}
} }
} }
return m, nil return m, nil

View File

@@ -0,0 +1,35 @@
package tui
func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
activeStyle := m.theme.TextAccent().Bold(true)
contentStyle := m.theme.TextBody().Width(width)
lines := titleStyle.Render("Address") + "\n\n"
if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses")
} else {
for i, addr := range m.SavedAddresses {
isSelected := m.account.addressListFocused && i == m.account.addressCursor
label := addr.Name + " " + addr.Street + ", " + addr.City
if len(label) > width-4 {
label = label[:width-4]
}
if m.account.addressDeleting != nil && *m.account.addressDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" deletes? (y/n)") + "\n"
} else if isSelected {
lines += activeStyle.Render("> "+label) + "\n"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.addressListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
return lines
}

36
pkg/tui/account_cards.go Normal file
View File

@@ -0,0 +1,36 @@
package tui
import (
"fmt"
)
func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
activeStyle := m.theme.TextAccent().Bold(true)
lines := titleSTyle.Render("Cards") + "\n\n"
if m.SavedCardsIsEmpty() {
lines += contentStyle.Render("No saved cards")
} else {
for i, card := range m.SavedCards {
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.account.cardDeleting != nil && *m.account.cardDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" delete? (y/s)") + "\n"
} else if isSelected {
lines += activeStyle.Render("> "+label) + "\n"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.cardListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
return lines
}

194
pkg/tui/account_orders.go Normal file
View File

@@ -0,0 +1,194 @@
package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) OrdersView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {
return titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
}
if len(m.Orders) == 0 {
return titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet")
}
if m.account.orderViewState == 2 {
return m.buildOrderDetailView(m.Orders[m.account.orderCursor], width)
}
boxWidth := width - 4
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
lines := titleStyle.Render("Order History") + "\n\n"
for i, order := range m.Orders {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, width)
if i < len(m.Orders)-1 {
lines += "\n"
}
}
if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders")
}
return lines
}
// buildOrderCard renders a single order as a bordered box for the order list.
// Shows order number, date, status, total, and the first 2 items as a preview.
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
nameStyle := m.theme.TextAccent().Bold(true)
dimStyle := m.theme.TextDim()
statusStyle := m.theme.TextHighlight().Bold(true)
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
rightSide := date + " " + status + " " + total
rightWidth := lipgloss.Width(rightSide)
leftWidth := lipgloss.Width(orderLabel)
spacing := boxWidth - leftWidth - rightWidth - 4 // 4 for padding
if spacing < 1 {
spacing = 1
}
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + rightSide
// Item preview lines (show first 2 items)
var lines []string
lines = append(lines, line1)
previewCount := 2
if len(order.Items) < previewCount {
previewCount = len(order.Items)
}
for i := 0; i < previewCount; i++ {
item := order.Items[i]
itemLine := dimStyle.Render(fmt.Sprintf("%dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0))
lines = append(lines, itemLine)
}
if len(order.Items) > 2 {
extra := len(order.Items) - 2
moreText := "item"
if extra > 1 {
moreText = "items"
}
lines = append(lines, dimStyle.Render(fmt.Sprintf("+ %d more %s", extra, moreText)))
}
boxContent := strings.Join(lines, "\n")
// Create bordered box — highlight if selected
var itemBox lipgloss.Style
if isSelected {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth)
}
centered := lipgloss.NewStyle().
Width(containerWidth).
Align(lipgloss.Center)
return centered.Render(itemBox.Render(boxContent))
}
// buildOrderDetailView renders the full detail view for a single order
func (m Model) buildOrderDetailView(order models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
labelStyle := m.theme.TextHighlight().Bold(true)
valueStyle := m.theme.TextBody()
dimStyle := m.theme.TextDim()
var b strings.Builder
// Header
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
b.WriteString("\n\n")
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
b.WriteString("\n")
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
b.WriteString("\n\n")
// Items
b.WriteString(labelStyle.Render("Items"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
for _, item := range order.Items {
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
b.WriteString(valueStyle.Render(line))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
// Totals
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingName))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
b.WriteString("\n")
if order.ShippingStreet2 != "" {
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
b.WriteString("\n")
}
cityLine := order.ShippingCity
if order.ShippingState != "" {
cityLine += ", " + order.ShippingState
}
cityLine += " " + order.ShippingZip
b.WriteString(valueStyle.Render(" " + cityLine))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
b.WriteString("\n\n")
// Footer hint
b.WriteString(dimStyle.Render("esc: back to orders"))
return b.String()
}

View File

@@ -0,0 +1,41 @@
package tui
func (m Model) SSHKeysView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
activeStyle := m.theme.TextAccent().Bold(true)
lines := titleSTyle.Render("SSH keys") + "\n\n"
if !m.SSHKeysLoaded {
lines += contentStyle.Render("Loading ssh keys...")
} else if len(m.SSHKeys) == 0 {
lines += contentStyle.Render("No saved ssh keys")
} else {
for i, key := range m.SSHKeys {
isSelected := m.account.sshKeyListFocused && i == m.account.sshKeyCursor
label := key.Fingerprint
if key.Comment != "" {
label += " " + key.Comment
}
if len(label) > width-4 {
label += label[:width-4]
}
if m.account.sshKeyDeleting != nil && *m.account.sshKeyDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n"
} else if isSelected {
lines += activeStyle.Render("> "+label) + "\n"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.sshKeyListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
return lines
}

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

@@ -1,194 +1,25 @@
package tui package tui
import ( import (
"fmt" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildFooter() string { func (m Model) BuildFooter() string {
// Style for keybinds (bold)
keybindStyle := m.theme.TextDim().Bold(true) keybindStyle := m.theme.TextDim().Bold(true)
// Style for descriptions (normal)
descStyle := m.theme.TextDim() descStyle := m.theme.TextDim()
// On small terminals, only show the menu hint
if m.size == small { if m.size == small {
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu") footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText) return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
} }
var footerText string parts := []string{}
if m.account.faqFocused { for _, cmd := range m.footer {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
keybindStyle.Render("j/k"),
descStyle.Render("scroll"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.currentPage == accountPage {
switch m.account.orderViewState {
case 2:
// Viewing single order detail
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
case 1:
// Browsing order list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("orders"),
keybindStyle.Render("enter"),
descStyle.Render("details"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
default:
// Account tabs
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("navigate"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else if m.currentPage == confirmPage {
// Confirmation screen
footerText = fmt.Sprintf("%s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back to shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.currentPage == shippingPage {
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"),
descStyle.Render("addresses"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("d/x"),
descStyle.Render("delete"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
} else {
// Shipping form
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("tab"),
descStyle.Render("next"),
keybindStyle.Render("enter"),
descStyle.Render("submit"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
}
} else if m.currentPage == paymentPage {
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"),
descStyle.Render("cards"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("d/x"),
descStyle.Render("delete"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
} else if m.CheckingOut {
// Submitting order
footerText = descStyle.Render("submitting order...")
} else {
// Payment form
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("tab"),
descStyle.Render("next"),
keybindStyle.Render("enter"),
descStyle.Render("submit"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
}
} else if m.currentPage == cartPage {
// In cart view, show proceed option
if len(m.Cart) > 0 {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("p/enter"),
descStyle.Render("checkout"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("pgup/pgdn"),
descStyle.Render("scroll"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("products"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} }
footerText := strings.Join(parts, " ")
return lipgloss.NewStyle(). return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
Align(lipgloss.Center).
Width(m.widthContainer).
Render(footerText)
} }

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 (
@@ -53,6 +53,11 @@ type resizeTickMsg struct {
// this prevents flickering when resizing the terminal // this prevents flickering when resizing the terminal
const resizeDebounce = 50 * time.Millisecond const resizeDebounce = 50 * time.Millisecond
type footerCommand struct {
key string
value string
}
type Model struct { type Model struct {
// User authentication // User authentication
User *models.User User *models.User
@@ -62,7 +67,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
@@ -121,6 +126,9 @@ type Model struct {
account accountState account accountState
review reviewState review reviewState
confirm confirmState confirm confirmState
// Footer
footer []footerCommand
} }
type shopState struct { type shopState struct {
@@ -208,18 +216,55 @@ func (m Model) SwitchPage(p page) Model {
func (m Model) ShopSwitch() (Model, tea.Cmd) { func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage) m = m.SwitchPage(shopPage)
m = m.updateShopViewports() m = m.updateShopViewports()
m.footer = []footerCommand{
{key: "j/k", value: "products"},
{key: "+/-", value: "qty"},
{key: "c", value: "cart"},
{key: "a", value: "account"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
return m, nil return m, nil
} }
func (m Model) CartSwitch() (Model, tea.Cmd) { func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage) m = m.SwitchPage(cartPage)
m = m.updateCartViewport() m = m.updateCartViewport()
if m.IsCartEmpty() {
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
{key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
} else {
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
{key: "p/enter", value: "checkout"},
{key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "pgup/pgdn", value: "scroll"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
}
return m, nil return m, nil
} }
func (m Model) AccountSwitch() (Model, tea.Cmd) { func (m Model) AccountSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(accountPage) m = m.SwitchPage(accountPage)
m.account = accountState{} m.account = accountState{}
m.footer = []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
if !m.OrdersLoaded { if !m.OrdersLoaded {
return m, m.fetchOrdersCmd() return m, m.fetchOrdersCmd()
} }
@@ -230,6 +275,12 @@ func (m Model) ShippingSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shippingPage) m = m.SwitchPage(shippingPage)
m.shipping = shippingState{} m.shipping = shippingState{}
m = m.updateShippingViewport() m = m.updateShippingViewport()
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, m.fetchAddressesCmd() return m, m.fetchAddressesCmd()
} }
@@ -237,11 +288,21 @@ func (m Model) PaymentSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(paymentPage) m = m.SwitchPage(paymentPage)
m.payment = paymentState{} m.payment = paymentState{}
m.SelectedCard = nil m.SelectedCard = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, m.fetchCardsCmd() return m, m.fetchCardsCmd()
} }
func (m Model) ReviewSwitch() (Model, tea.Cmd) { func (m Model) ReviewSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(reviewPage) m = m.SwitchPage(reviewPage)
m.footer = []footerCommand{
{key: "enter", value: "confirm"},
{key: "esc", value: "back"},
}
return m, nil return m, nil
} }
@@ -249,6 +310,10 @@ func (m Model) ConfirmSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(confirmPage) m = m.SwitchPage(confirmPage)
m.confirm = confirmState{} m.confirm = confirmState{}
m = m.updateConfirmViewport() m = m.updateConfirmViewport()
m.footer = []footerCommand{
{key: "esc", value: "back to shop"},
{key: "q", value: "quit"},
}
return m, nil return m, nil
} }
@@ -342,7 +407,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 +820,37 @@ 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
}
func (m Model) SavedAddressesIsEmpty() bool {
return len(m.SavedAddresses) == 0
}
func (m Model) SavedCardsIsEmpty() bool {
return len(m.SavedCards) == 0
}
// 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 +941,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

@@ -434,6 +434,11 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.ErrorMsg = fmt.Sprintf("failed to save card: %v", msg.Err) m.ErrorMsg = fmt.Sprintf("failed to save card: %v", msg.Err)
m.payment.view = 1 m.payment.view = 1
m.payment.form = m.InitPaymentForm() m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init() return m, m.payment.form.form.Init()
} }
m.SavedCards = append(m.SavedCards, msg.Card) m.SavedCards = append(m.SavedCards, msg.Card)
@@ -495,12 +500,20 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor == len(m.SavedCards) { if m.payment.cardCursor == len(m.SavedCards) {
m.payment.view = 1 m.payment.view = 1
m.payment.form = m.InitPaymentForm() m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init() return m, m.payment.form.form.Init()
} }
// Browser payment // Browser payment
m.payment.view = 2 m.payment.view = 2
m.payment.collectURL = nil m.payment.collectURL = nil
m.payment.collectCardCount = len(m.SavedCards) m.payment.collectCardCount = len(m.SavedCards)
m.footer = []footerCommand{
{key: "esc", value: "back"},
}
return m, m.collectCardCmd() return m, m.collectCardCmd()
case "d", "x": case "d", "x":
@@ -513,6 +526,11 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if len(m.SavedCards) == 0 { if len(m.SavedCards) == 0 {
m.payment.view = 1 m.payment.view = 1
m.payment.form = m.InitPaymentForm() m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID)) return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
} }
return m, m.deleteCardCmd(card.ID) return m, m.deleteCardCmd(card.ID)
@@ -526,6 +544,12 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
m.payment.view = 0 m.payment.view = 0
m.payment.collectURL = nil m.payment.collectURL = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil return m, nil
} }
return m, nil return m, nil
@@ -539,6 +563,12 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if len(m.SavedCards) > 0 { if len(m.SavedCards) > 0 {
m.payment.view = 0 m.payment.view = 0
m.payment.form = nil m.payment.form = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil return m, nil
} }
m.payment.form = nil m.payment.form = nil

View File

@@ -362,6 +362,11 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SavedAddresses = nil m.SavedAddresses = nil
m.shipping.view = 1 m.shipping.view = 1
m.shipping.form = m.InitShippingForm() m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.shipping.form.form.Init() return m, m.shipping.form.form.Init()
} }
m.SavedAddresses = msg.Addresses m.SavedAddresses = msg.Addresses
@@ -412,6 +417,11 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
m.shipping.view = 1 m.shipping.view = 1
m.shipping.form = m.InitShippingForm() m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.shipping.form.form.Init() return m, m.shipping.form.form.Init()
case "d", "x": case "d", "x":
if m.shipping.addressCursor < len(m.SavedAddresses) { if m.shipping.addressCursor < len(m.SavedAddresses) {
@@ -423,6 +433,11 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
if len(m.SavedAddresses) == 0 { if len(m.SavedAddresses) == 0 {
m.shipping.view = 1 m.shipping.view = 1
m.shipping.form = m.InitShippingForm() m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID)) return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID))
} }
return m, m.deleteAddressCmd(addr.ID) return m, m.deleteAddressCmd(addr.ID)
@@ -439,6 +454,12 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
if len(m.SavedAddresses) > 0 { if len(m.SavedAddresses) > 0 {
m.shipping.view = 0 m.shipping.view = 0
m.shipping.form = nil m.shipping.form = nil
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil return m, nil
} }
m.shipping.form = nil m.shipping.form = nil

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