Compare commits

...

11 Commits

Author SHA1 Message Date
ebbf66e76e docs(docs): update docs to get rid of gopls warnings. update some code to newer standards 2026-06-14 20:15:21 +02:00
aa34043e9b feat(tuie): drive roast meter from product roast_level field (#48) 2026-06-11 16:28:04 +02:00
5dd22fb8be style(tui): update rest of tui to new ui pattern (#47) 2026-06-11 15:53:48 +02:00
52d533703b feat(tui): remake parts of the tui (#46)
* feat(tui): remake parts of the tui

* chore(tui): remove unused newViewport helper
2026-06-11 11:24:56 +02:00
c9f8b1e674 test(test): smoke test deploy script with dirty box tree (#44) 2026-06-09 19:39:09 +02:00
d592249efb fix: fail deploy loudly on stale pull or HEAD mismatch (#43) 2026-06-09 19:20:08 +02:00
cfc97eb1ff fix/temp disable sshCardAdd button (#42)
* fix: disable add-card-via-ssh, mark coming soo, skip in cursor nav

* style(tui): reword (coming soon) to (WIP)
2026-06-09 14:33:09 +02:00
c112797875 refactor(sca): thread context.Context through reconciler entry points (#41)
* refactor(sca): thread context.Context through reconciler entry points

* test(sca): add mid-loop cancel test for stale 3ds sweep

* refactor(api): rename shutdown ctx to shutdownCtx for clarity

* style(sca): fix typo in ReconcileUnshipped commend
2026-06-09 00:31:56 +02:00
75225548ae feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep (#40)
* feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep

* test: assert sweep counter labels in remaining branch tests
2026-06-08 23:11:44 +02:00
53e934be3b chore(cap): raise per-order spend cap default from 200 to 500 (#39) 2026-06-07 23:47:43 +02:00
fc02f331d2 feat(audit): emit cart_rejected event when per-order cap blocks checkout (#38)
* feat(audit): emit cart_rejected event when per-order cap blocks checkout

* refactor(audit): drop dead persist call + harden cart_rejected test capture
2026-06-07 23:25:30 +02:00
53 changed files with 1176 additions and 399 deletions

View File

@@ -22,6 +22,15 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -euo pipefail
cd ~/terminalShop
git pull
git fetch origin master
git reset --hard origin/master
deployed=$(git rev-parse HEAD)
expected="${{ github.sha }}"
if [ "$deployed" != "$expected" ]; then
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
exit 1
fi
docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

1
.gitignore vendored
View File

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

View File

@@ -71,3 +71,4 @@ uses SQLite by default so there's nothing to spin up.
## inspiration
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
# something

3
api/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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,6 +1,7 @@
package handlers
import (
"context"
"os"
"testing"
"time"
@@ -58,7 +59,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
t.Fatalf("seed cart: %v", err)
}
ReconcileExpiredCards()
ReconcileExpiredCards(context.Background())
var remaining []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
@@ -117,7 +118,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
}
}
ReconcileExpiredCards()
ReconcileExpiredCards(context.Background())
var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {

View File

@@ -382,6 +382,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if h.maxOrderCents > 0 && total > h.maxOrderCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
map[string]any{

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -746,6 +747,19 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("SetCard: %d %s", w.Code, w.Body.String())
}
// Capture audit slog ONLY for ConvertCart's emit window. Placed
// here (not at top of t.Run) so setup INFO logs from
// database.Connect/Migrate/Seed don't pollute the buffer with
// non-audit records - json.Unmarshal below requires a single
// JSON object, not multi-document input. slog.Default is
// process-global - do NOT add t.Parallel() to subtests here.
var auditBuf bytes.Buffer
if tc.expectOverCap {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&auditBuf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
}
// Convert
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
w = httptest.NewRecorder()
@@ -772,6 +786,27 @@ func TestConvertCartLimit(t *testing.T) {
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
}
if auditBuf.Len() == 0 {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
var rec map[string]any
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
}
if got := rec["event"]; got != "cart_rejected" {
t.Errorf("audit event: want cart_rejected, got %v", got)
}
if n, _ := rec["user_id"].(float64); uint(n) != user.ID {
t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != tc.quantity*pinnedPrice {
t.Errorf("audit total_cents: want %d, got %v", tc.quantity*pinnedPrice, rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != tc.capCents {
t.Errorf("audit cap_cents: want %d, got %v", tc.capCents, rec["cap_cents"])
}
}
})
}

3
api/handlers/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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

@@ -1,10 +1,12 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
@@ -26,8 +28,8 @@ func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
// the case where the server crashed after charging but before the DB transaction
// completed.
func ReconcileOrders() {
db := database.GetDB()
func ReconcileOrders(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute)
@@ -41,9 +43,14 @@ func ReconcileOrders() {
reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
Context: ctx,
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
},
}
@@ -89,8 +96,8 @@ func ReconcileOrders() {
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
// stripe.Key is wired once at startup in api/main.go.
func ReconcileStale3DSOrders(threshold time.Duration) {
db := database.GetDB()
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
@@ -109,19 +116,26 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
for i := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
return
}
order := &orders[i]
if order.StripePaymentID == "" {
// respondRequiresAction writes stripe_payment_id and status in
// one UPDATE so this should be unreachable; guard anyway so a
// future regression noisily logs instead of 4xx'ing Stripe.
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
middleware.RecordAbandoned3DSSweep("missing_pi")
continue
}
actual, err := paymentIntentGetFn(order.StripePaymentID, nil)
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
if err != nil {
reconcileLog().Error("stripe get failed",
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
middleware.RecordAbandoned3DSSweep("stripe_error")
continue
}
switch actual.Status {
@@ -133,10 +147,12 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
// tick re-checks and surfaces the dropped webhook if it persists
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
"order_id", order.ID, "pi", order.StripePaymentID)
middleware.RecordAbandoned3DSSweep("succeeded_skip")
case stripe.PaymentIntentStatusProcessing,
stripe.PaymentIntentStatusRequiresCapture:
reconcileLog().Info("payment intent still transitioning; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("processing_skip")
case stripe.PaymentIntentStatusRequiresAction,
stripe.PaymentIntentStatusRequiresPaymentMethod,
stripe.PaymentIntentStatusRequiresConfirmation,
@@ -145,6 +161,7 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
default:
reconcileLog().Warn("unrecognized stripe status; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("unrecognized_status")
}
}
}
@@ -162,24 +179,27 @@ func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string)
Updates(map[string]any{"status": models.OrderStatusFailed})
if res.Error != nil {
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
middleware.RecordAbandoned3DSSweep("flip_failed")
return
}
if res.RowsAffected == 0 {
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
"order_id", order.ID)
middleware.RecordAbandoned3DSSweep("flip_noop")
return
}
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
reconcileLog().Info("3ds abandoned order flipped to failed",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
middleware.RecordAbandoned3DSSweep("flipped")
}
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
// Read paths filter out expired rows so users never see them; this job drives
// the Stripe detach + audit + physical row delete out of band so request
// handlers never block on Stripe API calls during a card sweep.
func ReconcileExpiredCards() {
db := database.GetDB()
func ReconcileExpiredCards(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
var cards []models.Card
if err := db.Where(
@@ -195,16 +215,20 @@ func ReconcileExpiredCards() {
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
for i := range cards {
if err := ctx.Err(); err != nil {
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
return
}
if err := expireStoredCard(db, &cards[i]); err != nil {
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
}
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
// 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) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)

View File

@@ -1,16 +1,19 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stripe/stripe-go/v78"
)
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
@@ -39,7 +42,10 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
before := testutil.ToFloat64(flipped)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -57,6 +63,9 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
if len(events) != 1 {
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
}
if got := testutil.ToFloat64(flipped) - before; got != 1 {
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
@@ -85,7 +94,7 @@ func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
@@ -124,7 +133,10 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -133,6 +145,9 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
@@ -161,7 +176,10 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
before := testutil.ToFloat64(noop)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -170,6 +188,9 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(noop) - before; got != 1 {
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
@@ -196,7 +217,7 @@ func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -232,7 +253,7 @@ func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
@@ -275,7 +296,10 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -284,6 +308,9 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 processing_skip increment, got %v", got)
}
})
}
}
@@ -312,7 +339,10 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
before := testutil.ToFloat64(unknown)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -321,6 +351,9 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(unknown) - before; got != 1 {
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
@@ -344,7 +377,10 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
return nil, fmt.Errorf("simulated stripe outage")
}
ReconcileStale3DSOrders(30 * time.Minute)
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
before := testutil.ToFloat64(stripeErr)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -353,6 +389,9 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
@@ -381,7 +420,10 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
ReconcileStale3DSOrders(30 * time.Minute)
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
before := testutil.ToFloat64(missing)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for empty-PI row")
@@ -393,6 +435,55 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(missing) - before; got != 1 {
t.Fatalf("want 1 missing_pi increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
// ctx.Err() check stops the sweep once the supervising context cancels, so a
// SIGTERM during a large stale-3DS batch does not block the binary past the
// 30s graceful-shutdown window in api/main.go
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
calls++
if calls == 1 {
cancel()
}
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(ctx, 30*time.Minute)
if calls != 1 {
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
}
var stillPending int64
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
t.Fatalf("count requires_action rows: %v", err)
}
if stillPending < 1 {
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
}
}
// seedRequiresActionOrder inserts a card + order pair in the requires_action

View File

@@ -114,7 +114,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileOrders()
handlers.ReconcileOrders(ctx)
case <-ctx.Done():
return
}
@@ -122,14 +122,18 @@ func main() {
}()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
select {
case <-time.After(30 * time.Second):
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
handlers.ReconcileUnshipped(ctx)
case <-ctx.Done():
return
}
@@ -145,7 +149,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards()
handlers.ReconcileExpiredCards(ctx)
case <-ctx.Done():
return
}
@@ -159,7 +163,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(threshold)
handlers.ReconcileStale3DSOrders(ctx, threshold)
case <-ctx.Done():
return
}
@@ -174,10 +178,10 @@ func main() {
log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(ctx); err != nil {
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}

4
api/middleware/doc.go Normal file
View File

@@ -0,0 +1,4 @@
// 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

@@ -70,6 +70,14 @@ var (
[]string{"outcome"},
)
abandoned3DSSweepTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "abandoned_3ds_sweep_total",
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
},
[]string{"outcome"},
)
orderValueDollars = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "order_value_dollars",
@@ -173,6 +181,19 @@ func ObserveOrderValueCents(cents int) {
orderValueDollars.Observe(float64(cents) / 100.0)
}
// RecordAbandoned3DSSweep records the outcome of one row processed by
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
// Help string.
func RecordAbandoned3DSSweep(outcome string) {
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
}
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
// per-label values via prometheus testutil. Not used in production code.
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
return abandoned3DSSweepTotal
}
// ObserveStripeRequest records latency of a Stripe API call.
func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)

3
api/routes/doc.go Normal file
View File

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

View File

@@ -0,0 +1,3 @@
// 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

1
go.mod
View File

@@ -81,6 +81,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect

3
pkg/api/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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

@@ -24,6 +24,12 @@ const EventOrderRequiresAction = "order_requires_action"
// stays decoupled from sweep activity
const EventOrder3DSAbandoned = "order_3ds_abandoned"
// EventCartRejected is the audit event-type string emitted when the per-order
// spend cap blocks a checkout BEFORE an order row is created. The event lives
// in slog only, persist() no-ops on zero order id, which is the correct shape
// here (cap rejection is upstream of order state). See caveat #13.
const EventCartRejected = "cart_rejected"
type eventType string
const (
@@ -36,6 +42,7 @@ const (
eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
@@ -46,19 +53,21 @@ const (
)
type event struct {
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
TotalCents int
CapCents int
}
func (e event) attrs() []any {
@@ -99,6 +108,12 @@ func (e event) attrs() []any {
if e.Actor != "" {
a = append(a, "actor", e.Actor)
}
if e.TotalCents != 0 {
a = append(a, "total_cents", e.TotalCents)
}
if e.CapCents != 0 {
a = append(a, "cap_cents", e.CapCents)
}
return a
}
@@ -349,3 +364,19 @@ func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrder3DSAbandoned, e)
}
// CartRejected records the per-order cap blocking a checkout BEFORE an
// order row is created. Slog-only by design: cap rejection runs upstream
// of order creation so there is no order id to scope a persistent
// OrderEvent row. persist() is intentionally not called - future refactors
// of the persist() zero-id guard must not silently start writing rows here.
// Counterpart to the validation_over_limit Prometheus counter.
func CartRejected(userID uint, totalCents, capCents int) {
e := event{
Event: eventCartRejected,
UserID: userID,
TotalCents: totalCents,
CapCents: capCents,
}
slog.Info("audit", e.attrs()...)
}

41
pkg/audit/audit_test.go Normal file
View File

@@ -0,0 +1,41 @@
package audit
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestCartRejected(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
CartRejected(42, 25000, 20000)
raw := strings.TrimSpace(buf.String())
if raw == "" {
t.Fatalf("CartRejected: no slog output captured")
}
var rec map[string]any
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
}
if got, want := rec["event"], "cart_rejected"; got != want {
t.Errorf("event: want %q, got %v", want, got)
}
if n, _ := rec["user_id"].(float64); int(n) != 42 {
t.Errorf("user_id want 42, got %v", rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
}
}

3
pkg/auth/doc.go Normal file
View File

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

3
pkg/bring/doc.go Normal file
View File

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

View File

@@ -137,11 +137,13 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
// the cap. Garbage or negative values fall back to the default with a warning
// so an operator typo cannot silently disable a fraud control.
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
func loadMaxOrderCents() int {
const defaultCap = 20000
const defaultCap = 50000
raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" {
return defaultCap

View File

@@ -24,12 +24,12 @@ func TestLoadMaxOrderCents(t *testing.T) {
env string // "" means unset
want int
}{
{"empty falls back to default", "", 20000},
{"empty falls back to default", "", 50000},
{"explicit zero disables", "0", 0},
{"valid override", "50000", 50000},
{"negative falls back to default", "-1", 20000},
{"garbage falls back to default", "two hundred", 20000},
{"empty after trim falls back to default", "", 20000},
{"valid override", "30000", 30000},
{"negative falls back to default", "-1", 50000},
{"garbage falls back to default", "two hundred", 50000},
{"empty after trim falls back to default", "", 50000},
}
for _, tc := range cases {

3
pkg/config/doc.go Normal file
View File

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

3
pkg/database/doc.go Normal file
View File

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

3
pkg/models/doc.go Normal file
View File

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

3
pkg/observability/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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

3
pkg/shipping/doc.go Normal file
View File

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

2
pkg/shippo/doc.go Normal file
View File

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

3
pkg/stripeclient/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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,6 +1,8 @@
package tui
import (
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -8,36 +10,54 @@ import (
"terminalShop/pkg/models"
)
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// Follows the reference pattern: each page owns its own viewport(s).
func (m Model) updateAccountViewport() Model {
// Match the reference: only subtract header + footer, no extra margins
// (account is a viewport page — views.go renders it without margin padding)
// 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.
func (m Model) accountBodyHeight() int {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
verticalMarginHeight := headerHeight + footerHeight
availableHeight := m.heightContainer - verticalMarginHeight
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailWidth := rightWidth
if m.size < large {
detailWidth = m.widthContent
// In stacked mode, reduce available height by the left panel
leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
budget := m.heightContainer - headerHeight - footerHeight - 2
cards := budget / orderCardHeight
if cards < 1 {
cards = 1
}
if availableHeight < 3 {
availableHeight = 3
return cards*orderCardHeight - 2
}
// accountDetailWidth returns the width of the unboxed detail area: the chrome
// width minus the menu panel and the gap. The detail side has no outer panel
// (its content draws its own boxes), so it reclaims the border + padding.
func (m Model) accountDetailWidth() int {
contentW, menuW, _, _ := m.shopLayout()
if m.size < large {
return contentW
}
return contentW - (menuW + 4) - 1
}
// 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
if m.size < large {
// Stacked: the menu panel sits above the detail area
menuPanelHeight := len(models.AccountMenuItems) + 2
availableHeight -= menuPanelHeight
if availableHeight < 3 {
availableHeight = 3
}
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetWidth(detailW)
m.account.detailViewport.SetHeight(availableHeight)
}
@@ -46,12 +66,7 @@ func (m Model) updateAccountViewport() Model {
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
detailContentWidth := m.accountDetailWidth()
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return ""
@@ -68,7 +83,6 @@ func (m Model) getAccountDetailContent() string {
case "cards":
return m.CardsView(detailContentWidth)
case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := ""
@@ -79,7 +93,7 @@ func (m Model) getAccountDetailContent() string {
faqContent += "\n\n"
}
}
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
detailView := faqContent
if !m.account.faqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
@@ -103,19 +117,25 @@ func (m Model) scrollToAccountDetailItem() Model {
switch {
case m.account.orderViewState == 1:
// 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
// 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:
headerOffset = 3
// Titles were removed from the detail views, lists start at the top
headerOffset = 0
itemHeight = 5
itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor
case m.account.cardListFocused:
headerOffset = 3
headerOffset = 0
itemHeight = 4
itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor
@@ -143,23 +163,30 @@ func (m Model) scrollToAccountDetailItem() Model {
return m
}
func (m Model) BuildAccountView(availableHeight int) string {
leftWidth := 18
func (m Model) BuildAccountView() string {
_, menuW, _, _ := m.shopLayout()
// Build the left panel (menu items)
leftPanel := ""
// 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
}
var menu []string
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Background(pillBg).Foreground(cWhite).Bold(true).
Render(" "+item))
} else {
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Render(" "+pBody.Render(item)))
}
}
leftContainer := m.theme.Base().
Width(leftWidth)
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
// Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent()
@@ -168,25 +195,43 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View()
// Responsive layout: side-by-side on large, stacked on small/medium
// Responsive layout: menu panel beside the unboxed detail area on large,
// stacked on small/medium. The detail side draws its own item boxes.
var body string
if m.size < large {
return lipgloss.JoinVertical(
body = lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
panel(menuW, 0, menuContent),
detailRendered,
)
} else {
bodyH := m.accountBodyHeight()
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
detailRendered,
)
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
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
}
func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true)
@@ -208,7 +253,7 @@ func (m Model) AboutView(width int) string {
width,
)) + "\n"
return titleStyle.Render("About") + "\n\n" + aboutContent
return aboutContent
}
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
@@ -335,6 +380,7 @@ 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,10 +1,9 @@
package tui
func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := titleStyle.Render("Address") + "\n\n"
lines := ""
if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses")
} else {

View File

@@ -5,10 +5,9 @@ import (
)
func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := titleSTyle.Render("Cards") + "\n\n"
lines := ""
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("Order History", m.HistoryOrders(), width)
return m.orderListView(m.HistoryOrders(), width)
}
func (m Model) ActiveOrdersView(width int) string {
return m.orderListView("Active Orders", m.ActiveOrders(), width)
return m.orderListView(m.ActiveOrders(), width)
}
func (m Model) ActiveOrders() []models.Order {
@@ -65,41 +65,95 @@ func (m Model) currentOrderList() []models.Order {
return nil
}
func (m Model) orderListView(title string, orders []models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
// 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 {
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
return contentStyle.Render("Loading orders...")
}
if len(orders) == 0 {
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
return contentStyle.Render("No orders yet")
}
if m.account.orderViewState == 2 {
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
}
boxWidth := width - 2
// 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)
}
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
var cards []string
for i, order := range orders {
var rows []string
for i := start; i < end; i++ {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
rows = append(rows, m.buildOrderCard(orders[i], width, 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")
}
// In list browsing mode (viewState 1), just show cards — no title
// (the left panel menu already shows "order history" as selected)
return cardList
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
// buildOrderCard renders a single order as a bordered box with fixed height.

View File

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

3
pkg/tui/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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

@@ -6,20 +6,26 @@ import (
"charm.land/lipgloss/v2"
)
// BuildFooter renders the bottom chrome bar with the current page keybinds.
// Layout ported from the design prototype at ~/programming/test/shop.go.
func (m Model) BuildFooter() string {
keybindStyle := m.theme.TextDim().Bold(true)
descStyle := m.theme.TextDim()
_, _, _, wideW := m.shopLayout()
item := func(k, v string) string {
return pBody.Render(k) + " " + pDim.Render(v)
}
var keys string
if m.size == small {
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
keys = item("m", "menu")
} else {
parts := make([]string, 0, len(m.footer))
for _, cmd := range m.footer {
parts = append(parts, item(cmd.key, cmd.value))
}
keys = strings.Join(parts, pDim.Render(" · "))
}
parts := []string{}
for _, cmd := range m.footer {
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
}
footerText := strings.Join(parts, " ")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys)
return panel(wideW, 0, row)
}

View File

@@ -2,57 +2,72 @@ package tui
import (
"fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
func (m Model) BuildHeader() string {
bold := m.theme.TextAccent().Bold(true).Render
accent := m.theme.TextAccent().Render
base := m.theme.Base().Render
cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ")
mark := bold("t") + cursor
logo := bold("terminal")
menu := bold("m") + base(" ☰")
tab := func(key, name string, active bool) string {
if active {
return accent(key + " " + name)
// cartBlendColor mixes the colors of all cart items, weighted by quantity,
// so the header price tints toward whatever dominates the cart.
func (m Model) cartBlendColor() color.Color {
var rSum, gSum, bSum, n int
for _, item := range m.Cart {
r, g, b, ok := parseHexColor(item.Coffee.Color)
if !ok {
continue
}
return accent(key) + base(" "+name)
rSum += r * item.Quantity
gSum += g * item.Quantity
bSum += b * item.Quantity
n += item.Quantity
}
shop := tab("s", "shop", m.currentPage == shopPage)
account := tab("a", "account", m.currentPage == accountPage)
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
cartLabel := base(" cart")
if m.inCartFlow() {
cartLabel = accent(" cart")
if n == 0 {
return cWhite
}
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
var tabs []string
switch m.size {
case small:
tabs = []string{mark, cart}
case medium:
tabs = []string{menu, logo, cart}
default:
tabs = []string{logo, shop, account, cart}
}
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
}).Render()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(tbl),
lipgloss.Center,
lipgloss.Center,
tbl,
)
return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", rSum/n, gSum/n, bSum/n))
}
// BuildHeader renders the top chrome bar: brand, tab pills, and cart summary.
// Layout ported from the design prototype at ~/programming/test/shop.go.
func (m Model) BuildHeader() string {
_, _, _, wideW := m.shopLayout()
brand := pWht.Bold(true).Render("terminal")
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1)
tab := func(key, label string, active bool) string {
if active {
return pill.Render(label)
}
return pDim.Render(key+" ") + pBody.Render(label)
}
var left string
if m.size == small {
left = brand
} else {
left = strings.Join([]string{
brand,
tab("s", "shop", m.currentPage == shopPage),
tab("a", "account", m.currentPage == accountPage),
}, " ")
}
cartLabel := pDim.Render("cart ")
if m.inCartFlow() {
cartLabel = pWht.Bold(true).Render("cart ")
}
cart := cartLabel +
lipgloss.NewStyle().Foreground(m.cartBlendColor()).Bold(true).
Render(fmt.Sprintf("$%d", m.CalculateSubtotal()/100)) +
pDim.Render(fmt.Sprintf(" · %d", m.CartItemCount()))
gap := wideW - lipgloss.Width(left) - lipgloss.Width(cart)
if gap < 1 {
gap = 1
}
row := left + strings.Repeat(" ", gap) + cart
return panel(wideW, 0, row)
}

View File

@@ -142,10 +142,7 @@ type Model struct {
}
type shopState struct {
selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
selected int
}
type splashState struct {
@@ -185,6 +182,7 @@ 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
@@ -240,7 +238,6 @@ func (m Model) SwitchPage(p page) Model {
func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
m.footer = []footerCommand{
{key: "j/k", value: "products"},
{key: "+/-", value: "qty"},
@@ -265,13 +262,12 @@ 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: "p/enter", value: "checkout"},
{key: "enter", value: "checkout"},
{key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "pgup/pgdn", value: "scroll"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
@@ -841,6 +837,7 @@ func NewModel(username string) Model {
{
Name: "Espresso",
RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2,
BeanType: "Arabica",
Price: 350, // $3.50
@@ -850,6 +847,7 @@ func NewModel(username string) Model {
{
Name: "Latte",
RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12,
BeanType: "Arabica Blend",
Price: 500, // $5.00
@@ -859,6 +857,7 @@ func NewModel(username string) Model {
{
Name: "Cappuccino",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8,
BeanType: "Italian Blend",
Price: 450, // $4.50
@@ -868,6 +867,7 @@ func NewModel(username string) Model {
{
Name: "Americano",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 16,
BeanType: "Arabica",
Price: 400, // $4.00
@@ -877,6 +877,7 @@ func NewModel(username string) Model {
{
Name: "Mocha",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 16,
BeanType: "Colombian",
Price: 550, // $5.50
@@ -886,6 +887,7 @@ func NewModel(username string) Model {
{
Name: "Macchiato",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3,
BeanType: "Robusta Blend",
Price: 425, // $4.25
@@ -930,10 +932,6 @@ 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 {

70
pkg/tui/palette.go Normal file
View File

@@ -0,0 +1,70 @@
package tui
import (
"image/color"
"strconv"
"strings"
"charm.land/lipgloss/v2"
)
// Palette and base styles for the boxed shop chrome.
// Ported from the design prototype at ~/programming/test/shop.go.
var (
cWhite = lipgloss.Color("#EAEAEA")
cBody = lipgloss.Color("#9A9A9A")
cDim = lipgloss.Color("#5A5A5A")
cLine = lipgloss.Color("#3A3A3A")
cInk = lipgloss.Color("#161616") // text on a light colored block
cGreen = lipgloss.Color("#4FB477")
cRed = lipgloss.Color("#E06C6C")
cGold = lipgloss.Color("#D0962E") // single accent
cPill = lipgloss.Color("#2E2E2E") // active-tab pill bg
pBody = lipgloss.NewStyle().Foreground(cBody)
pDim = lipgloss.NewStyle().Foreground(cDim)
pWht = lipgloss.NewStyle().Foreground(cWhite)
)
// parseHexColor splits a "#RRGGBB" string into its channel values.
func parseHexColor(s string) (r, g, b int, ok bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v >> 16), int((v >> 8) & 0xFF), int(v & 0xFF), true
}
// contrastColor returns ink for light backgrounds and white for dark ones,
// using perceived luminance so text stays readable on any product color.
func contrastColor(hex string) color.Color {
r, g, b, ok := parseHexColor(hex)
if !ok {
return cWhite
}
lum := (299*r + 587*g + 114*b) / 1000
if lum > 140 {
return cInk
}
return cWhite
}
// panel wraps content in a rounded box. textW is the inner text width and
// innerH the inner content height; the total render size is textW+4 by
// innerH+2. Ported from the design prototype at ~/programming/test/shop.go,
// adjusted for lipgloss v2 where Width/Height include the border.
func panel(textW, innerH int, content string) string {
s := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cLine).
Padding(0, 1).
Width(textW + 4)
if innerH > 0 {
s = s.Height(innerH + 2)
}
return s.Render(content)
}

View File

@@ -276,9 +276,7 @@ func (m Model) updatePaymentViewport() Model {
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
availH = max(availH, 1)
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{}
@@ -303,7 +301,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()
@@ -355,11 +353,16 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box)
}
sshFocused := m.payment.cardCursor == len(m.SavedCards)
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
// SSH inline card-add is dormant until i have a norwegian org
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
// discoverable, but render it dimmed and exclude it from cursor space
// (handled in PaymentUpdate below) so it can not be picked
dim := m.theme.TextDim().Render
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox)
@@ -369,7 +372,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()
@@ -501,7 +504,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor--
}
case "down", "j":
if m.payment.cardCursor < len(m.SavedCards)+1 {
if m.payment.cardCursor < len(m.SavedCards) {
m.payment.cardCursor++
}
@@ -511,17 +514,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected
return m.ReviewSwitch()
}
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.view = 1
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()
}
// Browser payment
// Browser flow, the only reachable add-card path while the SSH
// inline form is dormant (see RenderCardList)
m.payment.view = 2
m.payment.collectURL = nil
m.payment.collectTimeOut = false
@@ -546,16 +540,6 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor--
}
if len(m.SavedCards) == 0 {
m.payment.view = 1
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, m.deleteCardCmd(card.ID)
}
}

View File

@@ -0,0 +1,91 @@
package tui
import (
"strings"
"testing"
"terminalShop/pkg/models"
tea "charm.land/bubbletea/v2"
)
// newPaymentCardListModel parks a Model on the payment page card-list view
// with n synthetic saved cards. Baseline for assertint that the dormant
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
func newPaymentCardListModel(t *testing.T, n int) Model {
t.Helper()
m := NewModel("test")
m = m.updateLayout(120, 40)
m.currentPage = paymentPage
m.payment.view = 0
m.payment.form = nil
m.payment.cardCursor = 0
for i := range n {
m.SavedCards = append(m.SavedCards, models.Card{
ID: uint(i + 1),
Brand: "visa",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
})
}
return m
}
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
m := newPaymentCardListModel(t, 1)
out := m.RenderCardList()
if !strings.Contains(out, "add card via ssh") {
t.Fatalf("ssh option missing from card list output:\n%s", out)
}
if !strings.Contains(out, "WIP") {
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
}
}
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
m := newPaymentCardListModel(t, 2)
for range 10 {
var cmd tea.Cmd
m, cmd = m.PaymentUpdate(keyJ())
_ = cmd
}
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
if m.payment.cardCursor != want {
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
}
}
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 1)
m.payment.cardCursor = len(m.SavedCards) // browser slot
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 0)
if m.payment.cardCursor != 0 {
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
}
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}

3
pkg/tui/qrfefe/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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

@@ -0,0 +1,194 @@
package tui
import (
"regexp"
"strings"
"testing"
"terminalShop/pkg/models"
)
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
// TestRenderShopSmoke prints the rendered shop frame so chrome alignment can
// be inspected, and asserts that the header, body, and footer boxes share the
// same left and right edges.
func TestRenderShopSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 4}
m = m.updateLayout(100, 40)
m, _ = m.ShopSwitch()
out := m.renderString()
plain := ansiRE.ReplaceAllString(out, "")
t.Log("\n" + plain)
// Collect left/right border columns of every box-drawing row.
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")
}
for i := range lefts {
if lefts[i] != lefts[0] {
t.Errorf("left edge misaligned: row %d at col %d, expected %d", i, lefts[i], lefts[0])
break
}
if rights[i] != rights[0] {
t.Errorf("right edge misaligned: row %d at col %d, expected %d", i, rights[i], rights[0])
break
}
}
}
// TestRenderAccountSmoke verifies the account page boxes stay within the
// header/footer chrome bounds. The detail area is unboxed, so inner boxes may
// be narrower than the chrome but must never poke outside it.
func TestRenderAccountSmoke(t *testing.T) {
m := NewModel("smoke")
m.Loading = false
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
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")
}
// Row 0 is the header box: it defines the chrome bounds.
for i := range lefts {
if lefts[i] < lefts[0] || rights[i] > rights[0] {
t.Errorf("box outside chrome: row %d spans %d-%d, chrome is %d-%d",
i, lefts[i], rights[i], lefts[0], rights[0])
break
}
}
}
// 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}} {
m := NewModel("smoke")
m.Loading = false
m = m.updateLayout(size[0], size[1])
m, _ = m.ShopSwitch()
t.Logf("--- %dx%d ---\n%s", size[0], size[1], ansiRE.ReplaceAllString(m.renderString(), ""))
}
}

View File

@@ -4,163 +4,173 @@ import (
"fmt"
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
func (m Model) calculateShopWidths() (int, int) {
menuWidth := 14
if len(m.Coffees) == 0 {
if m.size < large {
return m.widthContent, m.widthContent
}
return menuWidth, m.widthContent - menuWidth
}
for _, c := range m.Coffees {
if w := lipgloss.Width(c.Name); w > menuWidth {
menuWidth = w
}
}
if menuWidth > 0 {
menuWidth += 4
}
const (
// shopBodyInnerHeight fixes the inner height of the shop body panels so
// the detail box never resizes with the description.
shopBodyInnerHeight = 13
// shopDescLines pins the description region to an exact line count.
shopDescLines = 3
)
// shopLayout returns the shared chrome widths, all derived from one content
// width so the header, body panels, and footer line up exactly.
// Math ported from the design prototype at ~/programming/test/shop.go:
// 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)
wideW = contentW - 4
if m.size < large {
menuWidth = m.widthContent
// Stacked layout: both panels span the full content width.
return contentW, wideW, wideW, wideW
}
detailWidth := m.widthContent - menuWidth
if m.size < large {
detailWidth = m.widthContent
}
return menuWidth, detailWidth
menuW = 16
detailW = contentW - 25
return contentW, menuW, detailW, wideW
}
func (m Model) updateShopViewports() Model {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
availableHeight := m.heightContainer - headerHeight - footerHeight
if len(m.Coffees) == 0 {
return m
// 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 {
case strings.Contains(rt, "dark"):
return 5
case strings.Contains(rt, "light"):
return 2
default:
return 3
}
menuWidth, detailWidth := m.calculateShopWidths()
if !m.shop.viewportsReady {
m.shop.menuViewport = newViewport(menuWidth, availableHeight)
m.shop.menuViewport.KeyMap = viewport.KeyMap{} // menu nav is manual
m.shop.detailViewport = newViewport(detailWidth, availableHeight)
m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true
} else {
m.shop.menuViewport.SetWidth(menuWidth)
m.shop.menuViewport.SetHeight(availableHeight)
m.shop.detailViewport.SetWidth(detailWidth)
m.shop.detailViewport.SetHeight(availableHeight)
}
return m
}
func (m Model) getShopMenuContent() string {
menuWidth, _ := m.calculateShopWidths()
selectedColor := m.theme.Highlight()
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
if c := m.Coffees[m.shop.selected].Color; c != "" {
selectedColor = lipgloss.Color(c)
}
// clampLines pads or truncates rendered text to exactly n lines so regions
// like the description never change the panel height.
func clampLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
var normal, highlighted lipgloss.Style
if m.size < large {
menuWidth = m.widthContent
normal = m.theme.TextBody().
Width(menuWidth - 1).Align(lipgloss.Center)
highlighted = m.theme.TextAccent().
Width(menuWidth - 1).Align(lipgloss.Center).
Background(selectedColor).Bold(true)
} else {
normal = m.theme.TextBody().
Width(menuWidth+2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth+2).Padding(0, 1).
Background(selectedColor).Bold(true)
for len(lines) < n {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
// 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, " ")
}
func (m Model) shopMenuContent(w int) string {
var b strings.Builder
for i, c := range m.Coffees {
if i == m.shop.selected {
b.WriteString(highlighted.Render(c.Name) + "\n")
b.WriteString(lipgloss.NewStyle().Width(w).
Background(lipgloss.Color(c.Color)).
Foreground(contrastColor(c.Color)).
Bold(true).
Render(" · " + c.Name))
} else {
b.WriteString(normal.Render(c.Name) + "\n")
dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·")
b.WriteString(lipgloss.NewStyle().Width(w).
Render(" " + dot + " " + pBody.Render(c.Name)))
}
if i < len(m.Coffees)-1 {
b.WriteString("\n")
}
}
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
return b.String()
}
func (m Model) getShopDetailContent() string {
if len(m.Coffees) == 0 {
return ""
}
func (m Model) shopDetailContent(w int) string {
coffee := m.Coffees[m.shop.selected]
_, detailWidth := m.calculateShopWidths()
if m.size >= large {
detailWidth -= 2
}
quantity := 0
if item, exists := m.Cart[coffee.ID]; exists {
quantity = item.Quantity
}
detail := lipgloss.JoinVertical(lipgloss.Left,
m.theme.TextAccent().Bold(true).Render(coffee.Name),
m.theme.TextBody().Render(
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
),
name := pWht.Bold(true).Render(coffee.Name)
sep := pDim.Render(" · ")
spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep +
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)
price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).
Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) +
pDim.Render(" usd")
// Stock is not modeled in the backend yet, so the indicator is static.
stock := lipgloss.NewStyle().Foreground(cGreen).Render("● in stock")
desc := clampLines(pBody.Width(w).Render(coffee.Description), shopDescLines)
// stepper: git-diff coloring — red minus, green plus
minus := lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("−")
plus := lipgloss.NewStyle().Foreground(cGreen).Bold(true).Render("+")
count := lipgloss.NewStyle().Foreground(cWhite).Bold(true).
Padding(0, 2).Render(fmt.Sprintf("%d", quantity))
stepper := pDim.Render("qty ") + minus + count + plus
return lipgloss.JoinVertical(lipgloss.Left,
name,
spec,
strength,
"",
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
price+" "+stock,
"",
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
desc,
"",
fmt.Sprintf("- %d +", quantity),
stepper,
)
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
}
func (m Model) ShopView() string {
if !m.shop.viewportsReady {
m = m.updateShopViewports()
}
if len(m.Coffees) == 0 {
return lipgloss.Place(m.widthContent, m.heightContainer,
lipgloss.Center, lipgloss.Center, "No products available.")
}
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
_, menuW, detailW, _ := m.shopLayout()
mc := m.shopMenuContent(menuW)
dc := m.shopDetailContent(detailW)
var body string
if m.size < large {
return lipgloss.JoinVertical(lipgloss.Top,
m.shop.menuViewport.View(),
m.shop.detailViewport.View(),
body = lipgloss.JoinVertical(lipgloss.Left,
panel(menuW, 0, mc),
panel(detailW, 0, dc),
)
} else {
bodyH := max(shopBodyInnerHeight, lipgloss.Height(mc), lipgloss.Height(dc))
body = lipgloss.JoinHorizontal(lipgloss.Top,
panel(menuW, bodyH, mc),
" ",
panel(detailW, bodyH, dc),
)
}
return lipgloss.JoinHorizontal(lipgloss.Top,
m.shop.menuViewport.View(),
" ",
m.shop.detailViewport.View(),
)
// Blank rows separate the body from the header and footer boxes.
return "\n" + body + "\n"
}
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
if len(m.Coffees) == 0 {
return m, nil
}
@@ -197,16 +207,7 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
}
// Pass messages to the detail viewport so pgup/pgdn scroll works
m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg)
cmds = append(cmds, cmd)
if m.shop.viewportsReady {
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
}
return m, tea.Batch(cmds...)
return m, nil
}
func (m Model) shopMoveSelected(previous bool) Model {
@@ -216,18 +217,8 @@ func (m Model) shopMoveSelected(previous bool) Model {
} else {
next++
}
if next < 0 {
next = 0
}
if next >= len(m.Coffees) {
next = len(m.Coffees) - 1
}
next = max(next, 0)
next = min(next, len(m.Coffees)-1)
m.shop.selected = next
if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
m.shop.detailViewport.GotoTop()
}
return m
}

View File

@@ -8,13 +8,13 @@ import (
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth)
var style lipgloss.Style
if selected {
style = base.BorderForeground(m.theme.Highlight())
style = base.BorderForeground(cWhite)
} else {
style = base.BorderForeground(m.theme.Border())
style = base.BorderForeground(cLine)
}
return style.Padding(paddingV, paddingH).Render(padded)
}

3
pkg/tui/theme/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// 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

@@ -16,8 +16,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick.seq == m.resizeSeq {
m = m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
case cartPage:
m = m.updateCartViewport()
case shippingPage:

View File

@@ -62,7 +62,8 @@ func (m Model) renderString() string {
if breadcrumbs != "" {
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
}
footerHeight := 1
footer := m.BuildFooter()
footerHeight := lipgloss.Height(footer)
marginTop := 1
marginBottom := 1
bufferSpace := 1
@@ -87,17 +88,15 @@ func (m Model) renderString() string {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
content = errorBanner + "\n" + m.buildPageContent()
} else if m.notice != nil {
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
noticeBanner := noticeStyle.Render(m.notice.message)
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
content = noticeBanner + "\n" + m.buildPageContent()
} else {
content = m.buildPageContent(availableContentHeight)
content = m.buildPageContent()
}
footer := m.BuildFooter()
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage || m.currentPage == accountPage
@@ -137,10 +136,10 @@ func (m Model) renderString() string {
)
}
func (m Model) buildPageContent(height int) string {
func (m Model) buildPageContent() string {
switch m.currentPage {
case accountPage:
return m.BuildAccountView(height)
return m.BuildAccountView()
case shippingPage:
return m.ShippingPageView()
case paymentPage:

3
pkg/utils/doc.go Normal file
View File

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

3
pkg/validate/doc.go Normal file
View File

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