Files
terminalShop-old/pkg/tui/payment_form.go
marcusDenslow 6598f63cb2 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
2026-05-25 19:00:08 +02:00

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
}