Compare commits
4 Commits
feat/refun
...
add-ci
| Author | SHA1 | Date | |
|---|---|---|---|
| d68379ff68 | |||
| 23b6835a2a | |||
| d33d6bd354 | |||
| 73697d4037 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,8 +98,6 @@ docs/
|
||||
# Migration state (if using a migration tool that creates state files)
|
||||
migrations/*.state
|
||||
|
||||
gh-dash/
|
||||
|
||||
|
||||
|
||||
loadtest/run.pid
|
||||
|
||||
@@ -2,18 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/bring"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/shipping"
|
||||
"terminalShop/pkg/shippo"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
@@ -24,7 +20,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/refund"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
@@ -34,16 +29,6 @@ type OrderHandler struct {
|
||||
bringCustomerNumber string
|
||||
}
|
||||
|
||||
type refundRequestPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// refundRequestCooldown is the minimum interval between customer-initiated
|
||||
// refund requests for the same order. Prevents accidental double-clicks and
|
||||
// repeated submissions from spamming the operator's Slack channel.
|
||||
const refundRequestCooldown = 15 * time.Minute
|
||||
|
||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
stripeKey: stripeSecretKey,
|
||||
@@ -53,94 +38,14 @@ 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.SlackPostToOrderThread(order.ID, text); err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Record the timestamp after a successful Slack post so a Slack outage
|
||||
// doesn't consume the user's cooldown — they can retry immediately.
|
||||
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
|
||||
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var orders []models.Order
|
||||
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)
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"orders": orders,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func setupOrderTestDB(t *testing.T) models.User {
|
||||
t.Helper()
|
||||
testDB := filepath.Join(t.TempDir(), "orders.db")
|
||||
database.ResetForTesting()
|
||||
t.Cleanup(database.ResetForTesting)
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
if err := database.Seed(db); err != nil {
|
||||
t.Fatalf("failed to seed: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
|
||||
Name: "Order Test User",
|
||||
Email: "orders@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
|
||||
req := authRequest(method, url, body, userID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", orderID)
|
||||
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||
}
|
||||
|
||||
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
|
||||
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Other","message":" "}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "MESSAGE_REQUIRED" {
|
||||
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusFailed,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "INVALID_STATE" {
|
||||
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
recent := time.Now().Add(-1 * time.Minute)
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
LastRefundRequestAt: &recent,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected status 429, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got == "" {
|
||||
t.Errorf("expected Retry-After header, got empty")
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "RATE_LIMITED" {
|
||||
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
@@ -76,26 +75,3 @@ func ReconcileOrders(stripeKey string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
db := database.GetDB()
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
err := db.Where(
|
||||
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
|
||||
models.OrderStatusPaid, cutoff,
|
||||
).Order("created_at ASC").Find(&orders).Error
|
||||
if err != nil {
|
||||
reconcileLog().Error("unshipped query failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("unshipped orders found", "count", len(orders))
|
||||
notify.SlackUnshippedReminder(orders)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
||||
_ = json.Unmarshal(bodyBytes, &result)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
||||
return
|
||||
}
|
||||
if order.TrackingNumber == "" {
|
||||
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||
notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 300 {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ViewHandler struct{}
|
||||
@@ -40,10 +38,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("user_id = ?", userID).Find(&cards)
|
||||
|
||||
var orders []models.Order
|
||||
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)
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
|
||||
var cartItems []models.CartItem
|
||||
if cart.ID != 0 {
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||
|
||||
@@ -373,9 +373,7 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if previousTrackingStatus != newStatus {
|
||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||
go func() {
|
||||
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
}()
|
||||
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
}
|
||||
if deliveredTransition {
|
||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||
|
||||
15
api/main.go
15
api/main.go
@@ -113,21 +113,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
handlers.ReconcileUnshipped()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileUnshipped()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||
|
||||
// Block until signal received
|
||||
|
||||
@@ -126,7 +126,6 @@ func SetupRoutes(
|
||||
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||
|
||||
// Addresses
|
||||
|
||||
55
go.mod
55
go.mod
@@ -1,14 +1,14 @@
|
||||
module terminalShop
|
||||
|
||||
go 1.25.9
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
charm.land/wish/v2 v2.0.1
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||
github.com/charmbracelet/wish v1.4.7
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -32,35 +32,38 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/log/v2 v2.0.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||
github.com/charmbracelet/log v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/input v0.3.4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
@@ -75,11 +78,15 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
@@ -97,11 +104,11 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
|
||||
124
go.sum
124
go.sum
@@ -1,15 +1,3 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
|
||||
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
|
||||
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
|
||||
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
@@ -22,8 +10,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
@@ -32,38 +22,52 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
|
||||
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
||||
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
||||
github.com/charmbracelet/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/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
|
||||
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
||||
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -71,6 +75,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||
@@ -85,8 +91,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -141,17 +147,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||
@@ -233,10 +247,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -259,17 +273,19 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
||||
16
main.go
16
main.go
@@ -11,11 +11,11 @@ import (
|
||||
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/wish/v2"
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
"github.com/charmbracelet/wish/bubbletea"
|
||||
"github.com/charmbracelet/wish/logging"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
@@ -36,6 +36,9 @@ var (
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
renderer := bubbletea.MakeRenderer(s)
|
||||
|
||||
// Extract SSH public key from session
|
||||
pubKey := s.PublicKey()
|
||||
|
||||
var fingerprint, pubKeyStr string
|
||||
@@ -44,8 +47,9 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
|
||||
}
|
||||
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, nil
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -636,20 +636,6 @@ type OrderResponse struct {
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RefundRequest is the customer-facing refund request payload.
|
||||
type RefundRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type refundRequestResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||
|
||||
@@ -678,42 +664,6 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
return ordersResp.Data.Orders, nil
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a refund request for an order.
|
||||
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
|
||||
|
||||
jsonData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal refund request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create refund request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send refund request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var refundResp refundRequestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
|
||||
return fmt.Errorf("failed to decode refund response: %w", err)
|
||||
}
|
||||
|
||||
if !refundResp.Success {
|
||||
if refundResp.Error != nil {
|
||||
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to send refund request")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Addresses API response
|
||||
type AddressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
@@ -66,19 +66,14 @@ type Order struct {
|
||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
||||
|
||||
// LastRefundRequestAt timestamps the most recent customer-initiated refund
|
||||
// request for this order. Used to rate-limit repeat submissions.
|
||||
LastRefundRequestAt *time.Time `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the Order model.
|
||||
@@ -117,35 +112,3 @@ func (o *Order) TotalInDollars() float64 {
|
||||
func (o *Order) IsActive() bool {
|
||||
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
||||
}
|
||||
|
||||
// RefundRequestReasons is the canonical list of customer-facing refund reasons
|
||||
// accepted by both the API and the TUI.
|
||||
var RefundRequestReasons = []string{
|
||||
"Wrong item received",
|
||||
"Item damaged in transit",
|
||||
"Item never arrived",
|
||||
"Quality issue",
|
||||
"Other",
|
||||
}
|
||||
|
||||
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
|
||||
const RefundRequestReasonOther = "Other"
|
||||
|
||||
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
|
||||
func IsValidRefundReason(r string) bool {
|
||||
for _, reason := range RefundRequestReasons {
|
||||
if reason == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanRequestRefund reports whether a customer is allowed to request a refund
|
||||
// for this order. Paid, shipped, and delivered orders qualify; everything else
|
||||
// is either still unpaid or already in a terminal state.
|
||||
func (o *Order) CanRequestRefund() bool {
|
||||
return o.Status == OrderStatusPaid ||
|
||||
o.Status == OrderStatusShipped ||
|
||||
o.Status == OrderStatusDelivered
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
type OrderEvent struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -17,47 +14,3 @@ type OrderEvent struct {
|
||||
func (OrderEvent) TableName() string {
|
||||
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,4 +1,3 @@
|
||||
// Package notify provides a simple interface to send notifications to Slack.
|
||||
package notify
|
||||
|
||||
import (
|
||||
@@ -15,10 +14,8 @@ import (
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// slack client
|
||||
var slackClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// dollars converts cents to dollars
|
||||
func dollars(cents int) string {
|
||||
return fmt.Sprintf("$%.2f", float64(cents)/100)
|
||||
}
|
||||
@@ -188,33 +185,29 @@ func SlackUnpinOrder(orderID uint) {
|
||||
}
|
||||
|
||||
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
||||
// It looks up the thread ts from the database and returns an error when the
|
||||
// reply cannot be posted.
|
||||
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||
// Looks up the thread ts from the database. Silent no-op if the order has no
|
||||
// saved thread (Slack was disabled when it was created, or post failed earlier).
|
||||
func SlackPostToOrderThread(orderID uint, text string) {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return fmt.Errorf("slack is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
||||
return fmt.Errorf("failed to look up order thread: %w", err)
|
||||
return
|
||||
}
|
||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||
return fmt.Errorf("order has no slack thread")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := postSlackMessage(token, map[string]any{
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": *order.SlackThreadTS,
|
||||
"text": text,
|
||||
}); !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) {
|
||||
@@ -268,44 +261,4 @@ func markStatusActionBlock(orderID uint) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
var _ = time.Second
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model {
|
||||
}
|
||||
|
||||
if !m.account.viewportReady {
|
||||
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
||||
m.account.detailViewport = viewport.New(detailWidth, availableHeight)
|
||||
m.account.detailViewport.KeyMap = modifiedKeyMap
|
||||
m.account.viewportReady = true
|
||||
} else {
|
||||
m.account.detailViewport.SetWidth(detailWidth)
|
||||
m.account.detailViewport.SetHeight(availableHeight)
|
||||
m.account.detailViewport.Width = detailWidth
|
||||
m.account.detailViewport.Height = availableHeight
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model {
|
||||
}
|
||||
|
||||
targetY := headerOffset + (selectedIndex * itemHeight)
|
||||
vpH := m.account.detailViewport.Height()
|
||||
offset := m.account.detailViewport.YOffset()
|
||||
vpH := m.account.detailViewport.Height
|
||||
offset := m.account.detailViewport.YOffset
|
||||
|
||||
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
||||
// not when the current item hits the very edge
|
||||
@@ -288,13 +288,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case "r":
|
||||
if m.account.orderViewState == 2 {
|
||||
if order, ok := m.selectedRefundableOrder(); ok {
|
||||
return m.OpenRefundComposer(order.ID)
|
||||
}
|
||||
}
|
||||
|
||||
case "x", "d":
|
||||
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
||||
m.account.addressDeleting = &m.account.addressCursor
|
||||
@@ -343,18 +336,14 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
case 1:
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset
|
||||
m.account.orderViewState = 2
|
||||
m.account.detailViewport.GotoTop()
|
||||
footer := []footerCommand{
|
||||
m.footer = []footerCommand{
|
||||
{key: "esc", value: "back"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
if _, ok := m.selectedRefundableOrder(); ok {
|
||||
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
|
||||
}
|
||||
m.footer = footer
|
||||
}
|
||||
}
|
||||
case "addresses":
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) OrdersView(width int) string {
|
||||
@@ -37,21 +37,6 @@ func (m Model) HistoryOrders() []models.Order {
|
||||
return out
|
||||
}
|
||||
|
||||
// selectedRefundableOrder returns the order under the cursor in the active
|
||||
// order-detail view, paired with ok=true, when the user is allowed to file a
|
||||
// refund request for it. Returns (zero, false) otherwise.
|
||||
func (m Model) selectedRefundableOrder() (models.Order, bool) {
|
||||
orders := m.currentOrderList()
|
||||
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
|
||||
return models.Order{}, false
|
||||
}
|
||||
order := orders[m.account.orderCursor]
|
||||
if !order.CanRequestRefund() {
|
||||
return models.Order{}, false
|
||||
}
|
||||
return order, true
|
||||
}
|
||||
|
||||
func (m Model) currentOrderList() []models.Order {
|
||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||
return nil
|
||||
@@ -216,24 +201,6 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
||||
}
|
||||
}
|
||||
|
||||
visible := make([]models.OrderEvent, 0, len(order.Events))
|
||||
for _, event := range order.Events {
|
||||
if _, ok := event.DisplayLabel(); ok {
|
||||
visible = append(visible, event)
|
||||
}
|
||||
}
|
||||
if len(visible) > 0 {
|
||||
b.WriteString(labelStyle.Render("Timeline"))
|
||||
b.WriteString("\n")
|
||||
for _, event := range visible {
|
||||
label, _ := event.DisplayLabel()
|
||||
when := event.CreatedAt.Format("Jan 02 3:04 PM")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Shipping address
|
||||
b.WriteString(labelStyle.Render("Ship To"))
|
||||
b.WriteString("\n")
|
||||
@@ -256,11 +223,7 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Footer hint
|
||||
if order.CanRequestRefund() {
|
||||
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
|
||||
} else {
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
}
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildBreadcrumbs() string {
|
||||
|
||||
@@ -3,9 +3,9 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) updateCartViewport() Model {
|
||||
@@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.cart.viewportReady {
|
||||
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.cart.viewport = viewport.New(m.widthContent, availH)
|
||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.cart.viewportReady = true
|
||||
} else {
|
||||
m.cart.viewport.SetWidth(m.widthContent)
|
||||
m.cart.viewport.SetHeight(availH)
|
||||
m.cart.viewport.Width = m.widthContent
|
||||
m.cart.viewport.Height = availH
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -116,11 +116,11 @@ func (m Model) CartView() string {
|
||||
}
|
||||
itemHeight := 4 + 2*boxPadding + 1
|
||||
targetY := m.cart.cursor * itemHeight
|
||||
if targetY < m.cart.viewport.YOffset() {
|
||||
if targetY < m.cart.viewport.YOffset {
|
||||
m.cart.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
|
||||
}
|
||||
}
|
||||
return lipgloss.Place(
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// RenderConfirmation renders the order confirmation view
|
||||
@@ -73,12 +73,12 @@ func (m Model) updateConfirmViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.confirm.viewportReady {
|
||||
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.confirm.viewport = viewport.New(m.widthContent, availH)
|
||||
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
|
||||
m.confirm.viewportReady = true
|
||||
} else {
|
||||
m.confirm.viewport.SetWidth(m.widthContent)
|
||||
m.confirm.viewport.SetHeight(availH)
|
||||
m.confirm.viewport.Width = m.widthContent
|
||||
m.confirm.viewport.Height = availH
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildFooter() string {
|
||||
|
||||
@@ -3,8 +3,8 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
)
|
||||
|
||||
func (m Model) BuildHeader() string {
|
||||
@@ -44,7 +44,7 @@ func (m Model) BuildHeader() string {
|
||||
tabs = []string{logo, shop, account, cart}
|
||||
}
|
||||
|
||||
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
|
||||
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(m.renderer.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
|
||||
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
|
||||
}).Render()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildHelpView() string {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildMenuView() string {
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
stripetoken "github.com/stripe/stripe-go/v78/token"
|
||||
|
||||
@@ -64,11 +65,6 @@ type VisibleError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
// VisibleNotice is a typed success message displayed to the user as a banner.
|
||||
type VisibleNotice struct {
|
||||
message string
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
// User authentication
|
||||
User *models.User
|
||||
@@ -115,11 +111,11 @@ type Model struct {
|
||||
|
||||
Loading bool // true when fetching data from API
|
||||
error *VisibleError
|
||||
notice *VisibleNotice
|
||||
APIClient *api.Client
|
||||
|
||||
// Rendering
|
||||
theme theme.Theme
|
||||
renderer *lipgloss.Renderer
|
||||
theme theme.Theme
|
||||
|
||||
// Checkout flow state (spans multiple pages)
|
||||
CheckingOut bool
|
||||
@@ -134,7 +130,6 @@ type Model struct {
|
||||
shipping shippingState
|
||||
payment paymentState
|
||||
account accountState
|
||||
refund refundState
|
||||
review reviewState
|
||||
confirm confirmState
|
||||
|
||||
@@ -224,7 +219,6 @@ func (m Model) SwitchPage(p page) Model {
|
||||
m.currentPage = p
|
||||
m.switched = true
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -246,6 +240,7 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
||||
m = m.SwitchPage(cartPage)
|
||||
m = m.updateCartViewport()
|
||||
if m.IsCartEmpty() {
|
||||
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "items"},
|
||||
{key: "+/-", value: "qty"},
|
||||
@@ -760,12 +755,18 @@ func (m Model) SavedCardsIsEmpty() bool {
|
||||
return len(m.SavedCards) == 0
|
||||
}
|
||||
|
||||
// NewModel creates a new model.
|
||||
// NewModel creates a new model using the default renderer.
|
||||
func NewModel(username string) Model {
|
||||
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
||||
}
|
||||
|
||||
// newModelWithRenderer creates a new model with the given renderer.
|
||||
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
m := Model{
|
||||
Username: username,
|
||||
currentPage: splashPage,
|
||||
theme: theme.BasicTheme(),
|
||||
renderer: renderer,
|
||||
theme: theme.BasicTheme(renderer),
|
||||
Coffees: []models.Coffee{
|
||||
{
|
||||
Name: "Espresso",
|
||||
@@ -832,8 +833,8 @@ func NewModel(username string) Model {
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context.
|
||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := NewModel("")
|
||||
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := newModelWithRenderer("", renderer)
|
||||
m.Fingerprint = fingerprint
|
||||
m.AuthFingerprintKey = clientSecret
|
||||
m.StripePublicKey = stripePublicKey
|
||||
@@ -859,7 +860,7 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
|
||||
}
|
||||
|
||||
func newViewport(w, h int) viewport.Model {
|
||||
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
|
||||
return viewport.New(w, h)
|
||||
}
|
||||
|
||||
func (m Model) collectCardCmd() tea.Cmd {
|
||||
@@ -875,10 +876,8 @@ func (m Model) collectCardCmd() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
ordersPollInterval = 10 * time.Second
|
||||
cardPollTimeout = 10 * time.Minute
|
||||
)
|
||||
const ordersPollInterval = 10 * time.Second
|
||||
const cardPollTimeout = 10 * time.Minute
|
||||
|
||||
type OrdersPollTickMsg struct{}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
@@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.payment.viewportReady {
|
||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.payment.viewport = viewport.New(m.widthContent, availH)
|
||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.payment.viewportReady = true
|
||||
} else {
|
||||
m.payment.viewport.SetWidth(m.widthContent)
|
||||
m.payment.viewport.SetHeight(availH)
|
||||
m.payment.viewport.Width = m.widthContent
|
||||
m.payment.viewport.Height = availH
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string {
|
||||
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
m.payment.viewport.Height(),
|
||||
m.payment.viewport.Height,
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.renderPaymentHttpsView(),
|
||||
)
|
||||
@@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string {
|
||||
m.payment.viewport.SetContent(content)
|
||||
itemHeight := 3
|
||||
targetY := m.payment.cardCursor * itemHeight
|
||||
if targetY < m.payment.viewport.YOffset() {
|
||||
if targetY < m.payment.viewport.YOffset {
|
||||
m.payment.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.viewport.GotoBottom()
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// Refund composer layout constants.
|
||||
const (
|
||||
refundMinTextareaRows = 5
|
||||
refundMaxTextareaRows = 10
|
||||
// chrome above the textarea inside the modal (title, label, padding, footer).
|
||||
refundModalChrome = 12
|
||||
refundModalMaxWidth = 58
|
||||
refundModalMinWidth = 24
|
||||
// horizontal chrome the modal subtracts from m.widthContent.
|
||||
refundModalHGutter = 6
|
||||
// inner padding around the textarea inside the modal body.
|
||||
refundTextareaHPad = 4
|
||||
)
|
||||
|
||||
// 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 (optional)..."
|
||||
ta.ShowLineNumbers = true
|
||||
ta.CharLimit = 65536
|
||||
ta.DynamicHeight = true
|
||||
ta.MinHeight = refundMinTextareaRows
|
||||
ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
ta.SetHeight(refundMinTextareaRows)
|
||||
ta.SetWidth(m.refundTextareaWidth())
|
||||
|
||||
base := lipgloss.NewStyle()
|
||||
ta.SetStyles(textarea.Styles{
|
||||
Focused: textarea.StyleState{
|
||||
Base: base,
|
||||
Text: base.Foreground(m.theme.Accent()),
|
||||
LineNumber: base.Foreground(m.theme.Dim()),
|
||||
CursorLine: base.Background(m.theme.Border()).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.MinHeight = refundMinTextareaRows
|
||||
state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
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.
|
||||
func (m Model) RenderRefundOverlay() string {
|
||||
state := &m.refund
|
||||
contentWidth := m.refundContentWidth()
|
||||
|
||||
title := m.theme.TextAccent().
|
||||
Bold(true).
|
||||
Width(contentWidth).
|
||||
Render(fmt.Sprintf("Refund request - Order #%d", state.orderID))
|
||||
|
||||
body := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
m.renderRefundReason(state),
|
||||
"",
|
||||
m.renderRefundTextarea(state),
|
||||
"",
|
||||
m.renderRefundFooter(state),
|
||||
)
|
||||
|
||||
modal := lipgloss.NewStyle().
|
||||
Width(contentWidth).
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(m.theme.Border()).
|
||||
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("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 (optional)..."
|
||||
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()
|
||||
}
|
||||
|
||||
counter := m.theme.TextDim().
|
||||
Width(m.refundTextareaWidth()).
|
||||
Align(lipgloss.Right).
|
||||
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(border).
|
||||
Padding(0, 1).
|
||||
Width(m.refundContentWidth()).
|
||||
Render(state.textarea.View() + "\n" + counter)
|
||||
|
||||
out := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
m.theme.TextLabel().Render(label),
|
||||
box,
|
||||
)
|
||||
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)
|
||||
}
|
||||
return m.theme.TextDim().Render("tab next field | shift+tab prev | ctrl+s submit | esc 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)
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// countries defines the available countries with their dial codes.
|
||||
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
|
||||
availH = 1
|
||||
}
|
||||
if !m.shipping.viewportReady {
|
||||
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.shipping.viewport = viewport.New(m.widthContent, availH)
|
||||
m.shipping.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.shipping.viewportReady = true
|
||||
} else {
|
||||
m.shipping.viewport.SetWidth(m.widthContent)
|
||||
m.shipping.viewport.SetHeight(availH)
|
||||
m.shipping.viewport.Width = m.widthContent
|
||||
m.shipping.viewport.Height = availH
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
|
||||
m.shipping.viewport.SetContent(content)
|
||||
itemHeight := 4
|
||||
targetY := m.shipping.addressCursor * itemHeight
|
||||
if targetY < m.shipping.viewport.YOffset() {
|
||||
if targetY < m.shipping.viewport.YOffset {
|
||||
m.shipping.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
|
||||
}
|
||||
if m.shipping.addressCursor == len(m.SavedAddresses) {
|
||||
m.shipping.viewport.GotoBottom()
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
|
||||
m.shop.detailViewport.KeyMap = modifiedKeyMap
|
||||
m.shop.viewportsReady = true
|
||||
} else {
|
||||
m.shop.menuViewport.SetWidth(menuWidth)
|
||||
m.shop.menuViewport.SetHeight(availableHeight)
|
||||
m.shop.detailViewport.SetWidth(detailWidth)
|
||||
m.shop.detailViewport.SetHeight(availableHeight)
|
||||
m.shop.menuViewport.Width = menuWidth
|
||||
m.shop.menuViewport.Height = availableHeight
|
||||
m.shop.detailViewport.Width = detailWidth
|
||||
m.shop.detailViewport.Height = availableHeight
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
||||
|
||||
if m.shop.viewportsReady {
|
||||
targetY := (m.shop.selected + 2) * 1
|
||||
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
|
||||
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2)
|
||||
m.shop.detailViewport.GotoTop()
|
||||
}
|
||||
return m
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type DelayCompleteMsg struct{}
|
||||
|
||||
@@ -3,7 +3,7 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// HuhTheme creates a huh form theme using this project's color palette.
|
||||
func HuhTheme(t Theme) huh.Theme {
|
||||
var s huh.Styles
|
||||
func HuhTheme(t Theme) *huh.Theme {
|
||||
var th huh.Theme
|
||||
|
||||
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n")
|
||||
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
|
||||
|
||||
f := &s.Focused
|
||||
f.Base = lipgloss.NewStyle().
|
||||
f := &th.Focused
|
||||
f.Base = t.renderer.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.accent)
|
||||
f.Title = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.Description = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
|
||||
f.Title = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.Description = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
|
||||
|
||||
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = t.renderer.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body)
|
||||
|
||||
s.Help = help.New().Styles
|
||||
th.Help = help.New().Styles
|
||||
|
||||
s.Blurred = copyFieldStyles(*f)
|
||||
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
|
||||
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
|
||||
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim)
|
||||
th.Blurred = copyFieldStyles(*f)
|
||||
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
|
||||
th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ")
|
||||
th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true)
|
||||
th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim)
|
||||
|
||||
return huh.ThemeFunc(func(bool) *huh.Styles { return &s })
|
||||
return &th
|
||||
}
|
||||
|
||||
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
|
||||
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/compat"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Theme holds all colors and styles for the TUI
|
||||
// Theme holds all colors and styles for the terminal coffee shop TUI.
|
||||
type Theme struct {
|
||||
background color.Color
|
||||
border color.Color
|
||||
body color.Color
|
||||
dim color.Color
|
||||
accent color.Color
|
||||
highlight color.Color
|
||||
brand color.Color
|
||||
label color.Color
|
||||
success color.Color
|
||||
error color.Color
|
||||
renderer *lipgloss.Renderer
|
||||
|
||||
background lipgloss.TerminalColor
|
||||
border lipgloss.TerminalColor
|
||||
body lipgloss.TerminalColor
|
||||
dim lipgloss.TerminalColor
|
||||
accent lipgloss.TerminalColor
|
||||
highlight lipgloss.TerminalColor
|
||||
brand lipgloss.TerminalColor
|
||||
label lipgloss.TerminalColor
|
||||
success lipgloss.TerminalColor
|
||||
error lipgloss.TerminalColor
|
||||
|
||||
base lipgloss.Style
|
||||
form huh.Theme
|
||||
form *huh.Theme
|
||||
}
|
||||
|
||||
func BasicTheme() Theme {
|
||||
t := Theme{}
|
||||
// BasicTheme constructs the default theme for terminal coffee shop.
|
||||
func BasicTheme(renderer *lipgloss.Renderer) Theme {
|
||||
t := Theme{renderer: renderer}
|
||||
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
|
||||
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
||||
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#666666")
|
||||
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")}
|
||||
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
|
||||
t.highlight = lipgloss.Color("#4682B4")
|
||||
t.brand = lipgloss.Color("#4682B4")
|
||||
t.label = lipgloss.Color("#999999")
|
||||
t.success = lipgloss.Color("#00FF00")
|
||||
t.error = lipgloss.Color("196")
|
||||
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.base = renderer.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func Catppuccin() Theme {
|
||||
t := Theme{}
|
||||
// this should resemble something like catppuccin
|
||||
func Catppuccin(renderer *lipgloss.Renderer) Theme {
|
||||
t := Theme{renderer: renderer}
|
||||
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
|
||||
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
|
||||
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#6C7086")
|
||||
t.accent = lipgloss.Color("#CBA6F7")
|
||||
@@ -59,25 +60,25 @@ func Catppuccin() Theme {
|
||||
t.success = lipgloss.Color("#A6E3A1")
|
||||
t.error = lipgloss.Color("#F38BA8")
|
||||
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.base = renderer.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// Color accessors.
|
||||
func (t Theme) Background() color.Color { return t.background }
|
||||
func (t Theme) Border() color.Color { return t.border }
|
||||
func (t Theme) Body() color.Color { return t.body }
|
||||
func (t Theme) Dim() color.Color { return t.dim }
|
||||
func (t Theme) Accent() color.Color { return t.accent }
|
||||
func (t Theme) Highlight() color.Color { return t.highlight }
|
||||
func (t Theme) Brand() color.Color { return t.brand }
|
||||
func (t Theme) Label() color.Color { return t.label }
|
||||
func (t Theme) Success() color.Color { return t.success }
|
||||
func (t Theme) Error() color.Color { return t.error }
|
||||
// Color accessors — use in .Foreground() / .Background() calls.
|
||||
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
|
||||
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
|
||||
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
|
||||
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
|
||||
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
|
||||
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
|
||||
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
|
||||
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
|
||||
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
|
||||
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
|
||||
|
||||
// Style helpers.
|
||||
// Style helpers — return a copy of base with the correct foreground.
|
||||
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
|
||||
func (t Theme) Base() lipgloss.Style { return t.base }
|
||||
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
|
||||
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
|
||||
@@ -89,8 +90,10 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc
|
||||
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
|
||||
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
|
||||
|
||||
// PanelError returns a style for the error banner: dark red bg, red fg.
|
||||
func (t Theme) PanelError() lipgloss.Style {
|
||||
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
||||
}
|
||||
|
||||
func (t Theme) Form() huh.Theme { return t.form }
|
||||
// Form returns the huh form theme for shipping and payment forms.
|
||||
func (t Theme) Form() *huh.Theme { return t.form }
|
||||
|
||||
@@ -2,12 +2,11 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -37,10 +36,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.payment.form != nil {
|
||||
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
|
||||
}
|
||||
@@ -56,15 +51,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case VisibleError:
|
||||
m.error = &msg
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
case VisibleNotice:
|
||||
m.notice = &msg
|
||||
m.error = nil
|
||||
return m, nil
|
||||
case error:
|
||||
m.error = &VisibleError{message: msg.Error()}
|
||||
m.notice = nil
|
||||
if m.currentPage == shopPage || m.currentPage == cartPage {
|
||||
return m, m.fetchCartCmd
|
||||
}
|
||||
@@ -178,14 +167,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.shipping.form != nil || m.payment.form != nil {
|
||||
break
|
||||
}
|
||||
// Refund overlay owns input when open; dispatched below before page handlers.
|
||||
if m.refund.open {
|
||||
break
|
||||
}
|
||||
// Dismiss banners with ESC.
|
||||
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
|
||||
// Dismiss error banner with ESC
|
||||
if msg.String() == "esc" && m.error != nil {
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
}
|
||||
// Global navigation
|
||||
@@ -208,12 +192,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// non-globals. fall through to page dispatch below
|
||||
}
|
||||
|
||||
// Refund overlay intercepts all input after global handling.
|
||||
if m.refund.open {
|
||||
return m.RefundUpdate(msg)
|
||||
}
|
||||
|
||||
// Page-specifics
|
||||
switch m.currentPage {
|
||||
case shopPage:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ResizeView renders a message when the terminal is too small.
|
||||
@@ -20,13 +19,7 @@ func (m Model) ResizeView() string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
v := tea.NewView(m.renderString())
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m Model) renderString() string {
|
||||
func (m Model) View() string {
|
||||
if m.viewportWidth == 0 {
|
||||
m.updateLayout(120, 30)
|
||||
}
|
||||
@@ -84,10 +77,6 @@ func (m Model) renderString() string {
|
||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||
errorBanner := errorStyle.Render(m.error.message)
|
||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
} else if m.notice != nil {
|
||||
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
||||
noticeBanner := noticeStyle.Render(m.notice.message)
|
||||
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
} else {
|
||||
content = m.buildPageContent(availableContentHeight)
|
||||
}
|
||||
@@ -119,10 +108,6 @@ func (m Model) renderString() string {
|
||||
MaxHeight(m.heightContainer).
|
||||
Render(child)
|
||||
|
||||
if m.refund.open {
|
||||
constrained = m.RenderRefundOverlay()
|
||||
}
|
||||
|
||||
// Center the entire container in the terminal viewport
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
|
||||
Reference in New Issue
Block a user