Compare commits

..

1 Commits

Author SHA1 Message Date
74685388f3 feat(tui): remake parts of the tui 2026-06-11 11:17:57 +02:00
38 changed files with 139 additions and 514 deletions

1
.gitignore vendored
View File

@@ -7,7 +7,6 @@
docs/sca-psd2-compliance.md
accept-a-payment/
codemap/
FULFILLMENT.md

View File

@@ -1,3 +0,0 @@
// Command api is the HTTP backend: it boots the router, bolts on all the
// middleware, and answers the requests the TUI throws at it.
package main

View File

@@ -1,3 +0,0 @@
// Package handlers is where the real work happens, one function per endpoint:
// carts, orders, products, addresses, cards, webhooks, and the rest.
package handlers

View File

@@ -225,23 +225,6 @@ func ReconcileExpiredCards(ctx context.Context) {
}
}
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
// reclaims tokens nobody ever followed so the table stays bounded.
func ReconcilePayRedirects(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
if res.Error != nil {
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
return
}
if res.RowsAffected == 0 {
return
}
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
}
// ReconcileUnshipped finds paid orders older than 24h with no tracking
// number and posts a single Slack reminder. Pure read + notify.
func ReconcileUnshipped(ctx context.Context) {

View File

@@ -518,81 +518,3 @@ func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt t
}
return order
}
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
// is still in the future survives. Seeding both sides through GORM keeps the
// stored timestamp format identical to the one the sweep binds for time.Now(),
// so the comparison is exercised exactly as it runs in production.
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
expired := models.PayRedirect{
Token: "tok_expired",
URL: "https://example.test/expired",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: now.Add(-20 * time.Minute),
ExpiresAt: now.Add(-10 * time.Minute),
}
live := models.PayRedirect{
Token: "tok_live",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeAddCard,
CreatedAt: now,
ExpiresAt: now.Add(10 * time.Minute),
}
if err := db.Create(&expired).Error; err != nil {
t.Fatalf("seed expired: %v", err)
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var remaining []models.PayRedirect
if err := db.Find(&remaining).Error; err != nil {
t.Fatalf("list remaining: %v", err)
}
if len(remaining) != 1 {
t.Fatalf("want 1 row remaining, got %d", len(remaining))
}
if remaining[0].Token != "tok_live" {
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
}
}
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
live := models.PayRedirect{
Token: "tok_live_only",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var count int64
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("want 1 row preserved, got %d", count)
}
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
@@ -53,17 +52,8 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" {
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
// in production, so the server should never boot into this state; this is
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
// Slack handlers, which already refuse to run without their secret.
if os.Getenv("ENVIRONMENT") == "production" {
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
return
}
// Development only; warn loudly but still process so local testing works.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
// No secret configured — log a loud warning but still process in dev.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
@@ -134,20 +124,6 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return
}
// Defense in depth: even behind a valid signature, refuse to mark an order
// paid unless the PaymentIntent's amount and currency match what we charged.
// Under ConvertCart these always match (Amount == order.Total at creation), so
// a mismatch means a malformed or unexpected event we should not trust.
if pi.Amount != int64(order.Total) {
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
return
}
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
return
}
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid,

View File

@@ -222,38 +222,3 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
}
}
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
// PI claims 1 cent; seed order.Total is 500.
pi := map[string]any{
"id": order.StripePaymentID,
"amount": 1,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.succeeded",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentSucceeded(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reloaded order: %v", err)
}
if reloaded.Status == models.OrderStatusPaid {
t.Fatalf("order marked paid despite amount mismatch")
}
}

View File

@@ -25,25 +25,6 @@ import (
const version = "v0.1.0"
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
// one place. ReconcileUnshipped intentionally does not use it, it needs a
// staggered warm-up run before its first tick.
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
go func() {
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn(ctx)
case <-ctx.Done():
return
}
}
}()
}
func main() {
observability.InitLogger()
middleware.SetBuildInfo(version)
@@ -125,8 +106,20 @@ func main() {
}
}()
// Catch orders charged but not recorded due to a crash. Every 5 minutes
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
// Start reconciliation job. runs every 5 minutes to catch any orders
// that were charged but not recorded due to a crash.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(ctx)
case <-ctx.Done():
return
}
}
}()
go func() {
select {
@@ -149,18 +142,33 @@ func main() {
// Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously. Hourly.
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
// detach + audit + physical row delete asynchronously.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(ctx)
case <-ctx.Done():
return
}
}
}()
// Flip abandoned 3DS orders to failed. Every 15 minutes.
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
handlers.ReconcileStale3DSOrders(ctx, threshold)
})
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
// on read is the primary path; this only reclaims tokens nobody follows.
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")

