package main import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Account menu items struct. var accountMenuItems = []string { "order history", "faq", "about", } // Coffee represents a coffee product type Coffee struct { Name string RoastType string Ounces int BeanType string Price float64 Color string // Hex color for background Description string } // CartItem represents an item in the cart type CartItem struct { Coffee Coffee Quantity int } type model struct { username string coffees []Coffee cursor int cart map[int]*CartItem // maps coffee index to cart item cartCursor int // cursor position in cart view accountCursor int accountPage string windowWidth int windowHeight int viewingCart bool // true when viewing cart details viewingAccount bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.windowWidth = msg.Width m.windowHeight = msg.Height return m, nil case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "s": m.viewingCart = false m.viewingAccount = false m.accountPage = "" case "c": m.viewingCart = true m.viewingAccount = false m.accountPage = "" case "a": m.viewingCart = false m.viewingAccount = true case "up", "k": if m.viewingAccount && m.accountPage == "" { if m.accountCursor > 0 { m.accountCursor-- } } else if !m.viewingCart && !m.viewingAccount && m.cursor > 0 { m.cursor-- } else if m.viewingCart && m.cartCursor > 0 { m.cartCursor-- } case "down", "j": if m.viewingAccount && m.accountPage == "" { if m.accountCursor < len(accountMenuItems)-1 { m.accountCursor++ } } else if !m.viewingCart && !m.viewingAccount && m.cursor < len(m.coffees)-1 { m.cursor++ } else if m.viewingCart && m.cartCursor < len(m.cart)-1 { m.cartCursor++ } case "+", "=": if !m.viewingCart { // Increment quantity in shop view if item, exists := m.cart[m.cursor]; exists { item.Quantity++ } else { m.cart[m.cursor] = &CartItem{ Coffee: m.coffees[m.cursor], Quantity: 1, } } } else { // Increment quantity in cart view cartItems := m.getCartItemsSlice() if m.cartCursor >= 0 && m.cartCursor < len(cartItems) { cartItems[m.cartCursor].Quantity++ } } case "enter": if m.viewingAccount && m.accountPage == "" { switch accountMenuItems[m.accountCursor]{ case "order history": m.accountPage = "orders" case "faq": m.accountPage = "faq" case "about": m.accountPage = "about" } } case "esc": if m.viewingAccount && m.accountPage != "" { m.accountPage = "" } case "-", "_": if !m.viewingCart { // Decrement quantity in shop view if item, exists := m.cart[m.cursor]; exists { item.Quantity-- if item.Quantity <= 0 { delete(m.cart, m.cursor) // Reset cart cursor if we deleted the last item if len(m.cart) == 0 { m.cartCursor = 0 } else if m.cartCursor >= len(m.cart) { m.cartCursor = len(m.cart) - 1 } } } } else { // Decrement quantity in cart view cartItems := m.getCartItemsSlice() if m.cartCursor >= 0 && m.cartCursor < len(cartItems) { cartItems[m.cartCursor].Quantity-- if cartItems[m.cartCursor].Quantity <= 0 { // Find and delete this item from the cart for idx, item := range m.cart { if item == cartItems[m.cartCursor] { delete(m.cart, idx) break } } // Reset cart cursor if len(m.cart) == 0 { m.cartCursor = 0 } else if m.cartCursor >= len(m.cart) { m.cartCursor = len(m.cart) - 1 } } } } } } return m, nil } func (m model) View() string { if m.windowWidth == 0 { m.windowWidth = 120 m.windowHeight = 30 } // Build header with cart tab header := m.buildHeader() // Build main content based on view var content string if m.viewingAccount { content = m.buildAccountView() } else if m.viewingCart { content = m.buildCartView() } else { content = m.buildShopView() } // Add vertical spacing around content contentWithPadding := lipgloss.NewStyle(). MarginTop(1). MarginBottom(4). Render(content) // Build footer footer := m.buildFooter() // Combine all app content appContent := lipgloss.JoinVertical( lipgloss.Left, header, contentWithPadding, footer, ) // Calculate vertical centering appHeight := lipgloss.Height(appContent) verticalPadding := 2 // Minimum padding from top and bottom availableSpace := m.windowHeight - appHeight - (verticalPadding * 2) if availableSpace < 0 { availableSpace = 0 verticalPadding = 0 } // Split remaining space between top and bottom topPadding := verticalPadding + (availableSpace / 2) // Create top spacing topSpacing := "" for i := 0; i < topPadding; i++ { topSpacing += "\n" } return topSpacing + appContent } func (m model) buildHeader() string { // Calculate cart total and item count total := 0.0 itemCount := 0 for _, item := range m.cart { total += float64(item.Quantity) * item.Coffee.Price itemCount += item.Quantity } // Define colors whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) // Build shop tab var shopKeybind, shopName string if !m.viewingCart { // Active: bold white keybind and name shopKeybind = boldWhiteStyle.Render("s") shopName = boldWhiteStyle.Render("shop") } else { // Inactive: bold gray keybind, gray name shopKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("s") shopName = grayStyle.Render("shop") } // Build cart tab var cartKeybind, cartName, cartInfo string itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount)) if m.viewingCart { // Active: bold white keybind and name cartKeybind = boldWhiteStyle.Render("c") cartName = boldWhiteStyle.Render("cart") cartInfo = fmt.Sprintf(" %s %s", boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)), itemCountText) } else { // Inactive: bold gray keybind, gray name boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true) boldWhitePriceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) cartKeybind = boldGrayStyle.Render("c") cartName = grayStyle.Render("cart") cartInfo = fmt.Sprintf(" %s %s", boldWhitePriceStyle.Render(fmt.Sprintf("$%.2f", total)), itemCountText) } // Build Account Tabs var accountKeybind, accountName string if m.viewingAccount { accountKeybind = boldWhiteStyle.Render("a") accountName = boldWhiteStyle.Render("account") } else { accountKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("a") accountName = grayStyle.Render("account") } // Build the tabs with separator only in the middle separator := whiteStyle.Render("|") tabsContent := fmt.Sprintf("%s %s %s %s %s%s %s %s %s", shopKeybind, shopName, separator, cartKeybind, cartName, cartInfo, separator, accountKeybind, accountName, ) // Add box with padding (no vertical padding, just horizontal) tabBox := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(0, 2). MarginBottom(1) // Center the whole thing centered := lipgloss.NewStyle(). Width(m.windowWidth). Align(lipgloss.Center) return centered.Render(tabBox.Render(tabsContent)) } func (m model) buildShopView() string { leftWidth := 18 // Narrower list leftMargin := 4 // Minimal left margin rightWidth := m.windowWidth - leftWidth - leftMargin - 4 // Build the left panel (coffee list) leftPanel := "" for i, coffee := range m.coffees { // Apply background color when selected, white text if m.cursor == i { style := lipgloss.NewStyle(). Background(lipgloss.Color(coffee.Color)). Foreground(lipgloss.Color("#FFFFFF")). Padding(0, 1). Width(leftWidth - 2). Align(lipgloss.Left) leftPanel += style.Render(coffee.Name) + "\n" } else { style := lipgloss.NewStyle(). Foreground(lipgloss.Color("#AAAAAA")). Padding(0, 1). Width(leftWidth - 2). Align(lipgloss.Left) leftPanel += style.Render(coffee.Name) + "\n" } } // Add left margin to the list leftContainer := lipgloss.NewStyle(). Width(leftWidth). MarginLeft(leftMargin) // Build the detail view (right panel) detailView := "" if m.cursor >= 0 && m.cursor < len(m.coffees) { selectedCoffee := m.coffees[m.cursor] nameStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")) infoStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#AAAAAA")) priceStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color(selectedCoffee.Color)). Bold(true) descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")). Width(rightWidth - 4) quantityStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")). Bold(true) // Get current quantity quantity := 0 if item, exists := m.cart[m.cursor]; exists { quantity = item.Quantity } detailView += nameStyle.Render(selectedCoffee.Name) + "\n" detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s", selectedCoffee.RoastType, selectedCoffee.Ounces, selectedCoffee.BeanType)) + "\n\n" detailView += priceStyle.Render(fmt.Sprintf("$%.2f", selectedCoffee.Price)) + "\n\n" detailView += descStyle.Render(selectedCoffee.Description) + "\n\n" detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n" } detailContainer := lipgloss.NewStyle(). Width(rightWidth) // Layout the panels side by side mainContent := lipgloss.JoinHorizontal( lipgloss.Top, leftContainer.Render(leftPanel), detailContainer.Render(detailView), ) return mainContent } func (m model) buildCartView() string { if len(m.cart) == 0 { emptyStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). Align(lipgloss.Center). Width(m.windowWidth). Padding(4, 0) return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop") } cartItems := "" boxWidth := m.windowWidth - 20 // Leave some margin itemSlice := m.getCartItemsSlice() for idx, item := range itemSlice { itemTotal := float64(item.Quantity) * item.Coffee.Price // Left side: name and info nameStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")) infoStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#AAAAAA")) // Right side: quantity controls and price quantityStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")) priceStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")). Bold(true) // Build content with proper spacing nameText := nameStyle.Render(item.Coffee.Name) infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s", item.Coffee.RoastType, item.Coffee.Ounces, item.Coffee.BeanType)) quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity)) priceText := priceStyle.Render(fmt.Sprintf("$%.2f", itemTotal)) // Calculate spacing nameWidth := lipgloss.Width(nameText) quantityWidth := lipgloss.Width(quantityText) priceWidth := lipgloss.Width(priceText) spacing := boxWidth - nameWidth - quantityWidth - priceWidth - 8 // 8 for padding and gaps if spacing < 1 { spacing = 1 } contentLine1 := nameText + lipgloss.NewStyle().Width(spacing).Render("") + quantityText + " " + priceText contentLine2 := infoText boxContent := contentLine1 + "\n" + contentLine2 // Create box for this item itemBox := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). Padding(0, 2). Width(boxWidth). Foreground(lipgloss.Color("#FFFFFF")) // Center the box centered := lipgloss.NewStyle(). Width(m.windowWidth). Align(lipgloss.Center) cartItems += centered.Render(itemBox.Render(boxContent)) // Add spacing between items if idx < len(itemSlice)-1 { cartItems += "\n\n" } } return cartItems } func (m model) buildAccountView() string { // If we're on a subpage, show that content if m.accountPage != "" { return m.buildAccountSubpage() } // Otherwise show the account menu leftWidth := 18 leftMargin := 4 leftPanel := "" for i, item := range accountMenuItems { // Apply background color when selected, white text if m.accountCursor == i { style := lipgloss.NewStyle(). Background(lipgloss.Color("#4682B4")). Foreground(lipgloss.Color("#FFFFFF")). Padding(0, 1). Width(leftWidth - 2). Align(lipgloss.Left) leftPanel += style.Render(item) + "\n" } else { style := lipgloss.NewStyle(). Foreground(lipgloss.Color("#AAAAAA")). Padding(0, 1). Width(leftWidth - 2). Align(lipgloss.Left) leftPanel += style.Render(item) + "\n" } } leftContainer := lipgloss.NewStyle(). Width(leftWidth). MarginLeft(leftMargin) return leftContainer.Render(leftPanel) } func (m model) buildAccountSubpage() string { titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FFFFFF")). MarginBottom(2) contentStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#AAAAAA")). Width(m.windowWidth - 20). MarginLeft(4) var content string switch m.accountPage { case "orders": content = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet. Your order history will appear here once you make a purchase.") case "faq": content = titleStyle.Render("FAQ") + "\n\n" + contentStyle.Render("Q: How do I place an order?\nA: Browse products with j/k, adjust quantity with +/-, and checkout via the cart.\n\nQ: What payment methods do you accept?\nA: We accept all major credit cards via Stripe.\n\nPress ESC to go back.") case "about": content = titleStyle.Render("About Terminal Coffee Shop") + "\n\n" + contentStyle.Render("A terminal-based coffee ordering experience.\nBuilt with Go and Bubbletea.\n\nPress ESC to go back.") default: content = "Unknown page" } return content } // getCartItemsSlice converts the cart map to a sorted slice for consistent iteration func (m model) getCartItemsSlice() []*CartItem { items := make([]*CartItem, 0, len(m.cart)) for _, item := range m.cart { items = append(items, item) } return items } func (m model) buildFooter() string { // Style for keybinds (bold) keybindStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). Bold(true) // Style for descriptions (normal) descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")) var footerText string if m.viewingAccount { if m.accountPage != "" { // In a subpage footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", keybindStyle.Render("esc"), descStyle.Render("back"), keybindStyle.Render("s"), descStyle.Render("shop"), keybindStyle.Render("c"), descStyle.Render("cart"), keybindStyle.Render("q"), descStyle.Render("quit"), ) } else { // In account menu footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", keybindStyle.Render("j/k"), descStyle.Render("menu"), keybindStyle.Render("enter"), descStyle.Render("select"), keybindStyle.Render("s"), descStyle.Render("shop"), keybindStyle.Render("c"), descStyle.Render("cart"), keybindStyle.Render("q"), descStyle.Render("quit"), ) } } else if m.viewingCart { footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", keybindStyle.Render("j/k"), descStyle.Render("items"), keybindStyle.Render("+/-"), descStyle.Render("qty"), keybindStyle.Render("s"), descStyle.Render("shop"), keybindStyle.Render("a"), descStyle.Render("account"), keybindStyle.Render("q"), descStyle.Render("quit"), ) } else { footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", keybindStyle.Render("j/k"), descStyle.Render("products"), keybindStyle.Render("+/-"), descStyle.Render("qty"), keybindStyle.Render("c"), descStyle.Render("cart"), keybindStyle.Render("a"), descStyle.Render("account"), keybindStyle.Render("q"), descStyle.Render("quit"), ) } return lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.windowWidth). Render(footerText) } // InitialModel creates a new model with default coffee options func InitialModel(username string) model { return model{ username: username, coffees: []Coffee{ { Name: "Espresso", RoastType: "Dark Roast", Ounces: 2, BeanType: "Arabica", Price: 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: 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: 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: 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: "Cold Brew", RoastType: "Medium Roast", Ounces: 16, BeanType: "Colombian", Price: 5.50, Color: "#4682B4", Description: "Steeped for 12 hours to create a smooth, less acidic coffee experience. Served cold because sometimes hot coffee is just too much effort.", }, { Name: "Macchiato", RoastType: "Dark Roast", Ounces: 3, BeanType: "Robusta Blend", Price: 4.25, Color: "#DAA520", Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", }, }, cursor: 0, cart: make(map[int]*CartItem), cartCursor: 0, accountCursor: 0, accountPage: "", windowWidth: 120, windowHeight: 30, viewingCart: false, viewingAccount: false, } }