mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
* refactor(tui): port to charm v2 bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly mechanical chur, new import paths, vewport getter/setter api, view() returns tea.view now, huh.theme became an interface so the theme package needed a small reshape. Render plumbing is gone (no more lipgloss.Render field on the Model or MakeRenderer in main.go). All pages still work over ssh. setting up for the refund composer next * style(tui): gofmt main.go and menu.go
609 lines
16 KiB
Go
609 lines
16 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/viewport"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/huh/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"terminalShop/pkg/models"
|
|
"terminalShop/pkg/tui/qrfefe"
|
|
"terminalShop/pkg/validate"
|
|
)
|
|
|
|
// PaymentFormState holds the payment form state.
|
|
// Always heap-allocated so huh's Value() bindings stay valid.
|
|
type PaymentFormState struct {
|
|
form *huh.Form
|
|
CardName string
|
|
Email string
|
|
CardNumber string
|
|
ExpiryMonth string
|
|
ExpiryYear string
|
|
CVC string
|
|
BillingZip string
|
|
submitting bool
|
|
}
|
|
|
|
// PaymentFormCompleteMsg is sent when the payment form passes validation
|
|
type PaymentFormCompleteMsg struct {
|
|
CardName string
|
|
Email string
|
|
CardNumber string
|
|
ExpiryMonth string
|
|
ExpiryYear string
|
|
CVC string
|
|
BillingZip string
|
|
}
|
|
|
|
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
|
|
// The user is taken to the review page to confirm before checkout
|
|
type CardSavedForReviewMsg struct {
|
|
Card models.Card
|
|
Err error
|
|
}
|
|
|
|
// PaymentFormErrorMsg is sent when payment form validation fails
|
|
type PaymentFormErrorMsg struct {
|
|
Message string
|
|
}
|
|
|
|
// PollPaymentInitMsg is sent when the collect endpoint returns a URL
|
|
type PollPaymentInitMsg struct {
|
|
URL string
|
|
}
|
|
|
|
// PollPaymentStatusMsg is sent each polling tick while waiting for the browser
|
|
// card entry to complete.
|
|
type PollPaymentStatusMsg struct {
|
|
Baseline time.Time
|
|
BaselineCount int
|
|
Deadline time.Time
|
|
}
|
|
|
|
// PollPaymentCompleteMsg is sent when the card count increases, meaning the
|
|
// webhook saved the new card
|
|
type PollPaymentCompleteMsg struct {
|
|
Cards []models.Card
|
|
Duplicate bool
|
|
}
|
|
|
|
type PollPaymentTimeoutMsg struct{}
|
|
|
|
// buildPaymentForm creates the huh form bound to state's fields.
|
|
// No field-level validators so Tab/Enter move freely.
|
|
func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
|
|
f := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Name on Card").
|
|
Key("card_name").
|
|
Value(&state.CardName),
|
|
huh.NewInput().
|
|
Title("Email Address").
|
|
Key("email").
|
|
Value(&state.Email),
|
|
huh.NewInput().
|
|
Title("Card Number").
|
|
Key("card_number").
|
|
Value(&state.CardNumber),
|
|
),
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Expiry Month (MM)").
|
|
Key("exp_month").
|
|
Value(&state.ExpiryMonth),
|
|
huh.NewInput().
|
|
Title("Expiry Year (YY)").
|
|
Key("exp_year").
|
|
Value(&state.ExpiryYear),
|
|
huh.NewInput().
|
|
Title("CVC").
|
|
Key("cvc").
|
|
Value(&state.CVC),
|
|
huh.NewInput().
|
|
Title("Billing ZIP").
|
|
Key("billing_zip").
|
|
Value(&state.BillingZip),
|
|
),
|
|
).
|
|
WithShowErrors(false).
|
|
WithShowHelp(false).
|
|
WithTheme(m.theme.Form())
|
|
|
|
if m.size < medium {
|
|
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
|
} else {
|
|
f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.widthContent)
|
|
}
|
|
|
|
return f
|
|
}
|
|
|
|
// InitPaymentForm creates a new heap-allocated payment form state.
|
|
func (m Model) InitPaymentForm() *PaymentFormState {
|
|
state := &PaymentFormState{}
|
|
|
|
// Pre-fill name and email from user if available
|
|
if m.User != nil {
|
|
if m.User.Name != "" {
|
|
state.CardName = m.User.Name
|
|
}
|
|
if m.User.Email != "" {
|
|
state.Email = m.User.Email
|
|
}
|
|
}
|
|
|
|
state.form = m.buildPaymentForm(state)
|
|
return state
|
|
}
|
|
|
|
// validatePaymentForm checks all required fields at submission time.
|
|
// Returns an error message or empty string if valid.
|
|
func validatePaymentForm(state *PaymentFormState) string {
|
|
if strings.TrimSpace(state.CardName) == "" {
|
|
return "name on card cannot be empty"
|
|
}
|
|
|
|
email := strings.TrimSpace(state.Email)
|
|
if email == "" {
|
|
return "email address cannot be empty"
|
|
}
|
|
if err := validate.EmailValidator(email); err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
cardNum := strings.TrimSpace(state.CardNumber)
|
|
if cardNum == "" {
|
|
return "card number cannot be empty"
|
|
}
|
|
if err := validate.CcnValidator(cardNum); err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
month := strings.TrimSpace(state.ExpiryMonth)
|
|
if month == "" {
|
|
return "expiry month cannot be empty"
|
|
}
|
|
if err := validate.Compose(
|
|
validate.IsDigits("expiry month"),
|
|
validate.MustBeLen(2, "expiry month"),
|
|
)(month); err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
year := strings.TrimSpace(state.ExpiryYear)
|
|
if year == "" {
|
|
return "expiry year cannot be empty"
|
|
}
|
|
if err := validate.Compose(
|
|
validate.IsDigits("expiry year"),
|
|
validate.MustBeLen(2, "expiry year"),
|
|
)(year); err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
cvc := strings.TrimSpace(state.CVC)
|
|
if cvc == "" {
|
|
return "CVC cannot be empty"
|
|
}
|
|
if err := validate.Compose(
|
|
validate.IsDigits("CVC"),
|
|
validate.WithinLen(3, 4, "CVC"),
|
|
)(cvc); err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
if strings.TrimSpace(state.BillingZip) == "" {
|
|
return "billing ZIP cannot be empty"
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// UpdatePaymentForm handles updates to the payment form.
|
|
// Mutates state in place, returns only the tea.Cmd.
|
|
func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
|
|
switch _, ok := msg.(tea.WindowSizeMsg); {
|
|
case ok:
|
|
if m.size < medium {
|
|
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
|
} else {
|
|
state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(m.widthContent)
|
|
}
|
|
}
|
|
|
|
form, cmd := state.form.Update(msg)
|
|
if f, ok := form.(*huh.Form); ok {
|
|
state.form = f
|
|
}
|
|
|
|
if state.form.State == huh.StateCompleted && !state.submitting {
|
|
if errMsg := validatePaymentForm(state); errMsg != "" {
|
|
state.form = m.buildPaymentForm(state)
|
|
return tea.Batch(
|
|
state.form.Init(),
|
|
func() tea.Msg { return PaymentFormErrorMsg{Message: errMsg} },
|
|
)
|
|
}
|
|
|
|
state.submitting = true
|
|
return func() tea.Msg {
|
|
return PaymentFormCompleteMsg{
|
|
CardName: state.CardName,
|
|
Email: state.Email,
|
|
CardNumber: state.CardNumber,
|
|
ExpiryMonth: state.ExpiryMonth,
|
|
ExpiryYear: state.ExpiryYear,
|
|
CVC: state.CVC,
|
|
BillingZip: state.BillingZip,
|
|
}
|
|
}
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
// RenderPaymentForm renders the payment form view
|
|
func (m Model) RenderPaymentForm(state *PaymentFormState) string {
|
|
if state.submitting {
|
|
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing payment...")
|
|
}
|
|
|
|
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
|
|
|
cardLast4 := ""
|
|
if len(state.CardNumber) >= 4 {
|
|
cardLast4 = fmt.Sprintf(" (ending %s)", state.CardNumber[len(state.CardNumber)-4:])
|
|
}
|
|
|
|
title := titleStyle.Render("Payment Details" + cardLast4)
|
|
form := state.form.View()
|
|
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
title,
|
|
form,
|
|
)
|
|
}
|
|
|
|
func (m Model) updatePaymentViewport() Model {
|
|
headerH := lipgloss.Height(m.BuildHeader())
|
|
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
|
footerH := lipgloss.Height(m.BuildFooter())
|
|
availH := m.heightContainer - headerH - footerH - breadH
|
|
if availH < 1 {
|
|
availH = 1
|
|
}
|
|
if !m.payment.viewportReady {
|
|
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
|
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
|
m.payment.viewportReady = true
|
|
} else {
|
|
m.payment.viewport.SetWidth(m.widthContent)
|
|
m.payment.viewport.SetHeight(availH)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m Model) PaymentPageView() string {
|
|
if !m.payment.viewportReady {
|
|
m = m.updatePaymentViewport()
|
|
}
|
|
var content string
|
|
if m.CheckingOut {
|
|
content = " submitting order..."
|
|
} else if m.payment.view == 2 {
|
|
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
m.payment.viewport.Height(),
|
|
lipgloss.Center, lipgloss.Center,
|
|
m.renderPaymentHttpsView(),
|
|
)
|
|
} else if m.payment.view == 0 && m.payment.form == nil {
|
|
content = m.RenderCardList()
|
|
m.payment.viewport.SetContent(content)
|
|
itemHeight := 3
|
|
targetY := m.payment.cardCursor * itemHeight
|
|
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 m.payment.cardCursor == len(m.SavedCards) {
|
|
m.payment.viewport.GotoBottom()
|
|
}
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(m.payment.viewport.View()),
|
|
lipgloss.Center, lipgloss.Center,
|
|
m.payment.viewport.View(),
|
|
)
|
|
} else if m.payment.form != nil {
|
|
content = m.RenderPaymentForm(m.payment.form)
|
|
}
|
|
m.payment.viewport.SetContent(content)
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(m.payment.viewport.View()),
|
|
lipgloss.Center, lipgloss.Center,
|
|
m.payment.viewport.View(),
|
|
)
|
|
}
|
|
|
|
func (m Model) RenderCardList() string {
|
|
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
|
|
|
title := titleStyle.Render("Select Payment Method")
|
|
|
|
var boxes []string
|
|
for i, card := range m.SavedCards {
|
|
focused := i == m.payment.cardCursor
|
|
|
|
brand := strings.ToUpper(card.Brand[:1]) + card.Brand[1:]
|
|
last4 := fmt.Sprintf("**** **** **** %s", card.Last4)
|
|
exp := fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100)
|
|
content := brand + " " + last4 + "\n" + exp
|
|
|
|
box := m.CreateBox(m.formatListItem(content, focused), focused)
|
|
boxes = append(boxes, box)
|
|
}
|
|
|
|
sshFocused := m.payment.cardCursor == len(m.SavedCards)
|
|
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
|
|
boxes = append(boxes, sshBox)
|
|
|
|
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
|
|
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
|
|
boxes = append(boxes, httpsBox)
|
|
|
|
parts := []string{title}
|
|
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...))
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
|
}
|
|
|
|
func (m Model) renderPaymentHttpsView() string {
|
|
baseStyle := m.theme.TextLabel()
|
|
accentStyle := m.theme.TextHighlight()
|
|
|
|
if m.payment.collectTimeOut {
|
|
minutes := int(cardPollTimeout / time.Minute)
|
|
return m.theme.TextError().Bold(true).Render("payment link timed out") + "\n\n" +
|
|
baseStyle.Render(fmt.Sprintf("no card saved after %d minutes.", minutes)) + "\n" +
|
|
baseStyle.Render("press ") + accentStyle.Render("esc") + baseStyle.Render(" to go back and try again")
|
|
}
|
|
|
|
if m.payment.collectURL == nil {
|
|
return baseStyle.Render(" generating payment link...")
|
|
}
|
|
|
|
qr, qrSize, err := qrfefe.Generate(*m.payment.collectURL)
|
|
if err != nil || qrSize > m.widthContent {
|
|
// QR too wide for the terminal — just show the URL.
|
|
return baseStyle.Render("open in browser to add payment information:") + "\n\n" +
|
|
accentStyle.Render(*m.payment.collectURL) + "\n"
|
|
}
|
|
|
|
// Concatenate manually — lipgloss layout functions (JoinVertical with Center)
|
|
// measure string widths and add padding that corrupts qrfefe's raw ANSI codes.
|
|
return qr +
|
|
baseStyle.Render("scan or visit to add payment information") + "\n" +
|
|
accentStyle.Render(*m.payment.collectURL) + "\n"
|
|
}
|
|
|
|
// PaymentUpdate handles all messages while on the payment page.
|
|
// Moved from model.go Update() lines 429-578.
|
|
func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case CardsMsg:
|
|
if msg.Err != nil {
|
|
m.SavedCards = nil
|
|
} else {
|
|
m.SavedCards = msg.Cards
|
|
}
|
|
m.payment.view = 0
|
|
m.payment.cardCursor = 0
|
|
m.payment.form = nil
|
|
return m, nil
|
|
|
|
case PaymentFormCompleteMsg:
|
|
if m.payment.form != nil && !m.payment.form.submitting {
|
|
m.payment.form.submitting = true
|
|
return m, m.saveCardOnlyCmd(msg)
|
|
}
|
|
return m, nil
|
|
|
|
case PaymentFormErrorMsg:
|
|
m.error = &VisibleError{message: msg.Message}
|
|
return m, nil
|
|
|
|
case CardSavedForReviewMsg:
|
|
if m.payment.form != nil {
|
|
m.payment.form.submitting = false
|
|
}
|
|
if msg.Err != nil {
|
|
m.error = &VisibleError{message: fmt.Sprintf("failed to save card: %v", msg.Err)}
|
|
m.payment.view = 1
|
|
m.payment.form = m.InitPaymentForm()
|
|
m.footer = []footerCommand{
|
|
{key: "tab", value: "next"},
|
|
{key: "enter", value: "submit"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, m.payment.form.form.Init()
|
|
}
|
|
m.SavedCards = append(m.SavedCards, msg.Card)
|
|
selected := msg.Card
|
|
m.SelectedCard = &selected
|
|
m.payment.form = nil
|
|
m.review.cardJustAdded = true
|
|
return m.ReviewSwitch()
|
|
|
|
case PollPaymentInitMsg:
|
|
url := msg.URL
|
|
m.payment.collectURL = &url
|
|
return m, m.pollCardsCmd(m.payment.collectBaseline, m.payment.collectCardCount, m.payment.collectDeadline)
|
|
|
|
case PollPaymentStatusMsg:
|
|
if m.payment.view != 2 {
|
|
return m, nil // user navigated away, stop polling
|
|
}
|
|
return m, m.pollCardsCmd(msg.Baseline, msg.BaselineCount, msg.Deadline)
|
|
case PollPaymentTimeoutMsg:
|
|
if m.payment.view != 2 {
|
|
return m, nil
|
|
}
|
|
m.payment.collectTimeOut = true
|
|
return m, nil
|
|
|
|
case PollPaymentCompleteMsg:
|
|
if m.payment.view != 2 {
|
|
return m, nil
|
|
}
|
|
m.SavedCards = msg.Cards
|
|
m.payment.view = 0
|
|
m.payment.collectURL = nil
|
|
var selected models.Card
|
|
if msg.Duplicate {
|
|
for _, card := range m.SavedCards {
|
|
if selected.ID == 0 || card.UpdatedAt.After(selected.UpdatedAt) {
|
|
selected = card
|
|
}
|
|
}
|
|
} else {
|
|
selected = m.SavedCards[len(m.SavedCards)-1]
|
|
}
|
|
m.SelectedCard = &selected
|
|
m.review.cardJustAdded = true
|
|
m.review.cardWasDuplicate = msg.Duplicate
|
|
return m.ReviewSwitch()
|
|
}
|
|
|
|
// Card list navigation
|
|
if m.payment.view == 0 && m.payment.form == nil {
|
|
keyMsg, ok := msg.(tea.KeyMsg)
|
|
if !ok || m.CheckingOut {
|
|
return m, nil
|
|
}
|
|
m.error = nil
|
|
switch keyMsg.String() {
|
|
case "esc":
|
|
return m.ShippingSwitch()
|
|
case "up", "k":
|
|
if m.payment.cardCursor > 0 {
|
|
m.payment.cardCursor--
|
|
}
|
|
case "down", "j":
|
|
if m.payment.cardCursor < len(m.SavedCards)+1 {
|
|
m.payment.cardCursor++
|
|
}
|
|
|
|
case "enter":
|
|
if m.payment.cardCursor < len(m.SavedCards) {
|
|
selected := m.SavedCards[m.payment.cardCursor]
|
|
m.SelectedCard = &selected
|
|
return m.ReviewSwitch()
|
|
}
|
|
if m.payment.cardCursor == len(m.SavedCards) {
|
|
m.payment.view = 1
|
|
m.payment.form = m.InitPaymentForm()
|
|
m.footer = []footerCommand{
|
|
{key: "tab", value: "next"},
|
|
{key: "enter", value: "submit"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, m.payment.form.form.Init()
|
|
}
|
|
// Browser payment
|
|
m.payment.view = 2
|
|
m.payment.collectURL = nil
|
|
m.payment.collectTimeOut = false
|
|
m.payment.collectCardCount = len(m.SavedCards)
|
|
var maxT time.Time
|
|
for _, card := range m.SavedCards {
|
|
if card.UpdatedAt.After(maxT) {
|
|
maxT = card.UpdatedAt
|
|
}
|
|
}
|
|
m.payment.collectBaseline = maxT
|
|
m.payment.collectDeadline = time.Now().Add(cardPollTimeout)
|
|
m.footer = []footerCommand{
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, m.collectCardCmd()
|
|
|
|
case "d", "x":
|
|
if m.payment.cardCursor < len(m.SavedCards) {
|
|
card := m.SavedCards[m.payment.cardCursor]
|
|
m.SavedCards = append(m.SavedCards[:m.payment.cardCursor], m.SavedCards[m.payment.cardCursor+1:]...)
|
|
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
|
|
m.payment.cardCursor--
|
|
}
|
|
if len(m.SavedCards) == 0 {
|
|
m.payment.view = 1
|
|
m.payment.form = m.InitPaymentForm()
|
|
m.footer = []footerCommand{
|
|
{key: "tab", value: "next"},
|
|
{key: "enter", value: "submit"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
|
|
}
|
|
return m, m.deleteCardCmd(card.ID)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// HTTPS/browser view navigation
|
|
if m.payment.view == 2 {
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
|
|
m.payment.view = 0
|
|
m.payment.collectURL = nil
|
|
m.payment.collectTimeOut = false
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "cards"},
|
|
{key: "enter", value: "select"},
|
|
{key: "d/x", value: "delete"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Payment form navigation
|
|
if m.payment.form != nil {
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
if keyMsg.String() == "esc" {
|
|
m.error = nil
|
|
if len(m.SavedCards) > 0 {
|
|
m.payment.view = 0
|
|
m.payment.form = nil
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "cards"},
|
|
{key: "enter", value: "select"},
|
|
{key: "d/x", value: "delete"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, nil
|
|
}
|
|
m.payment.form = nil
|
|
m = m.SwitchPage(shippingPage)
|
|
return m, m.fetchAddressesCmd()
|
|
}
|
|
// Clear error only when user starts typing, not on internal huh messages
|
|
m.error = nil
|
|
}
|
|
return m, m.UpdatePaymentForm(msg, m.payment.form)
|
|
}
|
|
return m, nil
|
|
}
|