mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 18:08:45 +01:00
changed the ssh key to just do fingerprinting and not store it at all, and not be able to edit it either in the account page
954 lines
26 KiB
Go
954 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
|
|
}
|
|
|
|
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
|
|
ErrorMsg string // error message if API fetch fails
|
|
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
|
|
scrollOffset int
|
|
orderViewState int
|
|
orderCursor int
|
|
faqFocused bool
|
|
addressListFocused bool
|
|
cardListFocused bool
|
|
addressCursor int
|
|
cardCursor int
|
|
addressDeleting *int
|
|
cardDeleting *int
|
|
}
|
|
|
|
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.ErrorMsg = ""
|
|
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"},
|
|
}
|
|
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}
|
|
})
|
|
}
|