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 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 SSHKeys []models.SSHKey SSHKeysLoaded 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 } 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 sshKeyListFocused bool sshKeyCursor int sshKeyDeleting *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() return m, nil } func (m Model) CartSwitch() (Model, tea.Cmd) { m = m.SwitchPage(cartPage) m = m.updateCartViewport() return m, nil } func (m Model) AccountSwitch() (Model, tea.Cmd) { m = m.SwitchPage(accountPage) m.account = accountState{} 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() return m, m.fetchAddressesCmd() } func (m Model) PaymentSwitch() (Model, tea.Cmd) { m = m.SwitchPage(paymentPage) m.payment = paymentState{} m.SelectedCard = nil return m, m.fetchCardsCmd() } func (m Model) ReviewSwitch() (Model, tea.Cmd) { m = m.SwitchPage(reviewPage) return m, nil } func (m Model) ConfirmSwitch() (Model, tea.Cmd) { m = m.SwitchPage(confirmPage) m.confirm = confirmState{} m = m.updateConfirmViewport() 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 } // SSHKeysMsg is sent when ssh keys are fetched from the API type SSHKeysMsg struct { Keys []models.SSHKey Err error } // SSHKeyDeletedMsg is sent when an ssh key is deleted type SSHKeyDeletedMsg 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} } } func (m Model) fetchSSHKeysCmd() tea.Cmd { return func() tea.Msg { if m.APIClient == nil || m.User == nil { return SSHKeysMsg{Err: fmt.Errorf("not authenticated")} } keys, err := m.APIClient.GetSSHKeys() return SSHKeysMsg{Keys: keys, Err: err} } } func (m Model) deleteSSHKeyCmd(id uint) tea.Cmd { return func() tea.Msg { if m.APIClient == nil || m.User == nil { return SSHKeyDeletedMsg{Err: fmt.Errorf("not authenticated")} } err := m.APIClient.DeleteSSHKey(id) return SSHKeyDeletedMsg{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 } // 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} }) }