Compare commits

..

2 Commits

49 changed files with 377 additions and 1046 deletions

View File

@@ -22,15 +22,6 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -euo pipefail
cd ~/terminalShop
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
git pull
docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

1
.gitignore vendored
View File

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

View File

@@ -71,4 +71,3 @@ 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

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

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

@@ -1,12 +1,10 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
@@ -28,8 +26,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(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
func ReconcileOrders() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute)
@@ -43,14 +41,9 @@ func ReconcileOrders(ctx context.Context) {
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{
Context: ctx,
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
},
}
@@ -96,8 +89,8 @@ func ReconcileOrders(ctx context.Context) {
// 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(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
func ReconcileStale3DSOrders(threshold time.Duration) {
db := database.GetDB()
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
@@ -116,26 +109,19 @@ func ReconcileStale3DSOrders(ctx context.Context, 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, piParams)
actual, err := paymentIntentGetFn(order.StripePaymentID, nil)
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 {
@@ -147,12 +133,10 @@ func ReconcileStale3DSOrders(ctx context.Context, 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,
@@ -161,7 +145,6 @@ func ReconcileStale3DSOrders(ctx context.Context, 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")
}
}
}
@@ -179,27 +162,24 @@ 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(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
func ReconcileExpiredCards() {
db := database.GetDB()
var cards []models.Card
if err := db.Where(
@@ -215,20 +195,16 @@ func ReconcileExpiredCards(ctx context.Context) {
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 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)
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)

View File

@@ -1,19 +1,16 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"terminalShop/api/middleware"
"github.com/stripe/stripe-go/v78"
"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
@@ -42,10 +39,7 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
}, nil
}
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
before := testutil.ToFloat64(flipped)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -63,9 +57,6 @@ 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
@@ -94,7 +85,7 @@ func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
@@ -133,10 +124,7 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -145,9 +133,6 @@ 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
@@ -176,10 +161,7 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
}, nil
}
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
before := testutil.ToFloat64(noop)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -188,9 +170,6 @@ 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:
@@ -217,7 +196,7 @@ func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -253,7 +232,7 @@ func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
@@ -296,10 +275,7 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -308,9 +284,6 @@ 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)
}
})
}
}
@@ -339,10 +312,7 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
}, nil
}
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
before := testutil.ToFloat64(unknown)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -351,9 +321,6 @@ 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
@@ -377,10 +344,7 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
return nil, fmt.Errorf("simulated stripe outage")
}
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
before := testutil.ToFloat64(stripeErr)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -389,9 +353,6 @@ 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
@@ -420,10 +381,7 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
before := testutil.ToFloat64(missing)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
if called {
t.Fatalf("stripe Get called for empty-PI row")
@@ -435,55 +393,6 @@ 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(ctx)
handlers.ReconcileOrders()
case <-ctx.Done():
return
}
@@ -122,18 +122,14 @@ func main() {
}()
go func() {
select {
case <-time.After(30 * time.Second):
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped(ctx)
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
@@ -149,7 +145,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(ctx)
handlers.ReconcileExpiredCards()
case <-ctx.Done():
return
}
@@ -163,7 +159,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
handlers.ReconcileStale3DSOrders(threshold)
case <-ctx.Done():
return
}
@@ -178,10 +174,10 @@ func main() {
log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}

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

@@ -70,14 +70,6 @@ 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",
@@ -181,19 +173,6 @@ 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)

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

1
go.mod
View File

@@ -81,7 +81,6 @@ 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

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

