diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 8cd123b..1452553 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -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 +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index b16b28c..af601fa 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -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) { diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 0150703..cd78a40 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -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 diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 47b00c2..da10e93 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -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: diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 1e79f44..4723a0a 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -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: