Compare commits
23 Commits
testing
...
chore/bump
| Author | SHA1 | Date | |
|---|---|---|---|
| 67b2a51800 | |||
| c8b7545ca6 | |||
| 3a3c7ee80b | |||
| 7c8a2b562f | |||
| 1c5765d25b | |||
| e6301e662f | |||
| 8b2889a524 | |||
| c2b2b6e834 | |||
| bba5a0dfa0 | |||
| a8e45bc82b | |||
| 95b9fa91b1 | |||
| 0fe013c399 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 | |||
| 48d65c48fa | |||
| c78021f520 | |||
| c429c1966e | |||
| 50e1ba32f3 | |||
| 85b0c53c0a | |||
| 366d80a9ee |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -8,9 +8,9 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
|
if: ${{ !env.ACT }}
|
||||||
uses: golangci/golangci-lint-action@v7
|
uses: golangci/golangci-lint-action@v7
|
||||||
with:
|
with:
|
||||||
version: v2.4.0
|
version: v2.4.0
|
||||||
|
|||||||
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -8,8 +8,8 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version: '1.23'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,6 +5,9 @@
|
|||||||
.env.example
|
.env.example
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
|
docs/sca-psd2-compliance.md
|
||||||
|
accept-a-payment/
|
||||||
|
|
||||||
|
|
||||||
FULFILLMENT.md
|
FULFILLMENT.md
|
||||||
.tmp-gocache/
|
.tmp-gocache/
|
||||||
@@ -98,6 +101,8 @@ docs/
|
|||||||
# Migration state (if using a migration tool that creates state files)
|
# Migration state (if using a migration tool that creates state files)
|
||||||
migrations/*.state
|
migrations/*.state
|
||||||
|
|
||||||
|
gh-dash/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
loadtest/run.pid
|
loadtest/run.pid
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
@@ -24,35 +24,43 @@ import (
|
|||||||
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// payRedirects maps short tokens to full Stripe checkout URLs.
|
const RedirectTTL = 10 * time.Minute
|
||||||
var (
|
|
||||||
payRedirects = map[string]string{}
|
|
||||||
payRedirectsMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
func storeRedirect(stripeURL string) (string, error) {
|
func storeRedirect(stripeURL, purpose string) (string, error) {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
payRedirectsMu.Lock()
|
now := time.Now()
|
||||||
payRedirects[token] = stripeURL
|
rec := models.PayRedirect{
|
||||||
payRedirectsMu.Unlock()
|
Token: token,
|
||||||
|
URL: stripeURL,
|
||||||
|
Purpose: purpose,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(RedirectTTL),
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(&rec).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
|
||||||
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
func PayRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
payRedirectsMu.Lock()
|
db := database.GetDB().WithContext(r.Context())
|
||||||
url, ok := payRedirects[token]
|
var rec models.PayRedirect
|
||||||
payRedirectsMu.Unlock()
|
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
|
||||||
if !ok {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, url, http.StatusFound)
|
if time.Now().After(rec.ExpiresAt) {
|
||||||
|
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, rec.URL, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardHandler handles saved payment method CRUD.
|
// CardHandler handles saved payment method CRUD.
|
||||||
@@ -168,7 +176,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := storeRedirect(sess.URL)
|
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||||
return
|
return
|
||||||
@@ -180,7 +188,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// SaveCardRequest holds the raw card fields submitted by the TUI.
|
// SaveCardRequest holds the raw card fields submitted by the TUI.
|
||||||
// Tokenization happens server-side so the Stripe secret key never
|
// Tokenization happens server-side so the Stripe secret key never
|
||||||
// touches the SSH client or TUI layer..
|
// touches the SSH client or TUI layer.
|
||||||
type SaveCardRequest struct {
|
type SaveCardRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ import (
|
|||||||
|
|
||||||
type CartHandler struct {
|
type CartHandler struct {
|
||||||
stripeKey string
|
stripeKey string
|
||||||
|
appURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
|
||||||
return &CartHandler{stripeKey: stripeSecretKey}
|
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateCart finds the users cart or creates one.
|
// 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
|
// Charge the card. The idempotency key is tied to the order ID so retries
|
||||||
// never produce a double charge.
|
// never produce a double charge.
|
||||||
piParams := &stripe.PaymentIntentParams{
|
piParams := &stripe.PaymentIntentParams{
|
||||||
Amount: stripe.Int64(int64(total)),
|
Amount: stripe.Int64(int64(total)),
|
||||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||||
Customer: stripe.String(user.StripeCustomerID),
|
Customer: stripe.String(user.StripeCustomerID),
|
||||||
PaymentMethod: stripe.String(paymentMethodID),
|
PaymentMethod: stripe.String(paymentMethodID),
|
||||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
OffSession: stripe.Bool(true),
|
||||||
Enabled: stripe.Bool(true),
|
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
|
||||||
AllowRedirects: stripe.String("never"),
|
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||||
},
|
Confirm: stripe.Bool(true),
|
||||||
Confirm: stripe.Bool(true),
|
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
||||||
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
|
|
||||||
Shipping: &stripe.ShippingDetailsParams{
|
Shipping: &stripe.ShippingDetailsParams{
|
||||||
Name: stripe.String(address.Name),
|
Name: stripe.String(address.Name),
|
||||||
Address: &stripe.AddressParams{
|
Address: &stripe.AddressParams{
|
||||||
@@ -423,13 +423,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
stripeDur := time.Since(stripeStart).Seconds()
|
stripeDur := time.Since(stripeStart).Seconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
|
||||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||||
|
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
||||||
|
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||||
switch stripeErr.Type {
|
switch stripeErr.Type {
|
||||||
case stripe.ErrorTypeCard:
|
case stripe.ErrorTypeCard:
|
||||||
middleware.RecordCartConversion("card_declined")
|
middleware.RecordCartConversion("card_declined")
|
||||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||||
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
|
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||||
case stripe.ErrorTypeInvalidRequest:
|
case stripe.ErrorTypeInvalidRequest:
|
||||||
middleware.RecordCartConversion("stripe_invalid_request")
|
middleware.RecordCartConversion("stripe_invalid_request")
|
||||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||||
@@ -441,6 +445,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||||
middleware.RecordCartConversion("stripe_error")
|
middleware.RecordCartConversion("stripe_error")
|
||||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||||
@@ -448,6 +453,11 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||||
|
|
||||||
|
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
||||||
|
h.respondRequiresAction(w, &order, paymentMethodID, pi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Commit payment ID and clear cart in a single transaction.
|
// Commit payment ID and clear cart in a single transaction.
|
||||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
order.StripePaymentID = pi.ID
|
order.StripePaymentID = pi.ID
|
||||||
@@ -489,3 +499,95 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
"order": order,
|
"order": order,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
|
||||||
|
// The order stays pending. the webhook flips it to paid once the customer
|
||||||
|
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
|
||||||
|
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// After an off-session authentication_required decline, the PI sits in
|
||||||
|
// status=requires_payment_method with payment_method detached. Re-confirm
|
||||||
|
// on-session with the same PM so Stripe pushes the PI into requires_action
|
||||||
|
// and populates next_action.redirect_to_url with the 3DS challenge URL.
|
||||||
|
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||||
|
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
|
||||||
|
PaymentMethod: stripe.String(paymentMethodID),
|
||||||
|
OffSession: stripe.Bool(false),
|
||||||
|
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||||
|
})
|
||||||
|
if cerr == nil && confirmed != nil {
|
||||||
|
pi = confirmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||||
|
middleware.RecordCartConversion("sca_missing_next_action")
|
||||||
|
db.Model(order).Update("status", models.OrderStatusFailed)
|
||||||
|
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
|
||||||
|
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the paymentIntent id AND reset status to pending. The
|
||||||
|
// payment_intent.payment_failed webhook from the initial off-session decline
|
||||||
|
// often arrives before this response and flips the order to failed —
|
||||||
|
// override it back so the 3DS-in-flight order shows the correct state.
|
||||||
|
if err := db.Model(order).Updates(map[string]any{
|
||||||
|
"stripe_payment_id": pi.ID,
|
||||||
|
"status": models.OrderStatusPending,
|
||||||
|
}).Error; err != nil {
|
||||||
|
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
|
||||||
|
if err != nil {
|
||||||
|
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.RecordCartConversion("requires_action")
|
||||||
|
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
|
||||||
|
|
||||||
|
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||||
|
"order_id": order.ID,
|
||||||
|
"payment_intent_id": pi.ID,
|
||||||
|
"status": "requires_action",
|
||||||
|
"redirect_token": token,
|
||||||
|
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStripeCardErrCode(code stripe.ErrorCode) string {
|
||||||
|
switch code {
|
||||||
|
case stripe.ErrorCodeAuthenticationRequired:
|
||||||
|
return "AUTHENTICATION_REQUIRED"
|
||||||
|
case "authentication_not_supported":
|
||||||
|
return "AUTHENTICATION_NOT_SUPPORTED"
|
||||||
|
case "card_declined", "":
|
||||||
|
return "CARD_DECLINED"
|
||||||
|
case "expired_card":
|
||||||
|
return "CARD_EXPIRED"
|
||||||
|
case "incorrect_cvc":
|
||||||
|
return "CARD_CVC_FAILED"
|
||||||
|
case "insufficient_funds":
|
||||||
|
return "CARD_INSUFFICIENT_FUNDS"
|
||||||
|
case "processing_error":
|
||||||
|
return "CARD_PROCESSING_ERROR"
|
||||||
|
}
|
||||||
|
return "CARD_DECLINED"
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:unused // wired into Convert cart later
|
||||||
|
func appURLForHandler(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||||
|
host = fh
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, host)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Add item (CoffeeID=1, Quantity=2)
|
// Add item (CoffeeID=1, Quantity=2)
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
@@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||||
@@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||||
@@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
@@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||||
@@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Add two items
|
// Add two items
|
||||||
for _, coffeeID := range []uint{1, 2} {
|
for _, coffeeID := range []uint{1, 2} {
|
||||||
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&card)
|
db.Create(&card)
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||||
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&card)
|
db.Create(&card)
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||||
@@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Add item but no address or card
|
// Add item but no address or card
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||||
@@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// Add item and address
|
// Add item and address
|
||||||
@@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Empty cart convert
|
// Empty cart convert
|
||||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||||
@@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
|
|
||||||
// Add three different items
|
// Add three different items
|
||||||
for i := uint(1); i <= 3; i++ {
|
for i := uint(1); i <= 3; i++ {
|
||||||
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&user2)
|
db.Create(&user2)
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("", "")
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Put("/cart/item", handler.SetItem)
|
r.Put("/cart/item", handler.SetItem)
|
||||||
r.Get("/cart", handler.GetCart)
|
r.Get("/cart", handler.GetCart)
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"terminalShop/pkg/bring"
|
"terminalShop/pkg/bring"
|
||||||
"terminalShop/pkg/notify"
|
"terminalShop/pkg/notify"
|
||||||
"terminalShop/pkg/shipping"
|
"terminalShop/pkg/shipping"
|
||||||
"terminalShop/pkg/shippo"
|
"terminalShop/pkg/shippo"
|
||||||
"time"
|
|
||||||
|
|
||||||
"terminalShop/api/middleware"
|
"terminalShop/api/middleware"
|
||||||
"terminalShop/pkg/audit"
|
"terminalShop/pkg/audit"
|
||||||
@@ -20,6 +24,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
"github.com/stripe/stripe-go/v78/refund"
|
"github.com/stripe/stripe-go/v78/refund"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OrderHandler struct {
|
type OrderHandler struct {
|
||||||
@@ -29,6 +34,16 @@ type OrderHandler struct {
|
|||||||
bringCustomerNumber string
|
bringCustomerNumber string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type refundRequestPayload struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundRequestCooldown is the minimum interval between customer-initiated
|
||||||
|
// refund requests for the same order. Prevents accidental double-clicks and
|
||||||
|
// repeated submissions from spamming the operator's Slack channel.
|
||||||
|
const refundRequestCooldown = 15 * time.Minute
|
||||||
|
|
||||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||||
return &OrderHandler{
|
return &OrderHandler{
|
||||||
stripeKey: stripeSecretKey,
|
stripeKey: stripeSecretKey,
|
||||||
@@ -38,18 +53,121 @@ func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNum
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRefundRequest posts a customer refund request into the order's Slack
|
||||||
|
// thread. It does not issue a Stripe refund.
|
||||||
|
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db := database.GetDB().WithContext(r.Context())
|
||||||
|
userID := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refundRequestPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Reason = strings.TrimSpace(req.Reason)
|
||||||
|
req.Message = strings.TrimSpace(req.Message)
|
||||||
|
|
||||||
|
if !models.IsValidRefundReason(req.Reason) {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var order models.Order
|
||||||
|
if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||||
|
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !order.CanRequestRefund() {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
|
||||||
|
"only paid, shipped, or delivered orders can request refunds", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.LastRefundRequestAt != nil {
|
||||||
|
elapsed := time.Since(*order.LastRefundRequestAt)
|
||||||
|
if elapsed < refundRequestCooldown {
|
||||||
|
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
|
||||||
|
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
|
||||||
|
nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
|
||||||
|
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
|
||||||
|
if req.Message != "" {
|
||||||
|
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
|
||||||
|
text += "\n\n" + quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
|
||||||
|
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the timestamp after a successful Slack post so a Slack outage
|
||||||
|
// doesn't consume the user's cooldown — they can retry immediately.
|
||||||
|
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
|
||||||
|
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
db := database.GetDB().WithContext(r.Context())
|
db := database.GetDB().WithContext(r.Context())
|
||||||
userID := middleware.UserIDFromContext(r.Context())
|
userID := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
db.Where("user_id = ?", userID).
|
||||||
|
Preload("Items").
|
||||||
|
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||||
|
Order("created_at desc").Find(&orders)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||||
"orders": orders,
|
"orders": orders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db := database.GetDB().WithContext(r.Context())
|
||||||
|
userID := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var order models.Order
|
||||||
|
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||||
|
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||||
|
"id": order.ID,
|
||||||
|
"status": string(order.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
// RefundOrder issues a full refund for an order via Stripe and marks it as
|
||||||
// refunded. Only orders in paid or shipped status can be refunded.
|
// refunded. Only orders in paid or shipped status can be refunded.
|
||||||
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
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/database"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
"terminalShop/pkg/notify"
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||||
@@ -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)
|
resp, err := h.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
|||||||
_ = json.Unmarshal(bodyBytes, &result)
|
_ = json.Unmarshal(bodyBytes, &result)
|
||||||
|
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if order.TrackingNumber == "" {
|
if order.TrackingNumber == "" {
|
||||||
notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
|||||||
|
|
||||||
resp, err := h.httpClient.Do(req)
|
resp, err := h.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"terminalShop/pkg/database"
|
"terminalShop/pkg/database"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
"terminalShop/pkg/utils"
|
"terminalShop/pkg/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ViewHandler struct{}
|
type ViewHandler struct{}
|
||||||
@@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.Where("user_id = ?", userID).Find(&cards)
|
db.Where("user_id = ?", userID).Find(&cards)
|
||||||
|
|
||||||
var orders []models.Order
|
var orders []models.Order
|
||||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
|
db.Where("user_id = ?", userID).
|
||||||
|
Preload("Items").
|
||||||
|
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||||
|
Order("created_at DESC").Find(&orders)
|
||||||
var cartItems []models.CartItem
|
var cartItems []models.CartItem
|
||||||
if cart.ID != 0 {
|
if cart.ID != 0 {
|
||||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||||
|
|||||||
@@ -167,6 +167,16 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authentication_required is recoverable — the cart handler re-confirms the
|
||||||
|
// PI on-session and the customer completes 3DS via redirect. Marking the
|
||||||
|
// order failed here would race with the recovery path and surface a
|
||||||
|
// momentarily-broken state in the TUI.
|
||||||
|
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
|
||||||
|
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
|
||||||
|
"order_id", orderIDStr, "pi", pi.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
db := database.GetDB().WithContext(ctx)
|
db := database.GetDB().WithContext(ctx)
|
||||||
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||||
Update("status", models.OrderStatusFailed).Error; err != nil {
|
Update("status", models.OrderStatusFailed).Error; err != nil {
|
||||||
@@ -373,7 +383,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if previousTrackingStatus != newStatus {
|
if previousTrackingStatus != newStatus {
|
||||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||||
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
go func() {
|
||||||
|
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
if deliveredTransition {
|
if deliveredTransition {
|
||||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||||
|
|||||||
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.")
|
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||||
|
|
||||||
// Block until signal received
|
// Block until signal received
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func SetupRoutes(
|
|||||||
healthHandler := handlers.NewHealthHandler(version)
|
healthHandler := handlers.NewHealthHandler(version)
|
||||||
productHandler := handlers.NewProductHandler()
|
productHandler := handlers.NewProductHandler()
|
||||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||||
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
|
||||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||||
@@ -61,6 +61,10 @@ func SetupRoutes(
|
|||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
|
||||||
})
|
})
|
||||||
|
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
|
||||||
|
})
|
||||||
r.Handle("/metrics", middleware.MetricsHandler())
|
r.Handle("/metrics", middleware.MetricsHandler())
|
||||||
|
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
@@ -126,6 +130,8 @@ func SetupRoutes(
|
|||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
r.Get("/orders", orderHandler.GetOrders)
|
r.Get("/orders", orderHandler.GetOrders)
|
||||||
|
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
||||||
|
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||||
|
|
||||||
// Addresses
|
// Addresses
|
||||||
|
|||||||
55
go.mod
55
go.mod
@@ -1,14 +1,14 @@
|
|||||||
module terminalShop
|
module terminalShop
|
||||||
|
|
||||||
go 1.25.7
|
go 1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
charm.land/bubbles/v2 v2.1.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
charm.land/bubbletea/v2 v2.0.6
|
||||||
github.com/charmbracelet/huh v0.8.0
|
charm.land/huh/v2 v2.0.3
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
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/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||||
github.com/charmbracelet/wish v1.4.7
|
|
||||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
|
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
@@ -23,7 +23,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0
|
go.opentelemetry.io/otel/sdk v1.43.0
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
go.opentelemetry.io/otel/trace v1.43.0
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.50.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
@@ -32,38 +32,35 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
charm.land/log/v2 v2.0.0 // indirect
|
||||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
github.com/charmbracelet/keygen v0.5.4 // indirect
|
||||||
github.com/charmbracelet/log v0.4.1 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/input v0.3.4 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
|
||||||
github.com/creack/pty v1.1.24 // indirect
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
@@ -78,15 +75,11 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/paulmach/orb v0.11.1 // indirect
|
github.com/paulmach/orb v0.11.1 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
@@ -104,11 +97,11 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.35.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/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
google.golang.org/grpc v1.80.0 // indirect
|
||||||
|
|||||||
124
go.sum
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 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||||
@@ -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/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
@@ -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/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
|
||||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
|
||||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
|
||||||
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
|
||||||
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
|
||||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
|
||||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
|
||||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
|
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
|
||||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
|
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
|
||||||
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||||
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
|
||||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
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 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
|
|
||||||
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||||
@@ -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/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -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/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||||
@@ -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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@@ -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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
|||||||
16
main.go
16
main.go
@@ -11,11 +11,11 @@ import (
|
|||||||
|
|
||||||
"time"
|
"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/ssh"
|
||||||
"github.com/charmbracelet/wish"
|
|
||||||
"github.com/charmbracelet/wish/bubbletea"
|
|
||||||
"github.com/charmbracelet/wish/logging"
|
|
||||||
|
|
||||||
"terminalShop/pkg/auth"
|
"terminalShop/pkg/auth"
|
||||||
"terminalShop/pkg/config"
|
"terminalShop/pkg/config"
|
||||||
@@ -36,9 +36,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
renderer := bubbletea.MakeRenderer(s)
|
|
||||||
|
|
||||||
// Extract SSH public key from session
|
|
||||||
pubKey := s.PublicKey()
|
pubKey := s.PublicKey()
|
||||||
|
|
||||||
var fingerprint, pubKeyStr string
|
var fingerprint, pubKeyStr string
|
||||||
@@ -47,9 +44,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||||||
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create TUI model with user context (no registration screen needed!)
|
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||||
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
return m, nil
|
||||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ type TokenResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||||
|
|
||||||
reqBody := TokenRequest{
|
reqBody := TokenRequest{
|
||||||
@@ -281,14 +280,31 @@ type CardResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||||
|
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
|
||||||
|
// redirect fields and OrderID instead.
|
||||||
type ConvertCartResponse struct {
|
type ConvertCartResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Order models.Order `json:"order"`
|
Order *models.Order `json:"order,omitempty"`
|
||||||
|
OrderID uint `json:"order_id,omitempty"`
|
||||||
|
PaymentIntentID string `json:"payment_intent_id,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
RedirectToken string `json:"redirect_token,omitempty"`
|
||||||
|
RedirectURL string `json:"redirect_url,omitempty"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
Error *APIError `json:"error,omitempty"`
|
Error *APIError `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
|
||||||
|
// means the customer needs to complete 3DS at RedirectURL; poll
|
||||||
|
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
|
||||||
|
type CheckoutOutcome struct {
|
||||||
|
Order *models.Order
|
||||||
|
OrderID uint
|
||||||
|
RequiresAction bool
|
||||||
|
RedirectURL string
|
||||||
|
}
|
||||||
|
|
||||||
// GetCart fetches the user's current cart.
|
// GetCart fetches the user's current cart.
|
||||||
func (c *Client) GetCart() (*CartData, error) {
|
func (c *Client) GetCart() (*CartData, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||||
@@ -468,7 +484,9 @@ func (c *Client) ClearCart() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertCart converts the cart into an order, charging the saved card.
|
// ConvertCart converts the cart into an order, charging the saved card.
|
||||||
func (c *Client) ConvertCart() (*models.Order, error) {
|
// Returns a CheckoutOutcome that either carries the completed Order or
|
||||||
|
// indicates the customer must complete 3DS authentication via RedirectURL.
|
||||||
|
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", url, nil)
|
req, err := http.NewRequest("POST", url, nil)
|
||||||
@@ -494,7 +512,55 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
|||||||
return nil, fmt.Errorf("failed to convert cart")
|
return nil, fmt.Errorf("failed to convert cart")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &convertResp.Data.Order, nil
|
if convertResp.Data.Status == "requires_action" {
|
||||||
|
return &CheckoutOutcome{
|
||||||
|
OrderID: convertResp.Data.OrderID,
|
||||||
|
RequiresAction: true,
|
||||||
|
RedirectURL: convertResp.Data.RedirectURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if convertResp.Data.Order == nil {
|
||||||
|
return nil, fmt.Errorf("malformed convert response: missing order")
|
||||||
|
}
|
||||||
|
return &CheckoutOutcome{
|
||||||
|
Order: convertResp.Data.Order,
|
||||||
|
OrderID: convertResp.Data.Order.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderStatusResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *APIError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := c.doRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch order status: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var sResp OrderStatusResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode order status response: %w", err)
|
||||||
|
}
|
||||||
|
if !sResp.Success {
|
||||||
|
if sResp.Error != nil {
|
||||||
|
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to fetch order status")
|
||||||
|
}
|
||||||
|
return sResp.Data.Status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCards fetches all saved cards for the user.
|
// GetCards fetches all saved cards for the user.
|
||||||
@@ -636,6 +702,20 @@ type OrderResponse struct {
|
|||||||
Error *APIError `json:"error,omitempty"`
|
Error *APIError `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundRequest is the customer-facing refund request payload.
|
||||||
|
type RefundRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type refundRequestResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *APIError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||||
|
|
||||||
@@ -664,6 +744,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
|||||||
return ordersResp.Data.Orders, nil
|
return ordersResp.Data.Orders, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRefundRequest posts a refund request for an order.
|
||||||
|
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal refund request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create refund request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.doRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send refund request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var refundResp refundRequestResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode refund response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !refundResp.Success {
|
||||||
|
if refundResp.Error != nil {
|
||||||
|
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to send refund request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// The Addresses API response
|
// The Addresses API response
|
||||||
type AddressResponse struct {
|
type AddressResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
eventOrderPaid eventType = "order_paid"
|
eventOrderPaid eventType = "order_paid"
|
||||||
eventOrderShipped eventType = "order_shipped"
|
eventOrderShipped eventType = "order_shipped"
|
||||||
eventOrderFailed eventType = "order_failed"
|
eventOrderFailed eventType = "order_failed"
|
||||||
|
eventOrderRequiresAction eventType = "order_requires_action"
|
||||||
eventOrderRefunded eventType = "order_refunded"
|
eventOrderRefunded eventType = "order_refunded"
|
||||||
eventPaymentCritical eventType = "payment_critical"
|
eventPaymentCritical eventType = "payment_critical"
|
||||||
eventLabelPurchased eventType = "label_purchased"
|
eventLabelPurchased eventType = "label_purchased"
|
||||||
@@ -298,3 +300,15 @@ func attrsToMap(a []any) map[string]any {
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:unused // called from ConvertCart in the next update
|
||||||
|
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||||
|
e := event{
|
||||||
|
Event: eventOrderRequiresAction,
|
||||||
|
UserID: userID,
|
||||||
|
OrderID: orderID,
|
||||||
|
StripeID: paymentIntentID,
|
||||||
|
}
|
||||||
|
slog.Info("audit", e.attrs()...)
|
||||||
|
persist(orderID, eventOrderRequiresAction, e)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -24,6 +25,7 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&models.OrderItem{}, // Line items within orders
|
&models.OrderItem{}, // Line items within orders
|
||||||
&models.Address{}, // Saved addresses
|
&models.Address{}, // Saved addresses
|
||||||
&models.OrderEvent{},
|
&models.OrderEvent{},
|
||||||
|
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("migration failed: %w", err)
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,14 +66,19 @@ type Order struct {
|
|||||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||||
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
||||||
|
|
||||||
|
// LastRefundRequestAt timestamps the most recent customer-initiated refund
|
||||||
|
// request for this order. Used to rate-limit repeat submissions.
|
||||||
|
LastRefundRequestAt *time.Time `json:"-"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||||
|
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName specifies the table name for the Order model.
|
// TableName specifies the table name for the Order model.
|
||||||
@@ -112,3 +117,35 @@ func (o *Order) TotalInDollars() float64 {
|
|||||||
func (o *Order) IsActive() bool {
|
func (o *Order) IsActive() bool {
|
||||||
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundRequestReasons is the canonical list of customer-facing refund reasons
|
||||||
|
// accepted by both the API and the TUI.
|
||||||
|
var RefundRequestReasons = []string{
|
||||||
|
"Wrong item received",
|
||||||
|
"Item damaged in transit",
|
||||||
|
"Item never arrived",
|
||||||
|
"Quality issue",
|
||||||
|
"Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
|
||||||
|
const RefundRequestReasonOther = "Other"
|
||||||
|
|
||||||
|
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
|
||||||
|
func IsValidRefundReason(r string) bool {
|
||||||
|
for _, reason := range RefundRequestReasons {
|
||||||
|
if reason == r {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRequestRefund reports whether a customer is allowed to request a refund
|
||||||
|
// for this order. Paid, shipped, and delivered orders qualify; everything else
|
||||||
|
// is either still unpaid or already in a terminal state.
|
||||||
|
func (o *Order) CanRequestRefund() bool {
|
||||||
|
return o.Status == OrderStatusPaid ||
|
||||||
|
o.Status == OrderStatusShipped ||
|
||||||
|
o.Status == OrderStatusDelivered
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,27 @@ const (
|
|||||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||||
DisplayKindWarning DisplayKind = "warning" // 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 {
|
func (order *Order) DisplayState() string {
|
||||||
|
if order.refundPending() {
|
||||||
|
return "Refund Requested"
|
||||||
|
}
|
||||||
switch order.Status {
|
switch order.Status {
|
||||||
case OrderStatusPending:
|
case OrderStatusPending:
|
||||||
return "Awaiting payment"
|
return "Awaiting payment"
|
||||||
@@ -54,6 +71,9 @@ func (order *Order) DisplayState() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (order *Order) DisplayKind() DisplayKind {
|
func (order *Order) DisplayKind() DisplayKind {
|
||||||
|
if order.refundPending() {
|
||||||
|
return DisplayKindRefund
|
||||||
|
}
|
||||||
switch order.Status {
|
switch order.Status {
|
||||||
case OrderStatusPending:
|
case OrderStatusPending:
|
||||||
return DisplayKindNeutral
|
return DisplayKindNeutral
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestOrderDisplayState(t *testing.T) {
|
func TestOrderDisplayState(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
order Order
|
order Order
|
||||||
@@ -23,6 +27,10 @@ func TestOrderDisplayState(t *testing.T) {
|
|||||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
||||||
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
||||||
|
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||||
|
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||||
|
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
|
||||||
|
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type OrderEvent struct {
|
type OrderEvent struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
@@ -14,3 +17,47 @@ type OrderEvent struct {
|
|||||||
func (OrderEvent) TableName() string {
|
func (OrderEvent) TableName() string {
|
||||||
return "order_events"
|
return "order_events"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *OrderEvent) PayloadMap() map[string]any {
|
||||||
|
if e.Payload == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OrderEvent) DisplayLabel() (string, bool) {
|
||||||
|
switch e.Type {
|
||||||
|
case "order_paid":
|
||||||
|
return "Paid", true
|
||||||
|
case "label_purchased":
|
||||||
|
return "Label created", true
|
||||||
|
case "order_delivered":
|
||||||
|
return "Delivered", true
|
||||||
|
case "order_refunded":
|
||||||
|
return "Refunded", true
|
||||||
|
case "order_failed":
|
||||||
|
return "Payment failed", true
|
||||||
|
case "tracking_updated":
|
||||||
|
status, _ := e.PayloadMap()["status"].(string)
|
||||||
|
return trackingLabel(status)
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackingLabel(status string) (string, bool) {
|
||||||
|
switch status {
|
||||||
|
case "transit":
|
||||||
|
return "In transit", true
|
||||||
|
case "failure":
|
||||||
|
return "Delivery issue", true
|
||||||
|
case "returned":
|
||||||
|
return "Returned to sender", true
|
||||||
|
case "pre_transit":
|
||||||
|
return "Label scanned by carrier", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|||||||
23
pkg/models/pay_redirect.go
Normal file
23
pkg/models/pay_redirect.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PayRedirect maps a short token to a stripe hosted url with a TTL.
|
||||||
|
// survives api restarts so a customer mid-3Ds authentication still
|
||||||
|
// lands on the bank-hosted challenge page after a deploy or crash
|
||||||
|
type PayRedirect struct {
|
||||||
|
Token string `gorm:"primaryKey;size:64" json:"token"`
|
||||||
|
URL string `gorm:"type:text;not null" json:"url"`
|
||||||
|
Purpose string `gorm:"size:32;not null" json:"purpose"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
RedirectPurposeCheckout3DS = "checkout_3ds"
|
||||||
|
RedirectPurposeAddCard = "add_card"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (PayRedirect) TableName() string {
|
||||||
|
return "pay_redirects"
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package notify provides a simple interface to send notifications to Slack.
|
||||||
package notify
|
package notify
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,8 +15,10 @@ import (
|
|||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// slack client
|
||||||
var slackClient = &http.Client{Timeout: 5 * time.Second}
|
var slackClient = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
|
// dollars converts cents to dollars
|
||||||
func dollars(cents int) string {
|
func dollars(cents int) string {
|
||||||
return fmt.Sprintf("$%.2f", float64(cents)/100)
|
return fmt.Sprintf("$%.2f", float64(cents)/100)
|
||||||
}
|
}
|
||||||
@@ -185,29 +188,49 @@ func SlackUnpinOrder(orderID uint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
||||||
// Looks up the thread ts from the database. Silent no-op if the order has no
|
// It looks up the thread ts from the database and returns an error when the
|
||||||
// saved thread (Slack was disabled when it was created, or post failed earlier).
|
// reply cannot be posted.
|
||||||
func SlackPostToOrderThread(orderID uint, text string) {
|
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")
|
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||||
channel := os.Getenv("SLACK_CHANNEL")
|
channel := os.Getenv("SLACK_CHANNEL")
|
||||||
if token == "" || channel == "" {
|
if token == "" || channel == "" {
|
||||||
return
|
return fmt.Errorf("slack is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
var order models.Order
|
var order models.Order
|
||||||
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||||
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
||||||
return
|
return fmt.Errorf("failed to look up order thread: %w", err)
|
||||||
}
|
}
|
||||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
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,
|
"channel": channel,
|
||||||
"thread_ts": *order.SlackThreadTS,
|
"thread_ts": *order.SlackThreadTS,
|
||||||
"text": text,
|
"text": text,
|
||||||
})
|
}
|
||||||
|
if broadcast {
|
||||||
|
payload["reply_broadcast"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := postSlackMessage(token, payload); !ok {
|
||||||
|
slog.Warn("failed to post slack thread reply", "order_id", orderID)
|
||||||
|
return fmt.Errorf("failed to post slack thread reply")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
|
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
|
||||||
@@ -261,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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
@@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !m.account.viewportReady {
|
if !m.account.viewportReady {
|
||||||
m.account.detailViewport = viewport.New(detailWidth, availableHeight)
|
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
||||||
m.account.detailViewport.KeyMap = modifiedKeyMap
|
m.account.detailViewport.KeyMap = modifiedKeyMap
|
||||||
m.account.viewportReady = true
|
m.account.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.account.detailViewport.Width = detailWidth
|
m.account.detailViewport.SetWidth(detailWidth)
|
||||||
m.account.detailViewport.Height = availableHeight
|
m.account.detailViewport.SetHeight(availableHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
@@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
targetY := headerOffset + (selectedIndex * itemHeight)
|
targetY := headerOffset + (selectedIndex * itemHeight)
|
||||||
vpH := m.account.detailViewport.Height
|
vpH := m.account.detailViewport.Height()
|
||||||
offset := m.account.detailViewport.YOffset
|
offset := m.account.detailViewport.YOffset()
|
||||||
|
|
||||||
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
||||||
// not when the current item hits the very edge
|
// not when the current item hits the very edge
|
||||||
@@ -288,6 +288,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
|
case "r":
|
||||||
|
if m.account.orderViewState == 2 {
|
||||||
|
if order, ok := m.selectedRefundableOrder(); ok {
|
||||||
|
return m.OpenRefundComposer(order.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "x", "d":
|
case "x", "d":
|
||||||
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
||||||
m.account.addressDeleting = &m.account.addressCursor
|
m.account.addressDeleting = &m.account.addressCursor
|
||||||
@@ -336,14 +343,18 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
{key: "q", value: "quit"},
|
{key: "q", value: "quit"},
|
||||||
}
|
}
|
||||||
case 1:
|
case 1:
|
||||||
m.account.orderYOffset = m.account.detailViewport.YOffset
|
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
||||||
m.account.orderViewState = 2
|
m.account.orderViewState = 2
|
||||||
m.account.detailViewport.GotoTop()
|
m.account.detailViewport.GotoTop()
|
||||||
m.footer = []footerCommand{
|
footer := []footerCommand{
|
||||||
{key: "esc", value: "back"},
|
{key: "esc", value: "back"},
|
||||||
{key: "s", value: "shop"},
|
{key: "s", value: "shop"},
|
||||||
{key: "q", value: "quit"},
|
{key: "q", value: "quit"},
|
||||||
}
|
}
|
||||||
|
if _, ok := m.selectedRefundableOrder(); ok {
|
||||||
|
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
|
||||||
|
}
|
||||||
|
m.footer = footer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "addresses":
|
case "addresses":
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) OrdersView(width int) string {
|
func (m Model) OrdersView(width int) string {
|
||||||
@@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectedRefundableOrder returns the order under the cursor in the active
|
||||||
|
// order-detail view, paired with ok=true, when the user is allowed to file a
|
||||||
|
// refund request for it. Returns (zero, false) otherwise.
|
||||||
|
func (m Model) selectedRefundableOrder() (models.Order, bool) {
|
||||||
|
orders := m.currentOrderList()
|
||||||
|
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
|
||||||
|
return models.Order{}, false
|
||||||
|
}
|
||||||
|
order := orders[m.account.orderCursor]
|
||||||
|
if !order.CanRequestRefund() {
|
||||||
|
return models.Order{}, false
|
||||||
|
}
|
||||||
|
return order, true
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) currentOrderList() []models.Order {
|
func (m Model) currentOrderList() []models.Order {
|
||||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||||
return nil
|
return nil
|
||||||
@@ -201,6 +216,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visible := make([]models.OrderEvent, 0, len(order.Events))
|
||||||
|
for _, event := range order.Events {
|
||||||
|
if _, ok := event.DisplayLabel(); ok {
|
||||||
|
visible = append(visible, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(visible) > 0 {
|
||||||
|
b.WriteString(labelStyle.Render("Timeline"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, event := range visible {
|
||||||
|
label, _ := event.DisplayLabel()
|
||||||
|
when := event.CreatedAt.Format("Jan 02 3:04 PM")
|
||||||
|
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Shipping address
|
// Shipping address
|
||||||
b.WriteString(labelStyle.Render("Ship To"))
|
b.WriteString(labelStyle.Render("Ship To"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
@@ -223,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
|||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
// Footer hint
|
// 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()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -241,10 +278,9 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
|
|||||||
case models.DisplayKindError:
|
case models.DisplayKindError:
|
||||||
return m.theme.TextError()
|
return m.theme.TextError()
|
||||||
case models.DisplayKindWarning:
|
case models.DisplayKindWarning:
|
||||||
// !TODO add refunds and change this
|
|
||||||
// No warning color in theme yet, reuse loading pink as visual flag
|
|
||||||
// for now
|
|
||||||
return m.theme.TextLoading()
|
return m.theme.TextLoading()
|
||||||
|
case models.DisplayKindRefund:
|
||||||
|
return m.theme.TextBrand()
|
||||||
}
|
}
|
||||||
return m.theme.TextHighlight()
|
return m.theme.TextHighlight()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) BuildBreadcrumbs() string {
|
func (m Model) BuildBreadcrumbs() string {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) updateCartViewport() Model {
|
func (m Model) updateCartViewport() Model {
|
||||||
@@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model {
|
|||||||
availH = 1
|
availH = 1
|
||||||
}
|
}
|
||||||
if !m.cart.viewportReady {
|
if !m.cart.viewportReady {
|
||||||
m.cart.viewport = viewport.New(m.widthContent, availH)
|
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||||
m.cart.viewportReady = true
|
m.cart.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.cart.viewport.Width = m.widthContent
|
m.cart.viewport.SetWidth(m.widthContent)
|
||||||
m.cart.viewport.Height = availH
|
m.cart.viewport.SetHeight(availH)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -116,11 +116,11 @@ func (m Model) CartView() string {
|
|||||||
}
|
}
|
||||||
itemHeight := 4 + 2*boxPadding + 1
|
itemHeight := 4 + 2*boxPadding + 1
|
||||||
targetY := m.cart.cursor * itemHeight
|
targetY := m.cart.cursor * itemHeight
|
||||||
if targetY < m.cart.viewport.YOffset {
|
if targetY < m.cart.viewport.YOffset() {
|
||||||
m.cart.viewport.SetYOffset(targetY)
|
m.cart.viewport.SetYOffset(targetY)
|
||||||
}
|
}
|
||||||
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
|
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
|
||||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
|
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"terminalShop/pkg/models"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"terminalShop/pkg/models"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
"terminalShop/pkg/tui/qrfefe"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderConfirmation renders the order confirmation view
|
// RenderConfirmation renders the order confirmation view
|
||||||
@@ -73,12 +75,12 @@ func (m Model) updateConfirmViewport() Model {
|
|||||||
availH = 1
|
availH = 1
|
||||||
}
|
}
|
||||||
if !m.confirm.viewportReady {
|
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.viewport.KeyMap = viewport.DefaultKeyMap()
|
||||||
m.confirm.viewportReady = true
|
m.confirm.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.confirm.viewport.Width = m.widthContent
|
m.confirm.viewport.SetWidth(m.widthContent)
|
||||||
m.confirm.viewport.Height = availH
|
m.confirm.viewport.SetHeight(availH)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -168,6 +170,18 @@ func (m Model) ReviewView() string {
|
|||||||
if m.CheckingOut {
|
if m.CheckingOut {
|
||||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
||||||
}
|
}
|
||||||
|
if m.review.authFailed {
|
||||||
|
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again")
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.widthContainer,
|
||||||
|
lipgloss.Height(content),
|
||||||
|
lipgloss.Center, lipgloss.Center,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if m.review.requiresAction {
|
||||||
|
return m.renderRequiresActionView()
|
||||||
|
}
|
||||||
if m.review.success {
|
if m.review.success {
|
||||||
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
@@ -186,18 +200,52 @@ func (m Model) ReviewView() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
|
||||||
|
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
|
||||||
|
func (m Model) renderRequiresActionView() string {
|
||||||
|
base := m.theme.TextLabel()
|
||||||
|
accent := m.theme.TextHighlight()
|
||||||
|
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
||||||
|
|
||||||
|
if m.review.redirectURL == "" {
|
||||||
|
return base.Render(" waiting for authentication link...")
|
||||||
|
}
|
||||||
|
|
||||||
|
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
||||||
|
if err != nil || qrSize > m.widthContent {
|
||||||
|
body := header + "\n\n" +
|
||||||
|
base.Render("open this url on your phone to authenticate:") + "\n\n" +
|
||||||
|
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||||
|
base.Render("waiting for completion...")
|
||||||
|
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
|
||||||
|
return qr +
|
||||||
|
header + "\n" +
|
||||||
|
base.Render("scan or visit to complete authentication") + "\n" +
|
||||||
|
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||||
|
base.Render("waiting for completion...")
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case CheckoutResultMsg:
|
case CheckoutResultMsg:
|
||||||
m.CheckingOut = false
|
m.CheckingOut = false
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
m = m.SwitchPage(paymentPage)
|
m = m.SwitchPage(paymentPage)
|
||||||
m.error = &VisibleError{message: fmt.Sprintf("checkout failed: %v", msg.Err)}
|
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||||
m.review.cardJustAdded = false
|
m.review.cardJustAdded = false
|
||||||
m.payment.view = 0
|
m.payment.view = 0
|
||||||
m.payment.form = nil
|
m.payment.form = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if msg.RequiresAction {
|
||||||
|
m.review.requiresAction = true
|
||||||
|
m.review.redirectURL = msg.RedirectURL
|
||||||
|
m.review.orderID = msg.OrderID
|
||||||
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||||
|
}
|
||||||
m.Cart = make(map[uint]*models.CartItem)
|
m.Cart = make(map[uint]*models.CartItem)
|
||||||
m.cart.cursor = 0
|
m.cart.cursor = 0
|
||||||
m.ShippingInfo = nil
|
m.ShippingInfo = nil
|
||||||
@@ -205,10 +253,57 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.review.success = true
|
m.review.success = true
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case OrderStatusMsg:
|
||||||
|
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if msg.Err != nil {
|
||||||
|
// Transient — keep polling.
|
||||||
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||||
|
}
|
||||||
|
switch msg.Status {
|
||||||
|
case "paid", "shipped", "delivered":
|
||||||
|
m.Cart = make(map[uint]*models.CartItem)
|
||||||
|
m.cart.cursor = 0
|
||||||
|
m.ShippingInfo = nil
|
||||||
|
m.OrdersLoaded = false
|
||||||
|
m.review.requiresAction = false
|
||||||
|
m.review.redirectURL = ""
|
||||||
|
m.review.success = true
|
||||||
|
return m, nil
|
||||||
|
case "failed", "cancelled":
|
||||||
|
m.review.requiresAction = false
|
||||||
|
m.review.redirectURL = ""
|
||||||
|
m.review.authFailed = true
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||||
|
}
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.CheckingOut {
|
if m.CheckingOut {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if m.review.authFailed {
|
||||||
|
if msg.String() == "esc" || msg.String() == "enter" {
|
||||||
|
m.review.authFailed = false
|
||||||
|
m = m.SwitchPage(paymentPage)
|
||||||
|
m.payment.view = 0
|
||||||
|
m.payment.form = nil
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.review.requiresAction {
|
||||||
|
if msg.String() == "esc" {
|
||||||
|
m.review.requiresAction = false
|
||||||
|
m.review.redirectURL = ""
|
||||||
|
m = m.SwitchPage(paymentPage)
|
||||||
|
m.payment.view = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.review.success {
|
if m.review.success {
|
||||||
m.review.success = false
|
m.review.success = false
|
||||||
m.review.cardJustAdded = false
|
m.review.cardJustAdded = false
|
||||||
@@ -234,3 +329,24 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func friendlyCheckoutError(raw error) string {
|
||||||
|
s := raw.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
||||||
|
return "your bank wants to verify this charge, authentication step coming soon."
|
||||||
|
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||||
|
return "this card doesn't support online authentication. Try another card"
|
||||||
|
case strings.Contains(s, "CARD_EXPIRED"):
|
||||||
|
return "card expired. Add a new card and try again"
|
||||||
|
case strings.Contains(s, "CARD_CVC_FAILED"):
|
||||||
|
return "card cvc incorrect. Check the back of the card and try again"
|
||||||
|
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
|
||||||
|
return "insufficient funds on this card"
|
||||||
|
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
|
||||||
|
return "card processing error. Try again in a moment"
|
||||||
|
case strings.Contains(s, "CARD_DECLINED"):
|
||||||
|
return "card declined"
|
||||||
|
}
|
||||||
|
return "checkout failed, try again."
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) BuildFooter() string {
|
func (m Model) BuildFooter() string {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/lipgloss/table"
|
"charm.land/lipgloss/v2/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) BuildHeader() string {
|
func (m Model) BuildHeader() string {
|
||||||
@@ -44,7 +44,7 @@ func (m Model) BuildHeader() string {
|
|||||||
tabs = []string{logo, shop, account, cart}
|
tabs = []string{logo, shop, account, cart}
|
||||||
}
|
}
|
||||||
|
|
||||||
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(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)
|
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
|
||||||
}).Render()
|
}).Render()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) BuildHelpView() string {
|
func (m Model) BuildHelpView() string {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) BuildMenuView() string {
|
func (m Model) BuildMenuView() string {
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
stripetoken "github.com/stripe/stripe-go/v78/token"
|
stripetoken "github.com/stripe/stripe-go/v78/token"
|
||||||
|
|
||||||
@@ -65,6 +64,11 @@ type VisibleError struct {
|
|||||||
message string
|
message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VisibleNotice is a typed success message displayed to the user as a banner.
|
||||||
|
type VisibleNotice struct {
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
// User authentication
|
// User authentication
|
||||||
User *models.User
|
User *models.User
|
||||||
@@ -111,11 +115,11 @@ type Model struct {
|
|||||||
|
|
||||||
Loading bool // true when fetching data from API
|
Loading bool // true when fetching data from API
|
||||||
error *VisibleError
|
error *VisibleError
|
||||||
|
notice *VisibleNotice
|
||||||
APIClient *api.Client
|
APIClient *api.Client
|
||||||
|
|
||||||
// Rendering
|
// Rendering
|
||||||
renderer *lipgloss.Renderer
|
theme theme.Theme
|
||||||
theme theme.Theme
|
|
||||||
|
|
||||||
// Checkout flow state (spans multiple pages)
|
// Checkout flow state (spans multiple pages)
|
||||||
CheckingOut bool
|
CheckingOut bool
|
||||||
@@ -130,6 +134,7 @@ type Model struct {
|
|||||||
shipping shippingState
|
shipping shippingState
|
||||||
payment paymentState
|
payment paymentState
|
||||||
account accountState
|
account accountState
|
||||||
|
refund refundState
|
||||||
review reviewState
|
review reviewState
|
||||||
confirm confirmState
|
confirm confirmState
|
||||||
|
|
||||||
@@ -197,6 +202,15 @@ type reviewState struct {
|
|||||||
cardJustAdded bool
|
cardJustAdded bool
|
||||||
cardWasDuplicate bool
|
cardWasDuplicate bool
|
||||||
success bool
|
success bool
|
||||||
|
|
||||||
|
// 3DS / SCA flow: when the backend returns 202 requires_action,
|
||||||
|
// requiresAction is true and redirectURL is the Stripe-hosted challenge
|
||||||
|
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
|
||||||
|
// order flips to paid or failed.
|
||||||
|
requiresAction bool
|
||||||
|
redirectURL string
|
||||||
|
orderID uint
|
||||||
|
authFailed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type confirmState struct {
|
type confirmState struct {
|
||||||
@@ -219,6 +233,7 @@ func (m Model) SwitchPage(p page) Model {
|
|||||||
m.currentPage = p
|
m.currentPage = p
|
||||||
m.switched = true
|
m.switched = true
|
||||||
m.error = nil
|
m.error = nil
|
||||||
|
m.notice = nil
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +255,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
|||||||
m = m.SwitchPage(cartPage)
|
m = m.SwitchPage(cartPage)
|
||||||
m = m.updateCartViewport()
|
m = m.updateCartViewport()
|
||||||
if m.IsCartEmpty() {
|
if m.IsCartEmpty() {
|
||||||
|
|
||||||
m.footer = []footerCommand{
|
m.footer = []footerCommand{
|
||||||
{key: "j/k", value: "items"},
|
{key: "j/k", value: "items"},
|
||||||
{key: "+/-", value: "qty"},
|
{key: "+/-", value: "qty"},
|
||||||
@@ -413,8 +427,18 @@ func (m Model) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutResultMsg struct {
|
type CheckoutResultMsg struct {
|
||||||
|
OrderID uint
|
||||||
|
Total int
|
||||||
|
RequiresAction bool
|
||||||
|
RedirectURL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderStatusMsg carries the result of a poll against /orders/{id}/status.
|
||||||
|
// Emitted on the 2s tick while waiting for 3DS authentication to complete.
|
||||||
|
type OrderStatusMsg struct {
|
||||||
OrderID uint
|
OrderID uint
|
||||||
Total int
|
Status string
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,15 +650,38 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Convert the cart to an order
|
// 3. Convert the cart to an order
|
||||||
order, err := m.APIClient.ConvertCart()
|
outcome, err := m.APIClient.ConvertCart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckoutResultMsg{Err: err}
|
return CheckoutResultMsg{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
|
if outcome.RequiresAction {
|
||||||
|
return CheckoutResultMsg{
|
||||||
|
OrderID: outcome.OrderID,
|
||||||
|
RequiresAction: true,
|
||||||
|
RedirectURL: outcome.RedirectURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
|
||||||
|
// waiting for the customer to complete 3DS authentication.
|
||||||
|
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
|
||||||
|
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
|
||||||
|
if m.APIClient == nil {
|
||||||
|
return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
|
||||||
|
}
|
||||||
|
status, err := m.APIClient.GetOrderStatus(orderID)
|
||||||
|
if err != nil {
|
||||||
|
return OrderStatusMsg{OrderID: orderID, Err: err}
|
||||||
|
}
|
||||||
|
return OrderStatusMsg{OrderID: orderID, Status: status}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
|
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
|
||||||
if m.APIClient == nil || m.User == nil {
|
if m.APIClient == nil || m.User == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -755,18 +802,12 @@ func (m Model) SavedCardsIsEmpty() bool {
|
|||||||
return len(m.SavedCards) == 0
|
return len(m.SavedCards) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModel creates a new model using the default renderer.
|
// NewModel creates a new model.
|
||||||
func NewModel(username string) Model {
|
func NewModel(username string) Model {
|
||||||
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
|
||||||
}
|
|
||||||
|
|
||||||
// newModelWithRenderer creates a new model with the given renderer.
|
|
||||||
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
|
||||||
m := Model{
|
m := Model{
|
||||||
Username: username,
|
Username: username,
|
||||||
currentPage: splashPage,
|
currentPage: splashPage,
|
||||||
renderer: renderer,
|
theme: theme.BasicTheme(),
|
||||||
theme: theme.BasicTheme(renderer),
|
|
||||||
Coffees: []models.Coffee{
|
Coffees: []models.Coffee{
|
||||||
{
|
{
|
||||||
Name: "Espresso",
|
Name: "Espresso",
|
||||||
@@ -833,8 +874,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewModelWithAuth creates a new model with user authentication context.
|
// 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 {
|
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||||
m := newModelWithRenderer("", renderer)
|
m := NewModel("")
|
||||||
m.Fingerprint = fingerprint
|
m.Fingerprint = fingerprint
|
||||||
m.AuthFingerprintKey = clientSecret
|
m.AuthFingerprintKey = clientSecret
|
||||||
m.StripePublicKey = stripePublicKey
|
m.StripePublicKey = stripePublicKey
|
||||||
@@ -860,7 +901,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newViewport(w, h int) viewport.Model {
|
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 {
|
func (m Model) collectCardCmd() tea.Cmd {
|
||||||
@@ -876,8 +917,10 @@ func (m Model) collectCardCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ordersPollInterval = 10 * time.Second
|
const (
|
||||||
const cardPollTimeout = 10 * time.Minute
|
ordersPollInterval = 10 * time.Second
|
||||||
|
cardPollTimeout = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
type OrdersPollTickMsg struct{}
|
type OrdersPollTickMsg struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/huh/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
"terminalShop/pkg/tui/qrfefe"
|
"terminalShop/pkg/tui/qrfefe"
|
||||||
@@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model {
|
|||||||
availH = 1
|
availH = 1
|
||||||
}
|
}
|
||||||
if !m.payment.viewportReady {
|
if !m.payment.viewportReady {
|
||||||
m.payment.viewport = viewport.New(m.widthContent, availH)
|
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||||
m.payment.viewportReady = true
|
m.payment.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.payment.viewport.Width = m.widthContent
|
m.payment.viewport.SetWidth(m.widthContent)
|
||||||
m.payment.viewport.Height = availH
|
m.payment.viewport.SetHeight(availH)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string {
|
|||||||
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.widthContainer,
|
m.widthContainer,
|
||||||
m.payment.viewport.Height,
|
m.payment.viewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
m.renderPaymentHttpsView(),
|
m.renderPaymentHttpsView(),
|
||||||
)
|
)
|
||||||
@@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string {
|
|||||||
m.payment.viewport.SetContent(content)
|
m.payment.viewport.SetContent(content)
|
||||||
itemHeight := 3
|
itemHeight := 3
|
||||||
targetY := m.payment.cardCursor * itemHeight
|
targetY := m.payment.cardCursor * itemHeight
|
||||||
if targetY < m.payment.viewport.YOffset {
|
if targetY < m.payment.viewport.YOffset() {
|
||||||
m.payment.viewport.SetYOffset(targetY)
|
m.payment.viewport.SetYOffset(targetY)
|
||||||
}
|
}
|
||||||
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
|
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
|
||||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
|
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
|
||||||
}
|
}
|
||||||
if m.payment.cardCursor == len(m.SavedCards) {
|
if m.payment.cardCursor == len(m.SavedCards) {
|
||||||
m.payment.viewport.GotoBottom()
|
m.payment.viewport.GotoBottom()
|
||||||
|
|||||||
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"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/huh/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// countries defines the available countries with their dial codes.
|
// countries defines the available countries with their dial codes.
|
||||||
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
|
|||||||
availH = 1
|
availH = 1
|
||||||
}
|
}
|
||||||
if !m.shipping.viewportReady {
|
if !m.shipping.viewportReady {
|
||||||
m.shipping.viewport = viewport.New(m.widthContent, availH)
|
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||||
m.shipping.viewport.KeyMap = viewport.KeyMap{}
|
m.shipping.viewport.KeyMap = viewport.KeyMap{}
|
||||||
m.shipping.viewportReady = true
|
m.shipping.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.shipping.viewport.Width = m.widthContent
|
m.shipping.viewport.SetWidth(m.widthContent)
|
||||||
m.shipping.viewport.Height = availH
|
m.shipping.viewport.SetHeight(availH)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
|
|||||||
m.shipping.viewport.SetContent(content)
|
m.shipping.viewport.SetContent(content)
|
||||||
itemHeight := 4
|
itemHeight := 4
|
||||||
targetY := m.shipping.addressCursor * itemHeight
|
targetY := m.shipping.addressCursor * itemHeight
|
||||||
if targetY < m.shipping.viewport.YOffset {
|
if targetY < m.shipping.viewport.YOffset() {
|
||||||
m.shipping.viewport.SetYOffset(targetY)
|
m.shipping.viewport.SetYOffset(targetY)
|
||||||
}
|
}
|
||||||
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
|
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
|
||||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
|
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
|
||||||
}
|
}
|
||||||
if m.shipping.addressCursor == len(m.SavedAddresses) {
|
if m.shipping.addressCursor == len(m.SavedAddresses) {
|
||||||
m.shipping.viewport.GotoBottom()
|
m.shipping.viewport.GotoBottom()
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
|
|||||||
m.shop.detailViewport.KeyMap = modifiedKeyMap
|
m.shop.detailViewport.KeyMap = modifiedKeyMap
|
||||||
m.shop.viewportsReady = true
|
m.shop.viewportsReady = true
|
||||||
} else {
|
} else {
|
||||||
m.shop.menuViewport.Width = menuWidth
|
m.shop.menuViewport.SetWidth(menuWidth)
|
||||||
m.shop.menuViewport.Height = availableHeight
|
m.shop.menuViewport.SetHeight(availableHeight)
|
||||||
m.shop.detailViewport.Width = detailWidth
|
m.shop.detailViewport.SetWidth(detailWidth)
|
||||||
m.shop.detailViewport.Height = availableHeight
|
m.shop.detailViewport.SetHeight(availableHeight)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
|||||||
|
|
||||||
if m.shop.viewportsReady {
|
if m.shop.viewportsReady {
|
||||||
targetY := (m.shop.selected + 2) * 1
|
targetY := (m.shop.selected + 2) * 1
|
||||||
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2)
|
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
|
||||||
m.shop.detailViewport.GotoTop()
|
m.shop.detailViewport.GotoTop()
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"terminalShop/pkg/api"
|
"terminalShop/pkg/api"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DelayCompleteMsg struct{}
|
type DelayCompleteMsg struct{}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"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 {
|
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||||
|
|||||||
@@ -1,51 +1,50 @@
|
|||||||
package theme
|
package theme
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/bubbles/help"
|
"charm.land/bubbles/v2/help"
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/huh/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HuhTheme creates a huh form theme using this project's color palette.
|
// HuhTheme creates a huh form theme using this project's color palette.
|
||||||
func HuhTheme(t Theme) *huh.Theme {
|
func HuhTheme(t Theme) huh.Theme {
|
||||||
var th 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 := &s.Focused
|
||||||
f.Base = t.renderer.NewStyle().
|
f.Base = lipgloss.NewStyle().
|
||||||
PaddingLeft(1).
|
PaddingLeft(1).
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
BorderLeft(true).
|
BorderLeft(true).
|
||||||
BorderForeground(t.accent)
|
BorderForeground(t.accent)
|
||||||
f.Title = t.renderer.NewStyle().Foreground(t.body)
|
f.Title = lipgloss.NewStyle().Foreground(t.body)
|
||||||
f.Description = t.renderer.NewStyle().Foreground(t.body)
|
f.Description = lipgloss.NewStyle().Foreground(t.body)
|
||||||
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
|
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
|
||||||
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
|
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
|
||||||
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
|
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
|
||||||
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
|
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
|
||||||
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
|
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
|
||||||
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
|
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
|
||||||
|
|
||||||
f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||||
f.Option = t.renderer.NewStyle().Foreground(t.body)
|
f.Option = lipgloss.NewStyle().Foreground(t.body)
|
||||||
f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true)
|
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
|
||||||
f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||||
f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||||
f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||||
f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body)
|
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
|
||||||
|
|
||||||
th.Help = help.New().Styles
|
s.Help = help.New().Styles
|
||||||
|
|
||||||
th.Blurred = copyFieldStyles(*f)
|
s.Blurred = copyFieldStyles(*f)
|
||||||
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||||
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
|
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
|
||||||
th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ")
|
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||||
th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true)
|
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
|
||||||
th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim)
|
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 }
|
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }
|
||||||
|
|||||||
@@ -1,56 +1,55 @@
|
|||||||
package theme
|
package theme
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/huh"
|
"image/color"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"charm.land/lipgloss/v2/compat"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme holds all colors and styles for the terminal coffee shop TUI.
|
// Theme holds all colors and styles for the TUI
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
renderer *lipgloss.Renderer
|
background color.Color
|
||||||
|
border color.Color
|
||||||
background lipgloss.TerminalColor
|
body color.Color
|
||||||
border lipgloss.TerminalColor
|
dim color.Color
|
||||||
body lipgloss.TerminalColor
|
accent color.Color
|
||||||
dim lipgloss.TerminalColor
|
highlight color.Color
|
||||||
accent lipgloss.TerminalColor
|
brand color.Color
|
||||||
highlight lipgloss.TerminalColor
|
label color.Color
|
||||||
brand lipgloss.TerminalColor
|
success color.Color
|
||||||
label lipgloss.TerminalColor
|
error color.Color
|
||||||
success lipgloss.TerminalColor
|
|
||||||
error lipgloss.TerminalColor
|
|
||||||
|
|
||||||
base lipgloss.Style
|
base lipgloss.Style
|
||||||
form *huh.Theme
|
form huh.Theme
|
||||||
}
|
}
|
||||||
|
|
||||||
// BasicTheme constructs the default theme for terminal coffee shop.
|
func BasicTheme() Theme {
|
||||||
func BasicTheme(renderer *lipgloss.Renderer) Theme {
|
t := Theme{}
|
||||||
t := Theme{renderer: renderer}
|
|
||||||
|
|
||||||
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
|
||||||
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
|
||||||
t.body = lipgloss.Color("#AAAAAA")
|
t.body = lipgloss.Color("#AAAAAA")
|
||||||
t.dim = lipgloss.Color("#666666")
|
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.highlight = lipgloss.Color("#4682B4")
|
||||||
t.brand = lipgloss.Color("#4682B4")
|
t.brand = lipgloss.Color("#4682B4")
|
||||||
t.label = lipgloss.Color("#999999")
|
t.label = lipgloss.Color("#999999")
|
||||||
t.success = lipgloss.Color("#00FF00")
|
t.success = lipgloss.Color("#00FF00")
|
||||||
t.error = lipgloss.Color("196")
|
t.error = lipgloss.Color("196")
|
||||||
|
|
||||||
t.base = renderer.NewStyle().Foreground(t.body)
|
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||||
t.form = HuhTheme(t)
|
t.form = HuhTheme(t)
|
||||||
|
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// this should resemble something like catppuccin
|
func Catppuccin() Theme {
|
||||||
func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
t := Theme{}
|
||||||
t := Theme{renderer: renderer}
|
|
||||||
|
|
||||||
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
|
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
|
||||||
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
|
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
|
||||||
t.body = lipgloss.Color("#AAAAAA")
|
t.body = lipgloss.Color("#AAAAAA")
|
||||||
t.dim = lipgloss.Color("#6C7086")
|
t.dim = lipgloss.Color("#6C7086")
|
||||||
t.accent = lipgloss.Color("#CBA6F7")
|
t.accent = lipgloss.Color("#CBA6F7")
|
||||||
@@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
|||||||
t.success = lipgloss.Color("#A6E3A1")
|
t.success = lipgloss.Color("#A6E3A1")
|
||||||
t.error = lipgloss.Color("#F38BA8")
|
t.error = lipgloss.Color("#F38BA8")
|
||||||
|
|
||||||
t.base = renderer.NewStyle().Foreground(t.body)
|
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||||
t.form = HuhTheme(t)
|
t.form = HuhTheme(t)
|
||||||
|
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color accessors — use in .Foreground() / .Background() calls.
|
// Color accessors.
|
||||||
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
|
func (t Theme) Background() color.Color { return t.background }
|
||||||
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
|
func (t Theme) Border() color.Color { return t.border }
|
||||||
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
|
func (t Theme) Body() color.Color { return t.body }
|
||||||
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
|
func (t Theme) Dim() color.Color { return t.dim }
|
||||||
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
|
func (t Theme) Accent() color.Color { return t.accent }
|
||||||
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
|
func (t Theme) Highlight() color.Color { return t.highlight }
|
||||||
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
|
func (t Theme) Brand() color.Color { return t.brand }
|
||||||
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
|
func (t Theme) Label() color.Color { return t.label }
|
||||||
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
|
func (t Theme) Success() color.Color { return t.success }
|
||||||
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
|
func (t Theme) Error() color.Color { return t.error }
|
||||||
|
|
||||||
// Style helpers — return a copy of base with the correct foreground.
|
// Style helpers.
|
||||||
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
|
|
||||||
func (t Theme) Base() lipgloss.Style { return t.base }
|
func (t Theme) Base() lipgloss.Style { return t.base }
|
||||||
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
|
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
|
||||||
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
|
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
|
||||||
@@ -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) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
|
||||||
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
|
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
|
||||||
|
|
||||||
// PanelError returns a style for the error banner: dark red bg, red fg.
|
|
||||||
func (t Theme) PanelError() lipgloss.Style {
|
func (t Theme) PanelError() lipgloss.Style {
|
||||||
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"terminalShop/pkg/api"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
"time"
|
"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) {
|
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 {
|
if m.payment.form != nil {
|
||||||
m.payment.form.form = m.buildPaymentForm(m.payment.form)
|
m.payment.form.form = m.buildPaymentForm(m.payment.form)
|
||||||
}
|
}
|
||||||
|
if m.refund.open {
|
||||||
|
m.refund.form = m.buildRefundForm(&m.refund)
|
||||||
|
m.resizeRefundTextarea(&m.refund)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case VisibleError:
|
case VisibleError:
|
||||||
m.error = &msg
|
m.error = &msg
|
||||||
|
m.notice = nil
|
||||||
|
return m, nil
|
||||||
|
case VisibleNotice:
|
||||||
|
m.notice = &msg
|
||||||
|
m.error = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
case error:
|
case error:
|
||||||
m.error = &VisibleError{message: msg.Error()}
|
m.error = &VisibleError{message: msg.Error()}
|
||||||
|
m.notice = nil
|
||||||
if m.currentPage == shopPage || m.currentPage == cartPage {
|
if m.currentPage == shopPage || m.currentPage == cartPage {
|
||||||
return m, m.fetchCartCmd
|
return m, m.fetchCartCmd
|
||||||
}
|
}
|
||||||
@@ -167,9 +178,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.shipping.form != nil || m.payment.form != nil {
|
if m.shipping.form != nil || m.payment.form != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Dismiss error banner with ESC
|
// Refund overlay owns input when open; dispatched below before page handlers.
|
||||||
if msg.String() == "esc" && m.error != nil {
|
if m.refund.open {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Dismiss banners with ESC.
|
||||||
|
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
|
||||||
m.error = nil
|
m.error = nil
|
||||||
|
m.notice = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
// Global navigation
|
// Global navigation
|
||||||
@@ -192,6 +208,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
// non-globals. fall through to page dispatch below
|
// non-globals. fall through to page dispatch below
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refund overlay intercepts all input after global handling.
|
||||||
|
if m.refund.open {
|
||||||
|
return m.RefundUpdate(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// Page-specifics
|
// Page-specifics
|
||||||
switch m.currentPage {
|
switch m.currentPage {
|
||||||
case shopPage:
|
case shopPage:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/lipgloss"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResizeView renders a message when the terminal is too small.
|
// 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 {
|
if m.viewportWidth == 0 {
|
||||||
m.updateLayout(120, 30)
|
m.updateLayout(120, 30)
|
||||||
}
|
}
|
||||||
@@ -75,8 +82,16 @@ func (m Model) View() string {
|
|||||||
content = loadingStyle.Render("Loading products from API...")
|
content = loadingStyle.Render("Loading products from API...")
|
||||||
} else if m.error != nil {
|
} else if m.error != nil {
|
||||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||||
|
// soft-wrap long messages instead of clipping.
|
||||||
|
if m.widthContent > 4 {
|
||||||
|
errorStyle = errorStyle.Width(m.widthContent - 4)
|
||||||
|
}
|
||||||
errorBanner := errorStyle.Render(m.error.message)
|
errorBanner := errorStyle.Render(m.error.message)
|
||||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
|
} else if m.notice != nil {
|
||||||
|
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
||||||
|
noticeBanner := noticeStyle.Render(m.notice.message)
|
||||||
|
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
} else {
|
} else {
|
||||||
content = m.buildPageContent(availableContentHeight)
|
content = m.buildPageContent(availableContentHeight)
|
||||||
}
|
}
|
||||||
@@ -108,6 +123,10 @@ func (m Model) View() string {
|
|||||||
MaxHeight(m.heightContainer).
|
MaxHeight(m.heightContainer).
|
||||||
Render(child)
|
Render(child)
|
||||||
|
|
||||||
|
if m.refund.open {
|
||||||
|
constrained = m.RenderRefundOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
// Center the entire container in the terminal viewport
|
// Center the entire container in the terminal viewport
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.viewportWidth,
|
m.viewportWidth,
|
||||||
|
|||||||
Reference in New Issue
Block a user