Compare commits
2 Commits
docs/packa
...
feat/refun
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d6a9fdf2 | |||
| 3737eea7f6 |
@@ -2,9 +2,12 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"terminalShop/pkg/bring"
|
"terminalShop/pkg/bring"
|
||||||
@@ -31,6 +34,16 @@ type OrderHandler struct {
|
|||||||
bringCustomerNumber string
|
bringCustomerNumber string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type refundRequestPayload struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundRequestCooldown is the minimum interval between customer-initiated
|
||||||
|
// refund requests for the same order. Prevents accidental double-clicks and
|
||||||
|
// repeated submissions from spamming the operator's Slack channel.
|
||||||
|
const refundRequestCooldown = 15 * time.Minute
|
||||||
|
|
||||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||||
return &OrderHandler{
|
return &OrderHandler{
|
||||||
stripeKey: stripeSecretKey,
|
stripeKey: stripeSecretKey,
|
||||||
@@ -40,6 +53,83 @@ func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNum
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRefundRequest posts a customer refund request into the order's Slack
|
||||||
|
// thread. It does not issue a Stripe refund.
|
||||||
|
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db := database.GetDB().WithContext(r.Context())
|
||||||
|
userID := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refundRequestPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Reason = strings.TrimSpace(req.Reason)
|
||||||
|
req.Message = strings.TrimSpace(req.Message)
|
||||||
|
|
||||||
|
if !models.IsValidRefundReason(req.Reason) {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var order models.Order
|
||||||
|
if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
|
||||||
|
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !order.CanRequestRefund() {
|
||||||
|
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
|
||||||
|
"only paid, shipped, or delivered orders can request refunds", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.LastRefundRequestAt != nil {
|
||||||
|
elapsed := time.Since(*order.LastRefundRequestAt)
|
||||||
|
if elapsed < refundRequestCooldown {
|
||||||
|
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
|
||||||
|
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
|
||||||
|
nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
|
||||||
|
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
|
||||||
|
if req.Message != "" {
|
||||||
|
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
|
||||||
|
text += "\n\n" + quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := notify.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) {
|
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
db := database.GetDB().WithContext(r.Context())
|
db := database.GetDB().WithContext(r.Context())
|
||||||
userID := middleware.UserIDFromContext(r.Context())
|
userID := middleware.UserIDFromContext(r.Context())
|
||||||
@@ -50,7 +140,7 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
|||||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||||
Order("created_at desc").Find(&orders)
|
Order("created_at desc").Find(&orders)
|
||||||
|
|
||||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||||
"orders": orders,
|
"orders": orders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
170
api/handlers/orders_test.go
Normal file
170
api/handlers/orders_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"terminalShop/pkg/database"
|
||||||
|
"terminalShop/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupOrderTestDB(t *testing.T) models.User {
|
||||||
|
t.Helper()
|
||||||
|
testDB := filepath.Join(t.TempDir(), "orders.db")
|
||||||
|
database.ResetForTesting()
|
||||||
|
t.Cleanup(database.ResetForTesting)
|
||||||
|
|
||||||
|
db, err := database.Connect(testDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to connect to test database: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.Migrate(db); err != nil {
|
||||||
|
t.Fatalf("failed to migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.Seed(db); err != nil {
|
||||||
|
t.Fatalf("failed to seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := models.User{
|
||||||
|
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
|
||||||
|
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
|
||||||
|
Name: "Order Test User",
|
||||||
|
Email: "orders@example.com",
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
|
||||||
|
req := authRequest(method, url, body, userID)
|
||||||
|
routeCtx := chi.NewRouteContext()
|
||||||
|
routeCtx.URLParams.Add("id", orderID)
|
||||||
|
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||||
|
user := setupOrderTestDB(t)
|
||||||
|
|
||||||
|
handler := NewOrderHandler("", "", "", "")
|
||||||
|
body := []byte(`{"reason":"Other","message":" "}`)
|
||||||
|
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.CreateRefundRequest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Error.Code != "MESSAGE_REQUIRED" {
|
||||||
|
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||||
|
user := setupOrderTestDB(t)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
order := models.Order{
|
||||||
|
UserID: user.ID,
|
||||||
|
CardID: 1,
|
||||||
|
Status: models.OrderStatusFailed,
|
||||||
|
Subtotal: 100,
|
||||||
|
ShippingCost: 0,
|
||||||
|
Total: 100,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewOrderHandler("", "", "", "")
|
||||||
|
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||||
|
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.CreateRefundRequest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Error.Code != "INVALID_STATE" {
|
||||||
|
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||||
|
user := setupOrderTestDB(t)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
recent := time.Now().Add(-1 * time.Minute)
|
||||||
|
order := models.Order{
|
||||||
|
UserID: user.ID,
|
||||||
|
CardID: 1,
|
||||||
|
Status: models.OrderStatusPaid,
|
||||||
|
Subtotal: 100,
|
||||||
|
ShippingCost: 0,
|
||||||
|
Total: 100,
|
||||||
|
LastRefundRequestAt: &recent,
|
||||||
|
}
|
||||||
|
if err := db.Create(&order).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewOrderHandler("", "", "", "")
|
||||||
|
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||||
|
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.CreateRefundRequest(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("expected status 429, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Retry-After"); got == "" {
|
||||||
|
t.Errorf("expected Retry-After header, got empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Error.Code != "RATE_LIMITED" {
|
||||||
|
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
|||||||
|
|
||||||
resp, err := h.httpClient.Do(req)
|
resp, err := h.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
@@ -151,7 +151,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
|
|||||||
_ = json.Unmarshal(bodyBytes, &result)
|
_ = json.Unmarshal(bodyBytes, &result)
|
||||||
|
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if order.TrackingNumber == "" {
|
if order.TrackingNumber == "" {
|
||||||
notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,12 +198,12 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
|
|||||||
|
|
||||||
resp, err := h.httpClient.Do(req)
|
resp, err := h.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if previousTrackingStatus != newStatus {
|
if previousTrackingStatus != newStatus {
|
||||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||||
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
go func() {
|
||||||
|
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
if deliveredTransition {
|
if deliveredTransition {
|
||||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ func SetupRoutes(
|
|||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
r.Get("/orders", orderHandler.GetOrders)
|
r.Get("/orders", orderHandler.GetOrders)
|
||||||
|
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||||
|
|
||||||
// Addresses
|
// Addresses
|
||||||
|
|||||||
@@ -636,6 +636,20 @@ type OrderResponse struct {
|
|||||||
Error *APIError `json:"error,omitempty"`
|
Error *APIError `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundRequest is the customer-facing refund request payload.
|
||||||
|
type RefundRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type refundRequestResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *APIError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||||
|
|
||||||
@@ -664,6 +678,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
|||||||
return ordersResp.Data.Orders, nil
|
return ordersResp.Data.Orders, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRefundRequest posts a refund request for an order.
|
||||||
|
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal refund request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create refund request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.doRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send refund request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var refundResp refundRequestResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode refund response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !refundResp.Success {
|
||||||
|
if refundResp.Error != nil {
|
||||||
|
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to send refund request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// The Addresses API response
|
// The Addresses API response
|
||||||
type AddressResponse struct {
|
type AddressResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ type Order struct {
|
|||||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||||
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
||||||
|
|
||||||
|
// LastRefundRequestAt timestamps the most recent customer-initiated refund
|
||||||
|
// request for this order. Used to rate-limit repeat submissions.
|
||||||
|
LastRefundRequestAt *time.Time `json:"-"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||||
@@ -113,3 +117,35 @@ func (o *Order) TotalInDollars() float64 {
|
|||||||
func (o *Order) IsActive() bool {
|
func (o *Order) IsActive() bool {
|
||||||
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundRequestReasons is the canonical list of customer-facing refund reasons
|
||||||
|
// accepted by both the API and the TUI.
|
||||||
|
var RefundRequestReasons = []string{
|
||||||
|
"Wrong item received",
|
||||||
|
"Item damaged in transit",
|
||||||
|
"Item never arrived",
|
||||||
|
"Quality issue",
|
||||||
|
"Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
|
||||||
|
const RefundRequestReasonOther = "Other"
|
||||||
|
|
||||||
|
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
|
||||||
|
func IsValidRefundReason(r string) bool {
|
||||||
|
for _, reason := range RefundRequestReasons {
|
||||||
|
if reason == r {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRequestRefund reports whether a customer is allowed to request a refund
|
||||||
|
// for this order. Paid, shipped, and delivered orders qualify; everything else
|
||||||
|
// is either still unpaid or already in a terminal state.
|
||||||
|
func (o *Order) CanRequestRefund() bool {
|
||||||
|
return o.Status == OrderStatusPaid ||
|
||||||
|
o.Status == OrderStatusShipped ||
|
||||||
|
o.Status == OrderStatusDelivered
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,29 +188,33 @@ func SlackUnpinOrder(orderID uint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
||||||
// Looks up the thread ts from the database. Silent no-op if the order has no
|
// It looks up the thread ts from the database and returns an error when the
|
||||||
// saved thread (Slack was disabled when it was created, or post failed earlier).
|
// reply cannot be posted.
|
||||||
func SlackPostToOrderThread(orderID uint, text string) {
|
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||||
channel := os.Getenv("SLACK_CHANNEL")
|
channel := os.Getenv("SLACK_CHANNEL")
|
||||||
if token == "" || channel == "" {
|
if token == "" || channel == "" {
|
||||||
return
|
return fmt.Errorf("slack is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
var order models.Order
|
var order models.Order
|
||||||
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||||
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
||||||
return
|
return fmt.Errorf("failed to look up order thread: %w", err)
|
||||||
}
|
}
|
||||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||||
return
|
return fmt.Errorf("order has no slack thread")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = postSlackMessage(token, map[string]any{
|
if _, ok := postSlackMessage(token, map[string]any{
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"thread_ts": *order.SlackThreadTS,
|
"thread_ts": *order.SlackThreadTS,
|
||||||
"text": text,
|
"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) {
|
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
|
||||||
|
|||||||
@@ -288,6 +288,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
|
case "r":
|
||||||
|
if m.account.orderViewState == 2 {
|
||||||
|
if order, ok := m.selectedRefundableOrder(); ok {
|
||||||
|
return m.OpenRefundComposer(order.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "x", "d":
|
case "x", "d":
|
||||||
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
||||||
m.account.addressDeleting = &m.account.addressCursor
|
m.account.addressDeleting = &m.account.addressCursor
|
||||||
@@ -339,11 +346,15 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
||||||
m.account.orderViewState = 2
|
m.account.orderViewState = 2
|
||||||
m.account.detailViewport.GotoTop()
|
m.account.detailViewport.GotoTop()
|
||||||
m.footer = []footerCommand{
|
footer := []footerCommand{
|
||||||
{key: "esc", value: "back"},
|
{key: "esc", value: "back"},
|
||||||
{key: "s", value: "shop"},
|
{key: "s", value: "shop"},
|
||||||
{key: "q", value: "quit"},
|
{key: "q", value: "quit"},
|
||||||
}
|
}
|
||||||
|
if _, ok := m.selectedRefundableOrder(); ok {
|
||||||
|
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
|
||||||
|
}
|
||||||
|
m.footer = footer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "addresses":
|
case "addresses":
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectedRefundableOrder returns the order under the cursor in the active
|
||||||
|
// order-detail view, paired with ok=true, when the user is allowed to file a
|
||||||
|
// refund request for it. Returns (zero, false) otherwise.
|
||||||
|
func (m Model) selectedRefundableOrder() (models.Order, bool) {
|
||||||
|
orders := m.currentOrderList()
|
||||||
|
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
|
||||||
|
return models.Order{}, false
|
||||||
|
}
|
||||||
|
order := orders[m.account.orderCursor]
|
||||||
|
if !order.CanRequestRefund() {
|
||||||
|
return models.Order{}, false
|
||||||
|
}
|
||||||
|
return order, true
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) currentOrderList() []models.Order {
|
func (m Model) currentOrderList() []models.Order {
|
||||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||||
return nil
|
return nil
|
||||||
@@ -241,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
|||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
// Footer hint
|
// Footer hint
|
||||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
if order.CanRequestRefund() {
|
||||||
|
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
|
||||||
|
} else {
|
||||||
|
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ type VisibleError struct {
|
|||||||
message string
|
message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VisibleNotice is a typed success message displayed to the user as a banner.
|
||||||
|
type VisibleNotice struct {
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
// User authentication
|
// User authentication
|
||||||
User *models.User
|
User *models.User
|
||||||
@@ -110,6 +115,7 @@ type Model struct {
|
|||||||
|
|
||||||
Loading bool // true when fetching data from API
|
Loading bool // true when fetching data from API
|
||||||
error *VisibleError
|
error *VisibleError
|
||||||
|
notice *VisibleNotice
|
||||||
APIClient *api.Client
|
APIClient *api.Client
|
||||||
|
|
||||||
// Rendering
|
// Rendering
|
||||||
@@ -128,6 +134,7 @@ type Model struct {
|
|||||||
shipping shippingState
|
shipping shippingState
|
||||||
payment paymentState
|
payment paymentState
|
||||||
account accountState
|
account accountState
|
||||||
|
refund refundState
|
||||||
review reviewState
|
review reviewState
|
||||||
confirm confirmState
|
confirm confirmState
|
||||||
|
|
||||||
@@ -217,6 +224,7 @@ func (m Model) SwitchPage(p page) Model {
|
|||||||
m.currentPage = p
|
m.currentPage = p
|
||||||
m.switched = true
|
m.switched = true
|
||||||
m.error = nil
|
m.error = nil
|
||||||
|
m.notice = nil
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
369
pkg/tui/refund.go
Normal file
369
pkg/tui/refund.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"terminalShop/pkg/api"
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"terminalShop/pkg/api"
|
||||||
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -36,6 +37,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.payment.form != nil {
|
if m.payment.form != nil {
|
||||||
m.payment.form.form = m.buildPaymentForm(m.payment.form)
|
m.payment.form.form = m.buildPaymentForm(m.payment.form)
|
||||||
}
|
}
|
||||||
|
if m.refund.open {
|
||||||
|
m.refund.form = m.buildRefundForm(&m.refund)
|
||||||
|
m.resizeRefundTextarea(&m.refund)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case VisibleError:
|
case VisibleError:
|
||||||
m.error = &msg
|
m.error = &msg
|
||||||
|
m.notice = nil
|
||||||
|
return m, nil
|
||||||
|
case VisibleNotice:
|
||||||
|
m.notice = &msg
|
||||||
|
m.error = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
case error:
|
case error:
|
||||||
m.error = &VisibleError{message: msg.Error()}
|
m.error = &VisibleError{message: msg.Error()}
|
||||||
|
m.notice = nil
|
||||||
if m.currentPage == shopPage || m.currentPage == cartPage {
|
if m.currentPage == shopPage || m.currentPage == cartPage {
|
||||||
return m, m.fetchCartCmd
|
return m, m.fetchCartCmd
|
||||||
}
|
}
|
||||||
@@ -167,9 +178,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.shipping.form != nil || m.payment.form != nil {
|
if m.shipping.form != nil || m.payment.form != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Dismiss error banner with ESC
|
// Refund overlay owns input when open; dispatched below before page handlers.
|
||||||
if msg.String() == "esc" && m.error != nil {
|
if m.refund.open {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Dismiss banners with ESC.
|
||||||
|
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
|
||||||
m.error = nil
|
m.error = nil
|
||||||
|
m.notice = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
// Global navigation
|
// Global navigation
|
||||||
@@ -192,6 +208,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
// non-globals. fall through to page dispatch below
|
// non-globals. fall through to page dispatch below
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refund overlay intercepts all input after global handling.
|
||||||
|
if m.refund.open {
|
||||||
|
return m.RefundUpdate(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// Page-specifics
|
// Page-specifics
|
||||||
switch m.currentPage {
|
switch m.currentPage {
|
||||||
case shopPage:
|
case shopPage:
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ func (m Model) renderString() string {
|
|||||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||||
errorBanner := errorStyle.Render(m.error.message)
|
errorBanner := errorStyle.Render(m.error.message)
|
||||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
|
} else if m.notice != nil {
|
||||||
|
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
||||||
|
noticeBanner := noticeStyle.Render(m.notice.message)
|
||||||
|
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
} else {
|
} else {
|
||||||
content = m.buildPageContent(availableContentHeight)
|
content = m.buildPageContent(availableContentHeight)
|
||||||
}
|
}
|
||||||
@@ -115,6 +119,10 @@ func (m Model) renderString() string {
|
|||||||
MaxHeight(m.heightContainer).
|
MaxHeight(m.heightContainer).
|
||||||
Render(child)
|
Render(child)
|
||||||
|
|
||||||
|
if m.refund.open {
|
||||||
|
constrained = m.RenderRefundOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
// Center the entire container in the terminal viewport
|
// Center the entire container in the terminal viewport
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.viewportWidth,
|
m.viewportWidth,
|
||||||
|
|||||||
Reference in New Issue
Block a user