feat: add browser-based card collection via Stripe Checkout Session

adds a /pay/{token} redirect endpoint that generates a short URL
pointing to a stripe checkout sessions in setup mode. The TUI shows a QR
code and URL on the payment page so users can add a card via the browser
instead of entering raw card data over SSH.

the checkout.session.completed webhook saves the card to the database.
the TUI polls GET /api/v1/cards every second and auto-proceeds to
confirmation once the new card is detected.

also adds a dev.sh startup script that runs all four required processes.
(SSH server, API server, Stripe CLI listener, ngrok) in a single
terminal
This commit is contained in:
2026-04-08 01:03:43 +02:00
parent 6094e05b6f
commit 062242e346
24 changed files with 1460 additions and 655 deletions

92
TODO.md
View File

@@ -1,42 +1,80 @@
# TODO
## Done
- [x] Cart persistence across sessions — sync cart to server, fetch on startup
- [x] Cart persistence across sessions
- [x] Token refresh — auto-refresh JWT before expiry
- [x] Help screen — press ? to show keybindings
- [x] Order detail view — press Enter on an order in account view to see full details
- [x] Order detail view — press Enter on an order to see full details
- [x] FAQ page
- [x] About page
- [x] Splash screen — logo + auth + data preload on connect
- [x] Bulk data preload — /view/init endpoint fetches all user data at once
- [x] Stripe webhook — payment_intent.succeeded/failed sync order status
- [x] PCI-safe card tokenization — publishable key in SSH process, raw card data never reaches backend
- [x] Rate limiting — per-IP and per-user limits on sensitive endpoints
- [x] Audit logging — structured JSON logs for all financial events
- [x] Shop page — dual viewport (menu left, detail right), matches reference shop.go
- [x] Cart page — single viewport with proper scroll
- [x] Shipping page — single viewport
- [x] Payment page — single viewport
- [x] Confirm page — single viewport (uses DefaultKeyMap for pgup/pgdn scrolling)
---
## Up Next
- [x] FAQ page — replace hardcoded 2 Q&As with real content (embedded JSON, ~12 entries)
- [x] About page — replace placeholder with real content/credits
- [ ] Loading spinner — replace plain text with animated bubbles/spinner
- [ ] Address update — PUT /api/v1/addresses/:id endpoint
- [ ] Single order endpoint — GET /api/v1/orders/:id
- [ ] User profile editing — PUT /api/v1/profile, set name/email from account view
## Do First (meaningful gaps vs reference)
## Feature Gaps (vs Reference)
- [ ] Bulk data preload — single /view/init endpoint to fetch all user data at once
- [ ] Splash screen — logo + auth + data preload on connect
- [ ] Product variants — multiple variants per product (roast types) with separate prices
- [ ] Featured/originals product sections — categorize products in shop view
- [ ] Per-product theme colors — dynamic accent color based on selected product
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle region
- [ ] Stripe webhook — sync card updates from Stripe events
- [ ] Shippo webhook — order tracking status updates
- [ ] Email system — order confirmation, shipped notification emails
### Product variants
The reference supports multiple variants per product (roast type, grind, size) with
separate prices. We treat every product as single-variant. Without this the shop is
very limited once you have real products.
- [ ] Variant model + DB migration
- [ ] API: return variants in /products response
- [ ] TUI: show variant selector in shop detail panel
### User profile editing
Users currently cannot update their name or email from inside the app.
- [ ] PUT /api/v1/profile endpoint
- [ ] TUI: editable name/email fields in account view
### Missing API endpoints
- [ ] PUT /api/v1/addresses/:id — update a saved address
- [ ] GET /api/v1/orders/:id — single order (needed for better order detail view)
---
## Polish (noticeable but not blocking)
- [ ] Animated cursor — reference has a blinking block cursor on the splash/logo
(cursor.go, CursorTickMsg, 700ms interval). Small but gives the app personality.
- [ ] Themed UI — reference has a theme/ package; we use hardcoded hex colors everywhere.
Makes it hard to change the look later.
- [ ] Shipping timeframe — Shippo returns estimated delivery window; show it on confirm page
- [ ] Loading spinner — replace plain "loading..." text with animated spinner
- [ ] HTTPS/QR payment option — third card-entry method, generates a one-time URL + QR code
so users can enter card details in their browser instead of over SSH.
Reference: payment.go paymentHttpsView, PollPaymentInitMsg/StatusMsg/CompleteMsg.
- [ ] Per-field card validation — use huh .Validate() on each form field so errors
show inline as user tabs through, instead of a single error on submit.
---
## Testing
- [ ] API client tests — pkg/api/client.go has no tests
- [ ] Card handler tests — api/handlers/cards.go
- [ ] Order handler tests — api/handlers/orders.go
- [ ] Address handler tests — api/handlers/addresses.go
- [ ] TUI tests — model_test.go and menu_test.go are empty skeletons
- [ ] Shippo/Bring integration tests
- [ ] Address handler tests — api/handlers/addresses.go (partial)
- [ ] Webhook handler tests — api/handlers/webhook.go
- [ ] TUI payment form tests — validatePaymentForm() is pure and easy to unit test
## Post-Release
- [ ] Subscription model — database table, CRUD endpoints, scheduling
- [ ] Subscription creation flow — TUI: select variant → shipping → payment → confirm
- [ ] Subscription management — TUI: list, detail view, cancel with confirmation
---
## Post-Launch
- [ ] Shippo webhook — order tracking status updates pushed from Shippo
- [ ] Email system — order confirmation + shipped notification
- [ ] Subscription model — DB table, CRUD endpoints, TUI flow (select → shipping → payment → confirm)
- [ ] Subscription management — list, detail, cancel with confirmation in account view
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete in account view
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle
- [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts

View File

@@ -110,7 +110,7 @@ func TestGetAddressesIsolation(t *testing.T) {
if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
}
if resp.Data.Addresses[0].Name != "User Addr" {
if resp.Data.Addresses[0].Name != "User1 Addr" {
t.Errorf("expected 'User1 Addr', got %q", resp.Data.Addresses[0].Name)
}
}
@@ -159,43 +159,3 @@ func TestGetAddressesWithData(t *testing.T) {
}
}
func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB)
defer database.ResetForTesting()
db := database.GetDB()
user2 := models.User{
SSHKeyFingerprint: "SHA256:otheraddressuser",
SSHPublicKey: "ssh-ed25519 AAAA otheraddresskey",
}
db.Create(&models.Address{
UserID: user1.ID, Name: "User1 Addr", Street: "111 St",
City: "Portland", State: "OR", Zip: "97201", Country: "US",
})
db.Create(&models.Address{
UserID: user2.ID, Name: "User2 Addr", Street: "222 St",
City: "Seattle", State: "WA", Zip: "98101", Country: "US",
})
handler := NewAddressHandler("", "", "")
req := authRequest("GET", "/api/v1/addresses", nil, user1.ID)
w := httptest.NewRecorder()
handler.GetAddresses(w, req)
var resp struct {
Success bool `json:"success"`
Data struct {
Addresses []interface{} `json:"addresses"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 {
t.Fatalf("expected to get 1 address for user1, found: %d", len(resp.Data.Addresses))
}
if resp.Data.Addresses[0].Name != "User1 Addr" {
t.Errorf("expected 'User1 Addr', got %q", resp.Data.Addresses[0].Name)
}
}

View File

@@ -3,19 +3,18 @@ package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/customer"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
type CartHandler struct {
@@ -235,7 +234,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
if len(items) <= 0 {
if len(items) == 0 {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
return
}
@@ -270,26 +269,35 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
stripe.Key = h.stripeKey
if user.StripeCustomerID == "" {
params := &stripe.CustomerParams{
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
}
if user.Email != "" {
params.Email = stripe.String(user.Email)
}
if user.Name != "" {
params.Name = stripe.String(user.Name)
}
cust, err := customer.New(params)
if err != nil {
// Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
user.StripeCustomerID = cust.ID
db.Save(&user)
// All cards are now saved as pm_ PaymentMethods. This branch handles any
// legacy tok_ records that predate the server-side tokenization refactor.
paymentMethodID := card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
}); attachErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
db.Save(&card)
}
var subtotal int
@@ -312,36 +320,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
// If the saved card has a token (tok_), convert to a PaymentMethod and attach
paymentMethodID := card.StripePaymentID
if len(paymentMethodID) >= 4 && paymentMethodID[:3] == "tok" {
pm, err := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
_, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
})
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
db.Save(&card)
}
// Create the order record BEFORE charging so we always have something to
// reconcile against if the server crashes mid-flight.
order := models.Order{
// Create the order record first before charging the card so we always
// have a record we can reconcile against if anything goes wrong mid-flight.
UserID: user.ID,
CardID: card.ID,
StripePaymentID: "",
@@ -365,7 +346,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
// Charge via stripe, keyed to the order ID so retrieves never double-charge.
audit.OrderCreated(userID, order.ID, total)
// Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge.
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
@@ -376,7 +360,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
AllowRedirects: stripe.String("never"),
},
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", userID)),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name),
Address: &stripe.AddressParams{
@@ -396,16 +380,27 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
pi, err := paymentintent.New(piParams)
if err != nil {
// charge failed, mark the otder so it can be identified and or cleaned up
db.Model(&order).Update("status", models.OrderStatusFailed)
if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
utils.RespondError(w, http.StatusPaymentRequired, "CARD_ERROR", stripeErr.Msg, nil)
if stripeErr, ok := err.(*stripe.Error); ok {
switch stripeErr.Type {
case stripe.ErrorTypeCard:
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
case stripe.ErrorTypeInvalidRequest:
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
default:
audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
}
return
}
utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment failed", nil)
audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
return
}
// Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error {
order.StripePaymentID = pi.ID
order.Status = models.OrderStatusPaid
@@ -421,14 +416,21 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}).Error
})
if txErr != nil {
log.Printf("[CRITICAL] order %d charged (pi=%s) but failed to record: %v", order.ID, pi.ID, txErr)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order", nil)
// Card was charged but we could not persist the outcome. This is the
// most dangerous failure mode: the customer paid but has no order.
// The reconciliation job will catch this within 5 minutes.
// The audit log entry below must be treated as a critical alert.
audit.PaymentCritical(order.ID, pi.ID, txErr)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
return
}
audit.OrderPaid(userID, order.ID, total, pi.ID)
db.Preload("Items").First(&order, order.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order": order,
})
}

View File

@@ -2,28 +2,31 @@ package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
type OrderHandler struct {}
func NewOrderHandler() *OrderHandler {
return &OrderHandler{}
type OrderHandler struct {
stripeKey string
}
func NewOrderHandler(stripeSecretKey string) *OrderHandler {
return &OrderHandler{stripeKey: stripeSecretKey}
}
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
@@ -31,3 +34,62 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
"orders": orders,
})
}
// RefundOrder issues a full refund for an order via Stripe and marks it as
// refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var order models.Order
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
// Only paid or shipped orders can be refunded.
if order.Status != models.OrderStatusPaid && order.Status != models.OrderStatusShipped {
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
"only paid or shipped orders can be refunded", nil)
return
}
if order.StripePaymentID == "" {
utils.RespondError(w, http.StatusBadRequest, "MISSING_PAYMENT_ID",
"order has no stripe payment id — contact support", nil)
return
}
stripe.Key = h.stripeKey
rf, err := refund.New(&stripe.RefundParams{
PaymentIntent: stripe.String(order.StripePaymentID),
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", stripeErr.Msg, nil)
return
}
utils.RespondError(w, http.StatusInternalServerError, "REFUND_FAILED", "refund failed", nil)
return
}
if err := db.Model(&order).Update("status", models.OrderStatusRefunded).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "refund issued but failed to update order status", nil)
return
}
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"refund_id": rf.ID,
"status": "refunded",
})
}

230
api/handlers/webhook.go Normal file
View File

@@ -0,0 +1,230 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentmethod"
"github.com/stripe/stripe-go/v78/setupintent"
"github.com/stripe/stripe-go/v78/webhook"
"gorm.io/gorm"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct {
webhookSecret string
stripeKey string
}
// NewWebhookHandler creates a new webhook handler.
func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler {
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey}
}
// HandleStripe validates the Stripe signature and routes the event.
// This endpoint is intentionally unauthenticated — authentication is
// provided by the webhook signature (HMAC-SHA256).
func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Read the entire body up front; webhook.ConstructEvent requires it.
const maxBodyBytes = 65536
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "READ_ERROR", "could not read request body", nil)
return
}
// Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" {
// No secret configured — log a loud warning but still process in dev.
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEvent(body, sig, h.webhookSecret); err != nil {
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
return
}
}
var event stripe.Event
if err := json.Unmarshal(body, &event); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil)
return
}
switch event.Type {
case "checkout.session.completed":
h.handleCheckoutSessionCompleted(event)
// payment_intent.succeeded fires when a charge completes asynchronously
// (e.g. 3DS, bank redirects). For confirm=true intents this is normally
// already handled by ConvertCart, but the webhook is the authoritative source.
case "payment_intent.succeeded":
h.handlePaymentIntentSucceeded(event)
// payment_intent.payment_failed fires when a payment fails after initial
// confirmation (e.g. bank decline on a deferred 3DS flow).
case "payment_intent.payment_failed":
h.handlePaymentIntentFailed(event)
// Sync local card state when Stripe's side changes.
case "payment_method.attached",
"payment_method.detached",
"payment_method.updated":
// Handled by the reconcile job and direct API calls; log only.
log.Printf("[webhook] %s received for payment method", event.Type)
}
// Always return 200 so Stripe doesn't retry.
w.WriteHeader(http.StatusOK)
}
func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
return
}
orderIDStr, ok := pi.Metadata["order_id"]
if !ok {
// PaymentIntent not tied to a local order — nothing to do.
return
}
db := database.GetDB()
var order models.Order
if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil {
log.Printf("[webhook] order %s not found for pi %s", orderIDStr, pi.ID)
return
}
if order.Status == models.OrderStatusPaid {
// Already recorded — idempotent.
return
}
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID,
}).Error; err != nil {
return err
}
// Clear the user's cart if it hasn't been cleared yet.
var cart models.Cart
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
return nil // Cart already gone.
}
tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error
})
if txErr != nil {
log.Printf("[webhook] failed to record paid status for order %s (pi=%s): %v", orderIDStr, pi.ID, txErr)
return
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
log.Printf("[webhook] order %s marked paid via payment_intent.succeeded (pi=%s)", orderIDStr, pi.ID)
}
func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
return
}
orderIDStr, ok := pi.Metadata["order_id"]
if !ok {
return
}
db := database.GetDB()
db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed)
errMsg := "unknown"
if pi.LastPaymentError != nil {
errMsg = pi.LastPaymentError.Msg
}
log.Printf("[webhook] payment failed for order %s (pi=%s): %s", orderIDStr, pi.ID, errMsg)
}
func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
log.Printf("[webhook] failed to unmarshal checkout session: %v", err)
return
}
if sess.Mode != stripe.CheckoutSessionModeSetup {
return
}
if sess.SetupIntent == nil || sess.Customer == nil {
log.Printf("[webhook] checkout session missing setup_intent or customer")
return
}
stripe.Key = h.stripeKey
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != nil {
log.Printf("[webhook] failed to get setup intent %s: %v", sess.SetupIntent.ID, err)
return
}
if si.PaymentMethod == nil {
log.Printf("[webhook] setup intent %s has no payment method", si.ID)
return
}
pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil)
if err != nil {
log.Printf("[webhook] failed to get payment method %s: %v", si.PaymentMethod.ID, err)
return
}
db := database.GetDB()
var user models.User
if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil {
log.Printf("[webhook] user not found for customer %s: %v", sess.Customer.ID, err)
return
}
// Idempotency: skip if this payment method is already saved.
var existing models.Card
if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil {
return
}
card := models.Card{
UserID: user.ID,
StripePaymentID: pm.ID,
Last4: pm.Card.Last4,
Brand: string(pm.Card.Brand),
ExpMonth: int(pm.Card.ExpMonth),
ExpYear: int(pm.Card.ExpYear),
}
if err := db.Create(&card).Error; err != nil {
log.Printf("[webhook] failed to save card for user %d: %v", user.ID, err)
return
}
audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4)
log.Printf("[webhook] card saved via checkout.session.completed for user %d", user.ID)
}

View File

@@ -0,0 +1,38 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/httprate"
"terminalShop/pkg/utils"
)
// RateLimitByIP returns a per-IP rate limiter for public endpoints.
func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Handler {
return httprate.Limit(
limit, window,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}),
)
}
// RateLimitByUser returns a per-authenticated-user rate limiter for protected endpoints.
func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Handler {
return httprate.Limit(
limit, window,
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
userID := UserIDFromContext(r.Context())
if userID == 0 {
// Fall back to IP if no user context (shouldn't happen on protected routes).
return httprate.KeyByIP(r)
}
return http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), nil
}),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}),
)
}

19
dev.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Load .env to get the Stripe key
set -a; source .env; set +a
# Kill all background jobs on exit
trap 'kill $(jobs -p) 2>/dev/null' EXIT
echo "Starting API server..."
(cd api && go run main.go) &
echo "Starting Stripe webhook listener..."
stripe listen --api-key "$STRIPE_SECRET_KEY" --forward-to localhost:8080/api/v1/webhooks/stripe &
echo "Starting ngrok..."
ngrok http 8080 --log stdout &
echo ""
echo "Starting SSH server (Ctrl+C to stop everything)..."
go run main.go

11
go.mod
View File

@@ -41,9 +41,16 @@ require (
github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c // indirect
github.com/go-chi/httprate v0.15.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -56,7 +63,11 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/stripe/stripe-go/v78 v78.12.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
rsc.io/qr v0.2.0 // indirect
)

23
go.sum
View File

@@ -65,20 +65,34 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -102,6 +116,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -109,11 +124,15 @@ github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0
github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -131,7 +150,11 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -29,6 +29,7 @@ const (
var (
apiURL string
authFingerprintKey string
stripePublicKey string
)
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -42,7 +43,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
}
// Create TUI model with user context (no registration screen needed!)
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
@@ -56,6 +57,7 @@ func main() {
// Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey
stripePublicKey = cfg.StripePublicKey
log.Printf("TUI will connect to API at: %s", apiURL)
// Create SSH server

View File

@@ -526,12 +526,18 @@ func (c *Client) GetCards() ([]models.Card, error) {
return cardsResp.Data.Cards, nil
}
// SaveCard creates a saved card from a Stripe token.
func (c *Client) SaveCard(token string) (*models.Card, error) {
// SaveCardRequest holds the raw card fields sent to the backend for
// server-side tokenization. The Stripe secret key never leaves the server.
type SaveCardRequest struct {
Token string `json:"token"`
}
// SaveCard sends raw card fields to the backend, which tokenizes them
// server-side and returns the saved card metadata.
func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
body := map[string]string{"token": token}
jsonData, err := json.Marshal(body)
jsonData, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
@@ -563,6 +569,42 @@ func (c *Client) SaveCard(token string) (*models.Card, error) {
return &cardResp.Data.Card, nil
}
type CollectCardResponse struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) CollectCard() (string, error) {
url := fmt.Sprintf("%s/api/v1/cards/collect", c.BaseURL)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err)
}
defer resp.Body.Close()
var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
return "", fmt.Errorf("failed to devode collect response: %w", err)
}
if !collectResp.Success {
if collectResp.Error != nil {
return "", fmt.Errorf("%s: %s", collectResp.Error.Code, collectResp.Error.Message)
}
return "", fmt.Errorf("failed to collect card")
}
return collectResp.Data.URL, nil
}
// DeleteCard removes a saved card by ID.
func (c *Client) DeleteCard(id uint) error {
url := fmt.Sprintf("%s/api/v1/cards/%d", c.BaseURL, id)

124
pkg/audit/audit.go Normal file
View File

@@ -0,0 +1,124 @@
// Package audit writes structured JSON logs for financial events.
// Every card addition, order creation, payment, and refund is recorded here.
// Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.).
package audit
import (
"encoding/json"
"log"
"time"
)
type eventType string
const (
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderFailed eventType = "order_failed"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
)
type event struct {
Time time.Time `json:"time"`
Event eventType `json:"event"`
UserID uint `json:"user_id,omitempty"`
OrderID uint `json:"order_id,omitempty"`
CardID uint `json:"card_id,omitempty"`
Amount int `json:"amount_cents,omitempty"`
StripeID string `json:"stripe_id,omitempty"`
CardLast4 string `json:"card_last4,omitempty"`
CardBrand string `json:"card_brand,omitempty"`
Error string `json:"error,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
func emit(e event) {
e.Time = time.Now().UTC()
b, err := json.Marshal(e)
if err != nil {
log.Printf("[audit] failed to marshal event: %v", err)
return
}
log.Printf("[AUDIT] %s", b)
}
// CardAdded records that a user saved a new payment method.
func CardAdded(userID, cardID uint, brand, last4 string) {
emit(event{
Event: eventCardAdded,
UserID: userID,
CardID: cardID,
CardBrand: brand,
CardLast4: last4,
})
}
// CardDeleted records that a user removed a saved payment method.
func CardDeleted(userID, cardID uint) {
emit(event{
Event: eventCardDeleted,
UserID: userID,
CardID: cardID,
})
}
// OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) {
emit(event{
Event: eventOrderCreated,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
})
}
// OrderPaid records a successful charge.
func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) {
emit(event{
Event: eventOrderPaid,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
StripeID: stripePaymentIntentID,
})
}
// OrderFailed records a failed charge attempt.
func OrderFailed(userID, orderID uint, amountCents int, reason string) {
emit(event{
Event: eventOrderFailed,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
Error: reason,
})
}
// OrderRefunded records a refund.
func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) {
emit(event{
Event: eventOrderRefunded,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
StripeID: stripeRefundID,
})
}
// PaymentCritical records the dangerous state where Stripe charged the card
// but the local database transaction failed. This MUST be investigated and
// manually reconciled. Emit a loud log line that monitoring can alert on.
func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
emit(event{
Event: eventPaymentCritical,
OrderID: orderID,
StripeID: stripePaymentIntentID,
Error: err.Error(),
})
// Second line so it's impossible to miss in plain-text log tails.
log.Printf("[AUDIT][CRITICAL] order %d was charged (pi=%s) but DB commit failed: %v — REQUIRES MANUAL RECONCILIATION",
orderID, stripePaymentIntentID, err)
}

