fix: rewrote the huge 700+ line update call with modularized calls

This commit is contained in:
2026-03-26 17:47:56 +01:00
parent 93b257a612
commit 3ff4aa9d50
11 changed files with 722 additions and 1193 deletions

446
README.md
View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

136
pkg/tui/update.go Normal file
View File

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