diff --git a/api/handlers/orders.go b/api/handlers/orders.go index b2bd03b..74c8748 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -2,9 +2,12 @@ package handlers import ( "encoding/json" + "fmt" + "log/slog" "net/http" "os" "strconv" + "strings" "time" "terminalShop/pkg/bring" @@ -31,6 +34,16 @@ type OrderHandler struct { bringCustomerNumber string } +type refundRequestPayload struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +// refundRequestCooldown is the minimum interval between customer-initiated +// refund requests for the same order. Prevents accidental double-clicks and +// repeated submissions from spamming the operator's Slack channel. +const refundRequestCooldown = 15 * time.Minute + func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler { return &OrderHandler{ stripeKey: stripeSecretKey, @@ -40,6 +53,83 @@ func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNum } } +// CreateRefundRequest posts a customer refund request into the order's Slack +// thread. It does not issue a Stripe refund. +func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) { + db := database.GetDB().WithContext(r.Context()) + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil) + return + } + + var req refundRequestPayload + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + req.Reason = strings.TrimSpace(req.Reason) + req.Message = strings.TrimSpace(req.Message) + + if !models.IsValidRefundReason(req.Reason) { + utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil) + return + } + if req.Reason == models.RefundRequestReasonOther && req.Message == "" { + utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil) + return + } + + var order models.Order + if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil) + return + } + if !order.CanRequestRefund() { + utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE", + "only paid, shipped, or delivered orders can request refunds", nil) + return + } + + if order.LastRefundRequestAt != nil { + elapsed := time.Since(*order.LastRefundRequestAt) + if elapsed < refundRequestCooldown { + retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1 + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", + fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1), + nil) + return + } + } + + text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f", + req.Reason, order.User.Name, order.User.Email, order.TotalInDollars()) + if req.Message != "" { + quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ") + text += "\n\n" + quoted + } + + if err := notify.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()) @@ -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") }). Order("created_at desc").Find(&orders) - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + utils.RespondSuccess(w, http.StatusOK, map[string]any{ "orders": orders, }) } diff --git a/api/handlers/orders_test.go b/api/handlers/orders_test.go new file mode 100644 index 0000000..d4aafef --- /dev/null +++ b/api/handlers/orders_test.go @@ -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) + } +} diff --git a/api/handlers/slack.go b/api/handlers/slack.go index 83e8aa7..5659176 100644 --- a/api/handlers/slack.go +++ b/api/handlers/slack.go @@ -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 } diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index ebe9b02..f67d4fa 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -373,7 +373,9 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) { if previousTrackingStatus != newStatus { audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus)) - go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) + go func() { + _ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails)) + }() } if deliveredTransition { audit.OrderDelivered(order.ID, payload.Data.TrackingNumber) diff --git a/api/routes/routes.go b/api/routes/routes.go index cad2fa4..83715f3 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -126,6 +126,7 @@ func SetupRoutes( // Orders r.Get("/orders", orderHandler.GetOrders) + r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest) r.Post("/orders/{id}/refund", orderHandler.RefundOrder) // Addresses diff --git a/pkg/api/client.go b/pkg/api/client.go index 9ccd4d9..a3742dc 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -636,6 +636,20 @@ type OrderResponse struct { Error *APIError `json:"error,omitempty"` } +// RefundRequest is the customer-facing refund request payload. +type RefundRequest struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type refundRequestResponse struct { + Success bool `json:"success"` + Data struct { + Status string `json:"status"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + func (c *Client) GetOrders() ([]models.Order, error) { url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL) @@ -664,6 +678,42 @@ func (c *Client) GetOrders() ([]models.Order, error) { return ordersResp.Data.Orders, nil } +// CreateRefundRequest posts a refund request for an order. +func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error { + url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID) + + jsonData, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to marshal refund request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create refund request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to send refund request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var refundResp refundRequestResponse + if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil { + return fmt.Errorf("failed to decode refund response: %w", err) + } + + if !refundResp.Success { + if refundResp.Error != nil { + return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message) + } + return fmt.Errorf("failed to send refund request") + } + + return nil +} + // The Addresses API response type AddressResponse struct { Success bool `json:"success"` diff --git a/pkg/models/order.go b/pkg/models/order.go index 9831983..3273c64 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -66,6 +66,10 @@ type Order struct { // Slack parent message ts for this order's thread (NULL if Slack notify disabled). SlackThreadTS *string `gorm:"size:64" json:"-"` + // LastRefundRequestAt timestamps the most recent customer-initiated refund + // request for this order. Used to rate-limit repeat submissions. + LastRefundRequestAt *time.Time `json:"-"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel @@ -113,3 +117,35 @@ func (o *Order) TotalInDollars() float64 { func (o *Order) IsActive() bool { return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped } + +// RefundRequestReasons is the canonical list of customer-facing refund reasons +// accepted by both the API and the TUI. +var RefundRequestReasons = []string{ + "Wrong item received", + "Item damaged in transit", + "Item never arrived", + "Quality issue", + "Other", +} + +// RefundRequestReasonOther is the sentinel reason that requires a free-form message. +const RefundRequestReasonOther = "Other" + +// IsValidRefundReason reports whether r is one of the accepted refund reasons. +func IsValidRefundReason(r string) bool { + for _, reason := range RefundRequestReasons { + if reason == r { + return true + } + } + return false +} + +// CanRequestRefund reports whether a customer is allowed to request a refund +// for this order. Paid, shipped, and delivered orders qualify; everything else +// is either still unpaid or already in a terminal state. +func (o *Order) CanRequestRefund() bool { + return o.Status == OrderStatusPaid || + o.Status == OrderStatusShipped || + o.Status == OrderStatusDelivered +} diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index aa80957..b94d579 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -188,29 +188,33 @@ func SlackUnpinOrder(orderID uint) { } // SlackPostToOrderThread posts a plain-text reply inside the order's thread. -// Looks up the thread ts from the database. Silent no-op if the order has no -// saved thread (Slack was disabled when it was created, or post failed earlier). -func SlackPostToOrderThread(orderID uint, text string) { +// It looks up the thread ts from the database and returns an error when the +// reply cannot be posted. +func SlackPostToOrderThread(orderID uint, text string) error { token := os.Getenv("SLACK_BOT_TOKEN") channel := os.Getenv("SLACK_CHANNEL") if token == "" || channel == "" { - return + return fmt.Errorf("slack is not configured") } var order models.Order if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil { slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err) - return + return fmt.Errorf("failed to look up order thread: %w", err) } if order.SlackThreadTS == nil || *order.SlackThreadTS == "" { - return + return fmt.Errorf("order has no slack thread") } - _, _ = postSlackMessage(token, map[string]any{ + if _, ok := 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) { diff --git a/pkg/tui/account.go b/pkg/tui/account.go index ec3e60e..589afbf 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -288,6 +288,13 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.account.detailViewport, cmd = m.account.detailViewport.Update(msg) return m, cmd + case "r": + if m.account.orderViewState == 2 { + if order, ok := m.selectedRefundableOrder(); ok { + return m.OpenRefundComposer(order.ID) + } + } + case "x", "d": if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) { m.account.addressDeleting = &m.account.addressCursor @@ -339,11 +346,15 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.account.orderYOffset = m.account.detailViewport.YOffset() m.account.orderViewState = 2 m.account.detailViewport.GotoTop() - m.footer = []footerCommand{ + footer := []footerCommand{ {key: "esc", value: "back"}, {key: "s", value: "shop"}, {key: "q", value: "quit"}, } + if _, ok := m.selectedRefundableOrder(); ok { + footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...) + } + m.footer = footer } } case "addresses": diff --git a/pkg/tui/account_orders.go b/pkg/tui/account_orders.go index 12a588c..dd3b93f 100644 --- a/pkg/tui/account_orders.go +++ b/pkg/tui/account_orders.go @@ -37,6 +37,21 @@ func (m Model) HistoryOrders() []models.Order { return out } +// selectedRefundableOrder returns the order under the cursor in the active +// order-detail view, paired with ok=true, when the user is allowed to file a +// refund request for it. Returns (zero, false) otherwise. +func (m Model) selectedRefundableOrder() (models.Order, bool) { + orders := m.currentOrderList() + if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) { + return models.Order{}, false + } + order := orders[m.account.orderCursor] + if !order.CanRequestRefund() { + return models.Order{}, false + } + return order, true +} + func (m Model) currentOrderList() []models.Order { if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) { return nil @@ -241,7 +256,11 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string { b.WriteString("\n\n") // Footer hint - b.WriteString(dimStyle.Render("esc: back to orders")) + if order.CanRequestRefund() { + b.WriteString(dimStyle.Render("r: refund esc: back to orders")) + } else { + b.WriteString(dimStyle.Render("esc: back to orders")) + } return b.String() } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9ec38e0..9fedead 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -64,6 +64,11 @@ type VisibleError struct { message string } +// VisibleNotice is a typed success message displayed to the user as a banner. +type VisibleNotice struct { + message string +} + type Model struct { // User authentication User *models.User @@ -110,6 +115,7 @@ type Model struct { Loading bool // true when fetching data from API error *VisibleError + notice *VisibleNotice APIClient *api.Client // Rendering @@ -128,6 +134,7 @@ type Model struct { shipping shippingState payment paymentState account accountState + refund refundState review reviewState confirm confirmState @@ -217,6 +224,7 @@ func (m Model) SwitchPage(p page) Model { m.currentPage = p m.switched = true m.error = nil + m.notice = nil return m } diff --git a/pkg/tui/refund.go b/pkg/tui/refund.go new file mode 100644 index 0000000..21011a3 --- /dev/null +++ b/pkg/tui/refund.go @@ -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) +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go index c333d11..84888db 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -2,11 +2,12 @@ package tui import ( "fmt" - "terminalShop/pkg/api" - "terminalShop/pkg/models" "time" tea "charm.land/bubbletea/v2" + + "terminalShop/pkg/api" + "terminalShop/pkg/models" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -36,6 +37,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.payment.form != nil { m.payment.form.form = m.buildPaymentForm(m.payment.form) } + if m.refund.open { + m.refund.form = m.buildRefundForm(&m.refund) + m.resizeRefundTextarea(&m.refund) + } } return m, nil } @@ -51,9 +56,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case VisibleError: m.error = &msg + m.notice = nil + return m, nil + case VisibleNotice: + m.notice = &msg + m.error = nil return m, nil case error: m.error = &VisibleError{message: msg.Error()} + m.notice = nil if m.currentPage == shopPage || m.currentPage == cartPage { return m, m.fetchCartCmd } @@ -167,9 +178,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.shipping.form != nil || m.payment.form != nil { break } - // Dismiss error banner with ESC - if msg.String() == "esc" && m.error != nil { + // Refund overlay owns input when open; dispatched below before page handlers. + if m.refund.open { + break + } + // Dismiss banners with ESC. + if msg.String() == "esc" && (m.error != nil || m.notice != nil) { m.error = nil + m.notice = nil return m, nil } // Global navigation @@ -192,6 +208,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // non-globals. fall through to page dispatch below } + + // Refund overlay intercepts all input after global handling. + if m.refund.open { + return m.RefundUpdate(msg) + } + // Page-specifics switch m.currentPage { case shopPage: diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 9ab5b1e..8aff00b 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -84,6 +84,10 @@ 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) } @@ -115,6 +119,10 @@ 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,