Compare commits
4 Commits
bugssssss
...
accountVie
| Author | SHA1 | Date | |
|---|---|---|---|
| 6db7748d9f | |||
| abe11853f6 | |||
| 280585c2d0 | |||
| 8a86965981 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
150
REFACTOR_PLAN.md
150
REFACTOR_PLAN.md
@@ -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
105
REFERENCE_PATTERNS.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
35
pkg/tui/account_addresses.go
Normal file
35
pkg/tui/account_addresses.go
Normal 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
36
pkg/tui/account_cards.go
Normal 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
194
pkg/tui/account_orders.go
Normal 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()
|
||||||
|
}
|
||||||
41
pkg/tui/account_sshkeys.go
Normal file
41
pkg/tui/account_sshkeys.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -755,6 +755,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())
|
||||||
|
|||||||
Reference in New Issue
Block a user