mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
Replace ErrorMsg string with typed VisibleError struct. Add catch-all error handler at top of Update() that auto-recovers cart on shop/cart errors. ESC now dismisses error banners. Refactor shipping address and payment card checkout lists to use CreateBox + formatListItem for consistent bordered cards with radio indicators. Written with Claude Code (claude.ai/code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
965 lines
26 KiB
Go
965 lines
26 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/stripe/stripe-go/v78"
|
|
stripetoken "github.com/stripe/stripe-go/v78/token"
|
|
|
|
"terminalShop/pkg/api"
|
|
"terminalShop/pkg/models"
|
|
"terminalShop/pkg/tui/theme"
|
|
)
|
|
|
|
// termSize represents the terminal size category for responsive layout.
|
|
type termSize int
|
|
|
|
const (
|
|
undersized termSize = iota // viewportWidth < 20 || viewportHeight < 10
|
|
small // viewportWidth < 50
|
|
medium // viewportWidth < 80
|
|
large // viewportWidth >= 80
|
|
)
|
|
|
|
// page represents the currently active TUI screen
|
|
type page int
|
|
|
|
const (
|
|
shopPage page = iota
|
|
cartPage
|
|
shippingPage
|
|
paymentPage
|
|
reviewPage
|
|
confirmPage
|
|
accountPage
|
|
splashPage
|
|
)
|
|
|
|
// This msg is sent after a debounce delay to apply a pending resize.
|
|
// the seq field makes sure that only the tick from the most recent resize is applied.
|
|
type resizeTickMsg struct {
|
|
seq int
|
|
}
|
|
|
|
// Delay before applying a terminal resize.
|
|
// this prevents flickering when resizing the terminal
|
|
const resizeDebounce = 50 * time.Millisecond
|
|
|
|
type footerCommand struct {
|
|
key string
|
|
value string
|
|
}
|
|
|
|
// VisibleError is a typed error message displayed to the user as a banner.
|
|
// Using a struct instead of a plain string lets the Update loop catch raw
|
|
// errors and convert them, and lets ESC dismiss the banner.
|
|
type VisibleError struct {
|
|
message string
|
|
}
|
|
|
|
type Model struct {
|
|
// User authentication
|
|
User *models.User
|
|
Fingerprint string
|
|
AccessToken string
|
|
AuthFingerprintKey string // shared secret for /auth/token refresh
|
|
StripePublicKey string
|
|
|
|
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
|
|
authCmd tea.Cmd
|
|
|
|
// Shared data
|
|
Username string
|
|
Coffees []models.Coffee
|
|
Cart map[uint]*models.CartItem
|
|
SavedAddresses []models.Address
|
|
SavedCards []models.Card
|
|
Orders []models.Order
|
|
OrdersLoaded bool
|
|
FAQs []FAQ
|
|
|
|
// Page navigation
|
|
currentPage page // currently active screen
|
|
switched bool // true when the page just changed (for state resets)
|
|
|
|
// Menu modal state
|
|
ShowingMenu bool // true when full-screen menu is showing
|
|
ShowingHelp bool // true when help-screen is showing
|
|
menuLastPage page // page we were on before opening menu
|
|
|
|
// Layout fields — responsive container system
|
|
viewportWidth int // raw terminal width
|
|
viewportHeight int // raw terminal height
|
|
widthContainer int // constrained outer container width (max 80)
|
|
heightContainer int // constrained outer container height (max 30)
|
|
widthContent int // widthContainer - 2 (usable inner width)
|
|
size termSize // current terminal size category
|
|
|
|
// Resize debouncing
|
|
resizeSeq int // incremented on each WindowSizeMsg
|
|
pendingWidth int // buffered width from latest resize
|
|
pendingHeight int // buffered height from latest resize
|
|
|
|
Loading bool // true when fetching data from API
|
|
error *VisibleError
|
|
APIClient *api.Client
|
|
|
|
// Rendering
|
|
renderer *lipgloss.Renderer
|
|
theme theme.Theme
|
|
|
|
// Checkout flow state (spans multiple pages)
|
|
CheckingOut bool
|
|
ShippingInfo *models.Address
|
|
SelectedCard *models.Card
|
|
lastCartUpdateID int64
|
|
|
|
// Per-page State
|
|
splash splashState
|
|
shop shopState
|
|
cart cartState
|
|
shipping shippingState
|
|
payment paymentState
|
|
account accountState
|
|
review reviewState
|
|
confirm confirmState
|
|
|
|
// Footer
|
|
footer []footerCommand
|
|
}
|
|
|
|
type shopState struct {
|
|
selected int
|
|
menuViewport viewport.Model
|
|
detailViewport viewport.Model
|
|
viewportsReady bool
|
|
}
|
|
|
|
type splashState struct {
|
|
dataReady bool
|
|
delayDone bool
|
|
cursor bool
|
|
}
|
|
|
|
type cartState struct {
|
|
cursor int
|
|
viewport viewport.Model
|
|
viewportReady bool
|
|
}
|
|
|
|
type shippingState struct {
|
|
form *ShippingFormState
|
|
view int
|
|
addressCursor int
|
|
viewport viewport.Model
|
|
viewportReady bool
|
|
}
|
|
|
|
type paymentState struct {
|
|
form *PaymentFormState
|
|
view int
|
|
cardCursor int
|
|
collectURL *string
|
|
collectCardCount int
|
|
viewport viewport.Model
|
|
viewportReady bool
|
|
}
|
|
|
|
type accountState struct {
|
|
cursor int
|
|
orderViewState int
|
|
orderCursor int
|
|
orderYOffset int // saved scroll pos when entering order detail
|
|
faqFocused bool
|
|
addressListFocused bool
|
|
cardListFocused bool
|
|
addressCursor int
|
|
cardCursor int
|
|
addressDeleting *int
|
|
cardDeleting *int
|
|
detailViewport viewport.Model
|
|
viewportReady bool
|
|
}
|
|
|
|
type reviewState struct {
|
|
cardJustAdded bool
|
|
success bool
|
|
}
|
|
|
|
type confirmState struct {
|
|
total int
|
|
items []models.CartItem
|
|
shipping *models.Address
|
|
viewport viewport.Model
|
|
viewportReady bool
|
|
}
|
|
|
|
var modifiedKeyMap = viewport.KeyMap{
|
|
PageDown: key.NewBinding(key.WithKeys("pgdown")),
|
|
PageUp: key.NewBinding(key.WithKeys("pgup")),
|
|
HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u")),
|
|
HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d")),
|
|
}
|
|
|
|
// SwitchPage changed the active page and marks that a switch happened
|
|
func (m Model) SwitchPage(p page) Model {
|
|
m.currentPage = p
|
|
m.switched = true
|
|
m.error = nil
|
|
return m
|
|
}
|
|
|
|
func (m Model) ShopSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(shopPage)
|
|
m = m.updateShopViewports()
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "products"},
|
|
{key: "+/-", value: "qty"},
|
|
{key: "c", value: "cart"},
|
|
{key: "a", value: "account"},
|
|
{key: "?", value: "help"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) CartSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(cartPage)
|
|
m = m.updateCartViewport()
|
|
if m.IsCartEmpty() {
|
|
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "items"},
|
|
{key: "+/-", value: "qty"},
|
|
{key: "s", value: "shop"},
|
|
{key: "a", value: "account"},
|
|
{key: "?", value: "help"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
} else {
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "items"},
|
|
{key: "+/-", value: "qty"},
|
|
{key: "p/enter", value: "checkout"},
|
|
{key: "s", value: "shop"},
|
|
{key: "a", value: "account"},
|
|
{key: "pgup/pgdn", value: "scroll"},
|
|
{key: "?", value: "help"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) AccountSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(accountPage)
|
|
m.account = accountState{}
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "navigate"},
|
|
{key: "enter", value: "select"},
|
|
{key: "s", value: "shop"},
|
|
{key: "?", value: "help"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
m = m.updateAccountViewport()
|
|
m.account.detailViewport.GotoTop()
|
|
if !m.OrdersLoaded {
|
|
return m, m.fetchOrdersCmd()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) ShippingSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(shippingPage)
|
|
m.shipping = shippingState{}
|
|
m = m.updateShippingViewport()
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "addresses"},
|
|
{key: "enter", value: "select"},
|
|
{key: "d/x", value: "delete"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, m.fetchAddressesCmd()
|
|
}
|
|
|
|
func (m Model) PaymentSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(paymentPage)
|
|
m.payment = paymentState{}
|
|
m.SelectedCard = nil
|
|
m.footer = []footerCommand{
|
|
{key: "j/k", value: "cards"},
|
|
{key: "enter", value: "select"},
|
|
{key: "d/x", value: "delete"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, m.fetchCardsCmd()
|
|
}
|
|
|
|
func (m Model) ReviewSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(reviewPage)
|
|
m.footer = []footerCommand{
|
|
{key: "enter", value: "confirm"},
|
|
{key: "esc", value: "back"},
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) ConfirmSwitch() (Model, tea.Cmd) {
|
|
m = m.SwitchPage(confirmPage)
|
|
m.confirm = confirmState{}
|
|
m = m.updateConfirmViewport()
|
|
m.footer = []footerCommand{
|
|
{key: "esc", value: "back to shop"},
|
|
{key: "q", value: "quit"},
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// inCartFlow returns true when the user is anywhere in the cart or checkout flow
|
|
func (m Model) inCartFlow() bool {
|
|
return m.currentPage == cartPage ||
|
|
m.currentPage == shippingPage ||
|
|
m.currentPage == paymentPage ||
|
|
m.currentPage == reviewPage
|
|
}
|
|
|
|
func (m Model) checkoutStep() int {
|
|
switch m.currentPage {
|
|
case shippingPage:
|
|
return 1
|
|
case paymentPage:
|
|
return 2
|
|
case reviewPage:
|
|
return 3
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// updateLayout recalculates all layout variables from the raw viewport dimensions.
|
|
// This should be called whenever the terminal is resized.
|
|
func (m *Model) updateLayout(width, height int) {
|
|
m.viewportWidth = width
|
|
m.viewportHeight = height
|
|
switch {
|
|
case width < 20 || height < 10:
|
|
m.size = undersized
|
|
m.widthContainer = width
|
|
m.heightContainer = height
|
|
case width < 50:
|
|
m.size = small
|
|
m.widthContainer = width
|
|
m.heightContainer = height
|
|
case width < 80:
|
|
m.size = medium
|
|
m.widthContainer = 50
|
|
m.heightContainer = int(math.Min(float64(height), 30))
|
|
default:
|
|
m.size = large
|
|
m.widthContainer = 80
|
|
m.heightContainer = int(math.Min(float64(height), 30))
|
|
}
|
|
|
|
m.widthContent = m.widthContainer - 2
|
|
}
|
|
|
|
// ProductsMsg is sent when products are fetched from API
|
|
type ProductsMsg struct {
|
|
Products []models.Coffee
|
|
Err error
|
|
}
|
|
|
|
// fetchCartCmd fetches the user's cart from the API on startup
|
|
func (m Model) fetchCartCmd() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return CartFetchedMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
|
|
log.Println("[TUI] Fetching cart from API")
|
|
cart, err := m.APIClient.GetCart()
|
|
if err != nil {
|
|
log.Printf("[TUI] Failed to fetch cart: %v", err)
|
|
} else {
|
|
log.Printf("[TUI] Loaded %d cart items from API", len(cart.Items))
|
|
}
|
|
|
|
return CartFetchedMsg{Cart: cart, Err: err}
|
|
}
|
|
|
|
// fetchProductsCmd fetches products from the API
|
|
func (m Model) fetchProductsCmd() tea.Msg {
|
|
if m.APIClient == nil {
|
|
log.Println("[TUI] APIClient is nil, using fallback products")
|
|
return ProductsMsg{Err: nil} // No API client, use fallback
|
|
}
|
|
|
|
log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL)
|
|
products, err := m.APIClient.GetProducts()
|
|
if err != nil {
|
|
log.Printf("[TUI] Failed to fetch products: %v", err)
|
|
} else {
|
|
log.Printf("[TUI] Loaded %d products from API", len(products))
|
|
}
|
|
return ProductsMsg{Products: products, Err: err}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.authCmd,
|
|
tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return DelayCompleteMsg{}
|
|
}),
|
|
tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return splashCursorTickMsg{}
|
|
}),
|
|
)
|
|
}
|
|
|
|
type CheckoutResultMsg struct {
|
|
OrderID uint
|
|
Total int
|
|
Err error
|
|
}
|
|
|
|
// CartSyncedMsg is recieved when the server responds to a cart item update
|
|
type CartSyncedMsg struct {
|
|
UpdateID int64
|
|
Cart *api.CartData
|
|
Err error
|
|
}
|
|
|
|
type CartFetchedMsg struct {
|
|
Cart *api.CartData
|
|
Err error
|
|
}
|
|
|
|
// tokenRefreshDuration is the interval between token refreshes.
|
|
// Tokens expire after 30 minutes; refresh 5 minutes early.
|
|
const tokenRefreshDuration = 25 * time.Minute
|
|
|
|
// tokenRetryDuration is the delay before retrying a failed token refresh.
|
|
const tokenRetryDuration = 1 * time.Minute
|
|
|
|
// tokenRefreshTickMsg is sent by the background timer to trigger a token refresh.
|
|
type tokenRefreshTickMsg struct{}
|
|
|
|
// TokenRefreshedMsg is sent when a token refresh attempt completes.
|
|
type TokenRefreshedMsg struct {
|
|
Token string
|
|
Err error
|
|
}
|
|
|
|
// scheduleTokenRefreshCmd returns a tea.Cmd that ticks after tokenRefreshDuration.
|
|
func (m Model) scheduleTokenRefreshCmd() tea.Cmd {
|
|
return tea.Tick(tokenRefreshDuration, func(t time.Time) tea.Msg {
|
|
return tokenRefreshTickMsg{}
|
|
})
|
|
}
|
|
|
|
// refreshTokenCmd calls the /auth/token endpoint to get a fresh JWT.
|
|
func (m Model) refreshTokenCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.Fingerprint == "" {
|
|
return TokenRefreshedMsg{Err: fmt.Errorf("not authenticated, cannot refresh token")}
|
|
}
|
|
|
|
token, err := m.APIClient.RefreshToken(m.Fingerprint, m.AuthFingerprintKey)
|
|
if err != nil {
|
|
log.Printf("[TUI] Token refresh failed: %v", err)
|
|
return TokenRefreshedMsg{Err: err}
|
|
}
|
|
|
|
log.Println("[TUI] Token refreshed successfully")
|
|
return TokenRefreshedMsg{Token: token}
|
|
}
|
|
}
|
|
|
|
// OrdersMsg is sent when order history is fetched from the API
|
|
type OrdersMsg struct {
|
|
Orders []models.Order
|
|
Err error
|
|
}
|
|
|
|
func (m Model) fetchOrdersCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return OrdersMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
orders, err := m.APIClient.GetOrders()
|
|
return OrdersMsg{Orders: orders, Err: err}
|
|
}
|
|
}
|
|
|
|
// AddressesMsg is sent when sdaved addresses are fetched from the API
|
|
type AddressesMsg struct {
|
|
Addresses []models.Address
|
|
Err error
|
|
}
|
|
|
|
// AddressSavedMsg is sent when an address is saved to the api
|
|
type AddressSavedMsg struct {
|
|
Address models.Address
|
|
Err error
|
|
}
|
|
|
|
type AddressDeletedMsg struct {
|
|
Err error
|
|
}
|
|
|
|
// CardsMsg is sent when saved cards are fetched form the API
|
|
type CardsMsg struct {
|
|
Cards []models.Card
|
|
Err error
|
|
}
|
|
|
|
// CardDeletedMsg is sent when card is deleted
|
|
type CardDeletedMsg struct {
|
|
Err error
|
|
}
|
|
|
|
func (m Model) fetchAddressesCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return AddressesMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
addresses, err := m.APIClient.GetAddresses()
|
|
return AddressesMsg{Addresses: addresses, Err: err}
|
|
}
|
|
}
|
|
|
|
func (m Model) saveAddressCmd(addr models.Address) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return AddressSavedMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
|
|
Name: addr.Name,
|
|
Street: addr.Street,
|
|
Street2: addr.Street2,
|
|
City: addr.City,
|
|
State: addr.State,
|
|
Zip: addr.Zip,
|
|
Country: addr.Country,
|
|
Phone: addr.Phone,
|
|
})
|
|
if err != nil {
|
|
return AddressSavedMsg{Err: err}
|
|
}
|
|
return AddressSavedMsg{Address: *saved}
|
|
}
|
|
}
|
|
|
|
func (m Model) deleteAddressCmd(id uint) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return AddressDeletedMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
err := m.APIClient.DeleteAddress(id)
|
|
return AddressDeletedMsg{Err: err}
|
|
}
|
|
}
|
|
|
|
func (m Model) fetchCardsCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return CardsMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
cards, err := m.APIClient.GetCards()
|
|
return CardsMsg{Cards: cards, Err: err}
|
|
}
|
|
}
|
|
|
|
func (m Model) deleteCardCmd(id uint) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return CardDeletedMsg{Err: fmt.Errorf("not authenticated")}
|
|
}
|
|
err := m.APIClient.DeleteCard(id)
|
|
return CardDeletedMsg{Err: err}
|
|
}
|
|
}
|
|
|
|
// checkoutWithSavedCard sets address and saved card on the cart, then converts.
|
|
// Used when the user selects an existing card from the list (no tokenization needed).
|
|
func (m Model) checkoutWithSavedCard() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil || m.SelectedCard == nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("API client or card not available")}
|
|
}
|
|
|
|
// 1. Set the shipping address on the cart
|
|
if m.ShippingInfo != nil && m.ShippingInfo.ID != 0 {
|
|
if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
|
|
}
|
|
} else if m.ShippingInfo != nil {
|
|
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
|
|
Name: m.ShippingInfo.Name,
|
|
Street: m.ShippingInfo.Street,
|
|
Street2: m.ShippingInfo.Street2,
|
|
City: m.ShippingInfo.City,
|
|
State: m.ShippingInfo.State,
|
|
Zip: m.ShippingInfo.Zip,
|
|
Country: m.ShippingInfo.Country,
|
|
Phone: m.ShippingInfo.Phone,
|
|
})
|
|
if err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to save address: %w", err)}
|
|
}
|
|
if err := m.APIClient.SetCartAddress(saved.ID); err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
|
|
}
|
|
}
|
|
|
|
// 2. Set the saved card on the cart
|
|
if err := m.APIClient.SetCartCard(m.SelectedCard.ID); err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
|
|
}
|
|
|
|
// 3. Convert the cart to an order
|
|
order, err := m.APIClient.ConvertCart()
|
|
if err != nil {
|
|
return CheckoutResultMsg{Err: err}
|
|
}
|
|
|
|
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
|
|
}
|
|
}
|
|
|
|
func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd {
|
|
if m.APIClient == nil || m.User == nil {
|
|
return nil
|
|
}
|
|
|
|
updateID := time.Now().UTC().UnixMilli()
|
|
m.lastCartUpdateID = updateID
|
|
|
|
return func() tea.Msg {
|
|
cart, err := m.APIClient.SetCartItem(coffeeID, quantity)
|
|
return CartSyncedMsg{
|
|
UpdateID: updateID,
|
|
Cart: cart,
|
|
Err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveCardAndConvert tokenizes the card locally using the Stripe publishable
|
|
// key (raw card data never reaches the backend), saves the token, then converts the cart
|
|
func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil {
|
|
return CheckoutResultMsg{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 CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
|
|
}
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
|
|
}
|
|
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
|
|
if err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
|
|
}
|
|
|
|
// 2. Set the shipping address on the cart
|
|
if m.ShippingInfo != nil && m.ShippingInfo.ID != 0 {
|
|
if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
|
|
}
|
|
} else if m.ShippingInfo != nil {
|
|
// Address was entered fresh (not from saved list), save it first
|
|
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
|
|
Name: m.ShippingInfo.Name,
|
|
Street: m.ShippingInfo.Street,
|
|
Street2: m.ShippingInfo.Street2,
|
|
City: m.ShippingInfo.City,
|
|
State: m.ShippingInfo.State,
|
|
Zip: m.ShippingInfo.Zip,
|
|
Country: m.ShippingInfo.Country,
|
|
Phone: m.ShippingInfo.Phone,
|
|
})
|
|
if err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to save address: %w", err)}
|
|
}
|
|
if err := m.APIClient.SetCartAddress(saved.ID); err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
|
|
}
|
|
}
|
|
|
|
// 3. Set the card on the cart
|
|
if err := m.APIClient.SetCartCard(card.ID); err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
|
|
}
|
|
|
|
// 4. Convert the cart to an order
|
|
order, err := m.APIClient.ConvertCart()
|
|
if err != nil {
|
|
return CheckoutResultMsg{Err: err}
|
|
}
|
|
|
|
return CheckoutResultMsg{
|
|
OrderID: order.ID,
|
|
Total: order.Total,
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
m.Cart = make(map[uint]*models.CartItem)
|
|
if cartData == nil {
|
|
return
|
|
}
|
|
|
|
for _, item := range cartData.Items {
|
|
m.Cart[item.CoffeeID] = &models.CartItem{
|
|
CoffeeID: item.CoffeeID,
|
|
Coffee: item.Coffee,
|
|
Quantity: item.Quantity,
|
|
}
|
|
}
|
|
|
|
if m.cart.cursor >= len(m.Cart) {
|
|
m.cart.cursor = 0
|
|
}
|
|
}
|
|
|
|
// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration.
|
|
func (m Model) GetCartItemsSlice() []*models.CartItem {
|
|
// Get keys and sort them for stable ordering
|
|
keys := make([]uint, 0, len(m.Cart))
|
|
for key := range m.Cart {
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
// Sort keys to ensure consistent order
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
|
|
|
// Build items slice in sorted order
|
|
items := make([]*models.CartItem, 0, len(m.Cart))
|
|
for _, key := range keys {
|
|
items = append(items, m.Cart[key])
|
|
}
|
|
return items
|
|
}
|
|
|
|
// IsCartEmpty returns true when the cart has no items
|
|
func (m Model) IsCartEmpty() bool {
|
|
return len(m.Cart) == 0
|
|
}
|
|
|
|
// CartItemCount returns the total number of items (sum of quanities) in the cart
|
|
func (m Model) CartItemCount() int {
|
|
count := 0
|
|
for _, item := range m.Cart {
|
|
count += item.Quantity
|
|
}
|
|
return count
|
|
}
|
|
|
|
// CalculateSubtotal returns the cart subtotal in cents
|
|
func (m Model) CalculateSubtotal() int {
|
|
subtotal := 0
|
|
for _, item := range m.Cart {
|
|
subtotal += item.Coffee.Price * item.Quantity
|
|
}
|
|
return subtotal
|
|
}
|
|
|
|
func (m Model) SavedAddressesIsEmpty() bool {
|
|
return len(m.SavedAddresses) == 0
|
|
}
|
|
|
|
func (m Model) SavedCardsIsEmpty() bool {
|
|
return len(m.SavedCards) == 0
|
|
}
|
|
|
|
// NewModel creates a new model using the default renderer.
|
|
func NewModel(username string) Model {
|
|
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
|
}
|
|
|
|
// newModelWithRenderer creates a new model with the given renderer.
|
|
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
|
m := Model{
|
|
Username: username,
|
|
currentPage: splashPage,
|
|
renderer: renderer,
|
|
theme: theme.BasicTheme(renderer),
|
|
Coffees: []models.Coffee{
|
|
{
|
|
Name: "Espresso",
|
|
RoastType: "Dark Roast",
|
|
Ounces: 2,
|
|
BeanType: "Arabica",
|
|
Price: 350, // $3.50
|
|
Color: "#8B4513",
|
|
Description: "A bold, concentrated shot of pure coffee bliss. Perfect for those who need an immediate caffeine injection to survive the day.",
|
|
},
|
|
{
|
|
Name: "Latte",
|
|
RoastType: "Medium Roast",
|
|
Ounces: 12,
|
|
BeanType: "Arabica Blend",
|
|
Price: 500, // $5.00
|
|
Color: "#D2691E",
|
|
Description: "Smooth espresso paired with steamed milk and a light layer of foam. For when you want coffee but also want to feel fancy about it.",
|
|
},
|
|
{
|
|
Name: "Cappuccino",
|
|
RoastType: "Medium Roast",
|
|
Ounces: 8,
|
|
BeanType: "Italian Blend",
|
|
Price: 450, // $4.50
|
|
Color: "#CD853F",
|
|
Description: "Equal parts espresso, steamed milk, and foam. The classic Italian choice for people who know what they're doing.",
|
|
},
|
|
{
|
|
Name: "Americano",
|
|
RoastType: "Dark Roast",
|
|
Ounces: 16,
|
|
BeanType: "Arabica",
|
|
Price: 400, // $4.00
|
|
Color: "#A0522D",
|
|
Description: "Espresso with hot water. Simple, strong, and no-nonsense. This is coffee for people who actually like the taste of coffee.",
|
|
},
|
|
{
|
|
Name: "Mocha",
|
|
RoastType: "Medium Roast",
|
|
Ounces: 16,
|
|
BeanType: "Colombian",
|
|
Price: 550, // $5.50
|
|
Color: "#4682B4",
|
|
Description: "Some really good Mocha",
|
|
},
|
|
{
|
|
Name: "Macchiato",
|
|
RoastType: "Dark Roast",
|
|
Ounces: 3,
|
|
BeanType: "Robusta Blend",
|
|
Price: 425, // $4.25
|
|
Color: "#DAA520",
|
|
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
|
},
|
|
},
|
|
Cart: make(map[uint]*models.CartItem),
|
|
Loading: true,
|
|
APIClient: api.NewClient("http://localhost:8080", ""),
|
|
FAQs: LoadFaqs(),
|
|
}
|
|
m.updateLayout(120, 30)
|
|
return m
|
|
}
|
|
|
|
// NewModelWithAuth creates a new model with user authentication context.
|
|
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
|
m := newModelWithRenderer("", renderer)
|
|
m.Fingerprint = fingerprint
|
|
m.AuthFingerprintKey = clientSecret
|
|
m.StripePublicKey = stripePublicKey
|
|
m.currentPage = splashPage
|
|
if apiURL != "" {
|
|
m.APIClient = api.NewClient(apiURL, "")
|
|
} else {
|
|
m.APIClient = api.NewClient("http://localhost:8000", "")
|
|
}
|
|
|
|
m.authCmd = func() tea.Msg {
|
|
token, user, err := m.APIClient.GetOrCreateToken(
|
|
fingerprint,
|
|
pubKeyStr,
|
|
clientSecret,
|
|
)
|
|
if err != nil {
|
|
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
|
|
}
|
|
return SplashAuthMsg{Token: token, User: user}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func newViewport(w, h int) viewport.Model {
|
|
return viewport.New(w, h)
|
|
}
|
|
|
|
func (m Model) collectCardCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.APIClient == nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
|
|
}
|
|
url, err := m.APIClient.CollectCard()
|
|
if err != nil {
|
|
return CheckoutResultMsg{Err: fmt.Errorf("failed to generate payment link: %w", err)}
|
|
}
|
|
return PollPaymentInitMsg{URL: url}
|
|
}
|
|
}
|
|
|
|
func (m Model) pollCardsCmd(cardCount int) tea.Cmd {
|
|
return tea.Tick(time.Second, func(_ time.Time) tea.Msg {
|
|
if m.APIClient == nil {
|
|
return PollPaymentStatusMsg{CardCount: cardCount}
|
|
}
|
|
cards, err := m.APIClient.GetCards()
|
|
if err != nil {
|
|
return PollPaymentStatusMsg{CardCount: cardCount}
|
|
}
|
|
if len(cards) > cardCount {
|
|
return PollPaymentCompleteMsg{Cards: cards}
|
|
}
|
|
return PollPaymentStatusMsg{CardCount: cardCount}
|
|
})
|
|
}
|