package tui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "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 } // 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 { CardCount int } // PollPaymentCompleteMsg is sent when the card count increases, meaning the // webhook saved the new card type PollPaymentCompleteMsg struct { Cards []models.Card } // 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 { if _, ok := msg.(tea.WindowSizeMsg); 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.paymentVPReady { m.paymentVP = viewport.New(m.widthContent, availH) m.paymentVP.KeyMap = viewport.KeyMap{} m.paymentVPReady = true } else { m.paymentVP.Width = m.widthContent m.paymentVP.Height = availH } return m } func (m Model) PaymentPageView() string { if !m.paymentVPReady { m = m.updatePaymentViewport() } var content string if m.CheckingOut { content = " submitting order..." } else if m.PaymentView == 2 { // Bypass viewport — it strips the raw ANSI codes qrfefe emits. return lipgloss.Place( m.widthContainer, m.paymentVP.Height, lipgloss.Center, lipgloss.Center, m.renderPaymentHttpsView(), ) } else if m.PaymentView == 0 && m.PaymentForm == nil { content = m.RenderCardList() m.paymentVP.SetContent(content) itemHeight := 3 targetY := m.CardCursor * itemHeight if targetY < m.paymentVP.YOffset { m.paymentVP.SetYOffset(targetY) } if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height { m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1) } if m.CardCursor == len(m.SavedCards) { m.paymentVP.GotoBottom() } return lipgloss.Place( m.widthContainer, lipgloss.Height(m.paymentVP.View()), lipgloss.Center, lipgloss.Center, m.paymentVP.View(), ) } else if m.PaymentForm != nil { content = m.RenderPaymentForm(m.PaymentForm) } m.paymentVP.SetContent(content) return lipgloss.Place( m.widthContainer, lipgloss.Height(m.paymentVP.View()), lipgloss.Center, lipgloss.Center, m.paymentVP.View(), ) } func (m Model) RenderCardList() string { titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0) activeStyle := m.theme.TextAccent().Bold(true) inactiveStyle := m.theme.TextDim() labelStyle := m.theme.TextLabel() title := titleStyle.Render("Select Payment Method") var lines []string for i, card := range m.SavedCards { cursor := " " style := inactiveStyle if i == m.CardCursor { cursor = "> " style = activeStyle } brand := style.Render(strings.ToUpper(card.Brand[:1]) + card.Brand[1:]) last4 := labelStyle.Render(fmt.Sprintf("**** **** **** %s", card.Last4)) exp := labelStyle.Render(fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100)) lines = append(lines, cursor+brand+" "+last4) lines = append(lines, " "+exp) lines = append(lines, "") } sshCursor := " " sshStyle := inactiveStyle if m.CardCursor == len(m.SavedCards) { sshCursor = "> " sshStyle = activeStyle } lines = append(lines, sshCursor+sshStyle.Render("+ add card via ssh")) httpsCursor := " " httpsStyle := inactiveStyle if m.CardCursor == len(m.SavedCards)+1 { httpsCursor = "> " httpsStyle = activeStyle } lines = append(lines, httpsCursor+httpsStyle.Render("+ add payment via browser")) parts := []string{title} parts = append(parts, strings.Join(lines, "\n")) return lipgloss.JoinVertical(lipgloss.Left, parts...) } func (m Model) renderPaymentHttpsView() string { baseStyle := m.theme.TextLabel() accentStyle := m.theme.TextHighlight() if m.CollectURL == nil { return baseStyle.Render(" generating payment link...") } qr, qrSize, err := qrfefe.Generate(0, *m.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.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.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.PaymentView = 0 m.CardCursor = 0 m.PaymentForm = nil return m, nil case PaymentFormCompleteMsg: if m.PaymentForm != nil { m.PaymentForm.submitting = true m.CheckingOut = true return m, m.saveCardAndConvert(msg) } return m, nil case CheckoutResultMsg: m.CheckingOut = false if msg.Err != nil { m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) if len(m.SavedCards) > 0 { m.PaymentView = 0 m.PaymentForm = nil return m, nil } // Re-open the form so the user can retry with corrected details. m.PaymentView = 1 m.PaymentForm = m.InitPaymentForm() return m, m.PaymentForm.form.Init() } // Snapshot order details before clearing cart/shipping m.ConfirmTotal = msg.Total for _, item := range m.GetCartItemsSlice() { m.ConfirmItems = append(m.ConfirmItems, *item) } m.ConfirmShipping = m.ShippingInfo // Clear cart and shipping immediately so they don't appear stale elsewhere m.Cart = make(map[uint]*models.CartItem) m.CartCursor = 0 m.ShippingInfo = nil m.OrdersLoaded = false m = m.SwitchPage(confirmPage) return m, nil case PaymentFormErrorMsg: m.ErrorMsg = msg.Message return m, nil case PollPaymentInitMsg: url := msg.URL m.CollectURL = &url return m, m.pollCardsCmd(m.CollectCardCount) case PollPaymentStatusMsg: if m.PaymentView != 2 { return m, nil // user navigated away, stop polling } return m, m.pollCardsCmd(msg.CardCount) case PollPaymentCompleteMsg: if m.PaymentView != 2 { return m, nil } m.SavedCards = msg.Cards m.PaymentView = 0 m.CollectURL = nil selected := m.SavedCards[len(m.SavedCards)-1] m.SelectedCard = &selected m.CheckingOut = true return m, m.checkoutWithSavedCard() } // Card list navigation if m.PaymentView == 0 && m.PaymentForm == nil { keyMsg, ok := msg.(tea.KeyMsg) if !ok || m.CheckingOut { return m, nil } m.ErrorMsg = "" switch keyMsg.String() { case "esc": m = m.SwitchPage(shippingPage) return m, m.fetchAddressesCmd() case "up", "k": if m.CardCursor > 0 { m.CardCursor-- } case "down", "j": if m.CardCursor < len(m.SavedCards)+1 { m.CardCursor++ } case "enter": if m.CardCursor < len(m.SavedCards) { selected := m.SavedCards[m.CardCursor] m.SelectedCard = &selected m.CheckingOut = true return m, m.checkoutWithSavedCard() } if m.CardCursor == len(m.SavedCards) { m.PaymentView = 1 m.PaymentForm = m.InitPaymentForm() return m, m.PaymentForm.form.Init() } // Browser payment m.PaymentView = 2 m.CollectURL = nil m.CollectCardCount = len(m.SavedCards) return m, m.collectCardCmd() case "d", "x": if m.CardCursor < len(m.SavedCards) { card := m.SavedCards[m.CardCursor] m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...) if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 { m.CardCursor-- } if len(m.SavedCards) == 0 { m.PaymentView = 1 m.PaymentForm = m.InitPaymentForm() return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID)) } return m, m.deleteCardCmd(card.ID) } } return m, nil } // HTTPS/browser view avigation if m.PaymentView == 2 { if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { m.PaymentView = 0 m.CollectURL = nil return m, nil } return m, nil } // Payment form navigation if m.PaymentForm != nil { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" { m.ErrorMsg = "" if len(m.SavedCards) > 0 { m.PaymentView = 0 m.PaymentForm = nil return m, nil } m.PaymentForm = nil m = m.SwitchPage(shippingPage) return m, m.fetchAddressesCmd() } // Clear error only when user starts typing, not on internal huh messages m.ErrorMsg = "" } return m, m.UpdatePaymentForm(msg, m.PaymentForm) } return m, nil }