View File

@@ -1,4 +0,0 @@
// Package middleware holds the stuff that runs on every request before a
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
// recovery net for panics.
package middleware

View File

@@ -1,3 +0,0 @@
// Package routes is the switchboard: it maps every URL to the handler that
// knows what to do with it.
package routes

View File

@@ -1,3 +0,0 @@
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
// about and detaches them. Run it with -dry-run first if you're nervous.
package main

View File

@@ -1,3 +0,0 @@
// Package api is the client side of the house: it talks to our HTTP backend
// so other Go code doesn't have to hand-roll requests.
package api

View File

@@ -1,3 +0,0 @@
// Package auth sorts out who you are: logging in with an SSH key and minting
// the JWT you carry around afterwards.
package auth

View File

@@ -1,3 +0,0 @@
// Package bring talks to Bring, the Norwegian postal service, for booking
// shipments and printing labels.
package bring

View File

@@ -92,7 +92,6 @@ func (c *Config) validate() error {
{"JWT_SECRET", c.JWTSecret},
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
}
var missing []string

View File

@@ -1,3 +0,0 @@
// Package config reads everything out of the environment so secrets and
// settings stay out of the source code.
package config

View File

@@ -1,3 +0,0 @@
// Package database opens the SQLite connection, runs the migrations, and
// drops in some seed data so the shop isn't empty on a fresh boot.
package database

View File