View File

@@ -3,8 +3,11 @@ package database
import (
"fmt"
"log"
"strings"
"sync"
"time"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -15,23 +18,42 @@ var (
once sync.Once
)
// Connect initializes the database connection (singleton pattern)
func Connect(dbPath string) (*gorm.DB, error) {
// Connect initializes the database connection (singleton pattern).
// If dsn starts with postgres:// or postgresql://, connects to PostgreSQL.
// Otherwise falls back to SQLite (development only).
func Connect(dsn string) (*gorm.DB, error) {
var err error
once.Do(func() {
// Configure GORM logger for development
config := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: logger.Default.LogMode(logger.Warn),
}
// Connect to SQLite database
db, err = gorm.Open(sqlite.Open(dbPath), config)
isPostgres := strings.HasPrefix(dsn, "postgres://") ||
strings.HasPrefix(dsn, "postgresql://")
if isPostgres {
db, err = gorm.Open(postgres.Open(dsn), config)
} else {
db, err = gorm.Open(sqlite.Open(dsn), config)
}
if err != nil {
err = fmt.Errorf("failed to connect to database: %w", err)
return
}
// Configure connection pool for PostgreSQL.
if isPostgres {
sqlDB, poolErr := db.DB()
if poolErr != nil {
err = fmt.Errorf("failed to get sql.DB: %w", poolErr)
return
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
}
log.Println("Database connection established")
})

View File

@@ -3,11 +3,31 @@ package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildCartView() string {
func (m Model) updateCartViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.cartVPReady {
m.cartVP = viewport.New(m.widthContent, availH)
m.cartVP.KeyMap = viewport.KeyMap{}
m.cartVPReady = true
} else {
m.cartVP.Width = m.widthContent
m.cartVP.Height = availH
}
return m
}
func (m Model) generateCartContent() string {
if len(m.Cart) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
@@ -18,10 +38,9 @@ func (m Model) BuildCartView() string {
}
cartItems := ""
boxWidth := m.widthContent - 4 // Leave some margin within the container
boxWidth := m.widthContent - 4
itemSlice := m.GetCartItemsSlice()
// Adjust padding based on container height
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
@@ -30,7 +49,6 @@ func (m Model) BuildCartView() string {
for idx, item := range itemSlice {
itemTotal := item.Quantity * item.Coffee.Price
// Left side: name and individual price
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
@@ -42,7 +60,6 @@ func (m Model) BuildCartView() string {
infoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
// Right side: quantity controls and total price
quantityStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
@@ -50,7 +67,6 @@ func (m Model) BuildCartView() string {
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
// Build content with proper spacing
nameText := nameStyle.Render(item.Coffee.Name)
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100))
infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s",
@@ -60,11 +76,10 @@ func (m Model) BuildCartView() string {
quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity))
totalPriceText := totalPriceStyle.Render(fmt.Sprintf("$%.2f", float64(itemTotal)/100))
// Calculate spacing
nameAndPriceWidth := lipgloss.Width(nameText) + lipgloss.Width(individualPriceText)
quantityWidth := lipgloss.Width(quantityText)
totalPriceWidth := lipgloss.Width(totalPriceText)
spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8 // 8 for padding and gaps
spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8
if spacing < 1 {
spacing = 1
@@ -78,7 +93,6 @@ func (m Model) BuildCartView() string {
boxContent := contentLine1 + "\n" + contentLine2
// Create box for this item - highlight if it's the selected item
var itemBox lipgloss.Style
if idx == m.CartCursor {
itemBox = lipgloss.NewStyle().
@@ -97,22 +111,43 @@ func (m Model) BuildCartView() string {
Foreground(lipgloss.Color("#FFFFFF"))
}
// Center the box within the container
centered := lipgloss.NewStyle().
Width(m.widthContent).
Align(lipgloss.Center)
cartItems += centered.Render(itemBox.Render(boxContent))
// Add spacing between items
if idx < len(itemSlice)-1 {
cartItems += "\n"
}
}
return cartItems
}
func (m Model) CartView() string {
if !m.cartVPReady {
m = m.updateCartViewport()
}
content := m.generateCartContent()
m.cartVP.SetContent(content)
if len(m.Cart) > 0 {
itemHeight := 5
targetY := m.CartCursor * itemHeight
if targetY < m.cartVP.YOffset {
m.cartVP.SetYOffset(targetY)
}
if targetY+itemHeight > m.cartVP.YOffset+m.cartVP.Height {
m.cartVP.SetYOffset(targetY - m.cartVP.Height + itemHeight)
}
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.cartVP.View()),
lipgloss.Center, lipgloss.Center,
m.cartVP.View(),
)
}
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
@@ -122,32 +157,11 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
case "up", "k":
if m.CartCursor > 0 {
m.CartCursor--
itemHeight := 5
targetLine := m.CartCursor * itemHeight
if targetLine < m.ScrollOffset {
m.ScrollOffset = targetLine
}
}
case "down", "j":
if m.CartCursor < len(m.Cart)-1 {
m.CartCursor++
itemHeight := 5
targetLineEnd := (m.CartCursor * itemHeight) + itemHeight
viewportHeight := m.heightContainer - 9
if viewportHeight < 6 {
viewportHeight = 6
}
if targetLineEnd > m.ScrollOffset+viewportHeight {
m.ScrollOffset = targetLineEnd - viewportHeight
}
}
case "pgup", "ctrl+u":
m.ScrollOffset -= 3
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
}
case "pgdown", "ctrl+d":
m.ScrollOffset += 3
case "+", "=":
cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) {
@@ -174,6 +188,7 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
case "p", "enter":
if len(m.Cart) > 0 {
m = m.SwitchPage(shippingPage)
m = m.updateShippingViewport()
return m, m.fetchAddressesCmd()
}
}

View File

@@ -5,8 +5,9 @@ import (
"terminalShop/pkg/models"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// RenderConfirmation renders the order confirmation view
@@ -73,14 +74,51 @@ func (m Model) RenderConfirmation() string {
)
}
func (m Model) updateConfirmViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.confirmVPReady {
m.confirmVP = viewport.New(m.widthContent, availH)
m.confirmVP.KeyMap = viewport.DefaultKeyMap()
m.confirmVPReady = true
} else {
m.confirmVP.Width = m.widthContent
m.confirmVP.Height = availH
}
return m
}
func (m Model) ConfirmView() string {
if !m.confirmVPReady {
m = m.updateConfirmViewport()
}
m.confirmVP.SetContent(m.RenderConfirmation())
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.confirmVP.View()),
lipgloss.Center, lipgloss.Center,
m.confirmVP.View(),
)
}
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
if m.confirmVPReady {
m.confirmVP, cmd = m.confirmVP.Update(msg)
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
return m, cmd
}
if keyMsg.String() != "esc" && keyMsg.String() != "s" {
return m, nil
return m, cmd
}
m = m.SwitchPage(shopPage)