@@ -137,13 +137,11 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// 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.
// 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.
func loadMaxOrderCents() int {
const defaultCap = 50000
const defaultCap = 20000
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", "", 50000},
{"empty falls back to default", "", 20000},
{"explicit zero disables", "0", 0},
{"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},
{"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},
}
for _, tc := range cases {

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"
@@ -10,54 +8,36 @@ import (
"terminalShop/pkg/models"
)
// 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 {
// 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)
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
budget := m.heightContainer - headerHeight - footerHeight - 2
cards := budget / orderCardHeight
if cards < 1 {
cards = 1
}
return cards*orderCardHeight - 2
}
verticalMarginHeight := headerHeight + footerHeight
// 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
}
availableHeight := m.heightContainer - verticalMarginHeight
// 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
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailWidth := rightWidth
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
}
detailWidth = m.widthContent
// In stacked mode, reduce available height by the left panel
leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
}
if availableHeight < 3 {
availableHeight = 3
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.SetWidth(detailW)
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight)
}
@@ -66,7 +46,12 @@ func (m Model) updateAccountViewport() Model {
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
detailContentWidth := m.accountDetailWidth()
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return ""
@@ -83,6 +68,7 @@ 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 := ""
@@ -93,7 +79,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 +103,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
@@ -163,30 +143,23 @@ func (m Model) scrollToAccountDetailItem() Model {
return m
}
func (m Model) BuildAccountView() string {
_, menuW, _, _ := m.shopLayout()
func (m Model) BuildAccountView(availableHeight int) string {
leftWidth := 18
// 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
// Build the left panel (menu items)
leftPanel := ""
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Background(pillBg).Foreground(cWhite).Bold(true).
Render(" "+item))
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"
} else {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Render(" "+pBody.Render(item)))
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
leftContainer := m.theme.Base().
Width(leftWidth)
// Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent()
@@ -195,43 +168,25 @@ func (m Model) BuildAccountView() string {
// Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View()
// 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
// Responsive layout: side-by-side on large, stacked on small/medium
if m.size < large {
body = lipgloss.JoinVertical(
return lipgloss.JoinVertical(
lipgloss.Left,
panel(menuW, 0, menuContent),
detailRendered,
)
} else {
bodyH := m.accountBodyHeight()
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
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
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
detailRendered,
)
}
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)
@@ -253,7 +208,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 +335,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 := m.theme.TextAccent().Bold(true).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 := m.theme.TextAccent().Bold(true).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 := m.theme.TextAccent().Bold(true).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

@@ -6,26 +6,20 @@ 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 {
_, _, _, wideW := m.shopLayout()
keybindStyle := m.theme.TextDim().Bold(true)
descStyle := m.theme.TextDim()
item := func(k, v string) string {
return pBody.Render(k) + " " + pDim.Render(v)
}
var keys string
if m.size == small {
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(" · "))
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
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)
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)
}

View File

@@ -2,72 +2,57 @@ package tui
import (
"fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
// 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
}
rSum += r * item.Quantity
gSum += g * item.Quantity
bSum += b * item.Quantity
n += item.Quantity
}
if n == 0 {
return cWhite
}
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()
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(" ")
brand := pWht.Bold(true).Render("terminal")
mark := bold("t") + cursor
logo := bold("terminal")
menu := bold("m") + base(" ☰")
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1)
tab := func(key, label string, active bool) string {
tab := func(key, name string, active bool) string {
if active {
return pill.Render(label)
return accent(key + " " + name)
}
return pDim.Render(key+" ") + pBody.Render(label)
return accent(key) + base(" "+name)
}
shop := tab("s", "shop", m.currentPage == shopPage)
account := tab("a", "account", m.currentPage == accountPage)
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 ")
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
cartLabel := base(" cart")
if m.inCartFlow() {
cartLabel = pWht.Bold(true).Render("cart ")
cartLabel = accent(" cart")
}
cart := cartLabel +
lipgloss.NewStyle().Foreground(m.cartBlendColor()).Bold(true).
Render(fmt.Sprintf("$%d", m.CalculateSubtotal()/100)) +
pDim.Render(fmt.Sprintf(" · %d", m.CartItemCount()))
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
gap := wideW - lipgloss.Width(left) - lipgloss.Width(cart)
if gap < 1 {
gap = 1
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}
}
row := left + strings.Repeat(" ", gap) + cart
return panel(wideW, 0, row)
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,
)
}

View File