@@ -26,7 +26,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Espresso",
RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2,
BeanType: "Arabica",
Price: 1800, // $18.00
@@ -36,7 +35,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Americano",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8,
BeanType: "Arabica",
Price: 2200, // $22.00
@@ -46,7 +44,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Cappuccino",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 6,
BeanType: "Arabica",
Price: 2200, // $22.00
@@ -56,7 +53,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Latte",
RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12,
BeanType: "Arabica",
Price: 2500, // $25.00
@@ -66,7 +62,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Mocha",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 12,
BeanType: "Arabica",
Price: 3000, // $30.00
@@ -76,7 +71,6 @@ func Seed(db *gorm.DB) error {
{
Name: "Macchiato",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3,
BeanType: "Robusta Blend",
Price: 1800, // $18.00

View File

@@ -11,7 +11,6 @@ type Coffee struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
RoastType string `gorm:"size:100" json:"roast_type"`
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"`
Price int `gorm:"not null" json:"price"`

View File

@@ -1,3 +0,0 @@
// Package models is the shared vocabulary of the app: coffee, carts, orders,
// addresses, cards, users, and the other nouns everything passes around.
package models

View File

@@ -1,3 +0,0 @@
// Package observability sets up tracing and structured logs so that when
// something breaks at 3am you stand a chance of figuring out why.
package observability

View File

@@ -1,3 +0,0 @@
// Package shipping holds the plain shipping types that the rest of the app
// passes back and forth.
package shipping

View File

@@ -1,2 +0,0 @@
// Package shippo talks to the Shippo API for shipping rates and labels.
package shippo

View File

@@ -1,3 +0,0 @@
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
// baked in) so everyone else can just make calls.
package stripeclient

View File

@@ -1,8 +1,6 @@
package tui
import (
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -11,18 +9,16 @@ import (
)
// accountBodyHeight returns the inner content height of the account body
// panels. The body is quantized to whole order cards so the menu panel's
// bottom border lines up exactly with the last visible card; the two gap
// rows around the body host the up/down scroll arrows.
// panels, mirroring the boxed shop layout: header + footer + 2 blank gap
// rows + 2 panel border rows are reserved.
func (m Model) accountBodyHeight() int {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2
cards := budget / orderCardHeight
if cards < 1 {
cards = 1
h := m.heightContainer - headerHeight - footerHeight - 4
if h < 3 {
h = 3
}
return cards*orderCardHeight - 2
return h
}
// accountDetailWidth returns the width of the unboxed detail area: the chrome
@@ -36,13 +32,19 @@ func (m Model) accountDetailWidth() int {
return contentW - (menuW + 4) - 1
}
// accountTitleStyle styles a section title for the detail area: centered
// across the full detail width so it sits over the content below it.
func accountTitleStyle(width int) lipgloss.Style {
return pWht.Bold(true).Width(width).Align(lipgloss.Center)
}
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// The viewport spans the unboxed detail area to the right of the menu panel.
func (m Model) updateAccountViewport() Model {
detailW := m.accountDetailWidth()
// +2: no panel on the detail side, so the viewport spans the same rows
// as the menu panel including its border rows — tops and bottoms align.
availableHeight := m.accountBodyHeight() + 2
// +1: no panel on the detail side, but one row is spent on the leading
// newline that aligns the content with the menu panel's first item row.
availableHeight := m.accountBodyHeight() + 1
if m.size < large {
// Stacked: the menu panel sits above the detail area
menuPanelHeight := len(models.AccountMenuItems) + 2
@@ -83,6 +85,7 @@ func (m Model) getAccountDetailContent() string {
case "cards":
return m.CardsView(detailContentWidth)
case "faq":
titleStyle := accountTitleStyle(detailContentWidth).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := ""
@@ -93,7 +96,7 @@ func (m Model) getAccountDetailContent() string {
faqContent += "\n\n"
}
}
detailView := faqContent
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.account.faqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
@@ -117,25 +120,19 @@ func (m Model) scrollToAccountDetailItem() Model {
switch {
case m.account.orderViewState == 1:
// Order list uses whole-card windowing instead of viewport scroll:
// shift the window just enough to keep the cursor's card visible.
page := m.orderListPageSize()
if m.account.orderCursor < m.account.orderWindowStart {
m.account.orderWindowStart = m.account.orderCursor
}
if m.account.orderCursor >= m.account.orderWindowStart+page {
m.account.orderWindowStart = m.account.orderCursor - page + 1
}
m.account.detailViewport.GotoTop()
return m
case m.account.addressListFocused:
// Titles were removed from the detail views, lists start at the top
// No title in list mode, cards joined with JoinVertical (no gaps)
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
headerOffset = 0
itemHeight = 4
itemCount = len(m.currentOrderList())
selectedIndex = m.account.orderCursor
case m.account.addressListFocused:
headerOffset = 3
itemHeight = 5
itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor
case m.account.cardListFocused:
headerOffset = 0
headerOffset = 3
itemHeight = 4
itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor
@@ -166,20 +163,12 @@ func (m Model) scrollToAccountDetailItem() Model {
func (m Model) BuildAccountView() string {
_, menuW, _, _ := m.shopLayout()
// Left panel: menu items. The highlight is blue while the user navigates
// the menu, and falls back to the gray pill when focus has moved into the
// detail area (browsing orders, managing addresses/cards, scrolling faq).
focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused ||
m.account.cardListFocused || m.account.faqFocused
pillBg := m.theme.Highlight()
if focusedInside {
pillBg = cPill
}
// Left panel: menu items, pill highlight like the shop chrome
var menu []string
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Background(pillBg).Foreground(cWhite).Bold(true).
Background(cPill).Foreground(cWhite).Bold(true).
Render(" "+item))
} else {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
@@ -206,32 +195,22 @@ func (m Model) BuildAccountView() string {
)
} else {
bodyH := m.accountBodyHeight()
// Leading newline drops the detail content one row so its title
// lines up with the menu panel's first item, not its top border.
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
detailRendered,
"\n"+detailRendered,
)
}
// Blank rows separate the body from the header and footer boxes. When the
// windowed order list has hidden items, the arrows occupy those gap rows,
// centered over the card column, so the cards themselves always span
// exactly the menu panel's rows — top and bottom borders aligned.
topGap := ""
if above := m.orderHiddenAbove(); above > 0 && m.size >= large {
topGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↑", above, m.accountDetailWidth())
}
bottomGap := ""
if below := m.orderHiddenBelow(); below > 0 && m.size >= large {
bottomGap = strings.Repeat(" ", menuW+5) +
orderScrollIndicator("↓", below, m.accountDetailWidth())
}
return topGap + "\n" + body + "\n" + bottomGap
// Blank rows separate the body from the header and footer boxes.
return "\n" + body + "\n"
}
func (m Model) AboutView(width int) string {
titleStyle := accountTitleStyle(width).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true)
@@ -253,7 +232,7 @@ func (m Model) AboutView(width int) string {
width,
)) + "\n"
return aboutContent
return titleStyle.Render("About") + "\n\n" + aboutContent
}
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
@@ -380,7 +359,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
case 0:
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.orderWindowStart = 0
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "j/k", value: "orders"},

View File

@@ -1,9 +1,10 @@
package tui
func (m Model) AddressesView(width int) string {
titleStyle := accountTitleStyle(width).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := ""
lines := titleStyle.Render("Address") + "\n\n"
if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses")
} else {

View File

@@ -5,9 +5,10 @@ import (
)
func (m Model) CardsView(width int) string {
titleSTyle := accountTitleStyle(width).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := ""
lines := titleSTyle.Render("Cards") + "\n\n"
if m.SavedCardsIsEmpty() {
lines += contentStyle.Render("No saved cards")
} else {

View File

@@ -10,11 +10,11 @@ import (
)
func (m Model) OrdersView(width int) string {
return m.orderListView(m.HistoryOrders(), width)
return m.orderListView("Order History", m.HistoryOrders(), width)
}
func (m Model) ActiveOrdersView(width int) string {
return m.orderListView(m.ActiveOrders(), width)
return m.orderListView("Active Orders", m.ActiveOrders(), width)
}
func (m Model) ActiveOrders() []models.Order {
@@ -65,95 +65,41 @@ func (m Model) currentOrderList() []models.Order {
return nil
}
// orderCardHeight is the rendered height of one order card:
// border (2) + content lines (2), no vertical padding.
const orderCardHeight = 4
// orderListPageSize returns how many whole order cards fit in the detail
// area. Both scroll arrows render outside the list, in the gap rows above
// and below the body, so the cards use the full viewport height.
func (m Model) orderListPageSize() int {
n := m.account.detailViewport.Height() / orderCardHeight
if n < 1 {
n = 1
}
return n
}
// orderHiddenAbove returns how many order cards are scrolled off above the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenAbove() int {
if m.account.orderViewState != 1 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
page := m.orderListPageSize()
start := m.account.orderWindowStart
if start > len(orders)-page {
start = len(orders) - page
}
if start < 0 {
start = 0
}
return start
}
// orderHiddenBelow returns how many order cards are scrolled off below the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenBelow() int {
if m.account.orderViewState == 2 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize())
if below < 0 {
below = 0
}
return below
}
// orderScrollIndicator renders the dim "more items" arrow row, centered
// across the card width.
func orderScrollIndicator(arrow string, count, width int) string {
return pDim.Width(width).Align(lipgloss.Center).
Render(fmt.Sprintf("%s %d more", arrow, count))
}
func (m Model) orderListView(orders []models.Order, width int) string {
func (m Model) orderListView(title string, orders []models.Order, width int) string {
titleStyle := accountTitleStyle(width).MarginBottom(1)
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {
return contentStyle.Render("Loading orders...")
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
}
if len(orders) == 0 {
return contentStyle.Render("No orders yet")
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
}
if m.account.orderViewState == 2 {
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
}
// Whole-card windowing: render only fully-visible cards, never clipping a
// card mid-border. Both scroll arrows render in BuildAccountView's gap
// rows, so the cards span exactly the menu panel's rows.
page := m.orderListPageSize()
start := m.orderHiddenAbove()
end := start + page
if end > len(orders) {
end = len(orders)
boxWidth := width - 2
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
var cards []string
for i, order := range orders {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
}
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
// In preview mode (viewState 0), show title + hint
if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
return titleStyle.Render(title) + "\n" +
cardList + "\n" +
hintStyle.Render("enter: browse orders")
}
var rows []string
for i := start; i < end; i++ {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
// In list browsing mode (viewState 1), just show cards — no title
// (the left panel menu already shows "order history" as selected)
return cardList
}
// buildOrderCard renders a single order as a bordered box with fixed height.

View File

@@ -9,42 +9,36 @@ import (
)
func (m Model) updateCartViewport() Model {
contentW, _, _, _ := m.shopLayout()
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
// 2 = blank gap rows around the cart list
availH := m.heightContainer - headerH - footerH - breadH - 2
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.cart.viewportReady {
m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH))
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true
} else {
m.cart.viewport.SetWidth(contentW)
m.cart.viewport.SetWidth(m.widthContent)
m.cart.viewport.SetHeight(availH)
}
return m
}
func (m Model) generateCartContent() string {
contentW, _, _, _ := m.shopLayout()
if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim().
Align(lipgloss.Center).
Width(contentW).
Width(m.widthContent).
Padding(2, 0)
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
return render
}
cartItems := ""
// Cart cards span the full chrome width so they line up with the
// header and footer boxes.
boxWidth := contentW
boxWidth := m.widthContent - 4
itemSlice := m.GetCartItemsSlice()
boxPadding := 0
@@ -100,7 +94,7 @@ func (m Model) generateCartContent() string {
boxContent = m.theme.TextAccent().Render(boxContent)
}
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
cartItems += box
cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
if idx < len(itemSlice)-1 {
cartItems += "\n"
@@ -129,9 +123,12 @@ func (m Model) CartView() string {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
}
}
// Blank rows separate the list from the chrome; no Place wrapper so the
// cards stay left-aligned with the header and footer boxes.
return "\n" + m.cart.viewport.View() + "\n"
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.cart.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.cart.viewport.View(),
)
}
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {

View File

@@ -1,3 +0,0 @@
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
// account, and the checkout flow, all drawn with Bubbletea.
package tui

View File

@@ -182,7 +182,6 @@ type accountState struct {
cursor int
orderViewState int
orderCursor int
orderWindowStart int // first fully-visible card in the windowed order list
orderYOffset int // saved scroll pos when entering order detail
faqFocused bool
addressListFocused bool
@@ -262,12 +261,13 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
{key: "q", value: "quit"},
}
} else {
// Keep this list short enough to fit on one footer line.
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
{key: "enter", value: "checkout"},
{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"},
}
@@ -837,7 +837,6 @@ func NewModel(username string) Model {
{
Name: "Espresso",
RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2,
BeanType: "Arabica",
Price: 350, // $3.50
@@ -847,7 +846,6 @@ func NewModel(username string) Model {
{
Name: "Latte",
RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12,
BeanType: "Arabica Blend",
Price: 500, // $5.00
@@ -857,7 +855,6 @@ func NewModel(username string) Model {
{
Name: "Cappuccino",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8,
BeanType: "Italian Blend",
Price: 450, // $4.50
@@ -867,7 +864,6 @@ func NewModel(username string) Model {
{
Name: "Americano",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 16,
BeanType: "Arabica",
Price: 400, // $4.00
@@ -877,7 +873,6 @@ func NewModel(username string) Model {
{
Name: "Mocha",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 16,
BeanType: "Colombian",
Price: 550, // $5.50
@@ -887,7 +882,6 @@ func NewModel(username string) Model {
{
Name: "Macchiato",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3,
BeanType: "Robusta Blend",
Price: 425, // $4.25
@@ -932,6 +926,10 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
return m
}
func newViewport(w, h int) viewport.Model {
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
}
func (m Model) collectCardCmd() tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {

View File

@@ -276,7 +276,9 @@ func (m Model) updatePaymentViewport() Model {
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
availH = max(availH, 1)
if availH < 1 {
availH = 1
}
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{}
@@ -301,7 +303,7 @@ func (m Model) PaymentPageView() string {
m.widthContainer,
m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center,
m.renderPaymentHTTPSView(),
m.renderPaymentHttpsView(),
)
} else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList()
@@ -372,7 +374,7 @@ func (m Model) RenderCardList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) renderPaymentHTTPSView() string {
func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight()

View File

@@ -1,3 +0,0 @@
// Package qrfefe turns text into a QR code drawn with terminal block
// characters, handy for showing links you can scan right off the screen.
package qrfefe

View File

@@ -99,89 +99,6 @@ func TestRenderAccountSmoke(t *testing.T) {
}
}
// TestRenderOrderWindowing verifies the windowed order list: whole cards
// only, with ↑/↓ arrow rows marking hidden items.
func TestRenderOrderWindowing(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m.OrdersLoaded = true
for i := 1; i <= 9; i++ {
m.Orders = append(m.Orders, models.Order{
ID: uint(9000 + i),
Status: models.OrderStatusPaid,
Total: 1000 + i*100,
})
}
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
// Preview mode: window starts at the top, arrow only below.
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
if !strings.Contains(plain, "↓ 4 more") {
t.Errorf("preview mode: expected '↓ 4 more' indicator")
}
if strings.Contains(plain, "↑") {
t.Errorf("preview mode: unexpected up arrow")
}
// Browse mode scrolled to the middle: arrows on both ends.
m.account.orderViewState = 1
m.account.orderCursor = 6
m = m.scrollToAccountDetailItem()
plain = ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
if !strings.Contains(plain, "↑ 2 more") || !strings.Contains(plain, "↓ 2 more") {
t.Errorf("browse mode: expected '↑ 2 more' and '↓ 2 more' indicators")
}
}
// TestRenderCartSmoke verifies the cart page cards line up with the chrome.
func TestRenderCartSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
// Seed coffees carry no IDs, so assign them for distinct cart keys
for i := range m.Coffees {
m.Coffees[i].ID = uint(i + 1)
}
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 2}
m.Cart[m.Coffees[1].ID] = &models.CartItem{CoffeeID: m.Coffees[1].ID, Coffee: m.Coffees[1], Quantity: 1}
m = m.updateLayout(100, 40)
m, _ = m.CartSwitch()
plain := ansiRE.ReplaceAllString(m.renderString(), "")
t.Log("\n" + plain)
var lefts, rights []int
for _, line := range strings.Split(plain, "\n") {
runes := []rune(line)
first, last := -1, -1
for i, r := range runes {
if strings.ContainsRune("╭╮╰╯│", r) {
if first == -1 {
first = i
}
last = i
}
}
if first != -1 {
lefts = append(lefts, first)
rights = append(rights, last)
}
}
if len(lefts) == 0 {
t.Fatal("no box-drawing rows found")
}
// Every box row (header, cards, footer) must share the chrome edges.
for i := range lefts {
if lefts[i] != lefts[0] || rights[i] != rights[0] {
t.Errorf("box edge misaligned: row %d spans %d-%d, expected %d-%d",
i, lefts[i], rights[i], lefts[0], rights[0])
break
}
}
}
// TestRenderShopSmokeStacked prints the stacked (medium/small) layouts.
func TestRenderShopSmokeStacked(t *testing.T) {
for _, size := range [][2]int{{60, 32}, {45, 30}} {

View File

@@ -24,7 +24,9 @@ const (
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4.
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
contentW = m.widthContent
contentW = min(contentW, 74)
if contentW > 74 {
contentW = 74
}
wideW = contentW - 4
if m.size < large {
// Stacked layout: both panels span the full content width.
@@ -36,7 +38,6 @@ func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
}
// roastLevel maps a roast type to a 1-5 intensity for the roast meter.
// It is the fallback for products stored before the roast_level column existed.
func roastLevel(roastType string) int {
rt := strings.ToLower(roastType)
switch {
@@ -64,7 +65,6 @@ func clampLines(s string, n int) string {
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○
func roastMeter(n int) string {
n = max(0, min(n, 5))
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
off := pDim.Render(strings.Repeat("○ ", 5-n))
return strings.TrimRight(on+off, " ")
@@ -105,11 +105,7 @@ func (m Model) shopDetailContent(w int) string {
pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep +
pDim.Render(strings.ToUpper(coffee.BeanType))
level := coffee.RoastLevel
if level < 1 || level > 5 {
level = roastLevel(coffee.RoastType)
}
strength := pDim.Render("roast ") + roastMeter(level)
strength := pDim.Render("roast ") + roastMeter(roastLevel(coffee.RoastType))
price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).
Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) +
@@ -217,8 +213,12 @@ func (m Model) shopMoveSelected(previous bool) Model {
} else {
next++
}
next = max(next, 0)
next = min(next, len(m.Coffees)-1)
if next < 0 {
next = 0
}
if next >= len(m.Coffees) {
next = len(m.Coffees) - 1
}
m.shop.selected = next
return m
}

View File

@@ -1,3 +0,0 @@
// Package theme keeps the colors and styles in one spot so the whole UI
// looks like it was built by the same person.
package theme

View File

@@ -1,3 +0,0 @@
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
// earn a package of their own.
package utils

View File

@@ -1,3 +0,0 @@
// Package validate gives user input a once-over before the rest of the app
// trusts it.
package validate