439 lines
12 KiB
Go
439 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"terminalShop/pkg/models"
|
|
"terminalShop/pkg/tui/qrfefe"
|
|
|
|
"charm.land/bubbles/v2/viewport"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
// RenderConfirmation renders the order confirmation view
|
|
func (m Model) RenderConfirmation() string {
|
|
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
|
|
|
sectionStyle := m.theme.TextBody().Padding(0, 0, 1, 2)
|
|
|
|
labelStyle := m.theme.TextDim()
|
|
|
|
valueStyle := m.theme.TextAccent()
|
|
|
|
successStyle := m.theme.TextSuccess().Bold(true).Padding(1, 0)
|
|
|
|
title := titleStyle.Render("Order Confirmation")
|
|
|
|
// Shipping summary
|
|
shippingSection := ""
|
|
if m.confirm.shipping != nil {
|
|
shippingSection = sectionStyle.Render(
|
|
labelStyle.Render("Ship to: ") +
|
|
valueStyle.Render(m.confirm.shipping.Name) + "\n" +
|
|
labelStyle.Render(" ") +
|
|
valueStyle.Render(m.confirm.shipping.Street) + "\n" +
|
|
labelStyle.Render(" ") +
|
|
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.confirm.shipping.City, m.confirm.shipping.State, m.confirm.shipping.Zip, m.confirm.shipping.Country)),
|
|
)
|
|
}
|
|
|
|
// Items summary
|
|
cartSection := ""
|
|
for _, item := range m.confirm.items {
|
|
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
|
|
cartSection += sectionStyle.Render(line) + "\n"
|
|
}
|
|
cartSection += sectionStyle.Render(
|
|
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
|
|
) + "\n"
|
|
cartSection += sectionStyle.Render(
|
|
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.confirm.total)/100)),
|
|
)
|
|
|
|
success := successStyle.Render("Order placed successfully!")
|
|
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
title,
|
|
"",
|
|
shippingSection,
|
|
"",
|
|
cartSection,
|
|
"",
|
|
success,
|
|
)
|
|
}
|
|
|
|
func (m Model) updateConfirmViewport() 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.confirm.viewportReady {
|
|
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
|
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
|
|
m.confirm.viewportReady = true
|
|
} else {
|
|
m.confirm.viewport.SetWidth(m.widthContent)
|
|
m.confirm.viewport.SetHeight(availH)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m Model) ConfirmView() string {
|
|
if !m.confirm.viewportReady {
|
|
m = m.updateConfirmViewport()
|
|
}
|
|
m.confirm.viewport.SetContent(m.RenderConfirmation())
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(m.confirm.viewport.View()),
|
|
lipgloss.Center, lipgloss.Center,
|
|
m.confirm.viewport.View(),
|
|
)
|
|
}
|
|
|
|
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
if m.confirm.viewportReady {
|
|
m.confirm.viewport, cmd = m.confirm.viewport.Update(msg)
|
|
}
|
|
|
|
keyMsg, ok := msg.(tea.KeyMsg)
|
|
if !ok {
|
|
return m, cmd
|
|
}
|
|
|
|
if keyMsg.String() != "esc" && keyMsg.String() != "s" {
|
|
return m, cmd
|
|
}
|
|
|
|
m.CheckingOut = false
|
|
m.confirm.total = 0
|
|
m.confirm.items = nil
|
|
m.confirm.shipping = nil
|
|
m, _ = m.ShopSwitch()
|
|
return m, func() tea.Msg {
|
|
if m.APIClient != nil {
|
|
_ = m.APIClient.ClearCart()
|
|
}
|
|
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.review.cardJustAdded {
|
|
if m.review.cardWasDuplicate {
|
|
view.WriteString(m.theme.TextDim().Render("identical card already saved... using existing card") + "\n\n")
|
|
} else {
|
|
view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)
|
|
}
|
|
view.WriteString("\n")
|
|
|
|
if m.SelectedCard != nil {
|
|
fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4)
|
|
}
|
|
view.WriteString("\n")
|
|
|
|
subtotal := m.CalculateSubtotal()
|
|
|
|
fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal))
|
|
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
|
|
view.WriteString("\n")
|
|
view.WriteString(m.theme.TextBrand().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...")
|
|
}
|
|
if m.review.retrying {
|
|
return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...")
|
|
}
|
|
if m.review.retryExhausted {
|
|
content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order")
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(content),
|
|
lipgloss.Center, lipgloss.Center,
|
|
content,
|
|
)
|
|
}
|
|
if m.review.authFailed {
|
|
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back")
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(content),
|
|
lipgloss.Center, lipgloss.Center,
|
|
content,
|
|
)
|
|
}
|
|
if m.review.requiresAction {
|
|
return m.renderRequiresActionView()
|
|
}
|
|
if m.review.success {
|
|
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(content),
|
|
lipgloss.Center, lipgloss.Center,
|
|
content,
|
|
)
|
|
}
|
|
content := m.generateReviewContent()
|
|
return lipgloss.Place(
|
|
m.widthContainer,
|
|
lipgloss.Height(content),
|
|
lipgloss.Center, lipgloss.Center,
|
|
content,
|
|
)
|
|
}
|
|
|
|
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
|
|
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
|
|
func (m Model) renderRequiresActionView() string {
|
|
base := m.theme.TextLabel()
|
|
accent := m.theme.TextHighlight()
|
|
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
|
|
|
if m.review.redirectURL == "" {
|
|
return base.Render(" verifying with your bank...")
|
|
}
|
|
|
|
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
|
if err != nil || qrSize > m.widthContent {
|
|
body := header + "\n\n" +
|
|
base.Render("open this url on your phone to authenticate:") + "\n\n" +
|
|
accent.Render(m.review.redirectURL) + "\n\n" +
|
|
base.Render("waiting for completion...")
|
|
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
|
|
}
|
|
|
|
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
|
|
return qr +
|
|
header + "\n" +
|
|
base.Render("scan or visit to complete authentication") + "\n" +
|
|
accent.Render(m.review.redirectURL) + "\n\n" +
|
|
base.Render("waiting for completion...")
|
|
}
|
|
|
|
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case CheckoutResultMsg:
|
|
m.CheckingOut = false
|
|
if msg.Err != nil {
|
|
var cmd tea.Cmd
|
|
m, cmd = m.switchForCheckoutError(msg.Err)
|
|
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
|
m.review.cardJustAdded = false
|
|
return m, cmd
|
|
}
|
|
if msg.RequiresAction {
|
|
m.review.requiresAction = true
|
|
m.review.redirectURL = msg.RedirectURL
|
|
m.review.orderID = msg.OrderID
|
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
}
|
|
m.Cart = make(map[uint]*models.CartItem)
|
|
m.cart.cursor = 0
|
|
m.ShippingInfo = nil
|
|
m.OrdersLoaded = false
|
|
m.review.success = true
|
|
return m, nil
|
|
|
|
case OrderStatusMsg:
|
|
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
|
|
return m, nil
|
|
}
|
|
if msg.Err != nil {
|
|
// Transient — keep polling.
|
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
}
|
|
switch msg.Status {
|
|
case "paid", "shipped", "delivered":
|
|
m.Cart = make(map[uint]*models.CartItem)
|
|
m.cart.cursor = 0
|
|
m.ShippingInfo = nil
|
|
m.OrdersLoaded = false
|
|
m.review.requiresAction = false
|
|
m.review.redirectURL = ""
|
|
m.review.success = true
|
|
return m, nil
|
|
case "failed", "cancelled":
|
|
m.review.requiresAction = false
|
|
m.review.redirectURL = ""
|
|
m.review.authFailed = true
|
|
m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}}
|
|
return m, nil
|
|
default:
|
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
}
|
|
|
|
case RetryAuthResultMsg:
|
|
if m.review.orderID != 0 && msg.OrderID != m.review.orderID {
|
|
return m, nil
|
|
}
|
|
m.review.retrying = false
|
|
if msg.Err != nil {
|
|
s := msg.Err.Error()
|
|
switch {
|
|
case strings.Contains(s, "RETRY_LIMIT"):
|
|
m.review.authFailed = false
|
|
m.review.retryExhausted = true
|
|
m.footer = []footerCommand{{key: "esc", value: "back"}}
|
|
return m, nil
|
|
case strings.Contains(s, "CARD_NO_LONGER_AVAILABLE"):
|
|
m.review.authFailed = false
|
|
m.review.requiresAction = false
|
|
m.review.redirectURL = ""
|
|
m.review.orderID = 0
|
|
m = m.SwitchPage(paymentPage)
|
|
m.payment.view = 0
|
|
m.payment.form = nil
|
|
m.error = &VisibleError{message: "saved card is no longer available. add it again to try."}
|
|
return m, nil
|
|
default:
|
|
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
|
return m, nil
|
|
}
|
|
}
|
|
m.review.authFailed = false
|
|
m.review.retryExhausted = false
|
|
m.review.requiresAction = true
|
|
m.review.redirectURL = msg.RedirectURL
|
|
m.review.orderID = msg.OrderID
|
|
m.footer = []footerCommand{{key: "esc", value: "cancel"}}
|
|
return m, m.schedulePollOrderStatus(msg.OrderID)
|
|
|
|
case tea.KeyMsg:
|
|
if m.CheckingOut {
|
|
return m, nil
|
|
}
|
|
if m.review.retrying {
|
|
return m, nil
|
|
}
|
|
if m.review.authFailed {
|
|
switch msg.String() {
|
|
case "r":
|
|
if m.review.orderID == 0 {
|
|
return m, nil
|
|
}
|
|
m.review.retrying = true
|
|
m.error = nil
|
|
return m, m.retryAuthCmd(m.review.orderID)
|
|
case "esc":
|
|
m.review.authFailed = false
|
|
m = m.SwitchPage(paymentPage)
|
|
m.payment.view = 0
|
|
m.payment.form = nil
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.review.retryExhausted {
|
|
if msg.String() == "esc" {
|
|
m.review.retryExhausted = false
|
|
m.review.orderID = 0
|
|
m = m.SwitchPage(paymentPage)
|
|
m.payment.view = 0
|
|
m.payment.form = nil
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.review.requiresAction {
|
|
if msg.String() == "esc" {
|
|
m.review.requiresAction = false
|
|
m.review.redirectURL = ""
|
|
m = m.SwitchPage(paymentPage)
|
|
m.payment.view = 0
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.review.success {
|
|
m.review.success = false
|
|
m.review.cardJustAdded = false
|
|
m.shipping.form = nil
|
|
m.payment.form = nil
|
|
m, _ = m.ShopSwitch()
|
|
return m, func() tea.Msg {
|
|
if m.APIClient != nil {
|
|
_ = m.APIClient.ClearCart()
|
|
}
|
|
return 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
|
|
}
|
|
|
|
func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) {
|
|
switch {
|
|
case strings.Contains(err.Error(), "CART_OVER_LIMIT"):
|
|
return m.CartSwitch()
|
|
default:
|
|
m = m.SwitchPage(paymentPage)
|
|
m.payment.view = 0
|
|
m.payment.form = nil
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
func friendlyCheckoutError(raw error) string {
|
|
s := raw.Error()
|
|
switch {
|
|
case strings.Contains(s, "CART_OVER_LIMIT"):
|
|
return "order total is over the per-order limit. remove items and try again."
|
|
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
|
return "your bank wants to verify this charge, authentication step coming soon."
|
|
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
|
return "this card doesn't support online authentication. Try another card"
|
|
case strings.Contains(s, "CARD_STORAGE_EXPIRED"):
|
|
return "saved card expired. Add it again to verify the card is still yours"
|
|
case strings.Contains(s, "CARD_EXPIRED"):
|
|
return "card expired. Add a new card and try again"
|
|
case strings.Contains(s, "CARD_CVC_FAILED"):
|
|
return "card cvc incorrect. Check the back of the card and try again"
|
|
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
|
|
return "insufficient funds on this card"
|
|
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
|
|
return "card processing error. Try again in a moment"
|
|
case strings.Contains(s, "CARD_DECLINED"):
|
|
return "card declined"
|
|
}
|
|
return "checkout failed, try again."
|
|
}
|