Compare commits

...

2 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
9 changed files with 518 additions and 452 deletions

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

@@ -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 !m.IsCartEmpty() {
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

@@ -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
@@ -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
} }
@@ -778,6 +843,14 @@ func (m Model) CalculateSubtotal() int {
return subtotal 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())

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