@@ -142,7 +142,10 @@ type Model struct {
}
type shopState struct {
selected int
selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
}
type splashState struct {
@@ -182,7 +185,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
@@ -238,6 +240,7 @@ 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"},
@@ -262,12 +265,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 +841,6 @@ func NewModel(username string) Model {
{
Name: "Espresso",
RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2,
BeanType: "Arabica",
Price: 350, // $3.50
@@ -847,7 +850,6 @@ func NewModel(username string) Model {
{
Name: "Latte",
RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12,
BeanType: "Arabica Blend",
Price: 500, // $5.00
@@ -857,7 +859,6 @@ func NewModel(username string) Model {
{
Name: "Cappuccino",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8,
BeanType: "Italian Blend",
Price: 450, // $4.50
@@ -867,7 +868,6 @@ func NewModel(username string) Model {
{
Name: "Americano",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 16,
BeanType: "Arabica",
Price: 400, // $4.00
@@ -877,7 +877,6 @@ func NewModel(username string) Model {
{
Name: "Mocha",
RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 16,
BeanType: "Colombian",
Price: 550, // $5.50
@@ -887,7 +886,6 @@ func NewModel(username string) Model {
{
Name: "Macchiato",
RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3,
BeanType: "Robusta Blend",
Price: 425, // $4.25
@@ -932,6 +930,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

@@ -1,70 +0,0 @@
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,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()
@@ -353,16 +355,11 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box)
}
// 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)
sshFocused := m.payment.cardCursor == len(m.SavedCards)
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox)
@@ -372,7 +369,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()
@@ -504,7 +501,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor--
}
case "down", "j":
if m.payment.cardCursor < len(m.SavedCards) {
if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++
}
@@ -514,8 +511,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected
return m.ReviewSwitch()
}
// Browser flow, the only reachable add-card path while the SSH
// inline form is dormant (see RenderCardList)
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
m.payment.view = 2
m.payment.collectURL = nil
m.payment.collectTimeOut = false
@@ -540,6 +546,16 @@ 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

@@ -1,91 +0,0 @@
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")
}
}

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

@@ -1,194 +0,0 @@
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,173 +4,163 @@ import (
"fmt"
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
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
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
}
if m.size < large {
// Stacked layout: both panels span the full content width.
return contentW, wideW, wideW, wideW
menuWidth = m.widthContent
}
menuW = 16
detailW = contentW - 25
return contentW, menuW, detailW, wideW
detailWidth := m.widthContent - menuWidth
if m.size < large {
detailWidth = m.widthContent
}
return menuWidth, detailWidth
}
// 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
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
}
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
}
// 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]
}
for len(lines) < n {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func (m Model) getShopMenuContent() string {
menuWidth, _ := m.calculateShopWidths()
// 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, " ")
}
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)
}
}
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)
}
func (m Model) shopMenuContent(w int) string {
var b strings.Builder
for i, c := range m.Coffees {
if i == m.shop.selected {
b.WriteString(lipgloss.NewStyle().Width(w).
Background(lipgloss.Color(c.Color)).
Foreground(contrastColor(c.Color)).
Bold(true).
Render(" · " + c.Name))
b.WriteString(highlighted.Render(c.Name) + "\n")
} else {
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")
b.WriteString(normal.Render(c.Name) + "\n")
}
}
return b.String()
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
}
func (m Model) shopDetailContent(w int) string {
func (m Model) getShopDetailContent() string {
if len(m.Coffees) == 0 {
return ""
}
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
}
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,
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),
),
"",
price+" "+stock,
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
"",
desc,
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
"",
stepper,
fmt.Sprintf("- %d +", quantity),
)
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.")
}
_, menuW, detailW, _ := m.shopLayout()
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
mc := m.shopMenuContent(menuW)
dc := m.shopDetailContent(detailW)
var body string
if m.size < large {
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.JoinVertical(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"
return lipgloss.JoinHorizontal(lipgloss.Top,
m.shop.menuViewport.View(),
" ",
m.shop.detailViewport.View(),
)
}
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
}
@@ -207,7 +197,16 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
}
return m, nil
// 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...)
}
func (m Model) shopMoveSelected(previous bool) Model {
@@ -217,8 +216,18 @@ 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
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.RoundedBorder()).Width(totalWidth)
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
var style lipgloss.Style
if selected {
style = base.BorderForeground(cWhite)
style = base.BorderForeground(m.theme.Highlight())
} else {
style = base.BorderForeground(cLine)
style = base.BorderForeground(m.theme.Border())
}
return style.Padding(paddingV, paddingH).Render(padded)
}

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

@@ -16,6 +16,8 @@ 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,8 +62,7 @@ func (m Model) renderString() string {
if breadcrumbs != "" {
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
}
footer := m.BuildFooter()
footerHeight := lipgloss.Height(footer)
footerHeight := 1
marginTop := 1
marginBottom := 1
bufferSpace := 1
@@ -88,15 +87,17 @@ func (m Model) renderString() string {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent()
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} 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()
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
} else {
content = m.buildPageContent()
content = m.buildPageContent(availableContentHeight)
}
footer := m.BuildFooter()
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage || m.currentPage == accountPage
@@ -136,10 +137,10 @@ func (m Model) renderString() string {
)
}
func (m Model) buildPageContent() string {
func (m Model) buildPageContent(height int) string {
switch m.currentPage {
case accountPage:
return m.BuildAccountView()
return m.BuildAccountView(height)
case shippingPage:
return m.ShippingPageView()
case paymentPage:

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