Files
terminalShop-old/pkg/tui/model.go
marcusDenslow 6559c4dd8f fix and feature: made checkout page cleaner, and added card and address modification to account page
the checkout flow was a bit wonky because it would display the same info twice which looked weird and confusing. this is now changed to only show once and update one of the pages independantly.

Now added the possibility to modify card and address list in the account page and not in the checkout flow
2026-04-12 12:11:04 +02:00

798 lines
24 KiB
Go

package tui
import (
"fmt"
"log"
"math"
"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 represetnts 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 Model struct {
// User authentication
User *models.User
Fingerprint string
SSHPublicKeyStr string
AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// Shop state
Username string
Coffees []models.Coffee
shop shopState
cartVP viewport.Model
cartVPReady bool
shippingVP viewport.Model
shippingVPReady bool
paymentVP viewport.Model
paymentVPReady bool
confirmVP viewport.Model
confirmVPReady bool
Cart map[uint]*models.CartItem
CartCursor int // cursor position in cart view
AccountCursor int
currentPage page // currently active screen
switched bool // true when the page just changed (for state resets)
ScrollOffset int // scroll position for content
CheckingOut bool // true while saveCardAndConvert is running
lastCartUpdateID int64 // debounce ID for optimistic cart sync
// 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
ErrorMsg string // error message if API fetch fails
APIClient *api.Client
// Rendering
renderer *lipgloss.Renderer
theme theme.Theme
// Checkout state
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
ShippingInfo *models.Address // Selected shipping address for current order
SavedAddresses []models.Address // Saved addresses from database
ShippingView int
AddressCursor int
SavedCards []models.Card // Saved cards from database
SelectedCard *models.Card // Card selected from saved list (nil = new card)
CardCursor int // cursor position in the card list
PaymentView int // 0=card list, 1=new card form, 2 = browser payment
CollectURL *string // URL for browser-based card entry
CollectCardCount int // card count when browser polling started
// Order history
Orders []models.Order
OrdersLoaded bool
OrderCursor int // which order is selected in the list
OrderViewState int // 0=preview, 1=browsing list, 2=viewing detail
// Static content
FAQs []FAQ // loaded from embedded faq.json
FaqFocused bool // true when FAQ detail is focused
// Account manegement state
AddressListFocused bool // true when browsing addresses in account
CardListFocused bool // true when browsing cards in account
AccountAddressCursor int // cursor within address list in AccountAddressCursor
AccountCardCursor int // cursor within card list in account
AccountAddressDeleting *int // index of address pending deletion
AccountCardDeleting *int // index of card pending deletion
// Splash screen
splashDataReady bool // true when products have loaded
splashDelayDone bool // true when minimum display time has elapsed
splashCursor bool // toggles for blinking cursor animation
// Review / confirmation state
CardJustAdded bool // true when arriving at review page after adding a new card
ReviewSuccess bool // true after a successful order, shows success msg on review page
ConfirmTotal int // server-confirmed order total in cents
ConfirmItems []models.CartItem // cart items at time of purchase
ConfirmShipping *models.Address // shipping address at time of purchase
}
type shopState struct {
selected int
menuViewport viewport.Model
detailViewport viewport.Model
viewportsReady 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
return m
}
// 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.splashAuthCmd,
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{}
}),
)
}
func (m Model) resetPageState() Model {
m.ShippingForm = nil
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AddressListFocused = false
m.CardListFocused = false
m.AccountAddressDeleting = nil
m.AccountCardDeleting = nil
return m
}
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.CartCursor >= len(m.Cart) {
m.CartCursor = 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
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
// 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
}
// 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),
CartCursor: 0,
AccountCursor: 0,
OrderCursor: 0,
OrderViewState: 0,
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.SSHPublicKeyStr = pubKeyStr
m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage
if apiURL != "" {
m.APIClient = api.NewClient(apiURL, "")
} else {
m.APIClient = api.NewClient("http://localhost:8000", "")
}
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}
})
}