fix: added user verification to continue checkout after adding card

when the user added their card it would instantly complete the order. this could be unwanted so now i have added an extra input requirement to confirm the payment after adding the card
This commit is contained in:
2026-04-11 20:03:17 +02:00
parent de9120d1eb
commit 157a752540
5 changed files with 159 additions and 38 deletions

View File

@@ -2,6 +2,8 @@ package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -42,7 +44,7 @@ func (m Model) RenderConfirmation() string {
cartSection += sectionStyle.Render(line) + "\n"
}
cartSection += sectionStyle.Render(
labelStyle.Render("Shipping: ") + valueStyle.Render("$0.00"),
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
) + "\n"
cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)),
@@ -122,3 +124,90 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
return nil
}
}
func formatUSD(cents int) string {
return fmt.Sprintf("$%d.%02d", cents/100, cents%100)
}
func (m Model) generateReviewContent() string {
view := strings.Builder{}
if m.ShippingInfo != nil {
view.WriteString(m.ShippingInfo.Name + "\n")
view.WriteString(m.ShippingInfo.Street + "\n")
if m.ShippingInfo.Street2 != "" {
view.WriteString(m.ShippingInfo.Street2 + "\n")
}
view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country))
}
view.WriteString("\n")
if m.SelectedCard != nil {
view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4))
}
view.WriteString("\n")
subtotal := 0
for _, item := range m.GetCartItemsSlice() {
subtotal += item.Coffee.Price * item.Quantity
}
view.WriteString(fmt.Sprintf("subtotal: %\n", formatUSD(subtotal)))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n")
view.WriteString(m.theme.TextDim().Render("press enter to confirm") + "\n")
return m.theme.Base().Padding(0, 1).Render(view.String())
}
func (m Model) ReviewView() string {
if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
}
content := m.generateReviewContent()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
m = m.SwitchPage(paymentPage)
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil
case tea.KeyMsg:
if m.CheckingOut {
return m, nil
}
switch msg.String() {
case "esc":
m = m.SwitchPage(paymentPage)
return m, nil
case "enter":
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
}
return m, nil
}

View File

@@ -36,6 +36,7 @@ const (
cartPage
shippingPage
paymentPage
reviewPage
confirmPage
accountPage
splashPage
@@ -169,7 +170,7 @@ func (m Model) inCartFlow() bool {
return m.currentPage == cartPage ||
m.currentPage == shippingPage ||
m.currentPage == paymentPage ||
m.currentPage == confirmPage
m.currentPage == reviewPage
}
func (m Model) checkoutStep() int {
@@ -178,7 +179,7 @@ func (m Model) checkoutStep() int {
return 1
case paymentPage:
return 2
case confirmPage:
case reviewPage:
return 3
default:
return 0
@@ -575,6 +576,37 @@ func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
}
}
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
}
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
Name: stripe.String(form.CardName),
Number: stripe.String(form.CardNumber),
ExpMonth: stripe.String(form.ExpiryMonth),
ExpYear: stripe.String(form.ExpiryYear),
CVC: stripe.String(form.CVC),
AddressZip: stripe.String(form.BillingZip),
},
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
return CardSavedForReviewMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
return CardSavedForReviewMsg{Card: *card}
}
}
// loadCartFromAPI populates the in-memory cart map from an API CartData response
// It matches cart items to local Coffees by ID so the Coffee struct is populated
func (m *Model) loadCartFromAPI(cartData *api.CartData) {

View File

@@ -39,6 +39,13 @@ type PaymentFormCompleteMsg struct {
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
@@ -411,44 +418,31 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
case PaymentFormCompleteMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = true
m.CheckingOut = true
return m, m.saveCardAndConvert(msg)
return m, m.saveCardOnlyCmd(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 CardSavedForReviewMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = false
}
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to save card: %w", msg.Err)
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
m.SavedCards = append(m.SavedCards, msg.Card)
selected := msg.Card
m.SelectedCard = &selected
m.PaymentForm = nil
m = m.SwitchPage(reviewPage)
return m, nil
case PollPaymentInitMsg:
url := msg.URL
m.CollectURL = &url
@@ -469,8 +463,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.CollectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
m = m.SwitchPage(reviewPage)
return m, nil
}
// Card list navigation
@@ -497,8 +491,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.CardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.CardCursor]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
m = m.SwitchPage(reviewPage)
return m, nil
}
if m.CardCursor == len(m.SavedCards) {
m.PaymentView = 1

View File

@@ -25,6 +25,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.updatePaymentViewport()
case confirmPage:
m = m.updateConfirmViewport()
case reviewPage:
// no viewport on review page
}
if m.ShippingForm != nil {
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
@@ -187,6 +189,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.ShippingUpdate(msg)
case paymentPage:
return m.PaymentUpdate(msg)
case reviewPage:
return m.ReviewUpdate(msg)
case confirmPage:
return m.ConfirmUpdate(msg)
case accountPage:

View File

@@ -160,6 +160,8 @@ func (m Model) buildPageContent(height int) string {
return m.ShippingPageView()
case paymentPage:
return m.PaymentPageView()
case reviewPage:
return m.ReviewView()
case confirmPage:
return m.ConfirmView()
case cartPage: