Files
terminalShop/model.go.old

711 lines
19 KiB
Go

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,
}
}