Compare commits
13 Commits
master
...
feat/card-
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d2e29cf4 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 | |||
| 48d65c48fa | |||
| c78021f520 | |||
| c429c1966e | |||
| 50e1ba32f3 | |||
| 85b0c53c0a | |||
| 366d80a9ee | |||
| 60f52f9851 |
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...))
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files need gofmt:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
uses: golangci/golangci-lint-action@v7
|
||||||
|
with:
|
||||||
|
version: v2.4.0
|
||||||
|
|
||||||
|
- name: Test (race detector)
|
||||||
|
run: go test -race ./...
|
||||||
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
|
||||||
|
|||||||
16
.golangci.yml
Normal file
16
.golangci.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
- terminal-shop-source
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func setupAddressTestDB(t *testing.T) (string, models.User) {
|
func setupAddressTestDB(t *testing.T) (string, models.User) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
testDB := "test_addresses.db"
|
testDB := "test_addresses.db"
|
||||||
os.Remove(testDB)
|
_ = os.Remove(testDB)
|
||||||
database.ResetForTesting()
|
database.ResetForTesting()
|
||||||
|
|
||||||
db, err := database.Connect(testDB)
|
db, err := database.Connect(testDB)
|
||||||
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
|
|||||||
|
|
||||||
func TestGetAddressesEmpty(t *testing.T) {
|
func TestGetAddressesEmpty(t *testing.T) {
|
||||||
testDB, user := setupAddressTestDB(t)
|
testDB, user := setupAddressTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewAddressHandler("", "", "")
|
handler := NewAddressHandler("", "", "")
|
||||||
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetAddressesIsolation(t *testing.T) {
|
func TestGetAddressesIsolation(t *testing.T) {
|
||||||
testDB, user1 := setupAddressTestDB(t)
|
testDB, user1 := setupAddressTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
|
|||||||
Addresses []models.Address `json:"addresses"`
|
Addresses []models.Address `json:"addresses"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Addresses) != 1 {
|
if len(resp.Data.Addresses) != 1 {
|
||||||
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
|
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
|
||||||
@@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetAddressesWithData(t *testing.T) {
|
func TestGetAddressesWithData(t *testing.T) {
|
||||||
testDB, user := setupAddressTestDB(t)
|
testDB, user := setupAddressTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
|
|||||||
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
|
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
testDB := "test_auth.db"
|
testDB := "test_auth.db"
|
||||||
os.Remove(testDB)
|
_ = os.Remove(testDB)
|
||||||
database.ResetForTesting()
|
database.ResetForTesting()
|
||||||
|
|
||||||
db, err := database.Connect(testDB)
|
db, err := database.Connect(testDB)
|
||||||
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
|
|||||||
|
|
||||||
func TestGetTokenSuccess(t *testing.T) {
|
func TestGetTokenSuccess(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
user := createAuthTestUser(t, "SHA256:testfingerprint123")
|
user := createAuthTestUser(t, "SHA256:testfingerprint123")
|
||||||
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetTokenBadSecret(t *testing.T) {
|
func TestGetTokenBadSecret(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetTokenMissingFingerprint(t *testing.T) {
|
func TestGetTokenMissingFingerprint(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetTokenUnknownFingerprint(t *testing.T) {
|
func TestGetTokenUnknownFingerprint(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetTokenInvalidJSON(t *testing.T) {
|
func TestGetTokenInvalidJSON(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
|
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
|
||||||
@@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
createAuthTestUser(t, "SHA256:existingfp")
|
createAuthTestUser(t, "SHA256:existingfp")
|
||||||
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetUserBySSHKeySuccess(t *testing.T) {
|
func TestGetUserBySSHKeySuccess(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
createAuthTestUser(t, "SHA256:lookupfp")
|
createAuthTestUser(t, "SHA256:lookupfp")
|
||||||
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
|
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
|
||||||
@@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
|
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
|
||||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)
|
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)
|
||||||
|
|||||||
@@ -30,14 +30,16 @@ var (
|
|||||||
payRedirectsMu sync.Mutex
|
payRedirectsMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
func storeRedirect(stripeURL string) string {
|
func storeRedirect(stripeURL string) (string, error) {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
rand.Read(b)
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
payRedirectsMu.Lock()
|
payRedirectsMu.Lock()
|
||||||
payRedirects[token] = stripeURL
|
payRedirects[token] = stripeURL
|
||||||
payRedirectsMu.Unlock()
|
payRedirectsMu.Unlock()
|
||||||
return token
|
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.
|
||||||
@@ -166,7 +168,11 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := storeRedirect(sess.URL)
|
token, err := storeRedirect(sess.URL)
|
||||||
|
if err != nil {
|
||||||
|
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||||
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||||
Customer: stripe.String(user.StripeCustomerID),
|
Customer: stripe.String(user.StripeCustomerID),
|
||||||
PaymentMethod: stripe.String(paymentMethodID),
|
PaymentMethod: stripe.String(paymentMethodID),
|
||||||
|
OffSession: stripe.Bool(true),
|
||||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||||
Enabled: stripe.Bool(true),
|
Enabled: stripe.Bool(true),
|
||||||
AllowRedirects: stripe.String("never"),
|
AllowRedirects: stripe.String("never"),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
func setupCartTestDB(t *testing.T) (string, models.User) {
|
func setupCartTestDB(t *testing.T) (string, models.User) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
testDB := "test_cart.db"
|
testDB := "test_cart.db"
|
||||||
os.Remove(testDB)
|
_ = os.Remove(testDB)
|
||||||
database.ResetForTesting()
|
database.ResetForTesting()
|
||||||
|
|
||||||
db, err := database.Connect(testDB)
|
db, err := database.Connect(testDB)
|
||||||
@@ -62,7 +62,7 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request {
|
|||||||
|
|
||||||
func TestGetCartEmpty(t *testing.T) {
|
func TestGetCartEmpty(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -101,7 +101,7 @@ func TestGetCartEmpty(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetItemAndGetCart(t *testing.T) {
|
func TestSetItemAndGetCart(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -157,7 +157,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetItemUpdateQuantity(t *testing.T) {
|
func TestSetItemUpdateQuantity(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -190,7 +190,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Cart.Items) != 1 {
|
if len(resp.Data.Cart.Items) != 1 {
|
||||||
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
|
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
|
||||||
@@ -202,7 +202,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetItemRemove(t *testing.T) {
|
func TestSetItemRemove(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -232,7 +232,7 @@ func TestSetItemRemove(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Cart.Items) != 0 {
|
if len(resp.Data.Cart.Items) != 0 {
|
||||||
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
|
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
|
||||||
@@ -241,7 +241,7 @@ func TestSetItemRemove(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetItemInvalidProduct(t *testing.T) {
|
func TestSetItemInvalidProduct(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -258,7 +258,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetItemMissingCoffeeID(t *testing.T) {
|
func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -275,7 +275,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
|||||||
|
|
||||||
func TestClearCart(t *testing.T) {
|
func TestClearCart(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -310,7 +310,7 @@ func TestClearCart(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Cart.Items) != 0 {
|
if len(resp.Data.Cart.Items) != 0 {
|
||||||
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
|
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
|
||||||
@@ -319,7 +319,7 @@ func TestClearCart(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetAddress(t *testing.T) {
|
func TestSetAddress(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -358,7 +358,7 @@ func TestSetAddress(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
|
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
|
||||||
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
|
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
|
||||||
@@ -367,7 +367,7 @@ func TestSetAddress(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetAddressWrongUser(t *testing.T) {
|
func TestSetAddressWrongUser(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -402,7 +402,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetCard(t *testing.T) {
|
func TestSetCard(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -440,7 +440,7 @@ func TestSetCard(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
|
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
|
||||||
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
|
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
|
||||||
@@ -449,7 +449,7 @@ func TestSetCard(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetCardWrongUser(t *testing.T) {
|
func TestSetCardWrongUser(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -482,7 +482,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestConvertCartMissingAddress(t *testing.T) {
|
func TestConvertCartMissingAddress(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -505,7 +505,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
|||||||
|
|
||||||
func TestConvertCartMissingCard(t *testing.T) {
|
func TestConvertCartMissingCard(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -536,7 +536,7 @@ func TestConvertCartMissingCard(t *testing.T) {
|
|||||||
|
|
||||||
func TestConvertCartEmpty(t *testing.T) {
|
func TestConvertCartEmpty(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -553,7 +553,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
|||||||
|
|
||||||
func TestMultipleItems(t *testing.T) {
|
func TestMultipleItems(t *testing.T) {
|
||||||
testDB, user := setupCartTestDB(t)
|
testDB, user := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("")
|
handler := NewCartHandler("")
|
||||||
@@ -583,7 +583,7 @@ func TestMultipleItems(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Cart.Items) != 3 {
|
if len(resp.Data.Cart.Items) != 3 {
|
||||||
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
|
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
|
||||||
@@ -595,7 +595,7 @@ func TestMultipleItems(t *testing.T) {
|
|||||||
|
|
||||||
func TestCartIsolation(t *testing.T) {
|
func TestCartIsolation(t *testing.T) {
|
||||||
testDB, user1 := setupCartTestDB(t)
|
testDB, user1 := setupCartTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -638,7 +638,7 @@ func TestCartIsolation(t *testing.T) {
|
|||||||
} `json:"cart"`
|
} `json:"cart"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Cart.Items) != 1 {
|
if len(resp.Data.Cart.Items) != 1 {
|
||||||
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||||
@@ -655,7 +655,7 @@ func TestCartIsolation(t *testing.T) {
|
|||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
if len(resp.Data.Cart.Items) != 1 {
|
if len(resp.Data.Cart.Items) != 1 {
|
||||||
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||||
|
|||||||
@@ -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,14 +53,94 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
|
|||||||
testDB := "test_products.db"
|
testDB := "test_products.db"
|
||||||
|
|
||||||
// Remove if exists
|
// Remove if exists
|
||||||
os.Remove(testDB)
|
_ = os.Remove(testDB)
|
||||||
|
|
||||||
// Reset singleton
|
// Reset singleton
|
||||||
database.ResetForTesting()
|
database.ResetForTesting()
|
||||||
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
|
|||||||
|
|
||||||
func TestGetProduct(t *testing.T) {
|
func TestGetProduct(t *testing.T) {
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewProductHandler()
|
handler := NewProductHandler()
|
||||||
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetProductNotFound(t *testing.T) {
|
func TestGetProductNotFound(t *testing.T) {
|
||||||
testDB := setupTestDB(t)
|
testDB := setupTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewProductHandler()
|
handler := NewProductHandler()
|
||||||
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetProductsEmptyDatabase(t *testing.T) {
|
func TestGetProductsEmptyDatabase(t *testing.T) {
|
||||||
testDB := "test_empty.db"
|
testDB := "test_empty.db"
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
// Create database but don't seed
|
// Create database but don't seed
|
||||||
|
|||||||
@@ -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,10 +130,10 @@ 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 resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
@@ -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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
|
|||||||
slog.Warn("slack mark_status: response_url edit failed", "error", err)
|
slog.Warn("slack mark_status: response_url edit failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
|
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
|
||||||
@@ -264,7 +264,7 @@ func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, []byte(h.signingSecret))
|
mac := hmac.New(sha256.New, []byte(h.signingSecret))
|
||||||
fmt.Fprintf(mac, "v0:%d:%s", ts, body)
|
_, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body)
|
||||||
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
|
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
return hmac.Equal([]byte(expected), []byte(sigHeader))
|
return hmac.Equal([]byte(expected), []byte(sigHeader))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -373,7 +373,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)
|
||||||
|
|||||||
20
api/main.go
20
api/main.go
@@ -40,7 +40,8 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
defer database.Close()
|
|
||||||
|
defer func() { _ = database.Close() }()
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
if err := database.Migrate(db); err != nil {
|
if err := database.Migrate(db); err != nil {
|
||||||
@@ -61,7 +62,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tracing init failed (non-fatal): %v", err)
|
log.Printf("tracing init failed (non-fatal): %v", err)
|
||||||
} else {
|
} else {
|
||||||
defer shutdown(context.Background())
|
defer func() { _ = shutdown(context.Background()) }()
|
||||||
}
|
}
|
||||||
|
|
||||||
stripeclient.InitOTel()
|
stripeclient.InitOTel()
|
||||||
@@ -112,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
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func SetupRoutes(
|
|||||||
r.Get("/t/{id}", handlers.TrackingRedirect)
|
r.Get("/t/{id}", handlers.TrackingRedirect)
|
||||||
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
|
||||||
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.Handle("/metrics", middleware.MetricsHandler())
|
r.Handle("/metrics", middleware.MetricsHandler())
|
||||||
|
|
||||||
@@ -126,6 +126,7 @@ func SetupRoutes(
|
|||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
r.Get("/orders", orderHandler.GetOrders)
|
r.Get("/orders", orderHandler.GetOrders)
|
||||||
|
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() {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
|
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
|
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
|
||||||
@@ -137,7 +137,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch products: %w", err)
|
return nil, fmt.Errorf("failed to fetch products: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
@@ -167,7 +167,7 @@ func (c *Client) Health() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("health check failed: %w", err)
|
return fmt.Errorf("health check failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
@@ -217,7 +217,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to register user: %w", err)
|
return nil, fmt.Errorf("failed to register user: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var registerResp RegisterResponse
|
var registerResp RegisterResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil {
|
||||||
@@ -302,7 +302,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch cart: %w", err)
|
return nil, fmt.Errorf("failed to fetch cart: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var cartResp CartResponse
|
var cartResp CartResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||||
@@ -342,7 +342,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to set cart item: %w", err)
|
return nil, fmt.Errorf("failed to set cart item: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var cartResp CartResponse
|
var cartResp CartResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||||
@@ -381,7 +381,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set cart address: %w", err)
|
return fmt.Errorf("failed to set cart address: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var result APIResponse
|
var result APIResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
@@ -420,7 +420,7 @@ func (c *Client) SetCartCard(cardID uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set cart card: %w", err)
|
return fmt.Errorf("failed to set cart card: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var result APIResponse
|
var result APIResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
@@ -450,7 +450,7 @@ func (c *Client) ClearCart() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to clear cart: %w", err)
|
return fmt.Errorf("failed to clear cart: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var result APIResponse
|
var result APIResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
@@ -480,7 +480,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to convert cart: %w", err)
|
return nil, fmt.Errorf("failed to convert cart: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var convertResp ConvertCartResponse
|
var convertResp ConvertCartResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
|
||||||
@@ -510,7 +510,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch cards: %w", err)
|
return nil, fmt.Errorf("failed to fetch cards: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var cardsResp CardsResponse
|
var cardsResp CardsResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
|
||||||
@@ -553,7 +553,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to save card: %w", err)
|
return nil, fmt.Errorf("failed to save card: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var cardResp CardResponse
|
var cardResp CardResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
|
||||||
@@ -590,7 +590,7 @@ func (c *Client) CollectCard() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to collect card: %w", err)
|
return "", fmt.Errorf("failed to collect card: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var collectResp CollectCardResponse
|
var collectResp CollectCardResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
|
||||||
@@ -619,7 +619,7 @@ func (c *Client) DeleteCard(id uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete card: %w", err)
|
return fmt.Errorf("failed to delete card: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
|
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
|
||||||
@@ -636,6 +636,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)
|
||||||
|
|
||||||
@@ -648,7 +662,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch orders: %w", err)
|
return nil, fmt.Errorf("failed to fetch orders: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var ordersResp OrderResponse
|
var ordersResp OrderResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
|
||||||
@@ -664,6 +678,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"`
|
||||||
@@ -686,7 +736,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
|
|||||||
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
|
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var addrResp AddressResponse
|
var addrResp AddressResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
|
||||||
@@ -732,7 +782,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create address: %w", err)
|
return nil, fmt.Errorf("failed to create address: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -769,7 +819,7 @@ func (c *Client) DeleteAddress(id uint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete address: %w", err)
|
return fmt.Errorf("failed to delete address: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
|
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
|
||||||
@@ -804,7 +854,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
|
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
|
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
func setupAuthTestDB(t *testing.T) string {
|
func setupAuthTestDB(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
testDB := "test_ssh_auth.db"
|
testDB := "test_ssh_auth.db"
|
||||||
os.Remove(testDB)
|
_ = os.Remove(testDB)
|
||||||
database.ResetForTesting()
|
database.ResetForTesting()
|
||||||
|
|
||||||
db, err := database.Connect(testDB)
|
db, err := database.Connect(testDB)
|
||||||
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
|
|||||||
|
|
||||||
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
||||||
testDB := setupAuthTestDB(t)
|
testDB := setupAuthTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
||||||
testDB := setupAuthTestDB(t)
|
testDB := setupAuthTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegisterWithSSHKey(t *testing.T) {
|
func TestRegisterWithSSHKey(t *testing.T) {
|
||||||
testDB := setupAuthTestDB(t)
|
testDB := setupAuthTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||||
testDB := setupAuthTestDB(t)
|
testDB := setupAuthTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
|||||||
|
|
||||||
func TestIsSSHKeyRegistered(t *testing.T) {
|
func TestIsSSHKeyRegistered(t *testing.T) {
|
||||||
testDB := setupAuthTestDB(t)
|
testDB := setupAuthTestDB(t)
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After authentication (which auto-creates)
|
// After authentication (which auto-creates)
|
||||||
svc.AuthenticateSSHKey(key)
|
_, _, _ = svc.AuthenticateSSHKey(key)
|
||||||
|
|
||||||
registered, err = svc.IsSSHKeyRegistered(key)
|
registered, err = svc.IsSSHKeyRegistered(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out an
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request: %w", err)
|
return fmt.Errorf("request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read response: %w", err)
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bring request failed: %w", err)
|
return nil, fmt.Errorf("bring request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func Close() error {
|
|||||||
// WARNING: Only use this in tests!
|
// WARNING: Only use this in tests!
|
||||||
func ResetForTesting() {
|
func ResetForTesting() {
|
||||||
if db != nil {
|
if db != nil {
|
||||||
Close()
|
_ = Close()
|
||||||
db = nil
|
db = nil
|
||||||
once = sync.Once{}
|
once = sync.Once{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestConnect(t *testing.T) {
|
func TestConnect(t *testing.T) {
|
||||||
// Use a temporary test database
|
// Use a temporary test database
|
||||||
testDB := "test_terminalshop.db"
|
testDB := "test_terminalshop.db"
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer ResetForTesting()
|
defer ResetForTesting()
|
||||||
|
|
||||||
db, err := Connect(testDB)
|
db, err := Connect(testDB)
|
||||||
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
|
|||||||
|
|
||||||
func TestMigrate(t *testing.T) {
|
func TestMigrate(t *testing.T) {
|
||||||
testDB := "test_migrate.db"
|
testDB := "test_migrate.db"
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer ResetForTesting()
|
defer ResetForTesting()
|
||||||
|
|
||||||
db, err := Connect(testDB)
|
db, err := Connect(testDB)
|
||||||
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
|
|||||||
|
|
||||||
func TestSeed(t *testing.T) {
|
func TestSeed(t *testing.T) {
|
||||||
testDB := "test_seed.db"
|
testDB := "test_seed.db"
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer ResetForTesting()
|
defer ResetForTesting()
|
||||||
|
|
||||||
db, err := Connect(testDB)
|
db, err := Connect(testDB)
|
||||||
@@ -115,7 +115,7 @@ func TestSeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetDB(t *testing.T) {
|
func TestGetDB(t *testing.T) {
|
||||||
testDB := "test_getdb.db"
|
testDB := "test_getdb.db"
|
||||||
defer os.Remove(testDB)
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer ResetForTesting()
|
defer ResetForTesting()
|
||||||
|
|
||||||
// Initialize connection
|
// Initialize connection
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,7 @@ func postSlackMessage(token string, payload map[string]any) (string, bool) {
|
|||||||
slog.Warn("slack post failed", "error", err)
|
slog.Warn("slack post failed", "error", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Ok bool `json:"ok"`
|
Ok bool `json:"ok"`
|
||||||
@@ -66,7 +69,7 @@ func slackPinCall(token, endpoint, channel, ts, benignErr string) bool {
|
|||||||
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
|
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Ok bool `json:"ok"`
|
Ok bool `json:"ok"`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body any, out
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request: %w", err)
|
return fmt.Errorf("request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read response: %w", err)
|
return fmt.Errorf("read response: %w", err)
|
||||||
@@ -432,7 +432,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("shippo request failed: %w", err)
|
return nil, fmt.Errorf("shippo request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -324,7 +331,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
switch selectedItem {
|
switch selectedItem {
|
||||||
case "order history", "active orders":
|
case "order history", "active orders":
|
||||||
if len(m.currentOrderList()) > 0 {
|
if len(m.currentOrderList()) > 0 {
|
||||||
if m.account.orderViewState == 0 {
|
switch m.account.orderViewState {
|
||||||
|
case 0:
|
||||||
m.account.orderViewState = 1
|
m.account.orderViewState = 1
|
||||||
m.account.orderCursor = 0
|
m.account.orderCursor = 0
|
||||||
m.account.detailViewport.GotoTop()
|
m.account.detailViewport.GotoTop()
|
||||||
@@ -334,16 +342,19 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
{key: "esc", value: "back"},
|
{key: "esc", value: "back"},
|
||||||
{key: "q", value: "quit"},
|
{key: "q", value: "quit"},
|
||||||
}
|
}
|
||||||
} else if m.account.orderViewState == 1 {
|
case 1:
|
||||||
// Save scroll position before entering detail view
|
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,11 +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(
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"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/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderConfirmation renders the order confirmation view
|
// RenderConfirmation renders the order confirmation view
|
||||||
@@ -73,12 +73,12 @@ func (m Model) updateConfirmViewport() Model {
|
|||||||
availH = 1
|
availH = 1
|
||||||
}
|
}
|
||||||
if !m.confirm.viewportReady {
|
if !m.confirm.viewportReady {
|
||||||
m.confirm.viewport = viewport.New(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
|
||||||
}
|
}
|
||||||
@@ -145,18 +145,18 @@ func (m Model) generateReviewContent() string {
|
|||||||
if m.ShippingInfo.Street2 != "" {
|
if m.ShippingInfo.Street2 != "" {
|
||||||
view.WriteString(m.ShippingInfo.Street2 + "\n")
|
view.WriteString(m.ShippingInfo.Street2 + "\n")
|
||||||
}
|
}
|
||||||
view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country))
|
fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)
|
||||||
}
|
}
|
||||||
view.WriteString("\n")
|
view.WriteString("\n")
|
||||||
|
|
||||||
if m.SelectedCard != nil {
|
if m.SelectedCard != nil {
|
||||||
view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4))
|
fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4)
|
||||||
}
|
}
|
||||||
view.WriteString("\n")
|
view.WriteString("\n")
|
||||||
|
|
||||||
subtotal := m.CalculateSubtotal()
|
subtotal := m.CalculateSubtotal()
|
||||||
|
|
||||||
view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal)))
|
fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal))
|
||||||
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
|
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
|
||||||
view.WriteString("\n")
|
view.WriteString("\n")
|
||||||
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")
|
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
110
pkg/tui/model.go
110
pkg/tui/model.go
@@ -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
|
||||||
|
|
||||||
@@ -219,6 +224,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 +246,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"},
|
||||||
@@ -400,23 +405,6 @@ func (m Model) fetchCartCmd() tea.Msg {
|
|||||||
return CartFetchedMsg{Cart: cart, Err: err}
|
return CartFetchedMsg{Cart: cart, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchProductsCmd fetches products from the API
|
|
||||||
func (m Model) fetchProductsCmd() tea.Msg {
|
|
||||||
if m.APIClient == nil {
|
|
||||||
log.Println("[TUI] APIClient is nil, using fallback products")
|
|
||||||
return ProductsMsg{Err: nil} // No API client, use fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL)
|
|
||||||
products, err := m.APIClient.GetProducts()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[TUI] Failed to fetch products: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("[TUI] Loaded %d products from API", len(products))
|
|
||||||
}
|
|
||||||
return ProductsMsg{Products: products, Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
m.authCmd,
|
m.authCmd,
|
||||||
@@ -670,58 +658,6 @@ func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveCardAndConvert tokenizes the card locally using the Stripe publishable
|
|
||||||
// key (raw card data never reaches the backend), saves the token, then converts the cart
|
|
||||||
func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
if m.APIClient == nil {
|
|
||||||
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
|
|
||||||
}
|
|
||||||
|
|
||||||
stripe.Key = m.StripePublicKey
|
|
||||||
tok, err := stripetoken.New(&stripe.TokenParams{
|
|
||||||
Card: &stripe.CardParams{
|
|
||||||
Name: stripe.String(form.CardName),
|
|
||||||
Number: stripe.String(form.CardNumber),
|
|
||||||
ExpMonth: stripe.String(form.ExpiryMonth),
|
|
||||||
ExpYear: stripe.String(form.ExpiryYear),
|
|
||||||
CVC: stripe.String(form.CVC),
|
|
||||||
AddressZip: stripe.String(form.BillingZip),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
|
||||||
return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
|
|
||||||
}
|
|
||||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
|
|
||||||
}
|
|
||||||
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.resolveCartAddress(); err != nil {
|
|
||||||
return CheckoutResultMsg{Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Set the card on the cart
|
|
||||||
if err := m.APIClient.SetCartCard(card.ID); err != nil {
|
|
||||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Convert the cart to an order
|
|
||||||
order, err := m.APIClient.ConvertCart()
|
|
||||||
if err != nil {
|
|
||||||
return CheckoutResultMsg{Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return CheckoutResultMsg{
|
|
||||||
OrderID: order.ID,
|
|
||||||
Total: order.Total,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
|
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if m.APIClient == nil {
|
if m.APIClient == nil {
|
||||||
@@ -824,18 +760,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",
|
||||||
@@ -902,8 +832,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
|
||||||
@@ -929,7 +859,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 {
|
||||||
@@ -945,8 +875,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)
|
||||||
}
|
}
|
||||||
@@ -77,6 +84,10 @@ func (m Model) View() string {
|
|||||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||||
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 +119,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,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
|||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(response)
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespondError sends an error JSON response
|
// RespondError sends an error JSON response
|
||||||
@@ -52,7 +52,7 @@ func RespondError(w http.ResponseWriter, statusCode int, code, message string, d
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(response)
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthData represents health check data
|
// HealthData represents health check data
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error {
|
|||||||
func IsDigits(name string) ErrorHandler {
|
func IsDigits(name string) ErrorHandler {
|
||||||
return func(str string) error {
|
return func(str string) error {
|
||||||
for _, c := range str {
|
for _, c := range str {
|
||||||
if !(c >= '0' && c <= '9') {
|
if c < '0' || c > '9' {
|
||||||
return fmt.Errorf("[%s] expected only digits but got %s", name, str)
|
return fmt.Errorf("[%s] expected only digits but got %s", name, str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user