From 3ff4aa9d501c4cf30dbe366b992c9e121bec5b62 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Thu, 26 Mar 2026 17:47:56 +0100 Subject: [PATCH] fix: rewrote the huge 700+ line update call with modularized calls --- README.md | 446 +++------------------- pkg/tui/account.go | 98 ++++- pkg/tui/cart.go | 68 ++++ pkg/tui/confirmation.go | 27 ++ pkg/tui/help.go | 15 + pkg/tui/menu.go | 31 ++ pkg/tui/model.go | 801 +-------------------------------------- pkg/tui/payment_form.go | 129 +++++++ pkg/tui/shipping_form.go | 115 ++++++ pkg/tui/shop.go | 49 +++ pkg/tui/update.go | 136 +++++++ 11 files changed, 722 insertions(+), 1193 deletions(-) create mode 100644 pkg/tui/update.go diff --git a/README.md b/README.md index 5e9bc83..05d3a95 100644 --- a/README.md +++ b/README.md @@ -1,415 +1,63 @@ # Terminal Coffee Shop -this file was generated by Claude Code very early in development, it is not up to date AT ALL. It is also very wrong about the stack i ended up using. I plan to come back to write this myself but this will do for now until i get my first working version of this app. - -A terminal-based e-commerce application for ordering coffee, built with Go and Bubbletea TUI framework. Inspired by [terminal.shop](https://terminal.shop). - -## Current Status - -**Phase 1: Enhanced UI & Navigation** - 80% Complete - -### What's Working -- ✅ Multi-view navigation (shop tab, cart tab) -- ✅ Product listing with left-aligned coffee names -- ✅ Product detail view with descriptions -- ✅ Cart management (add/remove items, quantity controls) -- ✅ Responsive vertical centering with padding -- ✅ Clean, minimal UI with proper spacing - -### What's Next -- ⬜ Account page view (add `viewingAccount bool` to model) -- ⬜ Breadcrumb navigation system -- ⬜ Viewport scrolling for long content -- ⬜ Loading states and error displays - -## Project Structure - -``` -terminalShop/ -├── main.go # Entry point, SSH server (future) -├── server.go # Current main entry point for TUI -├── model.go # Bubbletea model, views, and state management -├── README.md # This file - project overview and roadmap -├── MODELS.md # Technical specs, architecture, code standards -├── api/ # (Future) REST API backend -├── db/ # (Future) Database migrations and schemas -├── config/ # (Future) Configuration files -└── terminal-shop-source/ # Reference implementation (terminal.shop clone) -``` - -## Development Roadmap - -This roadmap is designed to be executed incrementally. Each phase builds on the previous one. - ---- - -## **PHASE 1: Enhanced UI & Navigation** (Current Phase) - -### Objective -Build a polished, navigable TUI with shop, cart, and account views. - -### Tasks - -#### 1.1 Add Account Page View -- [ ] Add `viewingAccount bool` to model struct in `model.go` -- [ ] Add keybind "a" to switch to account view in `Update()` function -- [ ] Create `buildAccountView()` function -- [ ] Show user info: username, email (mock data for now) -- [ ] Add account tab to header with proper active/inactive styling -- [ ] Update footer to show account navigation keybind - -**Files to modify**: `model.go` - -#### 1.2 Implement Breadcrumb Navigation -- [ ] Create breadcrumb component showing current page path -- [ ] Display breadcrumbs between header and content -- [ ] Style breadcrumbs to match terminal.shop aesthetic - -**Reference**: `terminal-shop-source/packages/go/pkg/tui/breadcrumbs.go` - -#### 1.3 Add Viewport Scrolling -- [ ] Import `github.com/charmbracelet/bubbles/viewport` -- [ ] Add viewport to cart view for long item lists -- [ ] Add viewport to shop view for product descriptions -- [ ] Handle scroll keybinds (pgup/pgdn, mouse wheel) - -**Reference**: `terminal-shop-source/packages/go/pkg/tui/cart.go` (lines 15-20) - -#### 1.4 Loading and Error States -- [ ] Create loading spinner component -- [ ] Add error message display system -- [ ] Show loading state when switching views -- [ ] Handle and display errors gracefully - ---- - -## **PHASE 2: Backend API Foundation** - -### Objective -Build a REST API backend in Go that the TUI can communicate with. - -### Tech Stack -- **Framework**: Chi router (`github.com/go-chi/chi/v5`) -- **Database**: PostgreSQL with GORM (`gorm.io/gorm`) -- **Auth**: JWT tokens (`github.com/golang-jwt/jwt/v5`) -- **Validation**: go-playground/validator (`github.com/go-playground/validator/v10`) - -### Tasks - -#### 2.1 Project Setup -- [ ] Create `api/` directory structure -- [ ] Initialize Go modules for API -- [ ] Set up Chi router -- [ ] Add middleware: logging, CORS, recovery -- [ ] Create `.env` file for configuration -- [ ] Add `godotenv` for environment variables - -**Directory structure**: -``` -api/ -├── main.go # API server entry point -├── routes/ # Route handlers -│ ├── products.go -│ ├── users.go -│ └── cart.go -├── middleware/ # Custom middleware -├── models/ # Database models -└── db/ # Database connection -``` - -#### 2.2 Database Setup -- [ ] Install PostgreSQL locally (or use Docker) -- [ ] Create database schema -- [ ] Set up GORM connection -- [ ] Create migration system -- [ ] Add seed data for coffee products - -**Schema to create**: -```sql --- See MODELS.md for complete schema -users, products, carts, cart_items, orders, order_items, addresses -``` - -#### 2.3 Product API Endpoints -- [ ] `GET /api/products` - List all products -- [ ] `GET /api/products/:id` - Get single product -- [ ] `POST /api/products` - Create product (admin only, future) -- [ ] Add pagination to product listing -- [ ] Add filtering by category/type - -**Expected response format**: -```json -{ - "products": [ - { - "id": "uuid", - "name": "Espresso", - "description": "Bold coffee...", - "price": 350, - "color": "#8B4513", - "roast_type": "Dark Roast", - "ounces": 2, - "bean_type": "Arabica" - } - ] -} -``` - -#### 2.4 Update TUI to Use API -- [ ] Create `pkg/api/client.go` - API client -- [ ] Replace hardcoded coffee data with API calls -- [ ] Add loading states during API calls -- [ ] Handle API errors in TUI -- [ ] Add retry logic for failed requests - ---- - -## **PHASE 3: User Authentication** - -### Objective -Implement user registration, login, and session management. - -### Tasks - -#### 3.1 Auth System Design -- [ ] Choose auth method: SSH fingerprint OR username/password -- [ ] Design user registration flow -- [ ] Design login flow -- [ ] Implement JWT token generation and validation - -**Recommended**: Start with username/password, add SSH fingerprint later - -#### 3.2 API Endpoints -- [ ] `POST /api/auth/register` - Create new user -- [ ] `POST /api/auth/login` - Get JWT token -- [ ] `POST /api/auth/refresh` - Refresh expired token -- [ ] `GET /api/auth/me` - Get current user info -- [ ] `PUT /api/auth/me` - Update user profile - -#### 3.3 TUI Login Flow -- [ ] Create login view with username/password form -- [ ] Use `github.com/charmbracelet/huh` for form inputs -- [ ] Store JWT token in memory (or file for persistence) -- [ ] Send token with all API requests -- [ ] Handle token expiration and refresh - -**Reference**: `terminal-shop-source/packages/go/pkg/tui/account.go` - -#### 3.4 User Profile -- [ ] Show user info in account page -- [ ] Allow editing name, email -- [ ] Add password change functionality - ---- - -## **PHASE 4: Cart Persistence** - -### Objective -Save cart data to database and sync across sessions. - -### Tasks - -#### 4.1 Cart API -- [ ] `GET /api/cart` - Get current user's cart -- [ ] `POST /api/cart/items` - Add item to cart -- [ ] `PUT /api/cart/items/:id` - Update item quantity -- [ ] `DELETE /api/cart/items/:id` - Remove item -- [ ] Calculate totals on backend - -#### 4.2 Update TUI Cart -- [ ] Fetch cart from API on startup -- [ ] Sync cart changes to API immediately -- [ ] Handle offline mode (local-only cart) -- [ ] Merge local cart with server cart on login - ---- - -## **PHASE 5: Payment Integration** - -### Objective -Accept real payments via Stripe. - -### Tasks - -#### 5.1 Stripe Setup -- [ ] Create Stripe account (test mode) -- [ ] Add `github.com/stripe/stripe-go/v78` -- [ ] Set up Stripe API keys in `.env` -- [ ] Create Stripe customer on user registration - -#### 5.2 Payment Method Management -- [ ] Create payment form in TUI (like terminal.shop) -- [ ] Collect: card number, expiry, CVC, zip -- [ ] Validate card details client-side -- [ ] Tokenize card with Stripe API -- [ ] Save card token to database - -**Reference**: `terminal-shop-source/packages/go/pkg/tui/payment.go` - -#### 5.3 Payment API -- [ ] `POST /api/cards` - Save payment method -- [ ] `GET /api/cards` - List saved cards -- [ ] `DELETE /api/cards/:id` - Remove card -- [ ] `POST /api/orders/checkout` - Create order and charge - -#### 5.4 Alternative Payment Flow -- [ ] Generate QR code for browser payment -- [ ] Create payment link with Stripe -- [ ] Poll for payment completion -- [ ] Update order status - -**Reference**: `terminal-shop-source/packages/go/pkg/tui/qrfefe/qr.go` - ---- - -## **PHASE 6: Shipping & Orders** - -### Objective -Collect shipping addresses and create orders. - -### Tasks - -#### 6.1 Address Management -- [ ] Create address form in TUI -- [ ] Validate addresses (use Shippo or similar) -- [ ] Save addresses to database -- [ ] Allow selecting from saved addresses - -#### 6.2 Shipping Calculation -- [ ] Integrate Shippo for shipping rates -- [ ] Calculate shipping based on address -- [ ] Show shipping options to user -- [ ] Add shipping cost to order total - -#### 6.3 Order Creation -- [ ] Create checkout confirmation screen -- [ ] Show order summary: items, subtotal, shipping, total -- [ ] `POST /api/orders` - Create order -- [ ] Charge payment method -- [ ] Send confirmation email (optional) - -#### 6.4 Order History -- [ ] `GET /api/orders` - List user's orders -- [ ] Show order history in account page -- [ ] Display order details and status - ---- - -## **PHASE 7: SSH Server** - -### Objective -Deploy as SSH server accessible via `ssh shop@yourserver.com` - -### Tasks - -#### 7.1 SSH Server Setup -- [ ] Add `github.com/charmbracelet/wish` -- [ ] Create SSH server in `main.go` -- [ ] Generate or load SSH host keys -- [ ] Handle concurrent user sessions - -**Reference**: `terminal-shop-source/packages/go/cmd/ssh/main.go` - -#### 7.2 Authentication -- [ ] Implement public key authentication -- [ ] Link SSH fingerprints to user accounts -- [ ] Auto-register new SSH keys -- [ ] Handle key-based login - -#### 7.3 Deployment -- [ ] Set up VPS/cloud server (DigitalOcean, AWS, etc.) -- [ ] Configure firewall (port 22 or custom) -- [ ] Set up systemd service -- [ ] Add logging and monitoring -- [ ] Configure domain name - ---- - -## **PHASE 8: Polish & Production** - -### Objective -Make it production-ready. - -### Tasks - -#### 8.1 Testing -- [ ] Write unit tests for API handlers -- [ ] Write integration tests for critical flows -- [ ] Test payment flow thoroughly -- [ ] Load test with multiple concurrent users - -#### 8.2 Admin Features -- [ ] Create admin dashboard (web or TUI) -- [ ] Manage products (CRUD operations) -- [ ] View orders and fulfill them -- [ ] Manage inventory - -#### 8.3 Production Stripe -- [ ] Switch to Stripe production mode -- [ ] Handle webhooks for payment events -- [ ] Implement proper error handling -- [ ] Set up refund capability - -#### 8.4 Monitoring & Logging -- [ ] Add structured logging -- [ ] Set up error tracking (Sentry) -- [ ] Add metrics (Prometheus) -- [ ] Create health check endpoint - ---- - -## Quick Start Commands +A terminal-based coffee shop, accessible over SSH. Built with Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, and Shippo. ```bash -# Run current TUI app -go run server.go model.go - -# Run API server (future) -cd api && go run main.go - -# Run tests (future) -go test ./... - -# Database migrations (future) -go run migrations/migrate.go up - -# Deploy (future) -make deploy +ssh localhost -p 23456 ``` -## Environment Variables +## Running locally ```bash -# .env file (create this) -DATABASE_URL=postgresql://user:password@localhost:5432/terminalshop -JWT_SECRET=your-secret-key-here -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLIC_KEY=pk_test_... -SSH_HOST_KEY_PATH=./ssh_host_key -API_PORT=8080 -SSH_PORT=2222 +# Copy and fill in env vars +cp .env.example .env + +# Start the API server +go run api/main.go + +# Start the SSH server +go run main.go ``` -## Resources +## Stack -- **Reference Implementation**: `terminal-shop-source/` directory -- **Technical Specs**: See `MODELS.md` -- **Bubbletea Tutorial**: https://github.com/charmbracelet/bubbletea/tree/master/tutorials -- **Stripe Go Docs**: https://stripe.com/docs/api?lang=go -- **Chi Router**: https://go-chi.io/ +| Layer | Tech | +|-------|------| +| TUI | Bubbletea + Wish (SSH) | +| HTTP API | Chi router | +| Database | PostgreSQL + GORM | +| Auth | SSH key fingerprint + JWT | +| Payments | Stripe | +| Shipping | Shippo | -## Contributing Guidelines for Claude Code +## What's working -When working on this project: +- SSH server — key-based auth, auto user creation +- Shop page — browse products with live detail panel +- Cart — add/remove items, synced to API +- Checkout flow — shipping address → payment → confirmation +- Saved addresses and cards +- Order history in account view +- FAQ page +- Responsive layout (small/medium/large terminal sizes) +- Token refresh -1. **Always check MODELS.md** before writing code to follow established patterns -2. **Read the current phase** in this README to understand what to build next -3. **Reference terminal.shop source code** in `terminal-shop-source/` when unsure -4. **Update this README** when completing tasks (check off boxes) -5. **Follow Go best practices** and the style guide in MODELS.md -6. **Write tests** for new API endpoints -7. **Keep commits focused** - one feature/fix per commit +## TODO -## License +**Features missing vs reference (terminal.shop):** +- [ ] Splash/loading screen on connect +- [ ] Animated logo with blinking cursor +- [ ] Subscriptions (recurring orders) — subscribe, manage, cancel +- [ ] API token management page +- [ ] OAuth app management page +- [ ] About page +- [ ] Theme system — currently 40+ hardcoded hex colors scattered across files +- [ ] Viewport-based scrolling — currently manual scroll math with hardcoded heights -MIT (or your preferred license) +**Known bugs:** +- [ ] Address delete slice bug in `shipping_form.go:386` — `[m.AddressCursor+1:]...` missing +- [ ] Account page has a placeholder "something" menu item in `models/coffee.go` + +**Code quality:** +- [ ] Nested state structs (reference uses `state.shop`, `state.cart`, etc. vs flat Model) +- [ ] Centralized styles file diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 52a2959..b670f22 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "terminalShop/pkg/models" @@ -126,7 +127,7 @@ func (m Model) BuildAccountView(availableHeight int) string { aboutContent += contentStyle.Render("And me:") + "\n\n" aboutContent += userStyle.Render("@marcusDenslow") + "\n\n" aboutContent += contentStyle.Render(wordWrap("This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt", - detailContentWidth)) + "\n" + detailContentWidth)) + "\n" aboutContent += accentStyle.Render("Terminal Products, Inc.") detailView = titleStyle.Render("About") + "\n\n" + aboutContent @@ -403,3 +404,98 @@ func (m Model) computeFaqScrollMax() int { } return maxScroll } + +func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "up", "k": + if m.FaqFocused { + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + } else if m.OrderViewState == 1 && m.OrderCursor > 0 { + m.OrderCursor-- + cardHeight := 5 + if m.heightContainer >= 25 { + cardHeight = 7 + } + targetTop := 3 + m.OrderCursor*cardHeight + if targetTop < m.ScrollOffset { + m.ScrollOffset = targetTop + } + } else if m.OrderViewState == 0 && m.AccountCursor > 0 { + m.AccountCursor-- + m.ScrollOffset = 0 + } + case "down", "j": + if m.FaqFocused { + m.ScrollOffset += 3 + maxScroll := m.computeFaqScrollMax() + if m.ScrollOffset > maxScroll { + m.ScrollOffset = maxScroll + } + } else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 { + m.OrderCursor++ + cardHeight := 5 + if m.heightContainer >= 25 { + cardHeight = 7 + } + viewportHeight := m.heightContainer - 7 + if m.size < large { + viewportHeight -= len(models.AccountMenuItems) + 1 + } + if viewportHeight < 5 { + viewportHeight = 5 + } + targetBottom := 3 + (m.OrderCursor+1)*cardHeight + if targetBottom > m.ScrollOffset+viewportHeight { + m.ScrollOffset = targetBottom - viewportHeight + } + } else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 { + m.AccountCursor++ + m.ScrollOffset = 0 + } + case "pgup", "ctrl+u": + if m.FaqFocused || m.OrderViewState >= 2 { + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + } + case "pgdown", "ctrl+d": + if m.FaqFocused || m.OrderViewState >= 2 { + m.ScrollOffset += 3 + maxScroll := m.computeFaqScrollMax() + if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll { + m.ScrollOffset = maxScroll + } + } + case "p", "enter": + if m.AccountCursor == 0 && len(m.Orders) > 0 { + if m.OrderViewState == 0 { + m.OrderViewState = 1 + m.OrderCursor = 0 + m.ScrollOffset = 0 + } else if m.OrderViewState == 1 { + m.OrderViewState = 2 + m.ScrollOffset = 0 + } + } else if m.AccountCursor == 1 && !m.FaqFocused { + m.FaqFocused = true + m.ScrollOffset = 0 + } + case "esc": + if m.FaqFocused { + m.FaqFocused = false + m.ScrollOffset = 0 + } else if m.OrderViewState > 0 { + m.OrderViewState-- + m.ScrollOffset = 0 + } + } + return m, nil +} diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index bf9cc7f..85d554b 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -3,6 +3,7 @@ package tui import ( "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -111,3 +112,70 @@ func (m Model) BuildCartView() string { return cartItems } + +func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "up", "k": + if m.CartCursor > 0 { + m.CartCursor-- + itemHeight := 5 + targetLine := m.CartCursor * itemHeight + if targetLine < m.ScrollOffset { + m.ScrollOffset = targetLine + } + } + case "down", "j": + if m.CartCursor < len(m.Cart)-1 { + m.CartCursor++ + itemHeight := 5 + targetLineEnd := (m.CartCursor * itemHeight) + itemHeight + viewportHeight := m.heightContainer - 9 + if viewportHeight < 6 { + viewportHeight = 6 + } + if targetLineEnd > m.ScrollOffset+viewportHeight { + m.ScrollOffset = targetLineEnd - viewportHeight + } + } + case "pgup", "ctrl+u": + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + case "pgdown", "ctrl+d": + m.ScrollOffset += 3 + case "+", "=": + cartItems := m.GetCartItemsSlice() + if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { + cartItems[m.CardCursor].Quantity++ + return m, m.syncCartItemCmd(cartItems[m.CartCursor].CoffeeID, cartItems[m.CartCursor].Quantity) + } + case "-", "_": + cartItems := m.GetCartItemsSlice() + if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { + coffeeID := cartItems[m.CartCursor].CoffeeID + cartItems[m.CartCursor].Quantity-- + newQty := cartItems[m.CartCursor].Quantity + if cartItems[m.CartCursor].Quantity <= 0 { + delete(m.Cart, coffeeID) + if len(m.Cart) == 0 { + m.CartCursor = 0 + } else if m.CartCursor >= len(m.Cart) { + m.CartCursor = len(m.Cart) - 1 + } + newQty = 0 + } + return m, m.syncCartItemCmd(coffeeID, newQty) + } + case "p", "enter": + if len(m.Cart) > 0 { + m = m.SwitchPage(shippingPage) + return m, m.fetchAddressesCmd() + } + } + return m, nil +} diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index d055d79..acecd5b 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -3,7 +3,10 @@ package tui import ( "fmt" + "terminalShop/pkg/models" + "github.com/charmbracelet/lipgloss" + tea "github.com/charmbracelet/bubbletea" ) // RenderConfirmation renders the order confirmation view @@ -69,3 +72,27 @@ func (m Model) RenderConfirmation() string { success, ) } + +func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if keyMsg.String() != "esc" { + return m, nil + } + + m = m.SwitchPage(shopPage) + m.ShippingInfo = nil + m.CheckingOut = false + m.Cart = make(map[uint]*models.CartItem) + m.CartCursor = 0 + m = m.resetPageState() + return m, func() tea.Msg { + if m.APIClient != nil { + _ = m.APIClient.ClearCart() + } + return nil + } +} diff --git a/pkg/tui/help.go b/pkg/tui/help.go index de05d48..9c6cc3e 100644 --- a/pkg/tui/help.go +++ b/pkg/tui/help.go @@ -1,6 +1,7 @@ package tui import ( + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -103,3 +104,17 @@ func (m Model) BuildHelpView() string { assembled, ) } + +func (m Model) HelpUpdate(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "esc", "?": + m.ShowingHelp = false + case "q", "ctrl+c": + return m, tea.Quit + } + return m, nil +} diff --git a/pkg/tui/menu.go b/pkg/tui/menu.go index 02a61c6..54170ea 100644 --- a/pkg/tui/menu.go +++ b/pkg/tui/menu.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "github.com/charmbracelet/lipgloss" + tea "github.com/charmbracelet/bubbletea" ) @@ -62,3 +63,33 @@ func (m Model) BuildMenuView() string { ) } + +func (m Model) MenuUpdate(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "s": + m.ShowingMenu = false + return m.SwitchPage(shopPage).resetPageState(), nil + case "a": + m.ShowingMenu = false + m = m.SwitchPage(accountPage).resetPageState() + if !m.OrdersLoaded { + return m, m.fetchOrdersCmd() + } + return m, nil + case "c": + m.ShowingMenu = false + return m.SwitchPage(cartPage).resetPageState(), nil + case "esc": + m.ShowingMenu = false + case "?": + m.ShowingMenu = false + m.ShowingHelp = true + case "q", "ctrl+c": + return m, tea.Quit + } + return m, nil +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index c3f10c4..a6ebeff 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -224,801 +224,16 @@ func (m Model) Init() tea.Cmd { return tea.Batch(cmds...) } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // When the shipping form is active, route ALL messages to it. - // huh generates internal messages (focus, cursor blink, etc.) from - // form.Init() and form.Update() that must reach the form to work. - - if tick, ok := msg.(resizeTickMsg); ok { - if tick.seq == m.resizeSeq { - m.updateLayout(m.pendingWidth, m.pendingHeight) - // Update active form widths to match the new layout - if m.ShippingForm != nil { - m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) - } - if m.PaymentForm != nil { - m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) - } - } - return m, nil - } - - if m.ShowingMenu { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "s": - m.ShowingMenu = false - m = m.SwitchPage(shopPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - case "a": - m.ShowingMenu = false - m = m.SwitchPage(accountPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - if !m.OrdersLoaded { - return m, m.fetchOrdersCmd() - } - case "c": - m.ShowingMenu = false - m.SwitchPage(cartPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - case "esc": - m.ShowingMenu = false - case "?": - m.ShowingMenu = false - m.ShowingHelp = true - case "q", "ctrl+c": - return m, tea.Quit - } - } - return m, nil - } - - // Help Modal - if m.ShowingHelp { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "esc", "?": - m.ShowingHelp = false - case "q", "ctrl+c": - return m, tea.Quit - } - } - return m, nil - } - - if m.currentPage == shippingPage { - switch msg := msg.(type) { - case ShippingFormCompleteMsg: - addr := models.Address{ - Name: msg.Name, - Street: msg.Street1, - Street2: msg.Street2, - City: msg.City, - State: msg.State, - Country: msg.Country, - Zip: msg.Zip, - Phone: msg.Phone, - } - return m, m.saveAddressCmd(addr) - - case AddressesMsg: - if msg.Err != nil || len(msg.Addresses) == 0 { - m.SavedAddresses = nil - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, m.ShippingForm.form.Init() - } - m.SavedAddresses = msg.Addresses - m.ShippingView = 0 - m.AddressCursor = 0 - m.ShippingForm = nil - return m, nil - - case AddressSavedMsg: - if msg.Err != nil { - log.Printf("address validation failed: %v", msg.Err) - if m.ShippingForm != nil { - m.ShippingForm.submitting = false - m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) - } - m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." - return m, m.ShippingForm.form.Init() - } - m.ShippingInfo = &msg.Address - m.ShippingForm = nil - m = m.SwitchPage(paymentPage) - m.SelectedCard = nil - return m, m.fetchCardsCmd() - - case ShippingFormErrorMsg: - m.ErrorMsg = msg.Message - return m, nil - case tea.WindowSizeMsg: - m.pendingWidth = msg.Width - m.pendingHeight = msg.Height - m.resizeSeq++ - seq := m.resizeSeq - return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg { - return resizeTickMsg{seq: seq} - }) - } - - // Address list view - if m.ShippingView == 0 && m.ShippingForm == nil { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - m.ErrorMsg = "" - switch keyMsg.String() { - case "esc": - m = m.SwitchPage(cartPage) - m.ErrorMsg = "" - return m, nil - case "up", "k": - if m.AddressCursor > 0 { - m.AddressCursor-- - } - case "down", "j": - if m.AddressCursor < len(m.SavedAddresses) { - m.AddressCursor++ - } - case "enter": - if m.AddressCursor < len(m.SavedAddresses) { - addr := m.SavedAddresses[m.AddressCursor] - selected := addr // copy so we have a stable pointer - m.ShippingInfo = &selected - m = m.SwitchPage(paymentPage) - m.SelectedCard = nil - return m, m.fetchCardsCmd() - } - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, m.ShippingForm.form.Init() - case "d", "x": - if m.AddressCursor < len(m.SavedAddresses) { - addr := m.SavedAddresses[m.AddressCursor] - m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor+1:]...) - if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 { - m.AddressCursor-- - } - if len(m.SavedAddresses) == 0 { - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID)) - } - return m, m.deleteAddressCmd(addr.ID) - } - } - } - return m, nil - } - - // Form view - if m.ShippingForm != nil { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "esc" { - m.ErrorMsg = "" - if len(m.SavedAddresses) > 0 { - m.ShippingView = 0 - m.ShippingForm = nil - return m, nil - } - - m.ShippingForm = nil - m = m.SwitchPage(cartPage) - return m, nil - } - m.ErrorMsg = "" - } - cmd := m.UpdateShippingForm(msg, m.ShippingForm) - return m, cmd - } - return m, nil - } - - // When the payment form is active, route ALL messages to it. - if m.currentPage == paymentPage { - switch msg := msg.(type) { - - case CardsMsg: - if msg.Err != nil || len(msg.Cards) == 0 { - m.SavedCards = nil - m.PaymentView = 1 // skip to form - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() - } - m.SavedCards = msg.Cards - m.PaymentView = 0 - m.CardCursor = 0 - m.PaymentForm = nil - return m, nil - - case PaymentFormCompleteMsg: - // kick off Stripe tokenization (runs async) - if m.PaymentForm != nil { - return m, m.tokenizeCard(msg) - } - return m, nil - case StripeTokenMsg: - // Token obtained — save card, set on cart, set address, convert - m.PaymentForm = nil - m.CheckingOut = true - return m, m.saveCardAndConvert(msg) - case CheckoutResultMsg: - m.CheckingOut = false - if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) - // already on paymentPage, no page switch needed - // Return to card list if we have saved cards, otherwise show form - if len(m.SavedCards) > 0 { - m.PaymentView = 0 - m.PaymentForm = nil - } else { - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() - } - return m, nil - } - m = m.SwitchPage(confirmPage) - m.OrdersLoaded = false - return m, nil - case StripeTokenErrMsg: - // Tokenization failed, show error and let user retry - if m.PaymentForm != nil { - m.PaymentForm.submitting = false - m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) - m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) - return m, m.PaymentForm.form.Init() - } - m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) - return m, nil - case PaymentFormErrorMsg: - m.ErrorMsg = msg.Message - return m, nil - - case CardDeletedMsg: - if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err) - } - - return m, nil - case tea.WindowSizeMsg: - m.pendingWidth = msg.Width - m.pendingHeight = msg.Height - m.resizeSeq++ - seq := m.resizeSeq - return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg { - return resizeTickMsg{seq: seq} - }) - } - - if m.PaymentView == 0 && m.PaymentForm == nil { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - if m.CheckingOut { - return m, nil - } - m.ErrorMsg = "" - switch keyMsg.String() { - case "esc": - m.ErrorMsg = "" - m = m.SwitchPage(shippingPage) - return m, m.fetchAddressesCmd() - case "up", "k": - if m.CardCursor > 0 { - m.CardCursor-- - } - case "down", "j": - if m.CardCursor < len(m.SavedCards) { - m.CardCursor++ - } - case "enter": - if m.CardCursor < len(m.SavedCards) { - card := m.SavedCards[m.CardCursor] - selected := card - m.SelectedCard = &selected - m.CheckingOut = true - return m, m.checkoutWithSavedCard() - } - // Cursor is at "add new card" - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() - case "d", "x": - if m.CardCursor < len(m.SavedCards) { - card := m.SavedCards[m.CardCursor] - m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...) - if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 { - m.CardCursor-- - } - if len(m.SavedCards) == 0 { - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID)) - } - return m, m.deleteCardCmd(card.ID) - } - } - } - return m, nil - } - - // Payment form view - if m.PaymentForm != nil { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "esc" { - m.ErrorMsg = "" - if len(m.SavedCards) > 0 { - m.PaymentView = 0 - m.PaymentForm = nil - return m, nil - } - // No saved cards - go back to shipping - m.PaymentForm = nil - m = m.SwitchPage(shippingPage) - return m, m.fetchAddressesCmd() - } - m.ErrorMsg = "" - } - cmd := m.UpdatePaymentForm(msg, m.PaymentForm) - return m, cmd - - } - return m, nil - } - - switch msg := msg.(type) { - case ShippingFormCompleteMsg: - addr := models.Address{ - Name: msg.Name, - Street: msg.Street1, - Street2: msg.Street2, - City: msg.City, - State: msg.State, - Country: msg.Country, - Zip: msg.Zip, - Phone: msg.Phone, - } - return m, m.saveAddressCmd(addr) - - case AddressSavedMsg: - if msg.Err != nil { - log.Printf("address validation failed: %v", msg.Err) - if m.ShippingForm != nil { - m.ShippingForm.submitting = false - m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) - } - m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." - if m.ShippingForm != nil { - return m, m.ShippingForm.form.Init() - } - return m, nil - } - m.ShippingInfo = &msg.Address - m.ShippingForm = nil - m = m.SwitchPage(paymentPage) - m.SelectedCard = nil - return m, m.fetchCardsCmd() - - case ShippingFormErrorMsg: - m.ErrorMsg = msg.Message - return m, nil - - case StripeTokenErrMsg: - m.ErrorMsg = fmt.Sprintf("payment failed: %v", msg.Err) - return m, nil - - case ProductsMsg: - m.Loading = false - if msg.Err != nil { - m.ErrorMsg = "Failed to load products from API, using fallback data" - // Keep the fallback products that were set in NewModel - } else if len(msg.Products) > 0 { - m.Coffees = msg.Products - m.ErrorMsg = "" - } - return m, nil - - case CartFetchedMsg: - if msg.Err == nil && msg.Cart != nil { - m.loadCartFromAPI(msg.Cart) - log.Printf("[TUI] Cart loaded: %d items", len(m.Cart)) - } - return m, nil - - case CartSyncedMsg: - if msg.Err != nil { - log.Printf("[TUI] Cart sync error, re-fetching: %v", msg.Err) - return m, m.fetchCartCmd - } - // Only apply if this is the most recent update (debounce) - if msg.UpdateID == m.lastCartUpdateID { - m.loadCartFromAPI(msg.Cart) - } - return m, nil - - case tokenRefreshTickMsg: - return m, m.refreshTokenCmd() - - case TokenRefreshedMsg: - if msg.Err != nil { - log.Printf("[TUI] Token refresh failed, retrying in %v: %v", tokenRetryDuration, msg.Err) - return m, tea.Tick(tokenRetryDuration, func(t time.Time) tea.Msg { - return tokenRefreshTickMsg{} - }) - } - m.AccessToken = msg.Token - m.APIClient.Token = msg.Token - log.Println("[TUI] Token updated, next refresh in", tokenRefreshDuration) - return m, m.scheduleTokenRefreshCmd() - - case OrdersMsg: - if msg.Err == nil { - m.Orders = msg.Orders - m.OrdersLoaded = true - } - return m, nil - - case AddressesMsg: - if msg.Err != nil || len(msg.Addresses) == 0 { - // No saved addresses (or some type of error), go straight - // to form and skip selecting address since we don't have one saved - m.SavedAddresses = nil - m.ShippingView = 1 - m.ShippingForm = m.InitShippingForm() - return m, m.ShippingForm.form.Init() - } - m.SavedAddresses = msg.Addresses - m.ShippingView = 0 - m.AddressCursor = 0 - m.ShippingForm = nil - return m, nil - - case AddressDeletedMsg: - // Address already removed from the in-memory slice in the key handler. - // Nothing to do here unless there is an error :( - if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to delete address: %v", msg.Err) - } - return m, nil - - case CardsMsg: - if msg.Err != nil || len(msg.Cards) == 0 { - m.SavedCards = nil - m.PaymentView = 1 - m.PaymentForm = m.InitPaymentForm() - return m, m.PaymentForm.form.Init() - } - m.SavedCards = msg.Cards - m.PaymentView = 0 - m.CardCursor = 0 - m.PaymentForm = nil - return m, nil - - case CardDeletedMsg: - if msg.Err != nil { - m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err) - } - return m, nil - - case tea.WindowSizeMsg: - m.pendingWidth = msg.Width - m.pendingHeight = msg.Height - m.resizeSeq++ - seq := m.resizeSeq - return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg { - return resizeTickMsg{seq: seq} - }) - - case tea.KeyMsg: - // Toggle help overlay from any view - if msg.String() == "?" { - m.ShowingHelp = true - return m, nil - } - - // Normal shop mode - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - - case "s": - m = m.SwitchPage(shopPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - - case "c": - m = m.SwitchPage(cartPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - - case "a": - m = m.SwitchPage(accountPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ScrollOffset = 0 - m.OrderViewState = 0 - m.FaqFocused = false - - if !m.OrdersLoaded { - return m, m.fetchOrdersCmd() - } - - case "m": - m.ShowingMenu = true - m.menuLastPage = m.currentPage - return m, nil - - case "p", "enter": - // Account view: enter order list or order detail - if m.currentPage == accountPage && m.AccountCursor == 0 && len(m.Orders) > 0 { - if m.OrderViewState == 0 { - m.OrderViewState = 1 - m.OrderCursor = 0 - m.ScrollOffset = 0 - return m, nil - } else if m.OrderViewState == 1 { - m.OrderViewState = 2 - m.ScrollOffset = 0 - return m, nil - } - } - // Account view: focus into FAQ for scrolling - if m.currentPage == accountPage && m.AccountCursor == 1 && !m.FaqFocused { - m.FaqFocused = true - m.ScrollOffset = 0 - return m, nil - } - // Proceed to checkout from cart - if m.currentPage == cartPage && len(m.Cart) > 0 { - m = m.SwitchPage(shippingPage) - return m, m.fetchAddressesCmd() - } - - case "esc": - // Unfocus FAQ scrolling - if m.currentPage == accountPage && m.FaqFocused { - m.FaqFocused = false - m.ScrollOffset = 0 - return m, nil - } - // Navigate back through order view states - if m.currentPage == accountPage && m.OrderViewState > 0 { - m.OrderViewState-- - m.ScrollOffset = 0 - return m, nil - } - // From confirmation, go back to shop - if m.currentPage == confirmPage { - m = m.SwitchPage(shopPage) - m.ShippingForm = nil - m.PaymentForm = nil - m.ShippingInfo = nil - m.CheckingOut = false - m.Cart = make(map[uint]*models.CartItem) - m.CartCursor = 0 - m.ScrollOffset = 0 - return m, func() tea.Msg { - if m.APIClient != nil { - _ = m.APIClient.ClearCart() - } - return nil - } - } - - case "up", "k": - if m.currentPage == accountPage { - if m.FaqFocused { - // Scroll FAQ content up - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 - } - } else if m.OrderViewState == 1 && m.OrderCursor > 0 { - // Navigate up within order list - m.OrderCursor-- - // Auto-scroll to keep cursor visible - cardHeight := 5 // 2 border + 3 content (no padding) - if m.heightContainer >= 25 { - cardHeight = 7 // 2 border + 2 padding + 3 content - } - titleOffset := 3 // "Order History" + blank lines - targetTop := titleOffset + m.OrderCursor*cardHeight - if targetTop < m.ScrollOffset { - m.ScrollOffset = targetTop - } - } else if m.OrderViewState == 0 && m.AccountCursor > 0 { - // Navigate up within account tabs - m.AccountCursor-- - m.ScrollOffset = 0 - } - } else if m.currentPage == shopPage && m.Cursor > 0 { - m.Cursor-- - } else if m.currentPage == cartPage && m.CartCursor > 0 { - m.CartCursor-- - // Auto-scroll to keep cursor visible (each cart item is ~5 lines) - itemHeight := 5 - targetLine := m.CartCursor * itemHeight - if targetLine < m.ScrollOffset { - m.ScrollOffset = targetLine - } - } - - case "down", "j": - if m.currentPage == accountPage { - if m.FaqFocused { - // Scroll FAQ content down - m.ScrollOffset += 3 - maxScroll := m.computeFaqScrollMax() - if m.ScrollOffset > maxScroll { - m.ScrollOffset = maxScroll - } - } else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 { - // Navigate down within order list - m.OrderCursor++ - // Auto-scroll to keep cursor visible - cardHeight := 5 // 2 border + 3 content (no padding) - if m.heightContainer >= 25 { - cardHeight = 7 // 2 border + 2 padding + 3 content - } - titleOffset := 3 - // Account view: header + footer + margins + buffer = 7, no breadcrumbs - viewportHeight := m.heightContainer - 7 - if m.size < large { - // Stacked mode: subtract left panel height - viewportHeight -= len(models.AccountMenuItems) + 1 - } - if viewportHeight < 5 { - viewportHeight = 5 - } - targetBottom := titleOffset + (m.OrderCursor+1)*cardHeight - if targetBottom > m.ScrollOffset+viewportHeight { - m.ScrollOffset = targetBottom - viewportHeight - } - } else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 { - // Navigate down within account tabs - m.AccountCursor++ - m.ScrollOffset = 0 - } - } else if m.currentPage == shopPage && m.Cursor < len(m.Coffees)-1 { - m.Cursor++ - } else if m.currentPage == cartPage && m.CartCursor < len(m.Cart)-1 { - m.CartCursor++ - // Auto-scroll to keep cursor visible (each cart item is ~5 lines) - itemHeight := 5 - targetLine := m.CartCursor * itemHeight - // Add extra lines to ensure the entire item is visible, not cut off - targetLineEnd := targetLine + itemHeight - - // Calculate viewport height - // In cart: header=3, breadcrumbs=2, footer=1, margins=2, buffer=1 = 9 - viewportHeight := m.heightContainer - 9 - if viewportHeight < 6 { - viewportHeight = 6 - } - - // Scroll if the bottom of item would be beyond viewport - if targetLineEnd > m.ScrollOffset+viewportHeight { - // Scroll just enough to show the entire item - m.ScrollOffset = targetLineEnd - viewportHeight - } - } - - case "pgup", "ctrl+u": - if m.currentPage == accountPage { - // Only scroll when focused into scrollable content - if m.FaqFocused || m.OrderViewState >= 2 { - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 - } - } - } else { - // Scroll up in shop/cart views - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 - } - } - - case "pgdown", "ctrl+d": - if m.currentPage == accountPage { - // Only scroll when focused into scrollable content - if m.FaqFocused || m.OrderViewState >= 2 { - m.ScrollOffset += 3 - maxScroll := m.computeFaqScrollMax() - if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll { - m.ScrollOffset = maxScroll - } - } - } else { - // Scroll down in shop/cart views - m.ScrollOffset += 3 - } - - case "+", "=": - if m.currentPage == shopPage { - // Increment quantity in shop view (keyed by CoffeeID) - coffeeID := m.Coffees[m.Cursor].ID - if item, exists := m.Cart[coffeeID]; exists { - item.Quantity++ - } else { - m.Cart[coffeeID] = &models.CartItem{ - CoffeeID: coffeeID, - Coffee: m.Coffees[m.Cursor], - Quantity: 1, - } - } - return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity) - } else if m.currentPage == cartPage { - // Increment quantity in cart view - cartItems := m.GetCartItemsSlice() - if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { - cartItems[m.CartCursor].Quantity++ - return m, m.syncCartItemCmd(cartItems[m.CartCursor].CoffeeID, cartItems[m.CartCursor].Quantity) - } - } - - case "-", "_": - if m.currentPage == shopPage { - // Decrement quantity in shop view (keyed by CoffeeID) - coffeeID := m.Coffees[m.Cursor].ID - if item, exists := m.Cart[coffeeID]; exists { - item.Quantity-- - newQty := item.Quantity - if item.Quantity <= 0 { - delete(m.Cart, coffeeID) - // 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 - } - newQty = 0 - } - return m, m.syncCartItemCmd(coffeeID, newQty) - } - } else if m.currentPage == cartPage { - // Decrement quantity in cart view - cartItems := m.GetCartItemsSlice() - if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { - coffeeID := cartItems[m.CartCursor].CoffeeID - cartItems[m.CartCursor].Quantity-- - newQty := cartItems[m.CartCursor].Quantity - if cartItems[m.CartCursor].Quantity <= 0 { - delete(m.Cart, coffeeID) - // Reset cart cursor - if len(m.Cart) == 0 { - m.CartCursor = 0 - } else if m.CartCursor >= len(m.Cart) { - m.CartCursor = len(m.Cart) - 1 - } - newQty = 0 - } - return m, m.syncCartItemCmd(coffeeID, newQty) - } - } - } - } - return m, nil +func (m Model) resetPageState() Model { + m.ShippingForm = nil + m.PaymentForm = nil + m.ScrollOffset = 0 + m.OrderViewState = 0 + m.FaqFocused = false + return m } + type StripeTokenMsg struct { TokenID string Last4 string diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 35b9322..5eb1fc5 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -283,3 +283,132 @@ func (m Model) RenderCardList() string { return lipgloss.JoinVertical(lipgloss.Left, parts...) } + +// PaymentUpdate handles all messages while on the payment page. +// Moved from model.go Update() lines 429-578. +func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case CardsMsg: + if msg.Err != nil || len(msg.Cards) == 0 { + m.SavedCards = nil + m.PaymentView = 1 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + } + m.SavedCards = msg.Cards + m.PaymentView = 0 + m.CardCursor = 0 + m.PaymentForm = nil + return m, nil + + case PaymentFormCompleteMsg: + if m.PaymentForm != nil { + return m, m.tokenizeCard(msg) + } + return m, nil + + case StripeTokenMsg: + m.PaymentForm = nil + m.CheckingOut = true + return m, m.saveCardAndConvert(msg) + + case CheckoutResultMsg: + m.CheckingOut = false + if msg.Err != nil { + m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) + if len(m.SavedCards) > 0 { + m.PaymentView = 0 + m.PaymentForm = nil + return m, nil + } + m.PaymentView = 1 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + } + m = m.SwitchPage(confirmPage) + m.OrdersLoaded = false + return m, nil + + case StripeTokenErrMsg: + if m.PaymentForm != nil { + m.PaymentForm.submitting = false + m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) + m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) + return m, m.PaymentForm.form.Init() + } + m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) + return m, nil + + case PaymentFormErrorMsg: + m.ErrorMsg = msg.Message + return m, nil + } + + // Card list navigation + if m.PaymentView == 0 && m.PaymentForm == nil { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok || m.CheckingOut { + return m, nil + } + m.ErrorMsg = "" + switch keyMsg.String() { + case "esc": + m = m.SwitchPage(shippingPage) + return m, m.fetchAddressesCmd() + case "up", "k": + if m.CardCursor > 0 { + m.CardCursor-- + } + case "down", "j": + if m.CardCursor < len(m.SavedCards) { + m.CardCursor++ + } + case "enter": + if m.CardCursor < len(m.SavedCards) { + selected := m.SavedCards[m.CardCursor] + m.SelectedCard = &selected + m.CheckingOut = true + return m, m.checkoutWithSavedCard() + } + m.PaymentView = 1 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + case "d", "x": + if m.CardCursor < len(m.SavedCards) { + card := m.SavedCards[m.CardCursor] + m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...) + if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 { + m.CardCursor-- + } + if len(m.SavedCards) == 0 { + m.PaymentView = 1 + m.PaymentForm = m.InitPaymentForm() + return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID)) + } + return m, m.deleteCardCmd(card.ID) + } + } + return m, nil + } + + // Payment form navigation + if m.PaymentForm != nil { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "esc" { + m.ErrorMsg = "" + if len(m.SavedCards) > 0 { + m.PaymentView = 0 + m.PaymentForm = nil + return m, nil + } + m.PaymentForm = nil + m = m.SwitchPage(shippingPage) + return m, m.fetchAddressesCmd() + } + // Clear error only when user starts typing, not on internal huh messages + m.ErrorMsg = "" + } + return m, m.UpdatePaymentForm(msg, m.PaymentForm) + } + return m, nil +} diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index 9fe3a5e..6901679 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" + "terminalShop/pkg/models" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -304,3 +306,116 @@ func (m Model) RenderAddressList() string { return lipgloss.JoinVertical(lipgloss.Left, parts...) } + +func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case ShippingFormCompleteMsg: + addr := models.Address{ + Name: msg.Name, Street: msg.Street1, Street2: msg.Street2, + City: msg.City, State: msg.State, Country: msg.Country, + Zip: msg.Zip, Phone: msg.Phone, + } + return m, m.saveAddressCmd(addr) + + case AddressesMsg: + if msg.Err != nil || len(msg.Addresses) == 0 { + m.SavedAddresses = nil + m.ShippingView = 1 + m.ShippingForm = m.InitShippingForm() + return m, m.ShippingForm.form.Init() + } + m.SavedAddresses = msg.Addresses + m.ShippingView = 0 + m.AddressCursor = 0 + m.ShippingForm = nil + return m, nil + + case AddressSavedMsg: + if msg.Err != nil { + if m.ShippingForm != nil { + m.ShippingForm.submitting = false + m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) + } + m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." + return m, m.ShippingForm.form.Init() + } + m.ShippingInfo = &msg.Address + m.ShippingForm = nil + m = m.SwitchPage(paymentPage) + m.SelectedCard = nil + return m, m.fetchCardsCmd() + + case ShippingFormErrorMsg: + m.ErrorMsg = msg.Message + return m, nil + } + + // Address list navigation + if m.ShippingView == 0 && m.ShippingForm == nil { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + m.ErrorMsg = "" + switch keyMsg.String() { + case "esc": + m = m.SwitchPage(cartPage) + return m, nil + case "up", "k": + if m.AddressCursor > 0 { + m.AddressCursor-- + } + case "down", "j": + if m.AddressCursor < len(m.SavedAddresses) { + m.AddressCursor++ + } + case "enter": + if m.AddressCursor < len(m.SavedAddresses) { + selected := m.SavedAddresses[m.AddressCursor] + m.ShippingInfo = &selected + m = m.SwitchPage(paymentPage) + m.SelectedCard = nil + return m, m.fetchCardsCmd() + } + m.ShippingView = 1 + m.ShippingForm = m.InitShippingForm() + return m, m.ShippingForm.form.Init() + case "d", "x": + if m.AddressCursor < len(m.SavedAddresses) { + addr := m.SavedAddresses[m.AddressCursor] + m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor]) + if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 { + m.AddressCursor-- + } + if len(m.SavedAddresses) == 0 { + m.ShippingView = 1 + m.ShippingForm = m.InitShippingForm() + return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID)) + } + return m, m.deleteAddressCmd(addr.ID) + } + } + return m, nil + } + + // Form navigation + if m.ShippingForm != nil { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "esc" { + m.ErrorMsg = "" + if len(m.SavedAddresses) > 0 { + m.ShippingView = 0 + m.ShippingForm = nil + return m, nil + } + m.ShippingForm = nil + m = m.SwitchPage(cartPage) + return m, nil + } + // Clear error only when user starts typing, not on internal huh messages + m.ErrorMsg = "" + } + return m, m.UpdateShippingForm(msg, m.ShippingForm) + } + return m, nil +} diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 6ebeb39..611d36c 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -2,8 +2,11 @@ package tui import ( "fmt" + + "terminalShop/pkg/models" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/bubbletea" ) func (m Model) BuildShopView() string { @@ -112,3 +115,49 @@ func (m Model) BuildShopView() string { detailContainer.Render(detailView), ) } + +func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "up", "k": + if m.Cursor > 0 { + m.Cursor-- + } + case "down", "j": + if m.Cursor < len(m.Coffees)-1 { + m.Cursor++ + } + case "+", "=": + coffeeID := m.Coffees[m.Cursor].ID + if item, exists := m.Cart[coffeeID]; exists { + item.Quantity++ + } else { + m.Cart[coffeeID] = &models.CartItem{ + CoffeeID: coffeeID, + Coffee: m.Coffees[m.Cursor], + Quantity: 1, + } + } + return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity) + case "-", "_": + coffeeID := m.Coffees[m.Cursor].ID + if item, exists := m.Cart[coffeeID]; exists { + item.Quantity-- + newQty := item.Quantity + if item.Quantity <= 0 { + delete(m.Cart, coffeeID) + if len(m.Cart) == 0 { + m.CardCursor = 0 + } else if m.CartCursor >= len(m.Cart) { + m.CartCursor = len(m.Cart) - 1 + } + newQty = 0 + } + return m, m.syncCartItemCmd(coffeeID, newQty) + } + } + return m, nil +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go new file mode 100644 index 0000000..58c133e --- /dev/null +++ b/pkg/tui/update.go @@ -0,0 +1,136 @@ +package tui + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Resize debounce tick, must fire before page dispatch + if tick, ok := msg.(resizeTickMsg); ok { + if tick.seq == m.resizeSeq { + m.updateLayout(m.pendingWidth, m.pendingHeight) + if m.ShippingForm != nil { + m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) + } + if m.PaymentForm != nil { + m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) + } + } + return m, nil + } + + // Modal overlays intercept all input + if m.ShowingMenu { + return m.MenuUpdate(msg) + } + if m.ShowingHelp { + return m.HelpUpdate(msg) + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.pendingWidth = msg.Width + m.pendingHeight = msg.Height + m.resizeSeq++ + seq := m.resizeSeq + return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg { + return resizeTickMsg{seq: seq} + }) + case ProductsMsg: + m.Loading = false + if msg.Err != nil { + m.ErrorMsg = "Failed to load products from the API, using the fallback values" + } else if len(msg.Products) > 0 { + m.Coffees = msg.Products + m.ErrorMsg = "" + } + return m, nil + case CartFetchedMsg: + if msg.Err == nil && msg.Cart != nil { + m.loadCartFromAPI(msg.Cart) + } + return m, nil + case CartSyncedMsg: + if msg.Err != nil { + return m, m.fetchCartCmd + } + if msg.UpdateID == m.lastCartUpdateID { + m.loadCartFromAPI(msg.Cart) + } + return m, nil + case tokenRefreshTickMsg: + return m, m.refreshTokenCmd() + case TokenRefreshedMsg: + if msg.Err != nil { + return m, tea.Tick(tokenRetryDuration, func(t time.Time) tea.Msg { + return tokenRefreshTickMsg{} + }) + } + m.AccessToken = msg.Token + m.APIClient.Token = msg.Token + return m, m.scheduleTokenRefreshCmd() + case OrdersMsg: + if msg.Err == nil { + m.Orders = msg.Orders + m.OrdersLoaded = true + } + return m, nil + case AddressDeletedMsg: + if msg.Err != nil { + m.ErrorMsg = fmt.Sprintf("failed to delete address: %v", msg.Err) + } + return m, nil + case CardDeletedMsg: + if msg.Err != nil { + m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err) + } + return m, nil + case tea.KeyMsg: + // If a form is active, skip global keys — let the page handler own input + if m.ShippingForm != nil || m.PaymentForm != nil { + break + } + // Global navigation + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "?": + m.ShowingHelp = true + return m, nil + case "m": + m.ShowingMenu = true + m.menuLastPage = m.currentPage + return m, nil + case "s": + return m.SwitchPage(shopPage).resetPageState(), nil + case "c": + return m.SwitchPage(cartPage).resetPageState(), nil + case "a": + m = m.SwitchPage(accountPage).resetPageState() + if !m.OrdersLoaded { + return m, m.fetchOrdersCmd() + } + return m, nil + } + // non-globals. fall through to page dispatch below + } + // Page-specifics + switch m.currentPage { + case shopPage: + return m.ShopUpdate(msg) + case cartPage: + return m.CartUpdate(msg) + case shippingPage: + return m.ShippingUpdate(msg) + case paymentPage: + return m.PaymentUpdate(msg) + case confirmPage: + return m.ConfirmUpdate(msg) + case accountPage: + return m.AccountUpdate(msg) + } + return m, nil +}