Files
terminalShop-old/pkg/tui/refund.go

396 lines
11 KiB
Go

package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
)
// Refund composer layout constants.
const (
refundMinTextareaRows = 5
refundMaxTextareaRows = 10
// chrome above the textarea inside the modal (title, label, padding, footer).
refundModalChrome = 12
refundModalMaxWidth = 58
refundModalMinWidth = 24
// horizontal chrome the modal subtracts from m.widthContent.
refundModalHGutter = 6
// inner padding around the textarea inside the modal body.
refundTextareaHPad = 4
)
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
// SSH client terminal; renders as tofu otherwise.
const (
refundIconUndo = "\uf0e2" // nf-fa-undo
refundIconTag = "\uf02b" // nf-oct-tag
refundIconComment = "\uf41f" // nf-oct-comment
refundIconKey = "\uf11c" // nf-fa-keyboard_o
)
// refundFocus identifies which field in the refund composer has keyboard focus.
type refundFocus int
const (
refundFocusReason refundFocus = iota
refundFocusMessage
refundFocusCount
)
type refundState struct {
open bool
orderID uint
gen uint64 // bumped each open so late async results from a prior session are ignored
reason string
form *huh.Form
textarea textarea.Model
focusIdx refundFocus
submitting bool
err string
reasonErr string
messageErr string
}
type refundRequestMsg struct {
OrderID uint
Gen uint64
Err error
}
// OpenRefundComposer opens the refund request composer for an order.
func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) {
state := refundState{
open: true,
orderID: orderID,
gen: m.refund.gen + 1,
}
state.textarea = m.newRefundTextarea()
state.form = m.buildRefundForm(&state)
m.refund = state
return m, m.refund.form.Init()
}
// RefundUpdate handles all input and async results while the refund composer is open.
func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
state := &m.refund
if !state.open {
return m, nil
}
switch msg := msg.(type) {
case refundRequestMsg:
if msg.Gen != state.gen || msg.OrderID != state.orderID {
return m, nil
}
state.submitting = false
if msg.Err != nil {
state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err)
return m, nil
}
gen := state.gen
m.refund = refundState{gen: gen}
m.notice = &VisibleNotice{message: "Refund request sent"}
return m, nil
case tea.WindowSizeMsg:
state.form = m.buildRefundForm(state)
m.resizeRefundTextarea(state)
return m, nil
case tea.KeyMsg:
if state.submitting {
return m, nil
}
switch msg.String() {
case "esc":
gen := state.gen
m.refund = refundState{gen: gen}
return m, nil
case "ctrl+s":
return m, m.submitRefundRequest(state)
case "tab", "shift+tab":
return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount)
}
}
if state.focusIdx == refundFocusReason {
state.err = ""
state.reasonErr = ""
form, cmd := state.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
state.form = f
}
if state.form.State == huh.StateCompleted {
state.reason = state.form.GetString("reason")
return m, m.setRefundFocus(state, refundFocusMessage)
}
return m, cmd
}
if _, ok := msg.(tea.KeyMsg); ok {
state.err = ""
state.messageErr = ""
}
ta, cmd := state.textarea.Update(msg)
state.textarea = ta
return m, cmd
}
func (m Model) buildRefundForm(state *refundState) *huh.Form {
options := make([]huh.Option[string], 0, len(models.RefundRequestReasons))
for _, reason := range models.RefundRequestReasons {
options = append(options, huh.NewOption(reason, reason))
}
return huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Reason").
Key("reason").
Options(options...).
Value(&state.reason).
Height(len(models.RefundRequestReasons) + 1),
),
).
WithShowErrors(false).
WithShowHelp(false).
WithTheme(m.theme.Form()).
WithLayout(huh.LayoutStack).
WithWidth(m.refundContentWidth())
}
func (m Model) newRefundTextarea() textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.Placeholder = "Describe the issue"
ta.ShowLineNumbers = true
ta.CharLimit = 65536
ta.DynamicHeight = false
ta.SetHeight(m.refundTextareaHeight())
ta.SetWidth(m.refundTextareaWidth())
base := lipgloss.NewStyle()
// Cursor-line uses a darker grey than theme.Border() so the active line
// reads as a subtle wash, not a bright bar competing with the highlight.
cursorLineBG := lipgloss.Color("236")
ta.SetStyles(textarea.Styles{
Focused: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Accent()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
CursorLineNumber: base.Foreground(m.theme.Body()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Blurred: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Body()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Foreground(m.theme.Background()),
CursorLineNumber: base.Foreground(m.theme.Dim()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Cursor: textarea.CursorStyle{
Color: m.theme.Highlight(),
Shape: tea.CursorBlock,
Blink: true,
},
})
return ta
}
func (m Model) resizeRefundTextarea(state *refundState) {
state.textarea.SetHeight(m.refundTextareaHeight())
state.textarea.SetWidth(m.refundTextareaWidth())
}
func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd {
state.focusIdx = focusIdx
if focusIdx == refundFocusReason {
state.textarea.Blur()
state.form = m.buildRefundForm(state)
return state.form.Init()
}
return state.textarea.Focus()
}
func (m Model) submitRefundRequest(state *refundState) tea.Cmd {
state.err = ""
state.reasonErr = ""
state.messageErr = ""
reason := strings.TrimSpace(state.reason)
message := strings.TrimSpace(state.textarea.Value())
if reason == "" {
state.reasonErr = "select a refund reason"
return nil
}
if reason == models.RefundRequestReasonOther && message == "" {
state.messageErr = "message is required when reason is other"
return nil
}
state.submitting = true
return m.createRefundRequestCmd(state.orderID, state.gen, reason, message)
}
func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{
Reason: reason,
Message: message,
})
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err}
}
}
// RenderRefundOverlay renders the refund composer as a centered modal.
// The modal has no border or background fill — only padding for breathing
// room. lipgloss.Place handles centering against the container.
func (m Model) RenderRefundOverlay() string {
state := &m.refund
contentWidth := m.refundContentWidth()
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
title := m.theme.TextAccent().
Bold(true).
Width(contentWidth).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(m.theme.Highlight()).
Render(titleText)
body := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
m.renderRefundReason(state),
"",
m.renderRefundTextarea(state),
"",
m.renderRefundFooter(state),
)
modal := lipgloss.NewStyle().
Padding(1, 2).
Render(body)
return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal)
}
func (m Model) renderRefundReason(state *refundState) string {
if state.focusIdx == 0 && state.form != nil {
out := state.form.View()
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
value := state.reason
if value == "" {
value = "Select a reason"
}
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
selected := m.theme.TextAccent().
Border(lipgloss.HiddenBorder()).
PaddingLeft(1).
Width(m.refundContentWidth()).
Render(value)
out := lipgloss.JoinVertical(lipgloss.Left, label, selected)
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
func (m Model) renderRefundTextarea(state *refundState) string {
label := "Message (optional)"
placeholder := "Describe the issue"
if state.reason == models.RefundRequestReasonOther {
label = "Message (required)"
placeholder = "Describe the issue..."
}
state.textarea.Placeholder = placeholder
border := m.theme.Border()
if state.focusIdx == refundFocusMessage {
border = m.theme.Highlight()
}
wrapper := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(border)
contentWidth := m.refundContentWidth()
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
state.textarea.SetHeight(m.refundTextareaHeight())
textareaBox := wrapper.Render(state.textarea.View())
counter := m.theme.TextDim().
Width(contentWidth).
Align(lipgloss.Right).
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
out := lipgloss.JoinVertical(
lipgloss.Left,
m.theme.TextLabel().Render(refundIconComment+" "+label),
textareaBox,
counter,
)
if state.messageErr != "" {
out += "\n" + m.theme.TextError().Render(state.messageErr)
}
return out
}
func (m Model) renderRefundFooter(state *refundState) string {
if state.submitting {
return m.theme.TextAccent().Render("sending refund request...")
}
if state.err != "" {
return m.theme.TextError().Render(state.err)
}
key := m.theme.TextHighlight().Bold(true)
sep := m.theme.TextDim().Render(" · ")
dim := m.theme.TextDim()
return dim.Render(refundIconKey+" ") +
key.Render("tab") + dim.Render(" next") + sep +
key.Render("shift+tab") + dim.Render(" prev") + sep +
key.Render("ctrl+s") + dim.Render(" submit") + sep +
key.Render("esc") + dim.Render(" cancel")
}
func (m Model) refundContentWidth() int {
width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth)
if width < refundModalMinWidth {
width = max(1, m.widthContent-refundTextareaHPad)
}
return width
}
func (m Model) refundTextareaWidth() int {
return max(1, m.refundContentWidth()-refundTextareaHPad)
}
func (m Model) refundTextareaHeight() int {
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
}