mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
fix: rewrote the huge 700+ line update call with modularized calls
This commit is contained in:
446
README.md
446
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
801
pkg/tui/model.go
801
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
136
pkg/tui/update.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user