Compare commits
13 Commits
feat/unshi
...
fix/sca-re
| Author | SHA1 | Date | |
|---|---|---|---|
| 76cf5c681b | |||
| e6301e662f | |||
| 8b2889a524 | |||
| c2b2b6e834 | |||
| bba5a0dfa0 | |||
| a8e45bc82b | |||
| 95b9fa91b1 | |||
| 0fe013c399 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,6 +5,9 @@
|
||||
.env.example
|
||||
.env.prod
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
.tmp-gocache/
|
||||
@@ -98,6 +101,8 @@ docs/
|
||||
# Migration state (if using a migration tool that creates state files)
|
||||
migrations/*.state
|
||||
|
||||
gh-dash/
|
||||
|
||||
|
||||
|
||||
loadtest/run.pid
|
||||
|
||||
@@ -24,11 +24,12 @@ import (
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey}
|
||||
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
@@ -391,16 +392,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
// Charge the card. The idempotency key is tied to the order ID so retries
|
||||
// never produce a double charge.
|
||||
piParams := &stripe.PaymentIntentParams{
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||
Enabled: stripe.Bool(true),
|
||||
AllowRedirects: stripe.String("never"),
|
||||
},
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(true),
|
||||
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||
Shipping: &stripe.ShippingDetailsParams{
|
||||
Name: stripe.String(address.Name),
|
||||
Address: &stripe.AddressParams{
|
||||
@@ -423,13 +423,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if err != nil {
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
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 {
|
||||
case stripe.ErrorTypeCard:
|
||||
middleware.RecordCartConversion("card_declined")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
case stripe.ErrorTypeInvalidRequest:
|
||||
middleware.RecordCartConversion("stripe_invalid_request")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
@@ -441,6 +445,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
@@ -448,6 +453,11 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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.
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
order.StripePaymentID = pi.ID
|
||||
@@ -489,3 +499,91 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
"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 before responding so the reconciliation jon
|
||||
// and webhook can match the eventual succeeded/failed event to this order
|
||||
// even if the customer drops off mid-auth
|
||||
if err := db.Model(order).Update("stripe_payment_id", pi.ID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item
|
||||
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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item
|
||||
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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||
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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||
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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item but no address or card
|
||||
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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
@@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Empty cart convert
|
||||
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 database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
|
||||
@@ -2,9 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/bring"
|
||||
@@ -31,6 +34,16 @@ type OrderHandler struct {
|
||||
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 {
|
||||
return &OrderHandler{
|
||||
stripeKey: stripeSecretKey,
|
||||
@@ -40,6 +53,83 @@ 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) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
@@ -50,11 +140,34 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
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]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"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
|
||||
// refunded. Only orders in paid or shipped status can be refunded.
|
||||
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
170
api/handlers/orders_test.go
Normal file
170
api/handlers/orders_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
@@ -75,3 +76,26 @@ 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)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
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
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
||||
_ = json.Unmarshal(bodyBytes, &result)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
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
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -373,7 +373,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if previousTrackingStatus != newStatus {
|
||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
go func() {
|
||||
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
}()
|
||||
}
|
||||
if deliveredTransition {
|
||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||
|
||||
15
api/main.go
15
api/main.go
@@ -113,6 +113,21 @@ 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.")
|
||||
|
||||
// Block until signal received
|
||||
|
||||
@@ -46,7 +46,7 @@ func SetupRoutes(
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
@@ -61,6 +61,10 @@ func SetupRoutes(
|
||||
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>`))
|
||||
})
|
||||
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.Route("/api/v1", func(r chi.Router) {
|
||||
@@ -126,6 +130,8 @@ func SetupRoutes(
|
||||
|
||||
// Orders
|
||||
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)
|
||||
|
||||
// Addresses
|
||||
|
||||
55
go.mod
55
go.mod
@@ -1,14 +1,14 @@
|
||||
module terminalShop
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
charm.land/wish/v2 v2.0.1
|
||||
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/go-chi/chi/v5 v5.2.5
|
||||
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/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -32,38 +32,35 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/log/v2 v2.0.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // 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/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||
github.com/charmbracelet/log v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // 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/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
@@ -78,15 +75,11 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // 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/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
@@ -104,11 +97,11 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // 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/grpc v1.80.0 // indirect
|
||||
|
||||
124
go.sum
124
go.sum
@@ -1,3 +1,15 @@
|
||||
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/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
@@ -10,10 +22,8 @@ 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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
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/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
@@ -22,52 +32,38 @@ 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
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/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
|
||||
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
|
||||
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/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
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/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/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
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/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
||||
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
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/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
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=
|
||||
@@ -75,8 +71,6 @@ 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/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/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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||
@@ -91,8 +85,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/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -147,25 +141,17 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/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/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/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
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/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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||
@@ -247,10 +233,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-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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -273,19 +259,17 @@ 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-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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
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.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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
||||
16
main.go
16
main.go
@@ -11,11 +11,11 @@ import (
|
||||
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/wish/v2"
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
"github.com/charmbracelet/wish/bubbletea"
|
||||
"github.com/charmbracelet/wish/logging"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
@@ -36,9 +36,6 @@ var (
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
renderer := bubbletea.MakeRenderer(s)
|
||||
|
||||
// Extract SSH public key from session
|
||||
pubKey := s.PublicKey()
|
||||
|
||||
var fingerprint, pubKeyStr string
|
||||
@@ -47,9 +44,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
||||
}
|
||||
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -56,7 +56,6 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||
|
||||
reqBody := TokenRequest{
|
||||
@@ -497,6 +496,40 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
return &convertResp.Data.Order, 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.
|
||||
func (c *Client) GetCards() ([]models.Card, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
|
||||
@@ -636,6 +669,20 @@ type OrderResponse struct {
|
||||
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) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||
|
||||
@@ -664,6 +711,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
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
|
||||
type AddressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = "order_requires_action"
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -298,3 +300,15 @@ func attrsToMap(a []any) map[string]any {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ type Order struct {
|
||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||
@@ -113,3 +117,35 @@ func (o *Order) TotalInDollars() float64 {
|
||||
func (o *Order) IsActive() bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,10 +11,27 @@ const (
|
||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||
DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
|
||||
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
|
||||
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 {
|
||||
if order.refundPending() {
|
||||
return "Refund Requested"
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
@@ -54,6 +71,9 @@ func (order *Order) DisplayState() string {
|
||||
}
|
||||
|
||||
func (order *Order) DisplayKind() DisplayKind {
|
||||
if order.refundPending() {
|
||||
return DisplayKindRefund
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOrderDisplayState(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
order Order
|
||||
@@ -23,6 +27,10 @@ func TestOrderDisplayState(t *testing.T) {
|
||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", 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 {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -188,29 +188,49 @@ func SlackUnpinOrder(orderID uint) {
|
||||
}
|
||||
|
||||
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
||||
// Looks up the thread ts from the database. Silent no-op if the order has no
|
||||
// saved thread (Slack was disabled when it was created, or post failed earlier).
|
||||
func SlackPostToOrderThread(orderID uint, text string) {
|
||||
// It looks up the thread ts from the database and returns an error when the
|
||||
// reply cannot be posted.
|
||||
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||
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")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return
|
||||
return fmt.Errorf("slack is not configured")
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
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)
|
||||
return
|
||||
return fmt.Errorf("failed to look up order thread: %w", err)
|
||||
}
|
||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||
return
|
||||
return fmt.Errorf("order has no slack thread")
|
||||
}
|
||||
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
payload := map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": *order.SlackThreadTS,
|
||||
"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) {
|
||||
@@ -264,4 +284,44 @@ func markStatusActionBlock(orderID uint) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
func SlackUnshippedReminder(orders []models.Order) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model {
|
||||
}
|
||||
|
||||
if !m.account.viewportReady {
|
||||
m.account.detailViewport = viewport.New(detailWidth, availableHeight)
|
||||
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
||||
m.account.detailViewport.KeyMap = modifiedKeyMap
|
||||
m.account.viewportReady = true
|
||||
} else {
|
||||
m.account.detailViewport.Width = detailWidth
|
||||
m.account.detailViewport.Height = availableHeight
|
||||
m.account.detailViewport.SetWidth(detailWidth)
|
||||
m.account.detailViewport.SetHeight(availableHeight)
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model {
|
||||
}
|
||||
|
||||
targetY := headerOffset + (selectedIndex * itemHeight)
|
||||
vpH := m.account.detailViewport.Height
|
||||
offset := m.account.detailViewport.YOffset
|
||||
vpH := m.account.detailViewport.Height()
|
||||
offset := m.account.detailViewport.YOffset()
|
||||
|
||||
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
||||
// not when the current item hits the very edge
|
||||
@@ -288,6 +288,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case "r":
|
||||
if m.account.orderViewState == 2 {
|
||||
if order, ok := m.selectedRefundableOrder(); ok {
|
||||
return m.OpenRefundComposer(order.ID)
|
||||
}
|
||||
}
|
||||
|
||||
case "x", "d":
|
||||
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
||||
m.account.addressDeleting = &m.account.addressCursor
|
||||
@@ -336,14 +343,18 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
case 1:
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
||||
m.account.orderViewState = 2
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = []footerCommand{
|
||||
footer := []footerCommand{
|
||||
{key: "esc", value: "back"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
if _, ok := m.selectedRefundableOrder(); ok {
|
||||
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
|
||||
}
|
||||
m.footer = footer
|
||||
}
|
||||
}
|
||||
case "addresses":
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) OrdersView(width int) string {
|
||||
@@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order {
|
||||
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 {
|
||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||
return nil
|
||||
@@ -241,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Footer hint
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
if order.CanRequestRefund() {
|
||||
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
|
||||
} else {
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -259,10 +278,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
|
||||
case models.DisplayKindError:
|
||||
return m.theme.TextError()
|
||||
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()
|
||||
case models.DisplayKindRefund:
|
||||
return m.theme.TextBrand()
|
||||
}
|
||||
return m.theme.TextHighlight()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildBreadcrumbs() string {
|
||||
|
||||
@@ -3,9 +3,9 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) updateCartViewport() Model {
|
||||
@@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.cart.viewportReady {
|
||||
m.cart.viewport = viewport.New(m.widthContent, availH)
|
||||
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.cart.viewportReady = true
|
||||
} else {
|
||||
m.cart.viewport.Width = m.widthContent
|
||||
m.cart.viewport.Height = availH
|
||||
m.cart.viewport.SetWidth(m.widthContent)
|
||||
m.cart.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -116,11 +116,11 @@ func (m Model) CartView() string {
|
||||
}
|
||||
itemHeight := 4 + 2*boxPadding + 1
|
||||
targetY := m.cart.cursor * itemHeight
|
||||
if targetY < m.cart.viewport.YOffset {
|
||||
if targetY < m.cart.viewport.YOffset() {
|
||||
m.cart.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||
}
|
||||
}
|
||||
return lipgloss.Place(
|
||||
|
||||
@@ -3,11 +3,12 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// RenderConfirmation renders the order confirmation view
|
||||
@@ -73,12 +74,12 @@ func (m Model) updateConfirmViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.confirm.viewportReady {
|
||||
m.confirm.viewport = viewport.New(m.widthContent, availH)
|
||||
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
|
||||
m.confirm.viewportReady = true
|
||||
} else {
|
||||
m.confirm.viewport.Width = m.widthContent
|
||||
m.confirm.viewport.Height = availH
|
||||
m.confirm.viewport.SetWidth(m.widthContent)
|
||||
m.confirm.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -192,7 +193,7 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.CheckingOut = false
|
||||
if msg.Err != nil {
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
m.review.cardJustAdded = false
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
@@ -234,3 +235,24 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
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."
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildFooter() string {
|
||||
|
||||
@@ -3,8 +3,8 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
)
|
||||
|
||||
func (m Model) BuildHeader() string {
|
||||
@@ -44,7 +44,7 @@ func (m Model) BuildHeader() string {
|
||||
tabs = []string{logo, shop, account, cart}
|
||||
}
|
||||
|
||||
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 {
|
||||
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
|
||||
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
|
||||
}).Render()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildHelpView() string {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildMenuView() string {
|
||||
|
||||
@@ -7,10 +7,9 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
stripetoken "github.com/stripe/stripe-go/v78/token"
|
||||
|
||||
@@ -65,6 +64,11 @@ type VisibleError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
// VisibleNotice is a typed success message displayed to the user as a banner.
|
||||
type VisibleNotice struct {
|
||||
message string
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
// User authentication
|
||||
User *models.User
|
||||
@@ -111,11 +115,11 @@ type Model struct {
|
||||
|
||||
Loading bool // true when fetching data from API
|
||||
error *VisibleError
|
||||
notice *VisibleNotice
|
||||
APIClient *api.Client
|
||||
|
||||
// Rendering
|
||||
renderer *lipgloss.Renderer
|
||||
theme theme.Theme
|
||||
theme theme.Theme
|
||||
|
||||
// Checkout flow state (spans multiple pages)
|
||||
CheckingOut bool
|
||||
@@ -130,6 +134,7 @@ type Model struct {
|
||||
shipping shippingState
|
||||
payment paymentState
|
||||
account accountState
|
||||
refund refundState
|
||||
review reviewState
|
||||
confirm confirmState
|
||||
|
||||
@@ -219,6 +224,7 @@ func (m Model) SwitchPage(p page) Model {
|
||||
m.currentPage = p
|
||||
m.switched = true
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -240,7 +246,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
||||
m = m.SwitchPage(cartPage)
|
||||
m = m.updateCartViewport()
|
||||
if m.IsCartEmpty() {
|
||||
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "items"},
|
||||
{key: "+/-", value: "qty"},
|
||||
@@ -755,18 +760,12 @@ func (m Model) SavedCardsIsEmpty() bool {
|
||||
return len(m.SavedCards) == 0
|
||||
}
|
||||
|
||||
// NewModel creates a new model using the default renderer.
|
||||
// NewModel creates a new 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{
|
||||
Username: username,
|
||||
currentPage: splashPage,
|
||||
renderer: renderer,
|
||||
theme: theme.BasicTheme(renderer),
|
||||
theme: theme.BasicTheme(),
|
||||
Coffees: []models.Coffee{
|
||||
{
|
||||
Name: "Espresso",
|
||||
@@ -833,8 +832,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context.
|
||||
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := newModelWithRenderer("", renderer)
|
||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := NewModel("")
|
||||
m.Fingerprint = fingerprint
|
||||
m.AuthFingerprintKey = clientSecret
|
||||
m.StripePublicKey = stripePublicKey
|
||||
@@ -860,7 +859,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
|
||||
}
|
||||
|
||||
func newViewport(w, h int) viewport.Model {
|
||||
return viewport.New(w, h)
|
||||
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
|
||||
}
|
||||
|
||||
func (m Model) collectCardCmd() tea.Cmd {
|
||||
@@ -876,8 +875,10 @@ func (m Model) collectCardCmd() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
const ordersPollInterval = 10 * time.Second
|
||||
const cardPollTimeout = 10 * time.Minute
|
||||
const (
|
||||
ordersPollInterval = 10 * time.Second
|
||||
cardPollTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
type OrdersPollTickMsg struct{}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
@@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.payment.viewportReady {
|
||||
m.payment.viewport = viewport.New(m.widthContent, availH)
|
||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.payment.viewportReady = true
|
||||
} else {
|
||||
m.payment.viewport.Width = m.widthContent
|
||||
m.payment.viewport.Height = availH
|
||||
m.payment.viewport.SetWidth(m.widthContent)
|
||||
m.payment.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string {
|
||||
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
m.payment.viewport.Height,
|
||||
m.payment.viewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.renderPaymentHttpsView(),
|
||||
)
|
||||
@@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string {
|
||||
m.payment.viewport.SetContent(content)
|
||||
itemHeight := 3
|
||||
targetY := m.payment.cardCursor * itemHeight
|
||||
if targetY < m.payment.viewport.YOffset {
|
||||
if targetY < m.payment.viewport.YOffset() {
|
||||
m.payment.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.viewport.GotoBottom()
|
||||
|
||||
395
pkg/tui/refund.go
Normal file
395
pkg/tui/refund.go
Normal file
@@ -0,0 +1,395 @@
|
||||
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))
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// countries defines the available countries with their dial codes.
|
||||
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.shipping.viewportReady {
|
||||
m.shipping.viewport = viewport.New(m.widthContent, availH)
|
||||
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.shipping.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.shipping.viewportReady = true
|
||||
} else {
|
||||
m.shipping.viewport.Width = m.widthContent
|
||||
m.shipping.viewport.Height = availH
|
||||
m.shipping.viewport.SetWidth(m.widthContent)
|
||||
m.shipping.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
|
||||
m.shipping.viewport.SetContent(content)
|
||||
itemHeight := 4
|
||||
targetY := m.shipping.addressCursor * itemHeight
|
||||
if targetY < m.shipping.viewport.YOffset {
|
||||
if targetY < m.shipping.viewport.YOffset() {
|
||||
m.shipping.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
|
||||
}
|
||||
if m.shipping.addressCursor == len(m.SavedAddresses) {
|
||||
m.shipping.viewport.GotoBottom()
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
|
||||
m.shop.detailViewport.KeyMap = modifiedKeyMap
|
||||
m.shop.viewportsReady = true
|
||||
} else {
|
||||
m.shop.menuViewport.Width = menuWidth
|
||||
m.shop.menuViewport.Height = availableHeight
|
||||
m.shop.detailViewport.Width = detailWidth
|
||||
m.shop.detailViewport.Height = availableHeight
|
||||
m.shop.menuViewport.SetWidth(menuWidth)
|
||||
m.shop.menuViewport.SetHeight(availableHeight)
|
||||
m.shop.detailViewport.SetWidth(detailWidth)
|
||||
m.shop.detailViewport.SetHeight(availableHeight)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
||||
|
||||
if m.shop.viewportsReady {
|
||||
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()
|
||||
}
|
||||
return m
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type DelayCompleteMsg struct{}
|
||||
|
||||
@@ -3,7 +3,7 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||
|
||||
@@ -1,51 +1,50 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// HuhTheme creates a huh form theme using this project's color palette.
|
||||
func HuhTheme(t Theme) *huh.Theme {
|
||||
var th huh.Theme
|
||||
func HuhTheme(t Theme) huh.Theme {
|
||||
var s huh.Styles
|
||||
|
||||
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
|
||||
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n")
|
||||
|
||||
f := &th.Focused
|
||||
f.Base = t.renderer.NewStyle().
|
||||
f := &s.Focused
|
||||
f.Base = lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.accent)
|
||||
f.Title = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.Description = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
|
||||
f.Title = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.Description = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
|
||||
|
||||
f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
|
||||
|
||||
th.Help = help.New().Styles
|
||||
s.Help = help.New().Styles
|
||||
|
||||
th.Blurred = copyFieldStyles(*f)
|
||||
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
|
||||
th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ")
|
||||
th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true)
|
||||
th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim)
|
||||
s.Blurred = copyFieldStyles(*f)
|
||||
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
|
||||
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
|
||||
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim)
|
||||
|
||||
return &th
|
||||
return huh.ThemeFunc(func(bool) *huh.Styles { return &s })
|
||||
}
|
||||
|
||||
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
|
||||
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }
|
||||
|
||||
@@ -1,56 +1,55 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"image/color"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/compat"
|
||||
)
|
||||
|
||||
// Theme holds all colors and styles for the terminal coffee shop TUI.
|
||||
// Theme holds all colors and styles for the TUI
|
||||
type Theme struct {
|
||||
renderer *lipgloss.Renderer
|
||||
|
||||
background lipgloss.TerminalColor
|
||||
border lipgloss.TerminalColor
|
||||
body lipgloss.TerminalColor
|
||||
dim lipgloss.TerminalColor
|
||||
accent lipgloss.TerminalColor
|
||||
highlight lipgloss.TerminalColor
|
||||
brand lipgloss.TerminalColor
|
||||
label lipgloss.TerminalColor
|
||||
success lipgloss.TerminalColor
|
||||
error lipgloss.TerminalColor
|
||||
background color.Color
|
||||
border color.Color
|
||||
body color.Color
|
||||
dim color.Color
|
||||
accent color.Color
|
||||
highlight color.Color
|
||||
brand color.Color
|
||||
label color.Color
|
||||
success color.Color
|
||||
error color.Color
|
||||
|
||||
base lipgloss.Style
|
||||
form *huh.Theme
|
||||
form huh.Theme
|
||||
}
|
||||
|
||||
// BasicTheme constructs the default theme for terminal coffee shop.
|
||||
func BasicTheme(renderer *lipgloss.Renderer) Theme {
|
||||
t := Theme{renderer: renderer}
|
||||
func BasicTheme() Theme {
|
||||
t := Theme{}
|
||||
|
||||
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
||||
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#666666")
|
||||
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
|
||||
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")}
|
||||
t.highlight = lipgloss.Color("#4682B4")
|
||||
t.brand = lipgloss.Color("#4682B4")
|
||||
t.label = lipgloss.Color("#999999")
|
||||
t.success = lipgloss.Color("#00FF00")
|
||||
t.error = lipgloss.Color("196")
|
||||
|
||||
t.base = renderer.NewStyle().Foreground(t.body)
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// this should resemble something like catppuccin
|
||||
func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
||||
t := Theme{renderer: renderer}
|
||||
func Catppuccin() Theme {
|
||||
t := Theme{}
|
||||
|
||||
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
|
||||
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#6C7086")
|
||||
t.accent = lipgloss.Color("#CBA6F7")
|
||||
@@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
||||
t.success = lipgloss.Color("#A6E3A1")
|
||||
t.error = lipgloss.Color("#F38BA8")
|
||||
|
||||
t.base = renderer.NewStyle().Foreground(t.body)
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// Color accessors — use in .Foreground() / .Background() calls.
|
||||
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
|
||||
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
|
||||
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
|
||||
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
|
||||
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
|
||||
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
|
||||
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
|
||||
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
|
||||
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
|
||||
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
|
||||
// Color accessors.
|
||||
func (t Theme) Background() color.Color { return t.background }
|
||||
func (t Theme) Border() color.Color { return t.border }
|
||||
func (t Theme) Body() color.Color { return t.body }
|
||||
func (t Theme) Dim() color.Color { return t.dim }
|
||||
func (t Theme) Accent() color.Color { return t.accent }
|
||||
func (t Theme) Highlight() color.Color { return t.highlight }
|
||||
func (t Theme) Brand() color.Color { return t.brand }
|
||||
func (t Theme) Label() color.Color { return t.label }
|
||||
func (t Theme) Success() color.Color { return t.success }
|
||||
func (t Theme) Error() color.Color { return t.error }
|
||||
|
||||
// Style helpers — return a copy of base with the correct foreground.
|
||||
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
|
||||
// Style helpers.
|
||||
func (t Theme) Base() lipgloss.Style { return t.base }
|
||||
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
|
||||
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
|
||||
@@ -90,10 +89,8 @@ 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) 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 {
|
||||
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
||||
}
|
||||
|
||||
// Form returns the huh form theme for shipping and payment forms.
|
||||
func (t Theme) Form() *huh.Theme { return t.form }
|
||||
func (t Theme) Form() huh.Theme { return t.form }
|
||||
|
||||
@@ -2,11 +2,12 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -36,6 +37,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.payment.form != nil {
|
||||
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
|
||||
}
|
||||
@@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case VisibleError:
|
||||
m.error = &msg
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
case VisibleNotice:
|
||||
m.notice = &msg
|
||||
m.error = nil
|
||||
return m, nil
|
||||
case error:
|
||||
m.error = &VisibleError{message: msg.Error()}
|
||||
m.notice = nil
|
||||
if m.currentPage == shopPage || m.currentPage == cartPage {
|
||||
return m, m.fetchCartCmd
|
||||
}
|
||||
@@ -167,9 +178,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.shipping.form != nil || m.payment.form != nil {
|
||||
break
|
||||
}
|
||||
// Dismiss error banner with ESC
|
||||
if msg.String() == "esc" && m.error != nil {
|
||||
// Refund overlay owns input when open; dispatched below before page handlers.
|
||||
if m.refund.open {
|
||||
break
|
||||
}
|
||||
// Dismiss banners with ESC.
|
||||
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
}
|
||||
// Global navigation
|
||||
@@ -192,6 +208,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// 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
|
||||
switch m.currentPage {
|
||||
case shopPage:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ResizeView renders a message when the terminal is too small.
|
||||
@@ -19,7 +20,13 @@ func (m Model) ResizeView() string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
func (m Model) View() tea.View {
|
||||
v := tea.NewView(m.renderString())
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m Model) renderString() string {
|
||||
if m.viewportWidth == 0 {
|
||||
m.updateLayout(120, 30)
|
||||
}
|
||||
@@ -75,8 +82,16 @@ func (m Model) View() string {
|
||||
content = loadingStyle.Render("Loading products from API...")
|
||||
} else if m.error != nil {
|
||||
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)
|
||||
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 {
|
||||
content = m.buildPageContent(availableContentHeight)
|
||||
}
|
||||
@@ -108,6 +123,10 @@ func (m Model) View() string {
|
||||
MaxHeight(m.heightContainer).
|
||||
Render(child)
|
||||
|
||||
if m.refund.open {
|
||||
constrained = m.RenderRefundOverlay()
|
||||
}
|
||||
|
||||
// Center the entire container in the terminal viewport
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
|
||||
Reference in New Issue
Block a user