View File

@@ -1,96 +1,20 @@
package tui
import (
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"testing"
gossh "golang.org/x/crypto/ssh"
)
func TestModel_BuildMenuView(t *testing.T) {
type fields struct {
User *models.User
IsNewUser bool
SSHPublicKey gossh.PublicKey
AccessToken string
UsernameInput string
Username string
Coffees []models.Coffee
Cursor int
Cart map[uint]*models.CartItem
CartCursor int
AccountCursor int
ScrollOffset int
currentPage page
viewportWidth int
viewportHeight int
widthContainer int
heightContainer int
widthContent int
size termSize
resizeSeq int
pendingWidth int
pendingHeight int
Loading bool
ErrorMsg string
APIClient *api.Client
ShippingForm *ShippingFormState
PaymentForm *PaymentFormState
ShippingInfo *models.Address
SavedAddresses []models.Address
ShippingView int
AddressCursor int
StripeKey string
Orders []models.Order
OrdersLoaded bool
}
tests := []struct {
name string
fields fields
m Model
want string
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := Model{
User: tt.fields.User,
IsNewUser: tt.fields.IsNewUser,
SSHPublicKey: tt.fields.SSHPublicKey,
AccessToken: tt.fields.AccessToken,
UsernameInput: tt.fields.UsernameInput,
Username: tt.fields.Username,
Coffees: tt.fields.Coffees,
Cursor: tt.fields.Cursor,
Cart: tt.fields.Cart,
CartCursor: tt.fields.CartCursor,
AccountCursor: tt.fields.AccountCursor,
ScrollOffset: tt.fields.ScrollOffset,
currentPage: tt.fields.currentPage,
viewportWidth: tt.fields.viewportWidth,
viewportHeight: tt.fields.viewportHeight,
widthContainer: tt.fields.widthContainer,
heightContainer: tt.fields.heightContainer,
widthContent: tt.fields.widthContent,
size: tt.fields.size,
resizeSeq: tt.fields.resizeSeq,
pendingWidth: tt.fields.pendingWidth,
pendingHeight: tt.fields.pendingHeight,
Loading: tt.fields.Loading,
ErrorMsg: tt.fields.ErrorMsg,
APIClient: tt.fields.APIClient,
ShippingForm: tt.fields.ShippingForm,
PaymentForm: tt.fields.PaymentForm,
ShippingInfo: tt.fields.ShippingInfo,
SavedAddresses: tt.fields.SavedAddresses,
ShippingView: tt.fields.ShippingView,
AddressCursor: tt.fields.AddressCursor,
StripeKey: tt.fields.StripeKey,
Orders: tt.fields.Orders,
OrdersLoaded: tt.fields.OrdersLoaded,
}
if got := m.BuildMenuView(); got != tt.want {
if got := tt.m.BuildMenuView(); got != tt.want {
t.Errorf("Model.BuildMenuView() = %v, want %v", got, tt.want)
}
})

View File

@@ -1,16 +1,16 @@
package tui
import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/stripe/stripe-go/v78"
stripetoken "github.com/stripe/stripe-go/v78/token"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
@@ -56,12 +56,21 @@ type Model struct {
SSHPublicKeyStr string
AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// Shop state
Username string
Coffees []models.Coffee
Cursor int
Cart map[uint]*models.CartItem // maps CoffeeID to cart item
shop shopState
cartVP viewport.Model
cartVPReady bool
shippingVP viewport.Model
shippingVPReady bool
paymentVP viewport.Model
paymentVPReady bool
confirmVP viewport.Model
confirmVPReady bool
Cart map[uint]*models.CartItem
CartCursor int // cursor position in cart view
AccountCursor int
currentPage page // currently active screen
@@ -99,11 +108,12 @@ type Model struct {
SavedAddresses []models.Address // Saved addresses from database
ShippingView int
AddressCursor int
StripeKey string // Stripe publishable key for client-side tokenization
SavedCards []models.Card // Saved cards from database
SelectedCard *models.Card // Card selected from saved list (nil = new card)
CardCursor int // cursor position in the card list
PaymentView int // 0=card list, 1=new card form
PaymentView int // 0=card list, 1=new card form, 2 = browser payment
CollectURL *string // URL for browser-based card entry
CollectCardCount int // card count when browser polling started
// Order history
Orders []models.Order
@@ -122,6 +132,20 @@ type Model struct {
}
type shopState struct {
selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady bool
}
var modifiedKeyMap = viewport.KeyMap{
PageDown: key.NewBinding(key.WithKeys("pgdown")),
PageUp: key.NewBinding(key.WithKeys("pgup")),
HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u")),
HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d")),
}
// SwitchPage changed the active page and marks that a switch happened
func (m Model) SwitchPage(p page) Model {
m.currentPage = p
@@ -238,15 +262,6 @@ func (m Model) resetPageState() Model {
return m
}
type StripeTokenMsg struct {
TokenID string
Last4 string
}
type StripeTokenErrMsg struct {
Err error
}
type CheckoutResultMsg struct {
OrderID uint
Total int
@@ -458,62 +473,6 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
}
}
func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
// Use the Stripe secret key for server-side tokenization.
// This is a terminal app running over SSH — there is no browser,
// so the publishable key / Stripe.js approach does not work.
// The secret key is allowed to create tokens with raw card data.
secretKey := os.Getenv("STRIPE_SECRET_KEY")
if secretKey == "" {
// Fall back to the publishable key (will fail on most accounts)
secretKey = m.StripeKey
}
data := fmt.Sprintf(
"card[number]=%s&card[exp_month]=%s&card[exp_year]=%s&card[cvc]=%s",
card.CardNumber, card.ExpiryMonth, card.ExpiryYear, card.CVC,
)
req, err := http.NewRequest("POST", "https://api.stripe.com/v1/tokens", strings.NewReader(data))
if err != nil {
return StripeTokenErrMsg{Err: err}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+secretKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return StripeTokenErrMsg{Err: err}
}
defer resp.Body.Close()
var result struct {
ID string `json:"id"`
Card struct {
Last4 string `json:"last4"`
Brand string `json:"brand"`
} `json:"card"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return StripeTokenErrMsg{Err: err}
}
if result.Error != nil {
return StripeTokenErrMsg{Err: fmt.Errorf("%s", result.Error.Message)}
}
return StripeTokenMsg{
TokenID: result.ID,
Last4: result.Card.Last4,
}
}
}
func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd {
if m.APIClient == nil || m.User == nil {
return nil
@@ -532,24 +491,34 @@ func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd {
}
}
// saveCardAndConvert saves the tokenized card, sets address and card on the
// server cart, then converts the cart to an order
func (m Model) saveCardAndConvert(tok StripeTokenMsg) tea.Cmd {
// saveCardAndConvert tokenizes the card locally using the Stripe publishable
// key (raw card data never reaches the backend), saves the token, then converts the cart
func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
}
// 1. Save the card
card, err := m.APIClient.SaveCard(tok.TokenID)
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
Name: stripe.String(form.CardName),
Number: stripe.String(form.CardNumber),
ExpMonth: stripe.String(form.ExpiryMonth),
ExpYear: stripe.String(form.ExpiryYear),
CVC: stripe.String(form.CVC),
AddressZip: stripe.String(form.BillingZip),
},
})
if err != nil {
// Card may already exist — try fetching existing cards
cards, listErr := m.APIClient.GetCards()
if listErr != nil || len(cards) == 0 {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
if stripeErr, ok := err.(*stripe.Error); ok {
return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
// Use the most recent card
card = &cards[0]
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
// 2. Set the shipping address on the cart
@@ -702,7 +671,6 @@ func NewModel(username string) Model {
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
},
},
Cursor: 0,
Cart: make(map[uint]*models.CartItem),
CartCursor: 0,
AccountCursor: 0,
@@ -710,7 +678,6 @@ func NewModel(username string) Model {
OrderViewState: 0,
Loading: true,
APIClient: api.NewClient("http://localhost:8080", ""),
StripeKey: os.Getenv("STRIPE_PUBLIC_KEY"),
FAQs: LoadFaqs(),
}
m.updateLayout(120, 30)
@@ -718,11 +685,12 @@ func NewModel(username string) Model {
}
// NewModelWithAuth creates a new model with user authentication context
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := NewModel("")
m.Fingerprint = fingerprint
m.SSHPublicKeyStr = pubKeyStr
m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage
if apiURL != "" {
m.APIClient = api.NewClient(apiURL, "")
@@ -731,3 +699,36 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
}
return m
}
func newViewport(w, h int) viewport.Model {
return viewport.New(w, h)
}
func (m Model) collectCardCmd() tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
}
url, err := m.APIClient.CollectCard()
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to generate payment link: %w", err)}
}
return PollPaymentInitMsg{URL: url}
}
}
func (m Model) pollCardsCmd(cardCount int) tea.Cmd {
return tea.Tick(time.Second, func(_ time.Time) tea.Msg {
if m.APIClient == nil {
return PollPaymentStatusMsg{CardCount: cardCount}
}
cards, err := m.APIClient.GetCards()
if err != nil {
return PollPaymentStatusMsg{CardCount: cardCount}
}
if len(cards) > cardCount {
return PollPaymentCompleteMsg{Cards: cards}
}
return PollPaymentStatusMsg{CardCount: cardCount}
})
}

View File

@@ -1,100 +1,30 @@
package tui
import (
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"testing"
gossh "golang.org/x/crypto/ssh"
)
func TestModel_updateLayout(t *testing.T) {
type fields struct {
User *models.User
IsNewUser bool
SSHPublicKey gossh.PublicKey
AccessToken string
UsernameInput string
Username string
Coffees []models.Coffee
Cursor int
Cart map[uint]*models.CartItem
CartCursor int
AccountCursor int
ScrollOffset int
currentPage page
viewportWidth int
viewportHeight int
widthContainer int
heightContainer int
widthContent int
size termSize
resizeSeq int
pendingWidth int
pendingHeight int
Loading bool
ErrorMsg string
APIClient *api.Client
ShippingForm *ShippingFormState
PaymentForm *PaymentFormState
ShippingInfo *models.Address
SavedAddresses []models.Address
ShippingView int
AddressCursor int
StripeKey string
Orders []models.Order
OrdersLoaded bool
}
type args struct {
width int
height int
}
tests := []struct {
name string
fields fields
args args
width int
height int
}{
// TODO: Add test cases.
{"large terminal", 120, 40},
{"medium terminal", 70, 25},
{"small terminal", 45, 20},
{"undersized", 15, 8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Model{
User: tt.fields.User,
IsNewUser: tt.fields.IsNewUser,
SSHPublicKey: tt.fields.SSHPublicKey,
AccessToken: tt.fields.AccessToken,
UsernameInput: tt.fields.UsernameInput,
Username: tt.fields.Username,
Coffees: tt.fields.Coffees,
Cursor: tt.fields.Cursor,
Cart: tt.fields.Cart,
CartCursor: tt.fields.CartCursor,
AccountCursor: tt.fields.AccountCursor,
ScrollOffset: tt.fields.ScrollOffset,
currentPage: tt.fields.currentPage,
viewportWidth: tt.fields.viewportWidth,
viewportHeight: tt.fields.viewportHeight,
widthContainer: tt.fields.widthContainer,
heightContainer: tt.fields.heightContainer,
widthContent: tt.fields.widthContent,
size: tt.fields.size,
resizeSeq: tt.fields.resizeSeq,
pendingWidth: tt.fields.pendingWidth,
pendingHeight: tt.fields.pendingHeight,
Loading: tt.fields.Loading,
ErrorMsg: tt.fields.ErrorMsg,
APIClient: tt.fields.APIClient,
ShippingForm: tt.fields.ShippingForm,
PaymentForm: tt.fields.PaymentForm,
ShippingInfo: tt.fields.ShippingInfo,
SavedAddresses: tt.fields.SavedAddresses,
ShippingView: tt.fields.ShippingView,
AddressCursor: tt.fields.AddressCursor,
StripeKey: tt.fields.StripeKey,
Orders: tt.fields.Orders,
OrdersLoaded: tt.fields.OrdersLoaded,
m := NewModel("")
m.updateLayout(tt.width, tt.height)
if m.viewportWidth != tt.width {
t.Errorf("viewportWidth = %d, want %d", m.viewportWidth, tt.width)
}
if m.viewportHeight != tt.height {
t.Errorf("viewportHeight = %d, want %d", m.viewportHeight, tt.height)
}
m.updateLayout(tt.args.width, tt.args.height)
})
}
}

View File

@@ -4,11 +4,13 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"terminalShop/pkg/validate"
)
@@ -42,6 +44,23 @@ type PaymentFormErrorMsg struct {
Message string
}
// PollPaymentInitMsg is sent when the collect endpoint returns a URL
type PollPaymentInitMsg struct {
URL string
}
// PollPaymentStatusMsg is sent each polling tick while waiting for the browser
// card entry to complete.
type PollPaymentStatusMsg struct {
CardCount int
}
// PollPaymentCompleteMsg is sent when the card count increases, meaning the
// webhook saved the new card
type PollPaymentCompleteMsg struct {
Cards []models.Card
}
// buildPaymentForm creates the huh form bound to state's fields.
// No field-level validators so Tab/Enter move freely.
func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
@@ -244,6 +263,72 @@ func (m Model) RenderPaymentForm(state *PaymentFormState) string {
)
}
func (m Model) updatePaymentViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.paymentVPReady {
m.paymentVP = viewport.New(m.widthContent, availH)
m.paymentVP.KeyMap = viewport.KeyMap{}
m.paymentVPReady = true
} else {
m.paymentVP.Width = m.widthContent
m.paymentVP.Height = availH
}
return m
}
func (m Model) PaymentPageView() string {
if !m.paymentVPReady {
m = m.updatePaymentViewport()
}
var content string
if m.CheckingOut {
content = " submitting order..."
} else if m.PaymentView == 2 {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place(
m.widthContainer,
m.paymentVP.Height,
lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(),
)
} else if m.PaymentView == 0 && m.PaymentForm == nil {
content = m.RenderCardList()
m.paymentVP.SetContent(content)
itemHeight := 3
targetY := m.CardCursor * itemHeight
if targetY < m.paymentVP.YOffset {
m.paymentVP.SetYOffset(targetY)
}
if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height {
m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1)
}
if m.CardCursor == len(m.SavedCards) {
m.paymentVP.GotoBottom()
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.paymentVP.View()),
lipgloss.Center, lipgloss.Center,
m.paymentVP.View(),
)
} else if m.PaymentForm != nil {
content = m.RenderPaymentForm(m.PaymentForm)
}
m.paymentVP.SetContent(content)
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.paymentVP.View()),
lipgloss.Center, lipgloss.Center,
m.paymentVP.View(),
)
}
func (m Model) RenderCardList() string {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
@@ -270,14 +355,21 @@ func (m Model) RenderCardList() string {
lines = append(lines, "")
}
addCursor := " "
addStyle := inactiveStyle
sshCursor := " "
sshStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards) {
addCursor = "> "
addStyle = activeStyle
sshCursor = "> "
sshStyle = activeStyle
}
lines = append(lines, sshCursor+sshStyle.Render("+ add card via ssh"))
lines = append(lines, addCursor+addStyle.Render("+ Add new card"))
httpsCursor := " "
httpsStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards)+1 {
httpsCursor = "> "
httpsStyle = activeStyle
}
lines = append(lines, httpsCursor+httpsStyle.Render("+ add payment via browser"))
parts := []string{title}
parts = append(parts, strings.Join(lines, "\n"))
@@ -285,18 +377,38 @@ func (m Model) RenderCardList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) renderPaymentHttpsView() string {
baseStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
accentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4"))
if m.CollectURL == nil {
return baseStyle.Render(" generating payment link...")
}
qr, qrSize, err := qrfefe.Generate(0, *m.CollectURL)
if err != nil || qrSize > m.widthContent {
// QR too wide for the terminal — just show the URL.
return baseStyle.Render("open in browser to add payment information:") + "\n\n" +
accentStyle.Render(*m.CollectURL) + "\n"
}
// Concatenate manually — lipgloss layout functions (JoinVertical with Center)
// measure string widths and add padding that corrupts qrfefe's raw ANSI codes.
return qr +
baseStyle.Render("scan or visit to add payment information") + "\n" +
accentStyle.Render(*m.CollectURL) + "\n"
}
// PaymentUpdate handles all messages while on the payment page.
// Moved from model.go Update() lines 429-578.
func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CardsMsg:
if msg.Err != nil || len(msg.Cards) == 0 {
if msg.Err != nil {
m.SavedCards = nil
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
} else {
m.SavedCards = msg.Cards
}
m.PaymentView = 0
m.CardCursor = 0
m.PaymentForm = nil
@@ -304,14 +416,11 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
case PaymentFormCompleteMsg:
if m.PaymentForm != nil {
return m, m.tokenizeCard(msg)
}
return m, nil
case StripeTokenMsg:
m.PaymentForm = nil
m.PaymentForm.submitting = true
m.CheckingOut = true
return m, m.saveCardAndConvert(msg)
}
return m, nil
case CheckoutResultMsg:
m.CheckingOut = false
@@ -322,6 +431,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.PaymentForm = nil
return m, nil
}
// Re-open the form so the user can retry with corrected details.
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
@@ -333,19 +443,32 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.ShippingInfo = nil
return m, nil
case StripeTokenErrMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = false
m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err)
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm)
return m, m.PaymentForm.form.Init()
}
m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err)
return m, nil
case PaymentFormErrorMsg:
m.ErrorMsg = msg.Message
return m, nil
case PollPaymentInitMsg:
url := msg.URL
m.CollectURL = &url
return m, m.pollCardsCmd(m.CollectCardCount)
case PollPaymentStatusMsg:
if m.PaymentView != 2 {
return m, nil // user navigated away, stop polling
}
return m, m.pollCardsCmd(msg.CardCount)
case PollPaymentCompleteMsg:
if m.PaymentView != 2 {
return m, nil
}
m.SavedCards = msg.Cards
m.PaymentView = 0
m.CollectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
// Card list navigation
@@ -364,9 +487,10 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.CardCursor--
}
case "down", "j":
if m.CardCursor < len(m.SavedCards) {
if m.CardCursor < len(m.SavedCards)+1 {
m.CardCursor++
}
case "enter":
if m.CardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.CardCursor]
@@ -374,9 +498,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
if m.CardCursor == len(m.SavedCards) {
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
// Browser payment
m.PaymentView = 2
m.CollectURL = nil
m.CollectCardCount = len(m.SavedCards)
return m, m.collectCardCmd()
case "d", "x":
if m.CardCursor < len(m.SavedCards) {
card := m.SavedCards[m.CardCursor]
@@ -395,6 +527,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// HTTPS/browser view avigation
if m.PaymentView == 2 {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
m.PaymentView = 0
m.CollectURL = nil
return m, nil
}
return m, nil
}
// Payment form navigation
if m.PaymentForm != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {

58
pkg/tui/qrfefe/qr.go Normal file
View File

@@ -0,0 +1,58 @@
package qrfefe
import (
sgr "github.com/foize/go.sgr"
"rsc.io/qr"
)
var tops_bottoms = []rune{' ', '▀', '▄', '█'}
func Generate(size int, text string) (string, int, error) {
return generate(qr.L, text)
}
func generate(level qr.Level, text string) (string, int, error) {
code, err := qr.Encode(text, qr.Level(level))
if err != nil {
return "", 0, err
}
qrRunes := make([]rune, 0)
for y := 0; y < code.Size-1; y+= 2 {
qrRunes = append(qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...)
for x := 0; x < code.Size; x += 1 {
var num int8
if code.Black(x, y) { // top pixel black
num += 1
}
if code.Black(x, y+1) { // bottom pixel black
num += 2
}
qrRunes = append(qrRunes, tops_bottoms[num])
}
qrRunes = append(qrRunes, []rune(sgr.Reset)...)
qrRunes = append(qrRunes, '\n')
}
addWhiteRow(&qrRunes, code.Size+4)
return string(qrRunes), code.Size, nil
}
func addWhiteRow(qrRunes *[]rune, width int) {
if qrRunes == nil {
return
}
*qrRunes = append(*qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...)
for i := 1; i < width-3; i++ {
*qrRunes = append(*qrRunes, '▀')
}
*qrRunes = append(*qrRunes, []rune(sgr.Reset)...)
*qrRunes = append(*qrRunes, '\n')
}

View File

@@ -6,6 +6,7 @@ import (
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
@@ -262,6 +263,54 @@ func (m Model) RenderShippingForm(state *ShippingFormState) string {
)
}
func (m Model) updateShippingViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.shippingVPReady {
m.shippingVP = viewport.New(m.widthContent, availH)
m.shippingVP.KeyMap = viewport.KeyMap{}
m.shippingVPReady = true
} else {
m.shippingVP.Width = m.widthContent
m.shippingVP.Height = availH
}
return m
}
func (m Model) ShippingPageView() string {
if !m.shippingVPReady {
m = m.updateShippingViewport()
}
if m.ShippingView == 0 && m.ShippingForm == nil {
content := m.RenderAddressList()
m.shippingVP.SetContent(content)
itemHeight := 4
targetY := m.AddressCursor * itemHeight
if targetY < m.shippingVP.YOffset {
m.shippingVP.SetYOffset(targetY)
}
if targetY+itemHeight > m.shippingVP.YOffset+m.shippingVP.Height {
m.shippingVP.SetYOffset(targetY - m.shippingVP.Height + itemHeight + 1)
}
if m.AddressCursor == len(m.SavedAddresses) {
m.shippingVP.GotoBottom()
}
} else if m.ShippingForm != nil {
m.shippingVP.SetContent(m.RenderShippingForm(m.ShippingForm))
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.shippingVP.View()),
lipgloss.Center, lipgloss.Center,
m.shippingVP.View(),
)
}
func (m Model) RenderAddressList() string {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)

View File

@@ -2,162 +2,232 @@ package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/bubbletea"
)
func (m Model) BuildShopView() string {
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2 // 2 for gap
// On small/medium, items are centered and span the full container width.
// On large, items are left-aligned in the narrow left column.
itemWidth := leftWidth - 2
itemAlign := lipgloss.Left
func (m Model) calculateShopWidths() (int, int) {
menuWidth := 14
if len(m.Coffees) == 0 {
if m.size < large {
itemWidth = m.widthContainer
itemAlign = lipgloss.Center
return m.widthContent, m.widthContent
}
// Build the left panel (coffee list)
leftPanel := ""
for i, coffee := range m.Coffees {
if m.Cursor == i {
style := lipgloss.NewStyle().
Background(lipgloss.Color(coffee.Color)).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Width(itemWidth).
Align(itemAlign)
leftPanel += style.Render(coffee.Name) + "\n"
} else {
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Padding(0, 1).
Width(itemWidth).
Align(itemAlign)
leftPanel += style.Render(coffee.Name) + "\n"
return menuWidth, m.widthContent - menuWidth
}
for _, c := range m.Coffees {
if w := lipgloss.Width(c.Name); w > menuWidth {
menuWidth = w
}
}
var leftContainer lipgloss.Style
if menuWidth > 0 {
menuWidth += 4
}
if m.size < large {
leftContainer = lipgloss.NewStyle().Width(m.widthContainer)
} else {
leftContainer = lipgloss.NewStyle().Width(leftWidth)
menuWidth = m.widthContent
}
detailWidth := m.widthContent - menuWidth
if m.size < large {
detailWidth = m.widthContent
}
return menuWidth, detailWidth
}
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
}
// Build the detail view (right panel)
detailView := ""
if m.Cursor >= 0 && m.Cursor < len(m.Coffees) {
selectedCoffee := m.Coffees[m.Cursor]
menuWidth, detailWidth := m.calculateShopWidths()
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
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.Width = menuWidth
m.shop.menuViewport.Height = availableHeight
m.shop.detailViewport.Width = detailWidth
m.shop.detailViewport.Height = availableHeight
}
return m
}
infoStyle := lipgloss.NewStyle().
func (m Model) getShopMenuContent() string {
menuWidth, _ := m.calculateShopWidths()
selectedColor := lipgloss.Color("#4682B4")
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 = lipgloss.NewStyle().
Width(menuWidth - 1).Align(lipgloss.Center).
Foreground(lipgloss.Color("#AAAAAA"))
priceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(selectedCoffee.Color)).
Bold(true)
// On small terminals, use full content width for description
descWidth := rightWidth - 2
if m.size < large {
descWidth = m.widthContent - 2
highlighted = lipgloss.NewStyle().
Width(menuWidth - 1).Align(lipgloss.Center).
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
} else {
normal = lipgloss.NewStyle().
Width(menuWidth + 2).Padding(0, 1).
Foreground(lipgloss.Color("#AAAAAA"))
highlighted = lipgloss.NewStyle().
Width(menuWidth + 2).Padding(0, 1).
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
}
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Width(descWidth)
var b strings.Builder
for i, c := range m.Coffees {
if i == m.shop.selected {
b.WriteString(highlighted.Render(c.Name) + "\n")
} else {
b.WriteString(normal.Render(c.Name) + "\n")
}
}
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
}
quantityStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
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
}
// Get current quantity (look up by CoffeeID)
quantity := 0
if item, exists := m.Cart[selectedCoffee.ID]; exists {
if item, exists := m.Cart[coffee.ID]; exists {
quantity = item.Quantity
}
detailView += nameStyle.Render(selectedCoffee.Name) + "\n"
detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s",
selectedCoffee.RoastType,
selectedCoffee.Ounces,
selectedCoffee.BeanType)) + "\n\n"
detailView += priceStyle.Render(fmt.Sprintf("$%.2f", float64(selectedCoffee.Price)/100)) + "\n\n"
detailView += descStyle.Render(selectedCoffee.Description) + "\n\n"
detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n"
detail := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Render(coffee.Name),
lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render(
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
),
"",
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
"",
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Width(detailWidth).Render(coffee.Description),
"",
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.")
}
detailContainer := lipgloss.NewStyle().
Width(rightWidth)
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
// Responsive layout: side-by-side on large, stacked on small/medium
if m.size < large {
return lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
detailContainer.Width(m.widthContainer).Render(detailView),
return lipgloss.JoinVertical(lipgloss.Top,
m.shop.menuViewport.View(),
m.shop.detailViewport.View(),
)
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
return lipgloss.JoinHorizontal(lipgloss.Top,
m.shop.menuViewport.View(),
" ",
detailContainer.Render(detailView),
m.shop.detailViewport.View(),
)
}
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
var cmd tea.Cmd
var cmds []tea.Cmd
if len(m.Coffees) == 0 {
return m, nil
}
switch keyMsg.String() {
switch msg := msg.(type) {
case tea.KeyMsg:
coffee := m.Coffees[m.shop.selected]
switch msg.String() {
case "up", "k":
if m.Cursor > 0 {
m.Cursor--
}
m = m.shopMoveSelected(true)
case "down", "j":
if m.Cursor < len(m.Coffees)-1 {
m.Cursor++
}
m = m.shopMoveSelected(false)
case "+", "=":
coffeeID := m.Coffees[m.Cursor].ID
coffeeID := coffee.ID
if item, exists := m.Cart[coffeeID]; exists {
item.Quantity++
} else {
m.Cart[coffeeID] = &models.CartItem{
CoffeeID: coffeeID,
Coffee: m.Coffees[m.Cursor],
Quantity: 1,
}
m.Cart[coffeeID] = &models.CartItem{CoffeeID: coffeeID, Coffee: coffee, Quantity: 1}
}
return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity)
case "-", "_":
coffeeID := m.Coffees[m.Cursor].ID
coffeeID := coffee.ID
if item, exists := m.Cart[coffeeID]; exists {
item.Quantity--
newQty := item.Quantity
if item.Quantity <= 0 {
delete(m.Cart, coffeeID)
if len(m.Cart) == 0 {
m.CardCursor = 0
} else if m.CartCursor >= len(m.Cart) {
m.CartCursor = len(m.Cart) - 1
}
newQty = 0
}
return m, m.syncCartItemCmd(coffeeID, newQty)
}
}
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 {
next := m.shop.selected
if previous {
next--
} else {
next++
}
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

@@ -14,6 +14,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if tick, ok := msg.(resizeTickMsg); ok {
if tick.seq == m.resizeSeq {
m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
case cartPage:
m = m.updateCartViewport()
case shippingPage:
m = m.updateShippingViewport()
case paymentPage:
m = m.updatePaymentViewport()
case confirmPage:
m = m.updateConfirmViewport()
}
if m.ShippingForm != nil {
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
}
@@ -79,7 +91,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.OrdersLoaded = true
m.splashDataReady = true
if m.splashDelayDone {
return m.SwitchPage(shopPage), nil
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
}
return m, nil
case CartSyncedMsg:
@@ -120,7 +134,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case DelayCompleteMsg:
m.splashDelayDone = true
if m.currentPage == splashPage && m.splashDataReady {
return m.SwitchPage(shopPage), nil
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
}
return m, nil
case splashCursorTickMsg:
@@ -145,9 +161,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.menuLastPage = m.currentPage
return m, nil
case "s":
return m.SwitchPage(shopPage).resetPageState(), nil
m = m.SwitchPage(shopPage).resetPageState()
m = m.updateShopViewports()
return m, nil
case "c":
return m.SwitchPage(cartPage).resetPageState(), nil
m = m.SwitchPage(cartPage).resetPageState()
m = m.updateCartViewport()
return m, nil
case "a":
m = m.SwitchPage(accountPage).resetPageState()
if !m.OrdersLoaded {

View File

@@ -116,33 +116,25 @@ func (m Model) View() string {
}
content = strings.Join(contentLines, "\n")
// Add vertical spacing around content with fixed height
contentWithPadding := lipgloss.NewStyle().
MarginTop(marginTop).
MarginBottom(marginBottom).
Height(availableContentHeight).
Render(content)
// Build footer
footer := m.BuildFooter()
// Assemble all layers vertically within the container
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage
var child string
if isViewportPage {
if breadcrumbs != "" {
child = lipgloss.JoinVertical(
lipgloss.Left,
header,
breadcrumbs,
contentWithPadding,
footer,
)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, content, footer)
} else {
child = lipgloss.JoinVertical(
lipgloss.Left,
header,
contentWithPadding,
footer,
)
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
}
} else if breadcrumbs != "" {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, ccontentWithPadding, footer)
} else {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, ccontentWithPadding, footer)
}
// Constrain the assembled layout to the container dimensions
@@ -166,23 +158,16 @@ func (m Model) buildPageContent(height int) string {
case accountPage:
return m.BuildAccountView(height)
case shippingPage:
if m.ShippingView == 0 && m.ShippingForm == nil {
return m.RenderAddressList()
}
return m.RenderShippingForm(m.ShippingForm)
return m.ShippingPageView()
case paymentPage:
if m.CheckingOut {
return " submitting order..."
}
if m.PaymentView == 0 && m.PaymentForm == nil {
return m.RenderCardList()
}
return m.RenderPaymentForm(m.PaymentForm)
return m.PaymentPageView()
case confirmPage:
return m.RenderConfirmation()
return m.ConfirmView()
case cartPage:
return m.BuildCartView()
return m.CartView()
case shopPage:
return m.ShopView()
default:
return m.BuildShopView()
return m.ShopView()
}
}