Compare commits

..

1 Commits

Author SHA1 Message Date
313f39b0ce docs(comments): add some comments 2026-05-24 12:16:13 +02:00
46 changed files with 371 additions and 1777 deletions

View File

@@ -8,9 +8,9 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: actions/setup-go@v6 - uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
@@ -30,7 +30,6 @@ jobs:
fi fi
- name: Lint - name: Lint
if: ${{ !env.ACT }}
uses: golangci/golangci-lint-action@v7 uses: golangci/golangci-lint-action@v7
with: with:
version: v2.4.0 version: v2.4.0

View File

@@ -8,10 +8,10 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: actions/setup-go@v6 - uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version: '1.23'
- name: Run tests - name: Run tests
run: go test ./... run: go test ./...
- name: Deploy to server - name: Deploy to server

5
.gitignore vendored
View File

@@ -5,9 +5,6 @@
.env.example .env.example
.env.prod .env.prod
docs/sca-psd2-compliance.md
accept-a-payment/
FULFILLMENT.md FULFILLMENT.md
.tmp-gocache/ .tmp-gocache/
@@ -101,8 +98,6 @@ docs/
# Migration state (if using a migration tool that creates state files) # Migration state (if using a migration tool that creates state files)
migrations/*.state migrations/*.state
gh-dash/
loadtest/run.pid loadtest/run.pid

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "sync"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
@@ -24,43 +24,35 @@ import (
stripeSession "github.com/stripe/stripe-go/v78/checkout/session" stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
) )
const RedirectTTL = 10 * time.Minute // payRedirects maps short tokens to full Stripe checkout URLs.
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL, purpose string) (string, error) { func storeRedirect(stripeURL string) (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", err return "", err
} }
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
now := time.Now() payRedirectsMu.Lock()
rec := models.PayRedirect{ payRedirects[token] = stripeURL
Token: token, payRedirectsMu.Unlock()
URL: stripeURL,
Purpose: purpose,
CreatedAt: now,
ExpiresAt: now.Add(RedirectTTL),
}
if err := database.GetDB().Create(&rec).Error; err != nil {
return "", err
}
return token, nil return token, nil
} }
// PayRedirect resolves a short token and redirects to the Stripe checkout URL. // PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) { func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token") token := chi.URLParam(r, "token")
db := database.GetDB().WithContext(r.Context()) payRedirectsMu.Lock()
var rec models.PayRedirect url, ok := payRedirects[token]
if err := db.Where("token = ?", token).First(&rec).Error; err != nil { payRedirectsMu.Unlock()
if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if time.Now().After(rec.ExpiresAt) { http.Redirect(w, r, url, http.StatusFound)
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
http.NotFound(w, r)
return
}
http.Redirect(w, r, rec.URL, http.StatusFound)
} }
// CardHandler handles saved payment method CRUD. // CardHandler handles saved payment method CRUD.
@@ -176,7 +168,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard) token, err := storeRedirect(sess.URL)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return return

View File

@@ -24,12 +24,11 @@ import (
type CartHandler struct { type CartHandler struct {
stripeKey string stripeKey string
appURL string
} }
// NewCartHandler creates a new cart handler with the Stripe secret key // NewCartHandler creates a new cart handler with the Stripe secret key
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler { func NewCartHandler(stripeSecretKey string) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL} return &CartHandler{stripeKey: stripeSecretKey}
} }
// getOrCreateCart finds the users cart or creates one. // getOrCreateCart finds the users cart or creates one.
@@ -392,15 +391,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
// Charge the card. The idempotency key is tied to the order ID so retries // Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge. // never produce a double charge.
piParams := &stripe.PaymentIntentParams{ piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)), Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)), Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID), Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID), PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(true), AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
PaymentMethodTypes: stripe.StringSlice([]string{"card"}), Enabled: stripe.Bool(true),
ReturnURL: stripe.String(h.appURL + "/post-3ds"), AllowRedirects: stripe.String("never"),
Confirm: stripe.Bool(true), },
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)), Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{ Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name), Name: stripe.String(address.Name),
Address: &stripe.AddressParams{ Address: &stripe.AddressParams{
@@ -423,17 +423,13 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
stripeDur := time.Since(stripeStart).Seconds() stripeDur := time.Since(stripeStart).Seconds()
if err != nil { if err != nil {
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
db.Model(&order).Update("status", models.OrderStatusFailed)
if stripeErr, ok := err.(*stripe.Error); ok { if stripeErr, ok := err.(*stripe.Error); ok {
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
switch stripeErr.Type { switch stripeErr.Type {
case stripe.ErrorTypeCard: case stripe.ErrorTypeCard:
middleware.RecordCartConversion("card_declined") middleware.RecordCartConversion("card_declined")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil) utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
case stripe.ErrorTypeInvalidRequest: case stripe.ErrorTypeInvalidRequest:
middleware.RecordCartConversion("stripe_invalid_request") middleware.RecordCartConversion("stripe_invalid_request")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
@@ -445,7 +441,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.RecordCartConversion("stripe_error") middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, err.Error()) audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
@@ -453,11 +448,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur) middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
h.respondRequiresAction(w, &order, paymentMethodID, pi)
return
}
// Commit payment ID and clear cart in a single transaction. // Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
order.StripePaymentID = pi.ID order.StripePaymentID = pi.ID
@@ -499,95 +489,3 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"order": order, "order": order,
}) })
} }
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
// The order stays pending. the webhook flips it to paid once the customer
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB()
// After an off-session authentication_required decline, the PI sits in
// status=requires_payment_method with payment_method detached. Re-confirm
// on-session with the same PM so Stripe pushes the PI into requires_action
// and populates next_action.redirect_to_url with the 3DS challenge URL.
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
if cerr == nil && confirmed != nil {
pi = confirmed
}
}
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
middleware.RecordCartConversion("sca_missing_next_action")
db.Model(order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
return
}
// Persist the paymentIntent id AND reset status to pending. The
// payment_intent.payment_failed webhook from the initial off-session decline
// often arrives before this response and flips the order to failed —
// override it back so the 3DS-in-flight order shows the correct state.
if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID,
"status": models.OrderStatusPending,
}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return
}
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
return
}
middleware.RecordCartConversion("requires_action")
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"payment_intent_id": pi.ID,
"status": "requires_action",
"redirect_token": token,
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
}
func mapStripeCardErrCode(code stripe.ErrorCode) string {
switch code {
case stripe.ErrorCodeAuthenticationRequired:
return "AUTHENTICATION_REQUIRED"
case "authentication_not_supported":
return "AUTHENTICATION_NOT_SUPPORTED"
case "card_declined", "":
return "CARD_DECLINED"
case "expired_card":
return "CARD_EXPIRED"
case "incorrect_cvc":
return "CARD_CVC_FAILED"
case "insufficient_funds":
return "CARD_INSUFFICIENT_FUNDS"
case "processing_error":
return "CARD_PROCESSING_ERROR"
}
return "CARD_DECLINED"
}
//nolint:unused // wired into Convert cart later
func appURLForHandler(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Host
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
host = fh
}
return fmt.Sprintf("%s://%s", scheme, host)
}

View File

@@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
req := authRequest("GET", "/api/v1/cart", nil, user.ID) req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Add item (CoffeeID=1, Quantity=2) // Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
@@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Add item // Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
@@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Add item // Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
@@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Add two items // Add two items
for _, coffeeID := range []uint{1, 2} { for _, coffeeID := range []uint{1, 2} {
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", "") handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
} }
db.Create(&addr) db.Create(&addr)
handler := NewCartHandler("", "") handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", "") handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
} }
db.Create(&card) db.Create(&card)
handler := NewCartHandler("", "") handler := NewCartHandler("")
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Add item but no address or card // Add item but no address or card
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
@@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
db := database.GetDB() db := database.GetDB()
// Add item and address // Add item and address
@@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Empty cart convert // Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) {
defer func() { _ = os.Remove(testDB) }() defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewCartHandler("", "") handler := NewCartHandler("")
// Add three different items // Add three different items
for i := uint(1); i <= 3; i++ { for i := uint(1); i <= 3; i++ {
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
} }
db.Create(&user2) db.Create(&user2)
handler := NewCartHandler("", "") handler := NewCartHandler("")
r := chi.NewRouter() r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem) r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart) r.Get("/cart", handler.GetCart)

View File

@@ -2,18 +2,14 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings"
"time"
"terminalShop/pkg/bring" "terminalShop/pkg/bring"
"terminalShop/pkg/notify" "terminalShop/pkg/notify"
"terminalShop/pkg/shipping" "terminalShop/pkg/shipping"
"terminalShop/pkg/shippo" "terminalShop/pkg/shippo"
"time"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
@@ -24,7 +20,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund" "github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
) )
type OrderHandler struct { type OrderHandler struct {
@@ -34,16 +29,6 @@ type OrderHandler struct {
bringCustomerNumber string bringCustomerNumber string
} }
type refundRequestPayload struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
// refundRequestCooldown is the minimum interval between customer-initiated
// refund requests for the same order. Prevents accidental double-clicks and
// repeated submissions from spamming the operator's Slack channel.
const refundRequestCooldown = 15 * time.Minute
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler { func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{ return &OrderHandler{
stripeKey: stripeSecretKey, stripeKey: stripeSecretKey,
@@ -53,121 +38,18 @@ func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNum
} }
} }
// CreateRefundRequest posts a customer refund request into the order's Slack
// thread. It does not issue a Stripe refund.
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
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 req refundRequestPayload
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
req.Reason = strings.TrimSpace(req.Reason)
req.Message = strings.TrimSpace(req.Message)
if !models.IsValidRefundReason(req.Reason) {
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
return
}
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
return
}
var order models.Order
if err := db.Preload("User").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
}
if !order.CanRequestRefund() {
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
"only paid, shipped, or delivered orders can request refunds", nil)
return
}
if order.LastRefundRequestAt != nil {
elapsed := time.Since(*order.LastRefundRequestAt)
if elapsed < refundRequestCooldown {
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
nil)
return
}
}
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
if req.Message != "" {
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
text += "\n\n" + quoted
}
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
return
}
// Record the timestamp after a successful Slack post so a Slack outage
// doesn't consume the user's cooldown — they can retry immediately.
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"status": "sent",
})
}
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) { func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context()) db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order var orders []models.Order
db.Where("user_id = ?", userID). db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]any{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders, "orders": orders,
}) })
} }
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
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
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"id": order.ID,
"status": string(order.Status),
})
}
// RefundOrder issues a full refund for an order via Stripe and marks it as // 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. // refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) { func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,170 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
func setupOrderTestDB(t *testing.T) models.User {
t.Helper()
testDB := filepath.Join(t.TempDir(), "orders.db")
database.ResetForTesting()
t.Cleanup(database.ResetForTesting)
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
if err := database.Seed(db); err != nil {
t.Fatalf("failed to seed: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
Name: "Order Test User",
Email: "orders@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return user
}
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
req := authRequest(method, url, body, userID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", orderID)
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
}
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
}
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
user := setupOrderTestDB(t)
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Other","message":" "}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "MESSAGE_REQUIRED" {
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
}
}
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
order := models.Order{
UserID: user.ID,
CardID: 1,
Status: models.OrderStatusFailed,
Subtotal: 100,
ShippingCost: 0,
Total: 100,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to create order: %v", err)
}
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "INVALID_STATE" {
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
}
}
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
recent := time.Now().Add(-1 * time.Minute)
order := models.Order{
UserID: user.ID,
CardID: 1,
Status: models.OrderStatusPaid,
Subtotal: 100,
ShippingCost: 0,
Total: 100,
LastRefundRequestAt: &recent,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to create order: %v", err)
}
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429, got %d", w.Code)
}
if got := w.Header().Get("Retry-After"); got == "" {
t.Errorf("expected Retry-After header, got empty")
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "RATE_LIMITED" {
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
}
}

View File

@@ -7,7 +7,6 @@ import (
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
@@ -76,26 +75,3 @@ func ReconcileOrders(stripeKey string) {
} }
} }
} }
// 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)
err := db.Where(
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
models.OrderStatusPaid, cutoff,
).Order("created_at ASC").Find(&orders).Error
if err != nil {
reconcileLog().Error("unshipped query failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("unshipped orders found", "count", len(orders))
notify.SlackUnshippedReminder(orders)
}

View File

@@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
resp, err := h.httpClient.Do(req) resp, err := h.httpClient.Do(req)
if err != nil { if err != nil {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err)) notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
return return
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
_ = json.Unmarshal(bodyBytes, &result) _ = json.Unmarshal(bodyBytes, &result)
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message)) notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
return return
} }
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
return return
} }
if order.TrackingNumber == "" { if order.TrackingNumber == "" {
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.") notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
return return
} }
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
resp, err := h.httpClient.Do(req) resp, err := h.httpClient.Do(req)
if err != nil { if err != nil {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err)) notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
return return
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode)) notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
return return
} }

View File

@@ -7,8 +7,6 @@ import (
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
"gorm.io/gorm"
) )
type ViewHandler struct{} type ViewHandler struct{}
@@ -40,10 +38,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&cards) db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order var orders []models.Order
db.Where("user_id = ?", userID). db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at DESC").Find(&orders)
var cartItems []models.CartItem var cartItems []models.CartItem
if cart.ID != 0 { if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)

View File

@@ -167,16 +167,6 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
return return
} }
// authentication_required is recoverable — the cart handler re-confirms the
// PI on-session and the customer completes 3DS via redirect. Marking the
// order failed here would race with the recovery path and surface a
// momentarily-broken state in the TUI.
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
"order_id", orderIDStr, "pi", pi.ID)
return
}
db := database.GetDB().WithContext(ctx) db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil { Update("status", models.OrderStatusFailed).Error; err != nil {
@@ -383,9 +373,7 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
if previousTrackingStatus != newStatus { if previousTrackingStatus != newStatus {
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus)) audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
go func() { go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
}()
} }
if deliveredTransition { if deliveredTransition {
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)

View File

@@ -113,21 +113,6 @@ func main() {
} }
}() }()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.") log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received // Block until signal received

View File

@@ -46,7 +46,7 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler() productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL) cartHandler := handlers.NewCartHandler(stripeSecretKey)
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
@@ -61,10 +61,6 @@ func SetupRoutes(
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`)) _, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
}) })
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
})
r.Handle("/metrics", middleware.MetricsHandler()) r.Handle("/metrics", middleware.MetricsHandler())
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
@@ -130,8 +126,6 @@ func SetupRoutes(
// Orders // Orders
r.Get("/orders", orderHandler.GetOrders) r.Get("/orders", orderHandler.GetOrders)
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder) r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
// Addresses // Addresses

55
go.mod
View File

@@ -1,14 +1,14 @@
module terminalShop module terminalShop
go 1.25.9 go 1.25.7
require ( require (
charm.land/bubbles/v2 v2.1.0 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
charm.land/bubbletea/v2 v2.0.6 github.com/charmbracelet/bubbletea v1.3.10
charm.land/huh/v2 v2.0.3 github.com/charmbracelet/huh v0.8.0
charm.land/lipgloss/v2 v2.0.3 github.com/charmbracelet/lipgloss v1.1.0
charm.land/wish/v2 v2.0.1
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
@@ -23,7 +23,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.49.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@@ -32,35 +32,38 @@ require (
) )
require ( require (
charm.land/log/v2 v2.0.0 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/keygen v0.5.4 // indirect github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/log v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/conpty v0.1.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/input v0.3.4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/creack/pty v1.1.24 // indirect github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
@@ -75,11 +78,15 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/paulmach/orb v0.11.1 // indirect github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect
@@ -97,11 +104,11 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.80.0 // indirect

124
go.sum
View File

@@ -1,15 +1,3 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4= github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg= github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo= github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
@@ -22,8 +10,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
@@ -32,38 +22,52 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -71,6 +75,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
@@ -85,8 +91,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -141,17 +147,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
@@ -233,10 +247,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -259,17 +273,19 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=

16
main.go
View File

@@ -11,11 +11,11 @@ import (
"time" "time"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/wish/v2"
"charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
@@ -36,6 +36,9 @@ var (
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session
pubKey := s.PublicKey() pubKey := s.PublicKey()
var fingerprint, pubKeyStr string var fingerprint, pubKeyStr string
@@ -44,8 +47,9 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey)
} }
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) // Create TUI model with user context (no registration screen needed!)
return m, nil m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, []tea.ProgramOption{tea.WithAltScreen()}
} }
func main() { func main() {

View File

@@ -56,6 +56,7 @@ type TokenResponse struct {
} }
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) { func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL) url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{ reqBody := TokenRequest{
@@ -280,31 +281,14 @@ type CardResponse struct {
} }
// ConvertCartResponse represents the cart conversion (checkout) API response. // ConvertCartResponse represents the cart conversion (checkout) API response.
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
// redirect fields and OrderID instead.
type ConvertCartResponse struct { type ConvertCartResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Order *models.Order `json:"order,omitempty"` Order models.Order `json:"order"`
OrderID uint `json:"order_id,omitempty"`
PaymentIntentID string `json:"payment_intent_id,omitempty"`
Status string `json:"status,omitempty"`
RedirectToken string `json:"redirect_token,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
} `json:"data"` } `json:"data"`
Error *APIError `json:"error,omitempty"` Error *APIError `json:"error,omitempty"`
} }
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
// means the customer needs to complete 3DS at RedirectURL; poll
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
type CheckoutOutcome struct {
Order *models.Order
OrderID uint
RequiresAction bool
RedirectURL string
}
// GetCart fetches the user's current cart. // GetCart fetches the user's current cart.
func (c *Client) GetCart() (*CartData, error) { func (c *Client) GetCart() (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -484,9 +468,7 @@ func (c *Client) ClearCart() error {
} }
// ConvertCart converts the cart into an order, charging the saved card. // ConvertCart converts the cart into an order, charging the saved card.
// Returns a CheckoutOutcome that either carries the completed Order or func (c *Client) ConvertCart() (*models.Order, error) {
// indicates the customer must complete 3DS authentication via RedirectURL.
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil) req, err := http.NewRequest("POST", url, nil)
@@ -512,55 +494,7 @@ func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
return nil, fmt.Errorf("failed to convert cart") return nil, fmt.Errorf("failed to convert cart")
} }
if convertResp.Data.Status == "requires_action" { return &convertResp.Data.Order, nil
return &CheckoutOutcome{
OrderID: convertResp.Data.OrderID,
RequiresAction: true,
RedirectURL: convertResp.Data.RedirectURL,
}, nil
}
if convertResp.Data.Order == nil {
return nil, fmt.Errorf("malformed convert response: missing order")
}
return &CheckoutOutcome{
Order: convertResp.Data.Order,
OrderID: convertResp.Data.Order.ID,
}, nil
}
type OrderStatusResponse struct {
Success bool `json:"success"`
Data struct {
ID uint `json:"id"`
Status string `json:"status"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetOrderStatus(id uint) (string, error) {
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
req, err := http.NewRequest("GET", 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 fetch order status: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var sResp OrderStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
return "", fmt.Errorf("failed to decode order status response: %w", err)
}
if !sResp.Success {
if sResp.Error != nil {
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
}
return "", fmt.Errorf("failed to fetch order status")
}
return sResp.Data.Status, nil
} }
// GetCards fetches all saved cards for the user. // GetCards fetches all saved cards for the user.
@@ -702,20 +636,6 @@ type OrderResponse struct {
Error *APIError `json:"error,omitempty"` Error *APIError `json:"error,omitempty"`
} }
// RefundRequest is the customer-facing refund request payload.
type RefundRequest struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
type refundRequestResponse struct {
Success bool `json:"success"`
Data struct {
Status string `json:"status"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetOrders() ([]models.Order, error) { func (c *Client) GetOrders() ([]models.Order, error) {
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL) url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
@@ -744,42 +664,6 @@ func (c *Client) GetOrders() ([]models.Order, error) {
return ordersResp.Data.Orders, nil return ordersResp.Data.Orders, nil
} }
// CreateRefundRequest posts a refund request for an order.
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
jsonData, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("failed to marshal refund request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create refund request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to send refund request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var refundResp refundRequestResponse
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
return fmt.Errorf("failed to decode refund response: %w", err)
}
if !refundResp.Success {
if refundResp.Error != nil {
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
}
return fmt.Errorf("failed to send refund request")
}
return nil
}
// The Addresses API response // The Addresses API response
type AddressResponse struct { type AddressResponse struct {
Success bool `json:"success"` Success bool `json:"success"`

View File

@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
@@ -22,7 +21,6 @@ const (
eventOrderPaid eventType = "order_paid" eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped" eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = "order_requires_action"
eventOrderRefunded eventType = "order_refunded" eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical" eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased" eventLabelPurchased eventType = "label_purchased"
@@ -300,15 +298,3 @@ func attrsToMap(a []any) map[string]any {
} }
return m return m
} }
//nolint:unused // called from ConvertCart in the next update
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrderRequiresAction,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e)
}

View File

@@ -3,7 +3,6 @@ package database
import ( import (
"fmt" "fmt"
"log" "log"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
@@ -25,7 +24,6 @@ func Migrate(db *gorm.DB) error {
&models.OrderItem{}, // Line items within orders &models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses &models.Address{}, // Saved addresses
&models.OrderEvent{}, &models.OrderEvent{},
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
); err != nil { ); err != nil {
return fmt.Errorf("migration failed: %w", err) return fmt.Errorf("migration failed: %w", err)
} }

View File

@@ -66,19 +66,14 @@ type Order struct {
// Slack parent message ts for this order's thread (NULL if Slack notify disabled). // Slack parent message ts for this order's thread (NULL if Slack notify disabled).
SlackThreadTS *string `gorm:"size:64" json:"-"` SlackThreadTS *string `gorm:"size:64" json:"-"`
// LastRefundRequestAt timestamps the most recent customer-initiated refund
// request for this order. Used to rate-limit repeat submissions.
LastRefundRequestAt *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
// Associations // Associations
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"` Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
} }
// TableName specifies the table name for the Order model. // TableName specifies the table name for the Order model.
@@ -117,35 +112,3 @@ func (o *Order) TotalInDollars() float64 {
func (o *Order) IsActive() bool { func (o *Order) IsActive() bool {
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
} }
// RefundRequestReasons is the canonical list of customer-facing refund reasons
// accepted by both the API and the TUI.
var RefundRequestReasons = []string{
"Wrong item received",
"Item damaged in transit",
"Item never arrived",
"Quality issue",
"Other",
}
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
const RefundRequestReasonOther = "Other"
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
func IsValidRefundReason(r string) bool {
for _, reason := range RefundRequestReasons {
if reason == r {
return true
}
}
return false
}
// CanRequestRefund reports whether a customer is allowed to request a refund
// for this order. Paid, shipped, and delivered orders qualify; everything else
// is either still unpaid or already in a terminal state.
func (o *Order) CanRequestRefund() bool {
return o.Status == OrderStatusPaid ||
o.Status == OrderStatusShipped ||
o.Status == OrderStatusDelivered
}

View File

@@ -11,27 +11,10 @@ const (
DisplayKindBrand DisplayKind = "brand" // shipped / in transit DisplayKindBrand DisplayKind = "brand" // shipped / in transit
DisplayKindSuccess DisplayKind = "success" // delivered DisplayKindSuccess DisplayKind = "success" // delivered
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
) )
// refundPending reports whether the order has an open customer refund request
// that has not yet been resolved by a terminal status change.
func (order *Order) refundPending() bool {
if order.LastRefundRequestAt == nil {
return false
}
switch order.Status {
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
return false
}
return true
}
func (order *Order) DisplayState() string { func (order *Order) DisplayState() string {
if order.refundPending() {
return "Refund Requested"
}
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return "Awaiting payment" return "Awaiting payment"
@@ -71,9 +54,6 @@ func (order *Order) DisplayState() string {
} }
func (order *Order) DisplayKind() DisplayKind { func (order *Order) DisplayKind() DisplayKind {
if order.refundPending() {
return DisplayKindRefund
}
switch order.Status { switch order.Status {
case OrderStatusPending: case OrderStatusPending:
return DisplayKindNeutral return DisplayKindNeutral

View File

@@ -1,12 +1,8 @@
package models package models
import ( import "testing"
"testing"
"time"
)
func TestOrderDisplayState(t *testing.T) { func TestOrderDisplayState(t *testing.T) {
now := time.Now()
tests := []struct { tests := []struct {
name string name string
order Order order Order
@@ -27,10 +23,6 @@ func TestOrderDisplayState(t *testing.T) {
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError}, {"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError}, {"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError}, {"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {

View File

@@ -1,9 +1,6 @@
package models package models
import ( import "time"
"encoding/json"
"time"
)
type OrderEvent struct { type OrderEvent struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
@@ -17,47 +14,3 @@ type OrderEvent struct {
func (OrderEvent) TableName() string { func (OrderEvent) TableName() string {
return "order_events" return "order_events"
} }
func (e *OrderEvent) PayloadMap() map[string]any {
if e.Payload == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
return nil
}
return m
}
func (e *OrderEvent) DisplayLabel() (string, bool) {
switch e.Type {
case "order_paid":
return "Paid", true
case "label_purchased":
return "Label created", true
case "order_delivered":
return "Delivered", true
case "order_refunded":
return "Refunded", true
case "order_failed":
return "Payment failed", true
case "tracking_updated":
status, _ := e.PayloadMap()["status"].(string)
return trackingLabel(status)
}
return "", false
}
func trackingLabel(status string) (string, bool) {
switch status {
case "transit":
return "In transit", true
case "failure":
return "Delivery issue", true
case "returned":
return "Returned to sender", true
case "pre_transit":
return "Label scanned by carrier", true
}
return "", false
}

View File

@@ -1,23 +0,0 @@
package models
import "time"
// PayRedirect maps a short token to a stripe hosted url with a TTL.
// survives api restarts so a customer mid-3Ds authentication still
// lands on the bank-hosted challenge page after a deploy or crash
type PayRedirect struct {
Token string `gorm:"primaryKey;size:64" json:"token"`
URL string `gorm:"type:text;not null" json:"url"`
Purpose string `gorm:"size:32;not null" json:"purpose"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
const (
RedirectPurposeCheckout3DS = "checkout_3ds"
RedirectPurposeAddCard = "add_card"
)
func (PayRedirect) TableName() string {
return "pay_redirects"
}

View File

@@ -1,4 +1,3 @@
// Package notify provides a simple interface to send notifications to Slack.
package notify package notify
import ( import (
@@ -15,7 +14,6 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second} var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars // dollars converts cents to dollars
@@ -188,49 +186,29 @@ func SlackUnpinOrder(orderID uint) {
} }
// SlackPostToOrderThread posts a plain-text reply inside the order's thread. // SlackPostToOrderThread posts a plain-text reply inside the order's thread.
// It looks up the thread ts from the database and returns an error when the // Looks up the thread ts from the database. Silent no-op if the order has no
// reply cannot be posted. // saved thread (Slack was disabled when it was created, or post failed earlier).
func SlackPostToOrderThread(orderID uint, text string) error { func SlackPostToOrderThread(orderID uint, text string) {
return slackPostToOrderThread(orderID, text, false)
}
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
// to the channel (reply_broadcast=true) so channel members get a normal
// notification. Use for messages that need immediate operator attention.
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, true)
}
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
token := os.Getenv("SLACK_BOT_TOKEN") token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL") channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" { if token == "" || channel == "" {
return fmt.Errorf("slack is not configured") return
} }
var order models.Order var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil { if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err) slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
return fmt.Errorf("failed to look up order thread: %w", err) return
} }
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" { if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return fmt.Errorf("order has no slack thread") return
} }
payload := map[string]any{ _, _ = postSlackMessage(token, map[string]any{
"channel": channel, "channel": channel,
"thread_ts": *order.SlackThreadTS, "thread_ts": *order.SlackThreadTS,
"text": text, "text": text,
} })
if broadcast {
payload["reply_broadcast"] = true
}
if _, ok := postSlackMessage(token, payload); !ok {
slog.Warn("failed to post slack thread reply", "order_id", orderID)
return fmt.Errorf("failed to post slack thread reply")
}
return nil
} }
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) { func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
@@ -284,44 +262,4 @@ func markStatusActionBlock(orderID uint) map[string]any {
} }
} }
func SlackUnshippedReminder(orders []models.Order) { var _ = time.Second
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" || len(orders) == 0 {
return
}
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
for _, order := range orders {
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"text": strings.Join(lines, "\n"),
})
}
func unshippedOrderLine(order models.Order, channel, workspace string) string {
age := time.Since(order.CreatedAt).Round(time.Hour)
location := order.ShippingCountry
if order.ShippingCity != "" {
location = order.ShippingCity + ", " + order.ShippingCountry
}
label := fmt.Sprintf("Order #%d", order.ID)
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
}
tag := ""
if order.ShippingCountry == "NO" {
tag = " _(NO * Bring blocked)_"
}
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
}

View File

@@ -1,9 +1,9 @@
package tui package tui
import ( import (
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model {
} }
if !m.account.viewportReady { if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight)) m.account.detailViewport = viewport.New(detailWidth, availableHeight)
m.account.detailViewport.KeyMap = modifiedKeyMap m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true m.account.viewportReady = true
} else { } else {
m.account.detailViewport.SetWidth(detailWidth) m.account.detailViewport.Width = detailWidth
m.account.detailViewport.SetHeight(availableHeight) m.account.detailViewport.Height = availableHeight
} }
return m return m
@@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model {
} }
targetY := headerOffset + (selectedIndex * itemHeight) targetY := headerOffset + (selectedIndex * itemHeight)
vpH := m.account.detailViewport.Height() vpH := m.account.detailViewport.Height
offset := m.account.detailViewport.YOffset() offset := m.account.detailViewport.YOffset
// 1-item buffer: scroll when the NEXT item would be off-screen, // 1-item buffer: scroll when the NEXT item would be off-screen,
// not when the current item hits the very edge // not when the current item hits the very edge
@@ -288,13 +288,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg) m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd return m, cmd
case "r":
if m.account.orderViewState == 2 {
if order, ok := m.selectedRefundableOrder(); ok {
return m.OpenRefundComposer(order.ID)
}
}
case "x", "d": case "x", "d":
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) { if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
m.account.addressDeleting = &m.account.addressCursor m.account.addressDeleting = &m.account.addressCursor
@@ -343,18 +336,14 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
case 1: case 1:
m.account.orderYOffset = m.account.detailViewport.YOffset() m.account.orderYOffset = m.account.detailViewport.YOffset
m.account.orderViewState = 2 m.account.orderViewState = 2
m.account.detailViewport.GotoTop() m.account.detailViewport.GotoTop()
footer := []footerCommand{ m.footer = []footerCommand{
{key: "esc", value: "back"}, {key: "esc", value: "back"},
{key: "s", value: "shop"}, {key: "s", value: "shop"},
{key: "q", value: "quit"}, {key: "q", value: "quit"},
} }
if _, ok := m.selectedRefundableOrder(); ok {
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
}
m.footer = footer
} }
} }
case "addresses": case "addresses":

View File

@@ -6,7 +6,7 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
func (m Model) OrdersView(width int) string { func (m Model) OrdersView(width int) string {
@@ -37,21 +37,6 @@ func (m Model) HistoryOrders() []models.Order {
return out return out
} }
// selectedRefundableOrder returns the order under the cursor in the active
// order-detail view, paired with ok=true, when the user is allowed to file a
// refund request for it. Returns (zero, false) otherwise.
func (m Model) selectedRefundableOrder() (models.Order, bool) {
orders := m.currentOrderList()
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
return models.Order{}, false
}
order := orders[m.account.orderCursor]
if !order.CanRequestRefund() {
return models.Order{}, false
}
return order, true
}
func (m Model) currentOrderList() []models.Order { func (m Model) currentOrderList() []models.Order {
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return nil return nil
@@ -216,24 +201,6 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
} }
} }
visible := make([]models.OrderEvent, 0, len(order.Events))
for _, event := range order.Events {
if _, ok := event.DisplayLabel(); ok {
visible = append(visible, event)
}
}
if len(visible) > 0 {
b.WriteString(labelStyle.Render("Timeline"))
b.WriteString("\n")
for _, event := range visible {
label, _ := event.DisplayLabel()
when := event.CreatedAt.Format("Jan 02 3:04 PM")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Shipping address // Shipping address
b.WriteString(labelStyle.Render("Ship To")) b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n") b.WriteString("\n")
@@ -256,11 +223,7 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
b.WriteString("\n\n") b.WriteString("\n\n")
// Footer hint // Footer hint
if order.CanRequestRefund() { b.WriteString(dimStyle.Render("esc: back to orders"))
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
} else {
b.WriteString(dimStyle.Render("esc: back to orders"))
}
return b.String() return b.String()
} }
@@ -278,9 +241,10 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
case models.DisplayKindError: case models.DisplayKindError:
return m.theme.TextError() return m.theme.TextError()
case models.DisplayKindWarning: case models.DisplayKindWarning:
// !TODO add refunds and change this
// No warning color in theme yet, reuse loading pink as visual flag
// for now
return m.theme.TextLoading() return m.theme.TextLoading()
case models.DisplayKindRefund:
return m.theme.TextBrand()
} }
return m.theme.TextHighlight() return m.theme.TextHighlight()
} }

View File

@@ -1,7 +1,7 @@
package tui package tui
import ( import (
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildBreadcrumbs() string { func (m Model) BuildBreadcrumbs() string {

View File

@@ -3,9 +3,9 @@ package tui
import ( import (
"fmt" "fmt"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
func (m Model) updateCartViewport() Model { func (m Model) updateCartViewport() Model {
@@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model {
availH = 1 availH = 1
} }
if !m.cart.viewportReady { if !m.cart.viewportReady {
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.cart.viewport = viewport.New(m.widthContent, availH)
m.cart.viewport.KeyMap = viewport.KeyMap{} m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true m.cart.viewportReady = true
} else { } else {
m.cart.viewport.SetWidth(m.widthContent) m.cart.viewport.Width = m.widthContent
m.cart.viewport.SetHeight(availH) m.cart.viewport.Height = availH
} }
return m return m
} }
@@ -116,11 +116,11 @@ func (m Model) CartView() string {
} }
itemHeight := 4 + 2*boxPadding + 1 itemHeight := 4 + 2*boxPadding + 1
targetY := m.cart.cursor * itemHeight targetY := m.cart.cursor * itemHeight
if targetY < m.cart.viewport.YOffset() { if targetY < m.cart.viewport.YOffset {
m.cart.viewport.SetYOffset(targetY) m.cart.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() { if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight) m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
} }
} }
return lipgloss.Place( return lipgloss.Place(

View File

@@ -3,13 +3,11 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
// RenderConfirmation renders the order confirmation view // RenderConfirmation renders the order confirmation view
@@ -75,12 +73,12 @@ func (m Model) updateConfirmViewport() Model {
availH = 1 availH = 1
} }
if !m.confirm.viewportReady { if !m.confirm.viewportReady {
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.confirm.viewport = viewport.New(m.widthContent, availH)
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap() m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
m.confirm.viewportReady = true m.confirm.viewportReady = true
} else { } else {
m.confirm.viewport.SetWidth(m.widthContent) m.confirm.viewport.Width = m.widthContent
m.confirm.viewport.SetHeight(availH) m.confirm.viewport.Height = availH
} }
return m return m
} }
@@ -170,18 +168,6 @@ func (m Model) ReviewView() string {
if m.CheckingOut { if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...") return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
} }
if m.review.authFailed {
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
if m.review.requiresAction {
return m.renderRequiresActionView()
}
if m.review.success { if m.review.success {
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!") content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
return lipgloss.Place( return lipgloss.Place(
@@ -200,52 +186,18 @@ func (m Model) ReviewView() string {
) )
} }
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
func (m Model) renderRequiresActionView() string {
base := m.theme.TextLabel()
accent := m.theme.TextHighlight()
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
if m.review.redirectURL == "" {
return base.Render(" waiting for authentication link...")
}
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
if err != nil || qrSize > m.widthContent {
body := header + "\n\n" +
base.Render("open this url on your phone to authenticate:") + "\n\n" +
accent.Render(m.review.redirectURL) + "\n\n" +
base.Render("waiting for completion...")
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
}
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
return qr +
header + "\n" +
base.Render("scan or visit to complete authentication") + "\n" +
accent.Render(m.review.redirectURL) + "\n\n" +
base.Render("waiting for completion...")
}
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case CheckoutResultMsg: case CheckoutResultMsg:
m.CheckingOut = false m.CheckingOut = false
if msg.Err != nil { if msg.Err != nil {
m = m.SwitchPage(paymentPage) m = m.SwitchPage(paymentPage)
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)} m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
m.review.cardJustAdded = false m.review.cardJustAdded = false
m.payment.view = 0 m.payment.view = 0
m.payment.form = nil m.payment.form = nil
return m, nil return m, nil
} }
if msg.RequiresAction {
m.review.requiresAction = true
m.review.redirectURL = msg.RedirectURL
m.review.orderID = msg.OrderID
return m, m.schedulePollOrderStatus(msg.OrderID)
}
m.Cart = make(map[uint]*models.CartItem) m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0 m.cart.cursor = 0
m.ShippingInfo = nil m.ShippingInfo = nil
@@ -253,57 +205,10 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.review.success = true m.review.success = true
return m, nil return m, nil
case OrderStatusMsg:
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
return m, nil
}
if msg.Err != nil {
// Transient — keep polling.
return m, m.schedulePollOrderStatus(msg.OrderID)
}
switch msg.Status {
case "paid", "shipped", "delivered":
m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.success = true
return m, nil
case "failed", "cancelled":
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.authFailed = true
return m, nil
default:
return m, m.schedulePollOrderStatus(msg.OrderID)
}
case tea.KeyMsg: case tea.KeyMsg:
if m.CheckingOut { if m.CheckingOut {
return m, nil return m, nil
} }
if m.review.authFailed {
if msg.String() == "esc" || msg.String() == "enter" {
m.review.authFailed = false
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
return m, nil
}
if m.review.requiresAction {
if msg.String() == "esc" {
m.review.requiresAction = false
m.review.redirectURL = ""
m = m.SwitchPage(paymentPage)
m.payment.view = 0
return m, nil
}
return m, nil
}
if m.review.success { if m.review.success {
m.review.success = false m.review.success = false
m.review.cardJustAdded = false m.review.cardJustAdded = false
@@ -329,24 +234,3 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
func friendlyCheckoutError(raw error) string {
s := raw.Error()
switch {
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
return "your bank wants to verify this charge, authentication step coming soon."
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
return "this card doesn't support online authentication. Try another card"
case strings.Contains(s, "CARD_EXPIRED"):
return "card expired. Add a new card and try again"
case strings.Contains(s, "CARD_CVC_FAILED"):
return "card cvc incorrect. Check the back of the card and try again"
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
return "insufficient funds on this card"
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
return "card processing error. Try again in a moment"
case strings.Contains(s, "CARD_DECLINED"):
return "card declined"
}
return "checkout failed, try again."
}

View File

@@ -3,7 +3,7 @@ package tui
import ( import (
"strings" "strings"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildFooter() string { func (m Model) BuildFooter() string {

View File

@@ -3,8 +3,8 @@ package tui
import ( import (
"fmt" "fmt"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2/table" "github.com/charmbracelet/lipgloss/table"
) )
func (m Model) BuildHeader() string { func (m Model) BuildHeader() string {
@@ -44,7 +44,7 @@ func (m Model) BuildHeader() string {
tabs = []string{logo, shop, account, cart} tabs = []string{logo, shop, account, cart}
} }
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style { tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(m.renderer.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) return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
}).Render() }).Render()

View File

@@ -1,8 +1,8 @@
package tui package tui
import ( import (
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildHelpView() string { func (m Model) BuildHelpView() string {

View File

@@ -1,9 +1,9 @@
package tui package tui
import ( import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt" "fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildMenuView() string { func (m Model) BuildMenuView() string {

View File

@@ -7,9 +7,10 @@ import (
"sort" "sort"
"time" "time"
"charm.land/bubbles/v2/key" "github.com/charmbracelet/bubbles/key"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
stripetoken "github.com/stripe/stripe-go/v78/token" stripetoken "github.com/stripe/stripe-go/v78/token"
@@ -64,11 +65,6 @@ type VisibleError struct {
message string message string
} }
// VisibleNotice is a typed success message displayed to the user as a banner.
type VisibleNotice struct {
message string
}
type Model struct { type Model struct {
// User authentication // User authentication
User *models.User User *models.User
@@ -115,11 +111,11 @@ type Model struct {
Loading bool // true when fetching data from API Loading bool // true when fetching data from API
error *VisibleError error *VisibleError
notice *VisibleNotice
APIClient *api.Client APIClient *api.Client
// Rendering // Rendering
theme theme.Theme renderer *lipgloss.Renderer
theme theme.Theme
// Checkout flow state (spans multiple pages) // Checkout flow state (spans multiple pages)
CheckingOut bool CheckingOut bool
@@ -134,7 +130,6 @@ type Model struct {
shipping shippingState shipping shippingState
payment paymentState payment paymentState
account accountState account accountState
refund refundState
review reviewState review reviewState
confirm confirmState confirm confirmState
@@ -202,15 +197,6 @@ type reviewState struct {
cardJustAdded bool cardJustAdded bool
cardWasDuplicate bool cardWasDuplicate bool
success bool success bool
// 3DS / SCA flow: when the backend returns 202 requires_action,
// requiresAction is true and redirectURL is the Stripe-hosted challenge
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
// order flips to paid or failed.
requiresAction bool
redirectURL string
orderID uint
authFailed bool
} }
type confirmState struct { type confirmState struct {
@@ -233,7 +219,6 @@ func (m Model) SwitchPage(p page) Model {
m.currentPage = p m.currentPage = p
m.switched = true m.switched = true
m.error = nil m.error = nil
m.notice = nil
return m return m
} }
@@ -255,6 +240,7 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage) m = m.SwitchPage(cartPage)
m = m.updateCartViewport() m = m.updateCartViewport()
if m.IsCartEmpty() { if m.IsCartEmpty() {
m.footer = []footerCommand{ m.footer = []footerCommand{
{key: "j/k", value: "items"}, {key: "j/k", value: "items"},
{key: "+/-", value: "qty"}, {key: "+/-", value: "qty"},
@@ -427,18 +413,8 @@ func (m Model) Init() tea.Cmd {
} }
type CheckoutResultMsg struct { type CheckoutResultMsg struct {
OrderID uint
Total int
RequiresAction bool
RedirectURL string
Err error
}
// OrderStatusMsg carries the result of a poll against /orders/{id}/status.
// Emitted on the 2s tick while waiting for 3DS authentication to complete.
type OrderStatusMsg struct {
OrderID uint OrderID uint
Status string Total int
Err error Err error
} }
@@ -650,38 +626,15 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
} }
// 3. Convert the cart to an order // 3. Convert the cart to an order
outcome, err := m.APIClient.ConvertCart() order, err := m.APIClient.ConvertCart()
if err != nil { if err != nil {
return CheckoutResultMsg{Err: err} return CheckoutResultMsg{Err: err}
} }
if outcome.RequiresAction { return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
return CheckoutResultMsg{
OrderID: outcome.OrderID,
RequiresAction: true,
RedirectURL: outcome.RedirectURL,
}
}
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
} }
} }
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
// waiting for the customer to complete 3DS authentication.
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
if m.APIClient == nil {
return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
}
status, err := m.APIClient.GetOrderStatus(orderID)
if err != nil {
return OrderStatusMsg{OrderID: orderID, Err: err}
}
return OrderStatusMsg{OrderID: orderID, Status: status}
})
}
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) { func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil { if m.APIClient == nil || m.User == nil {
return m, nil return m, nil
@@ -802,12 +755,18 @@ func (m Model) SavedCardsIsEmpty() bool {
return len(m.SavedCards) == 0 return len(m.SavedCards) == 0
} }
// NewModel creates a new model. // NewModel creates a new model using the default renderer.
func NewModel(username string) Model { func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
}
// newModelWithRenderer creates a new model with the given renderer.
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
m := Model{ m := Model{
Username: username, Username: username,
currentPage: splashPage, currentPage: splashPage,
theme: theme.BasicTheme(), renderer: renderer,
theme: theme.BasicTheme(renderer),
Coffees: []models.Coffee{ Coffees: []models.Coffee{
{ {
Name: "Espresso", Name: "Espresso",
@@ -874,8 +833,8 @@ func NewModel(username string) Model {
} }
// NewModelWithAuth creates a new model with user authentication context. // NewModelWithAuth creates a new model with user authentication context.
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := NewModel("") m := newModelWithRenderer("", renderer)
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey m.StripePublicKey = stripePublicKey
@@ -901,7 +860,7 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
} }
func newViewport(w, h int) viewport.Model { func newViewport(w, h int) viewport.Model {
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h)) return viewport.New(w, h)
} }
func (m Model) collectCardCmd() tea.Cmd { func (m Model) collectCardCmd() tea.Cmd {
@@ -917,10 +876,8 @@ func (m Model) collectCardCmd() tea.Cmd {
} }
} }
const ( const ordersPollInterval = 10 * time.Second
ordersPollInterval = 10 * time.Second const cardPollTimeout = 10 * time.Minute
cardPollTimeout = 10 * time.Minute
)
type OrdersPollTickMsg struct{} type OrdersPollTickMsg struct{}

View File

@@ -5,10 +5,10 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/huh/v2" "github.com/charmbracelet/huh"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe" "terminalShop/pkg/tui/qrfefe"
@@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model {
availH = 1 availH = 1
} }
if !m.payment.viewportReady { if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.payment.viewport = viewport.New(m.widthContent, availH)
m.payment.viewport.KeyMap = viewport.KeyMap{} m.payment.viewport.KeyMap = viewport.KeyMap{}
m.payment.viewportReady = true m.payment.viewportReady = true
} else { } else {
m.payment.viewport.SetWidth(m.widthContent) m.payment.viewport.Width = m.widthContent
m.payment.viewport.SetHeight(availH) m.payment.viewport.Height = availH
} }
return m return m
} }
@@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits. // Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
m.payment.viewport.Height(), m.payment.viewport.Height,
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(), m.renderPaymentHttpsView(),
) )
@@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string {
m.payment.viewport.SetContent(content) m.payment.viewport.SetContent(content)
itemHeight := 3 itemHeight := 3
targetY := m.payment.cardCursor * itemHeight targetY := m.payment.cardCursor * itemHeight
if targetY < m.payment.viewport.YOffset() { if targetY < m.payment.viewport.YOffset {
m.payment.viewport.SetYOffset(targetY) m.payment.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() { if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1) m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
} }
if m.payment.cardCursor == len(m.SavedCards) { if m.payment.cardCursor == len(m.SavedCards) {
m.payment.viewport.GotoBottom() m.payment.viewport.GotoBottom()

View File

@@ -1,395 +0,0 @@
package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
)
// Refund composer layout constants.
const (
refundMinTextareaRows = 5
refundMaxTextareaRows = 10
// chrome above the textarea inside the modal (title, label, padding, footer).
refundModalChrome = 12
refundModalMaxWidth = 58
refundModalMinWidth = 24
// horizontal chrome the modal subtracts from m.widthContent.
refundModalHGutter = 6
// inner padding around the textarea inside the modal body.
refundTextareaHPad = 4
)
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
// SSH client terminal; renders as tofu otherwise.
const (
refundIconUndo = "\uf0e2" // nf-fa-undo
refundIconTag = "\uf02b" // nf-oct-tag
refundIconComment = "\uf41f" // nf-oct-comment
refundIconKey = "\uf11c" // nf-fa-keyboard_o
)
// refundFocus identifies which field in the refund composer has keyboard focus.
type refundFocus int
const (
refundFocusReason refundFocus = iota
refundFocusMessage
refundFocusCount
)
type refundState struct {
open bool
orderID uint
gen uint64 // bumped each open so late async results from a prior session are ignored
reason string
form *huh.Form
textarea textarea.Model
focusIdx refundFocus
submitting bool
err string
reasonErr string
messageErr string
}
type refundRequestMsg struct {
OrderID uint
Gen uint64
Err error
}
// OpenRefundComposer opens the refund request composer for an order.
func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) {
state := refundState{
open: true,
orderID: orderID,
gen: m.refund.gen + 1,
}
state.textarea = m.newRefundTextarea()
state.form = m.buildRefundForm(&state)
m.refund = state
return m, m.refund.form.Init()
}
// RefundUpdate handles all input and async results while the refund composer is open.
func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
state := &m.refund
if !state.open {
return m, nil
}
switch msg := msg.(type) {
case refundRequestMsg:
if msg.Gen != state.gen || msg.OrderID != state.orderID {
return m, nil
}
state.submitting = false
if msg.Err != nil {
state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err)
return m, nil
}
gen := state.gen
m.refund = refundState{gen: gen}
m.notice = &VisibleNotice{message: "Refund request sent"}
return m, nil
case tea.WindowSizeMsg:
state.form = m.buildRefundForm(state)
m.resizeRefundTextarea(state)
return m, nil
case tea.KeyMsg:
if state.submitting {
return m, nil
}
switch msg.String() {
case "esc":
gen := state.gen
m.refund = refundState{gen: gen}
return m, nil
case "ctrl+s":
return m, m.submitRefundRequest(state)
case "tab", "shift+tab":
return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount)
}
}
if state.focusIdx == refundFocusReason {
state.err = ""
state.reasonErr = ""
form, cmd := state.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
state.form = f
}
if state.form.State == huh.StateCompleted {
state.form = m.buildRefundForm(state)
return m, m.setRefundFocus(state, refundFocusMessage)
}
return m, cmd
}
if _, ok := msg.(tea.KeyMsg); ok {
state.err = ""
state.messageErr = ""
}
ta, cmd := state.textarea.Update(msg)
state.textarea = ta
return m, cmd
}
func (m Model) buildRefundForm(state *refundState) *huh.Form {
options := make([]huh.Option[string], 0, len(models.RefundRequestReasons))
for _, reason := range models.RefundRequestReasons {
options = append(options, huh.NewOption(reason, reason))
}
return huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Reason").
Key("reason").
Options(options...).
Value(&state.reason).
Height(len(models.RefundRequestReasons) + 1),
),
).
WithShowErrors(false).
WithShowHelp(false).
WithTheme(m.theme.Form()).
WithLayout(huh.LayoutStack).
WithWidth(m.refundContentWidth())
}
func (m Model) newRefundTextarea() textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.Placeholder = "Describe the issue"
ta.ShowLineNumbers = true
ta.CharLimit = 65536
ta.DynamicHeight = false
ta.SetHeight(m.refundTextareaHeight())
ta.SetWidth(m.refundTextareaWidth())
base := lipgloss.NewStyle()
// Cursor-line uses a darker grey than theme.Border() so the active line
// reads as a subtle wash, not a bright bar competing with the highlight.
cursorLineBG := lipgloss.Color("236")
ta.SetStyles(textarea.Styles{
Focused: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Accent()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
CursorLineNumber: base.Foreground(m.theme.Body()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Blurred: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Body()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Foreground(m.theme.Background()),
CursorLineNumber: base.Foreground(m.theme.Dim()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Cursor: textarea.CursorStyle{
Color: m.theme.Highlight(),
Shape: tea.CursorBlock,
Blink: true,
},
})
return ta
}
func (m Model) resizeRefundTextarea(state *refundState) {
state.textarea.SetHeight(m.refundTextareaHeight())
state.textarea.SetWidth(m.refundTextareaWidth())
}
func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd {
state.focusIdx = focusIdx
if focusIdx == refundFocusReason {
state.textarea.Blur()
state.form = m.buildRefundForm(state)
return state.form.Init()
}
return state.textarea.Focus()
}
func (m Model) submitRefundRequest(state *refundState) tea.Cmd {
state.err = ""
state.reasonErr = ""
state.messageErr = ""
reason := strings.TrimSpace(state.reason)
message := strings.TrimSpace(state.textarea.Value())
if reason == "" {
state.reasonErr = "select a refund reason"
return nil
}
if reason == models.RefundRequestReasonOther && message == "" {
state.messageErr = "message is required when reason is other"
return nil
}
state.submitting = true
return m.createRefundRequestCmd(state.orderID, state.gen, reason, message)
}
func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{
Reason: reason,
Message: message,
})
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err}
}
}
// RenderRefundOverlay renders the refund composer as a centered modal.
// The modal has no border or background fill — only padding for breathing
// room. lipgloss.Place handles centering against the container.
func (m Model) RenderRefundOverlay() string {
state := &m.refund
contentWidth := m.refundContentWidth()
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
title := m.theme.TextAccent().
Bold(true).
Width(contentWidth).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(m.theme.Highlight()).
Render(titleText)
body := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
m.renderRefundReason(state),
"",
m.renderRefundTextarea(state),
"",
m.renderRefundFooter(state),
)
modal := lipgloss.NewStyle().
Padding(1, 2).
Render(body)
return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal)
}
func (m Model) renderRefundReason(state *refundState) string {
if state.focusIdx == 0 && state.form != nil {
out := state.form.View()
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
value := state.reason
if value == "" {
value = "Select a reason"
}
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
selected := m.theme.TextAccent().
Border(lipgloss.HiddenBorder()).
PaddingLeft(1).
Width(m.refundContentWidth()).
Render(value)
out := lipgloss.JoinVertical(lipgloss.Left, label, selected)
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
func (m Model) renderRefundTextarea(state *refundState) string {
label := "Message (optional)"
placeholder := "Describe the issue"
if state.reason == models.RefundRequestReasonOther {
label = "Message (required)"
placeholder = "Describe the issue..."
}
state.textarea.Placeholder = placeholder
border := m.theme.Border()
if state.focusIdx == refundFocusMessage {
border = m.theme.Highlight()
}
wrapper := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderForeground(border)
contentWidth := m.refundContentWidth()
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
state.textarea.SetHeight(m.refundTextareaHeight())
textareaBox := wrapper.Render(state.textarea.View())
counter := m.theme.TextDim().
Width(contentWidth).
Align(lipgloss.Right).
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
out := lipgloss.JoinVertical(
lipgloss.Left,
m.theme.TextLabel().Render(refundIconComment+" "+label),
textareaBox,
counter,
)
if state.messageErr != "" {
out += "\n" + m.theme.TextError().Render(state.messageErr)
}
return out
}
func (m Model) renderRefundFooter(state *refundState) string {
if state.submitting {
return m.theme.TextAccent().Render("sending refund request...")
}
if state.err != "" {
return m.theme.TextError().Render(state.err)
}
key := m.theme.TextHighlight().Bold(true)
sep := m.theme.TextDim().Render(" · ")
dim := m.theme.TextDim()
return dim.Render(refundIconKey+" ") +
key.Render("tab") + dim.Render(" next") + sep +
key.Render("shift+tab") + dim.Render(" prev") + sep +
key.Render("ctrl+s") + dim.Render(" submit") + sep +
key.Render("esc") + dim.Render(" cancel")
}
func (m Model) refundContentWidth() int {
width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth)
if width < refundModalMinWidth {
width = max(1, m.widthContent-refundTextareaHPad)
}
return width
}
func (m Model) refundTextareaWidth() int {
return max(1, m.refundContentWidth()-refundTextareaHPad)
}
func (m Model) refundTextareaHeight() int {
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
}

View File

@@ -6,10 +6,10 @@ import (
"terminalShop/pkg/models" "terminalShop/pkg/models"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/huh/v2" "github.com/charmbracelet/huh"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
// countries defines the available countries with their dial codes. // countries defines the available countries with their dial codes.
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
availH = 1 availH = 1
} }
if !m.shipping.viewportReady { if !m.shipping.viewportReady {
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH)) m.shipping.viewport = viewport.New(m.widthContent, availH)
m.shipping.viewport.KeyMap = viewport.KeyMap{} m.shipping.viewport.KeyMap = viewport.KeyMap{}
m.shipping.viewportReady = true m.shipping.viewportReady = true
} else { } else {
m.shipping.viewport.SetWidth(m.widthContent) m.shipping.viewport.Width = m.widthContent
m.shipping.viewport.SetHeight(availH) m.shipping.viewport.Height = availH
} }
return m return m
} }
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
m.shipping.viewport.SetContent(content) m.shipping.viewport.SetContent(content)
itemHeight := 4 itemHeight := 4
targetY := m.shipping.addressCursor * itemHeight targetY := m.shipping.addressCursor * itemHeight
if targetY < m.shipping.viewport.YOffset() { if targetY < m.shipping.viewport.YOffset {
m.shipping.viewport.SetYOffset(targetY) m.shipping.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() { if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1) m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
} }
if m.shipping.addressCursor == len(m.SavedAddresses) { if m.shipping.addressCursor == len(m.SavedAddresses) {
m.shipping.viewport.GotoBottom() m.shipping.viewport.GotoBottom()

View File

@@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"strings" "strings"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models" "terminalShop/pkg/models"
) )
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
m.shop.detailViewport.KeyMap = modifiedKeyMap m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true m.shop.viewportsReady = true
} else { } else {
m.shop.menuViewport.SetWidth(menuWidth) m.shop.menuViewport.Width = menuWidth
m.shop.menuViewport.SetHeight(availableHeight) m.shop.menuViewport.Height = availableHeight
m.shop.detailViewport.SetWidth(detailWidth) m.shop.detailViewport.Width = detailWidth
m.shop.detailViewport.SetHeight(availableHeight) m.shop.detailViewport.Height = availableHeight
} }
return m return m
} }
@@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
if m.shop.viewportsReady { if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1 targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2) m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2)
m.shop.detailViewport.GotoTop() m.shop.detailViewport.GotoTop()
} }
return m return m

View File

@@ -5,9 +5,9 @@ import (
"terminalShop/pkg/api" "terminalShop/pkg/api"
"terminalShop/pkg/models" "terminalShop/pkg/models"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
type DelayCompleteMsg struct{} type DelayCompleteMsg struct{}

View File

@@ -3,7 +3,7 @@ package tui
import ( import (
"strings" "strings"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string { func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {

View File

@@ -1,50 +1,51 @@
package theme package theme
import ( import (
"charm.land/bubbles/v2/help" "github.com/charmbracelet/bubbles/help"
"charm.land/huh/v2" "github.com/charmbracelet/huh"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
) )
// HuhTheme creates a huh form theme using this project's color palette. // HuhTheme creates a huh form theme using this project's color palette.
func HuhTheme(t Theme) huh.Theme { func HuhTheme(t Theme) *huh.Theme {
var s huh.Styles var th huh.Theme
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
f := &s.Focused f := &th.Focused
f.Base = lipgloss.NewStyle(). f.Base = t.renderer.NewStyle().
PaddingLeft(1). PaddingLeft(1).
BorderStyle(lipgloss.ThickBorder()). BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true). BorderLeft(true).
BorderForeground(t.accent) BorderForeground(t.accent)
f.Title = lipgloss.NewStyle().Foreground(t.body) f.Title = t.renderer.NewStyle().Foreground(t.body)
f.Description = lipgloss.NewStyle().Foreground(t.body) f.Description = t.renderer.NewStyle().Foreground(t.body)
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight) f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim) f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent) f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent) f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error) f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error) f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ") f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
f.Option = lipgloss.NewStyle().Foreground(t.body) f.Option = t.renderer.NewStyle().Foreground(t.body)
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true) f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true)
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ") f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ") f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ")
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ") f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ")
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body) f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body)
s.Help = help.New().Styles th.Help = help.New().Styles
s.Blurred = copyFieldStyles(*f) th.Blurred = copyFieldStyles(*f)
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
s.Blurred.Title = s.Blurred.Title.Foreground(t.body) th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ")
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true) th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true)
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim) th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim)
return huh.ThemeFunc(func(bool) *huh.Styles { return &s }) return &th
} }
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f } func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }

View File

@@ -1,55 +1,56 @@
package theme package theme
import ( import (
"image/color" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/compat"
) )
// Theme holds all colors and styles for the TUI // Theme holds all colors and styles for the terminal coffee shop TUI.
type Theme struct { type Theme struct {
background color.Color renderer *lipgloss.Renderer
border color.Color
body color.Color background lipgloss.TerminalColor
dim color.Color border lipgloss.TerminalColor
accent color.Color body lipgloss.TerminalColor
highlight color.Color dim lipgloss.TerminalColor
brand color.Color accent lipgloss.TerminalColor
label color.Color highlight lipgloss.TerminalColor
success color.Color brand lipgloss.TerminalColor
error color.Color label lipgloss.TerminalColor
success lipgloss.TerminalColor
error lipgloss.TerminalColor
base lipgloss.Style base lipgloss.Style
form huh.Theme form *huh.Theme
} }
func BasicTheme() Theme { // BasicTheme constructs the default theme for terminal coffee shop.
t := Theme{} func BasicTheme(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")} t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")} t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
t.body = lipgloss.Color("#AAAAAA") t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#666666") t.dim = lipgloss.Color("#666666")
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")} t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
t.highlight = lipgloss.Color("#4682B4") t.highlight = lipgloss.Color("#4682B4")
t.brand = lipgloss.Color("#4682B4") t.brand = lipgloss.Color("#4682B4")
t.label = lipgloss.Color("#999999") t.label = lipgloss.Color("#999999")
t.success = lipgloss.Color("#00FF00") t.success = lipgloss.Color("#00FF00")
t.error = lipgloss.Color("196") t.error = lipgloss.Color("196")
t.base = lipgloss.NewStyle().Foreground(t.body) t.base = renderer.NewStyle().Foreground(t.body)
t.form = HuhTheme(t) t.form = HuhTheme(t)
return t return t
} }
func Catppuccin() Theme { // this should resemble something like catppuccin
t := Theme{} func Catppuccin(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")} t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")} t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
t.body = lipgloss.Color("#AAAAAA") t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#6C7086") t.dim = lipgloss.Color("#6C7086")
t.accent = lipgloss.Color("#CBA6F7") t.accent = lipgloss.Color("#CBA6F7")
@@ -59,25 +60,25 @@ func Catppuccin() Theme {
t.success = lipgloss.Color("#A6E3A1") t.success = lipgloss.Color("#A6E3A1")
t.error = lipgloss.Color("#F38BA8") t.error = lipgloss.Color("#F38BA8")
t.base = lipgloss.NewStyle().Foreground(t.body) t.base = renderer.NewStyle().Foreground(t.body)
t.form = HuhTheme(t) t.form = HuhTheme(t)
return t return t
} }
// Color accessors. // Color accessors — use in .Foreground() / .Background() calls.
func (t Theme) Background() color.Color { return t.background } func (t Theme) Background() lipgloss.TerminalColor { return t.background }
func (t Theme) Border() color.Color { return t.border } func (t Theme) Border() lipgloss.TerminalColor { return t.border }
func (t Theme) Body() color.Color { return t.body } func (t Theme) Body() lipgloss.TerminalColor { return t.body }
func (t Theme) Dim() color.Color { return t.dim } func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
func (t Theme) Accent() color.Color { return t.accent } func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
func (t Theme) Highlight() color.Color { return t.highlight } func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
func (t Theme) Brand() color.Color { return t.brand } func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
func (t Theme) Label() color.Color { return t.label } func (t Theme) Label() lipgloss.TerminalColor { return t.label }
func (t Theme) Success() color.Color { return t.success } func (t Theme) Success() lipgloss.TerminalColor { return t.success }
func (t Theme) Error() color.Color { return t.error } func (t Theme) Error() lipgloss.TerminalColor { return t.error }
// Style helpers. // Style helpers — return a copy of base with the correct foreground.
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
func (t Theme) Base() lipgloss.Style { return t.base } func (t Theme) Base() lipgloss.Style { return t.base }
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) } func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) } func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
@@ -89,8 +90,10 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) } func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) } func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
// PanelError returns a style for the error banner: dark red bg, red fg.
func (t Theme) PanelError() lipgloss.Style { func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196")) return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
} }
func (t Theme) Form() huh.Theme { return t.form } // Form returns the huh form theme for shipping and payment forms.
func (t Theme) Form() *huh.Theme { return t.form }

View File

@@ -2,12 +2,11 @@ package tui
import ( import (
"fmt" "fmt"
"time"
tea "charm.land/bubbletea/v2"
"terminalShop/pkg/api" "terminalShop/pkg/api"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"time"
tea "github.com/charmbracelet/bubbletea"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -37,10 +36,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.payment.form != nil { if m.payment.form != nil {
m.payment.form.form = m.buildPaymentForm(m.payment.form) m.payment.form.form = m.buildPaymentForm(m.payment.form)
} }
if m.refund.open {
m.refund.form = m.buildRefundForm(&m.refund)
m.resizeRefundTextarea(&m.refund)
}
} }
return m, nil return m, nil
} }
@@ -56,15 +51,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case VisibleError: case VisibleError:
m.error = &msg m.error = &msg
m.notice = nil
return m, nil
case VisibleNotice:
m.notice = &msg
m.error = nil
return m, nil return m, nil
case error: case error:
m.error = &VisibleError{message: msg.Error()} m.error = &VisibleError{message: msg.Error()}
m.notice = nil
if m.currentPage == shopPage || m.currentPage == cartPage { if m.currentPage == shopPage || m.currentPage == cartPage {
return m, m.fetchCartCmd return m, m.fetchCartCmd
} }
@@ -178,14 +167,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.shipping.form != nil || m.payment.form != nil { if m.shipping.form != nil || m.payment.form != nil {
break break
} }
// Refund overlay owns input when open; dispatched below before page handlers. // Dismiss error banner with ESC
if m.refund.open { if msg.String() == "esc" && m.error != nil {
break
}
// Dismiss banners with ESC.
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
m.error = nil m.error = nil
m.notice = nil
return m, nil return m, nil
} }
// Global navigation // Global navigation
@@ -208,12 +192,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
// non-globals. fall through to page dispatch below // non-globals. fall through to page dispatch below
} }
// Refund overlay intercepts all input after global handling.
if m.refund.open {
return m.RefundUpdate(msg)
}
// Page-specifics // Page-specifics
switch m.currentPage { switch m.currentPage {
case shopPage: case shopPage:

View File

@@ -1,8 +1,7 @@
package tui package tui
import ( import (
tea "charm.land/bubbletea/v2" "github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
) )
// ResizeView renders a message when the terminal is too small. // ResizeView renders a message when the terminal is too small.
@@ -20,13 +19,7 @@ func (m Model) ResizeView() string {
) )
} }
func (m Model) View() tea.View { func (m Model) View() string {
v := tea.NewView(m.renderString())
v.AltScreen = true
return v
}
func (m Model) renderString() string {
if m.viewportWidth == 0 { if m.viewportWidth == 0 {
m.updateLayout(120, 30) m.updateLayout(120, 30)
} }
@@ -82,16 +75,8 @@ func (m Model) renderString() string {
content = loadingStyle.Render("Loading products from API...") content = loadingStyle.Render("Loading products from API...")
} else if m.error != nil { } else if m.error != nil {
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1) errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
// soft-wrap long messages instead of clipping.
if m.widthContent > 4 {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message) errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) 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(availableContentHeight)
} else { } else {
content = m.buildPageContent(availableContentHeight) content = m.buildPageContent(availableContentHeight)
} }
@@ -123,10 +108,6 @@ func (m Model) renderString() string {
MaxHeight(m.heightContainer). MaxHeight(m.heightContainer).
Render(child) Render(child)
if m.refund.open {
constrained = m.RenderRefundOverlay()
}
// Center the entire container in the terminal viewport // Center the entire container in the terminal viewport
return lipgloss.Place( return lipgloss.Place(
m.viewportWidth, m.viewportWidth,