commit f62b28d29b7b5caf25f0a41524840b2d3252d839 Author: marcusDenslow Date: Mon Feb 16 11:42:43 2026 +0100 first commit after chaning git structure on pc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2601992 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Environment variables +.env +.env.local +.env.production +.env.example + +# Build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib +terminalshop +server_bin +terminalshop_* + +# Go test/coverage files +*.test +*.out +coverage.txt +coverage.html + +# Dependency directories +vendor/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +.claude/ + +// ssh config +.ssh/ + +# Logs +*.log +output.log +logs/ + +# Database files (if using SQLite for development) +*.db +*.sqlite +*.sqlite3 + +# SSH keys +ssh_host_key +ssh_host_key.pub +*.pem +*.key + +# Temporary files +tmp/ +temp/ +*.tmp + +# OS-specific +Thumbs.db +.DS_Store + +# Don't commit the cloned reference repo (optional - you might want to keep it) +# terminal-shop-source/ + +# API documentation (auto-generated) +api/docs/ + +# Migration state (if using a migration tool that creates state files) +migrations/*.state diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b84a46e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,153 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project Overview + +Terminal Coffee Shop is a terminal-based e-commerce app for ordering coffee, built with Go and the Bubbletea TUI framework. It runs as an SSH server (Wish framework) with a REST API backend (Chi router), SQLite database (GORM), and JWT authentication. + +## Build & Run Commands + +```bash +# Build and run SSH server (TUI) +go build -o terminalshop && ./terminalshop +# Connect: ssh localhost -p 23457 + +# Quick run without building +go run main.go + +# Run API server (separate process, port 8080) +go run main.go # from api/ directory +``` + +## Test Commands + +```bash +# Run all tests +go test ./... + +# Run a single test by name +go test ./api/handlers/ -run TestGetHealth + +# Run tests in a specific package +go test ./pkg/database/ + +# Run tests with verbose output +go test -v ./... + +# Run tests with coverage +go test -coverprofile=coverage.out ./... +go tool cover -func=coverage.out +``` + +## Project Structure + +- `main.go` -- SSH server entry point (Wish framework) +- `api/` -- REST API server: `handlers/`, `middleware/`, `routes/` +- `pkg/api/` -- API client used by TUI +- `pkg/auth/` -- JWT + SSH key authentication +- `pkg/config/` -- Environment configuration +- `pkg/database/` -- SQLite connection, migrations, seeding +- `pkg/models/` -- GORM data models (Coffee, User, CartItem) +- `pkg/tui/` -- Bubbletea TUI components +- `pkg/utils/` -- Standard API response helpers +- `pkg/validate/` -- Generic validators; `pkg/validation/` -- request validation +- `terminal-shop-source/` -- Reference implementation (read-only, do not modify) + +## Code Style + +### Imports + +Three groups separated by blank lines: (1) stdlib, (2) external packages, (3) internal packages. + +```go +import ( + "context" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "gorm.io/gorm" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) +``` + +### Naming Conventions + +- **Types/exports**: `PascalCase` (`ProductHandler`, `JWTManager`) +- **Local variables**: `camelCase` +- **Constructors**: `New` (`NewModel`, `NewJWTManager`, `NewClient`) +- **Files**: `snake_case.go` (`ssh_auth.go`, `shipping_form.go`) +- **JSON tags**: `snake_case` (`json:"roast_type"`, `json:"created_at"`) +- **Error codes**: `PascalCase` constants (`ErrCodeValidation`) + +### Error Handling + +- Messages are lowercase, no trailing punctuation +- Wrap errors with `%w`: `fmt.Errorf("failed to create user: %w", err)` +- Use guard clauses with early returns in handlers +- API errors use: `utils.RespondError(w, statusCode, "ERROR_CODE", "message", details)` +- API success uses: `utils.RespondSuccess(w, statusCode, data)` + +### Comments + +- Doc comments start with the name of the thing: `// GenerateToken generates a new JWT token for a user` +- Complete sentences with proper punctuation +- Every exported type and function should have a doc comment + +### Struct Tags + +Always include `json` tags. Use GORM tags for database models. + +```go +type Coffee struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + RoastType string `gorm:"size:50;not null" json:"roast_type"` + Price float64 `gorm:"not null" json:"price"` +} +``` + +### Function Ordering in Files + +1. Exported types and constants +2. Constructors (`New...`) +3. Public methods (alphabetically) +4. Private methods +5. Helpers + +### Context + +Always pass `context.Context` as the first parameter in functions that need it. + +## Testing Patterns + +- Use stdlib `testing` package with `t.Errorf`/`t.Fatalf` (no testify) +- HTTP tests use `httptest.NewRequest` and `httptest.NewRecorder` +- Test DB setup: create temp SQLite file, connect, migrate, seed, defer cleanup +- Always `defer os.Remove(testDB)` and `defer database.ResetForTesting()` +- Test functions named `Test` or `Test` + +## TUI Architecture (Bubbletea / Elm Architecture) + +- All state lives in `Model` struct in `pkg/tui/model.go` +- Views follow **left-panel + right-panel** layout with `lipgloss.JoinHorizontal()` +- Right panel auto-updates based on cursor position (no Enter/ESC to view details) +- Left panel width is 18 chars; right panel fills remaining space +- Each view has a builder: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()` +- Styling: active=#FFFFFF bold, inactive=#666666, accent=#4682B4 +- Cart uses `map[int]*CartItem` -- always sort via `GetCartItemsSlice()` before display +- Reset `ScrollOffset = 0` when switching views + +## Commit Messages + +Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`. + +## Important Notes + +- The `terminal-shop-source/` directory is a read-only reference -- do not modify it +- Environment config goes in `.env` (copy from `.env.example`); never commit `.env` +- Read CLAUDE.md for detailed architecture decisions and common gotchas +- Read MODELS.md for complete API specs, database schema, and design patterns diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72a6b9a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,202 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Terminal Coffee Shop is a terminal-based e-commerce application for ordering coffee, built with Go and the Bubbletea TUI framework. It's designed to eventually run as an SSH server similar to terminal.shop, with a backend API, PostgreSQL database, and Stripe payment integration. + +**Current Status**: Phase 1 (Enhanced UI & Navigation) - The TUI is functional with shop, cart, and account views. Backend API has not been implemented yet. + +## Essential Reading + +Before making any changes, read these files in order: +1. **README.md** - Complete roadmap and current phase status +2. **MODELS.md** - Technical specifications, architecture, code standards, error handling patterns +3. **CONTRIBUTING.md** - Workflow guidance for AI-assisted development + +## Architecture + +### Current Structure (TUI-only) + +``` +terminalShop/ +├── main.go # SSH server entry point (Wish framework) +├── pkg/ +│ ├── tui/ # Bubbletea TUI components +│ │ ├── model.go # Main model with state management +│ │ ├── views.go # View rendering and layout +│ │ ├── shop.go # Shop view (left: coffee list, right: details) +│ │ ├── cart.go # Cart view with scrolling +│ │ ├── account.go # Account view (auto-updating right panel) +│ │ ├── header.go # Tab navigation header +│ │ ├── footer.go # Keybind hints +│ │ └── breadcrumbs.go # Checkout flow breadcrumbs (cart only) +│ └── models/ +│ └── coffee.go # Coffee and CartItem models +└── .ssh/ # SSH host keys +``` + +### Planned Structure (Backend) + +The `api/` directory structure from MODELS.md will be created in Phase 2 with: +- Chi router for HTTP endpoints +- GORM models for PostgreSQL +- JWT authentication middleware +- Stripe payment integration + +## Key Architectural Patterns + +### TUI State Management (Bubbletea/Elm Architecture) + +The `Model` struct in `pkg/tui/model.go` contains all application state: +- Navigation state: `ViewingCart`, `ViewingAccount`, `CheckoutStep` +- Cursor positions: `Cursor` (shop), `CartCursor` (cart), `AccountCursor` (account) +- Data: `Coffees` (product list), `Cart` (map[int]*CartItem) +- Scroll: `ScrollOffset` for handling overflow + +**Critical Pattern**: All views (shop, cart, account) follow a **left-panel + right-panel layout**: +- Left panel: List of items with cursor navigation (j/k) +- Right panel: Details that auto-update based on cursor position +- No Enter/ESC navigation - hovering updates the right panel immediately + +### View Rendering Flow + +1. `views.go:View()` - Main entry point, handles vertical centering and scrolling +2. Calls view builders: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()` +3. Each view uses `lipgloss.JoinHorizontal()` for left+right layout +4. Fixed content height calculated dynamically to prevent UI shifting + +### Cart Management + +**Important**: Cart uses `map[int]*models.CartItem` where the key is the coffee index in `m.Coffees`. This enables fast lookups but requires sorting in `GetCartItemsSlice()` because Go maps have randomized iteration order. + +### Scrolling System + +Implemented for cart view to handle long lists on small terminals: +- `ScrollOffset` tracks current scroll position (line-based, not item-based) +- Each cart item is ~5 lines tall (including borders) +- Auto-scrolls when navigating with j/k to keep selected item visible +- Manual scroll with pgup/pgdn + +## Build and Run Commands + +```bash +# Build and run SSH server +go build -o terminalshop && ./terminalshop +# Server listens on localhost:23457 +# Connect with: ssh localhost -p 23457 + +# Quick run without building +go run main.go + +# Add dependencies +go get +go mod tidy +``` + +## Development Workflow + +### Adding a New View Component + +1. Create new file in `pkg/tui/` (e.g., `shipping.go`) +2. Add build function following the pattern: `func (m Model) BuildShippingView() string` +3. Use left-panel + right-panel layout with `lipgloss.JoinHorizontal()` +4. Update `views.go:View()` to conditionally render the new view +5. Add navigation state to `Model` struct in `model.go` +6. Add keybinds in `model.go:Update()` +7. Update `footer.go` with new keybind hints + +### Modifying Existing Views + +The shop, cart, and account views are split across multiple files but all follow the same pattern: +- Use `lipgloss.NewStyle()` for styling +- Fixed widths for panels (leftWidth typically 18, rightWidth calculated) +- Cursor-based selection with colored highlights +- Responsive to `WindowWidth` and `WindowHeight` + +### Working with the Model + +When adding state to the Model struct: +1. Add field to struct definition in `model.go` +2. Initialize in `NewModel()` constructor +3. Reset appropriately when changing views (see `case "s"`, `case "c"`, `case "a"`) +4. Update in `Update()` function based on user input + +## Important Implementation Details + +### Styling Conventions + +- Active/selected: White (#FFFFFF), bold +- Inactive: Gray (#666666) +- Accent color: Steel blue (#4682B4) - used for cart breadcrumbs and highlights +- Coffee colors: Use individual coffee's `.Color` field (hex values like "#8B4513") + +### Window Size Handling + +`views.go` automatically adjusts content height based on terminal size: +- Calculates available space after header, breadcrumbs, footer, margins +- Minimum content height of 3 lines +- Margins scale down on small windows (< 25 lines height) +- Truncates content and enables scrolling when needed + +### Breadcrumbs + +Only visible in cart view, showing checkout flow: `cart / shipping / payment / confirmation` +- Current step (tracked in `CheckoutStep`) is white/bold +- Future steps are gray +- Will be used when implementing checkout pages in later phases + +### SSH Server + +`main.go` uses Wish (Charm's SSH framework) to serve the TUI over SSH: +- Host key in `.ssh/term_info_ed25519` +- Port 23457 +- Each SSH session gets its own Bubbletea program instance +- Username passed to `tui.NewModel(username)` + +## Common Gotchas + +1. **Map iteration order**: Always sort cart items in `GetCartItemsSlice()` to prevent random reordering +2. **Scroll offset**: When adding scrolling to new views, update viewport height calculation (currently hardcoded as `-9` for cart with breadcrumbs) +3. **Content height**: Fixed height prevents UI shifting between pages but must account for all UI elements (header, breadcrumbs, footer, margins) +4. **Panel widths**: Left panel is 18 chars, right panel is `WindowWidth - leftWidth - leftMargin - 4` +5. **Reset scroll**: Always reset `ScrollOffset = 0` when switching views + +## Code Style (from MODELS.md) + +- Imports: stdlib, external deps, internal packages (separated by blank lines) +- Error messages: lowercase, no punctuation, use `%w` for wrapping +- Comments: Start with function/type name, complete sentences +- Struct tags: Always include `json` tags with snake_case +- Context: Always first parameter in functions + +## Reference Implementation + +`terminal-shop-source/` contains the full terminal.shop codebase (TypeScript/Go hybrid): +- `packages/go/pkg/tui/*.go` - TUI patterns and components +- `packages/go/cmd/ssh/main.go` - SSH server setup +- Study patterns, don't copy verbatim (different tech stack) + +## Next Steps (from Roadmap) + +Current phase is wrapping up. Next major phase: +- **Phase 2**: Backend API Foundation with Chi router, PostgreSQL, GORM models +- See MODELS.md for complete API specification and database schema +- See README.md Phase 2 tasks for implementation order + +## Environment Setup + +Copy `.env.example` to `.env` and configure: +- `DATABASE_URL` - PostgreSQL connection (for Phase 2) +- `JWT_SECRET` - Auth token secret (for Phase 3) +- `STRIPE_SECRET_KEY` / `STRIPE_PUBLIC_KEY` - Payment integration (for Phase 5) +- `SSH_PORT` - Default 2222 (currently using 23457) + +## Testing + +Currently no tests. Testing standards defined in MODELS.md for future API implementation: +- Unit tests for business logic +- Integration tests for API endpoints +- Use testify for assertions +- Table-driven test pattern diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f094deb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,265 @@ +# Contributing with Claude Code + +This project is designed to be built incrementally with AI assistance (Claude Code). This guide explains how to effectively collaborate with Claude Code on this project. + +## Quick Start for Claude Code Sessions + +When starting a new Claude Code session, provide this context: + +``` +I'm working on a terminal-based coffee shop app in Go. Please read README.md and MODELS.md to understand the project structure, current status, and technical standards before making any changes. +``` + +## Project Documents - Read These First! + +1. **README.md** - Project overview, roadmap, current status, and what to build next +2. **MODELS.md** - Technical specs, architecture, code standards, error handling +3. **terminal-shop-source/** - Reference implementation of terminal.shop (when stuck) + +## How to Ask Claude Code for Help + +### ✅ Good Prompts + +**When starting a new feature:** +``` +I want to implement Phase 1, Task 1.1 from the README - adding the account page view. +Can you check the current model.go structure and add the account view following the +patterns already established for shop and cart views? +``` + +**When stuck on something:** +``` +The cart isn't syncing properly between views. Can you check the cart state management +in model.go and compare it to how terminal.shop handles this in their cart.go file? +``` + +**When you want to follow best practices:** +``` +I need to create the API endpoint for adding items to cart. Can you implement this +following the error handling patterns and response format defined in MODELS.md? +``` + +### ❌ Avoid Vague Prompts + +``` +"Make the cart better" // Too vague - better what? Performance? UI? Features? +"Add payment stuff" // Be specific - which phase? What functionality exactly? +"Fix the bug" // What bug? Where? What's the expected behavior? +``` + +## Workflow for Building Features + +### Step 1: Identify the Task +Look at README.md and find the current phase and next unchecked task: +``` +## **PHASE 1: Enhanced UI & Navigation** (Current Phase) +### Tasks +#### 1.1 Add Account Page View +- [ ] Add `viewingAccount bool` to model struct in `model.go` +``` + +### Step 2: Reference the Specs +Check MODELS.md for: +- Code style rules +- Error handling patterns +- Similar examples +- Architecture decisions + +### Step 3: Ask Claude Code +``` +I want to implement [specific task from README]. I've checked MODELS.md for +the code standards. Can you implement this following the established patterns? +``` + +### Step 4: Test and Verify +``` +Run the app and test the new feature. If issues: +"The account view isn't showing up when I press 'a'. Can you debug this?" +``` + +### Step 5: Update Documentation +``` +The account view is working! Can you check off the completed tasks in README.md? +``` + +## Common Scenarios + +### Starting a New Feature + +1. Read the next task in README.md +2. Check if there are related patterns in MODELS.md +3. Look at terminal-shop-source for reference +4. Ask Claude Code to implement following the patterns + +**Example:** +``` +I'm ready to start Phase 2 - Backend API Foundation. Let's begin with task 2.1 +"Project Setup". Can you create the directory structure and initialize the Chi +router following the organization defined in MODELS.md? +``` + +### Debugging Issues + +Always provide context: +- What you were trying to do +- What you expected +- What actually happened +- Any error messages + +**Example:** +``` +When I add items to the cart and switch to the cart view, the items aren't showing. +I expected to see the cart items listed. The console shows no errors. Can you check +the cart state management in model.go? +``` + +### Refactoring Code + +Reference the style guide: + +``` +The buildShopView function is getting long (300+ lines). Can you refactor it following +the code organization principles in MODELS.md? Split it into smaller helper functions. +``` + +### Adding API Endpoints + +Be specific about the endpoint: + +``` +I need to add the POST /api/v1/cart/items endpoint from the API specs in MODELS.md. +Can you implement this with proper error handling and validation? +``` + +## Using the Reference Implementation + +The `terminal-shop-source/` directory contains terminal.shop's code. Use it when: + +### When to Reference + +1. **You need UI inspiration** - Look at their TUI components +2. **You're stuck on Bubbletea patterns** - See how they handle state +3. **You want to understand payment flow** - Check their payment.go +4. **You need form examples** - Look at how they use `huh` + +### How to Ask Claude Code to Reference It + +``` +Can you look at how terminal.shop implements the payment form in +terminal-shop-source/packages/go/pkg/tui/payment.go and create a similar +pattern for our checkout flow? +``` + +``` +I want to add breadcrumb navigation like terminal.shop has. Can you check their +breadcrumbs.go implementation and adapt it to our project structure? +``` + +## Best Practices for AI-Assisted Development + +### 1. Be Incremental +Build one small feature at a time. Don't try to implement entire phases at once. + +✅ "Let's add the account view to the TUI" +❌ "Implement the entire authentication system" + +### 2. Verify After Each Change +Run and test the code after each feature: +```bash +go run server.go model.go +``` + +### 3. Keep Context Clear +Start sessions with context, reference documents frequently: +``` +Working on the cart API endpoints. I've checked MODELS.md for the API spec and +error handling patterns. Ready to implement POST /api/v1/cart/items. +``` + +### 4. Follow the Roadmap +Stick to the phases in README.md. Don't skip ahead or work on random features. + +### 5. Update Documentation +When tasks are completed, update the checkboxes in README.md: +``` +Can you mark task 1.1 "Add Account Page View" as complete in README.md? +``` + +## Troubleshooting Common Issues + +### "Claude Code made changes that don't match the style guide" +``` +I noticed the error messages aren't following the format in MODELS.md (they start +with capitals). Can you fix them to match our error handling standards? +``` + +### "The code doesn't follow the project structure" +``` +The new handler was added to main.go but according to MODELS.md it should be in +api/handlers/. Can you reorganize this to match our structure? +``` + +### "I want to try a different approach" +``` +Instead of using the pattern from MODELS.md, I want to try [your approach]. +Can you implement it this way and we'll compare? +``` + +## Git Workflow + +### Before Committing +- [ ] Code follows MODELS.md standards +- [ ] Tests pass (when applicable) +- [ ] Documentation updated (README checkboxes) +- [ ] No sensitive data (check .gitignore) + +### Commit Messages +Use conventional commits: +```bash +feat: add account page view to TUI +fix: cart items not displaying correctly +refactor: split buildShopView into helper functions +docs: update README with completed tasks +``` + +## Tips for Effective Sessions + +1. **Start each session by providing context** about what you're working on +2. **Reference the docs** (README.md, MODELS.md) frequently +3. **Ask Claude Code to check the reference implementation** when uncertain +4. **Build incrementally** - one feature at a time +5. **Test frequently** - run the app after each change +6. **Update the README** - check off completed tasks +7. **Follow the roadmap** - stick to the phases in order + +## Example Full Session Flow + +``` +Session start: +"I'm working on Terminal Coffee Shop. Last session we completed the cart view. +Today I want to start Phase 1, Task 1.1 - adding the account page. Can you read +README.md to see what needs to be done?" + +Implementation: +"Great! Now implement the account view following the same pattern as buildShopView +and buildCartView in model.go. Add the viewingAccount bool and the keybind handling." + +Testing: +"I ran it but pressing 'a' doesn't switch to account view. Can you debug?" + +Completion: +"Perfect! It's working now. Can you update README.md to check off all the tasks +we completed for 1.1?" +``` + +## Resources + +- [Bubbletea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials) +- [Lipgloss Examples](https://github.com/charmbracelet/lipgloss/tree/master/examples) +- [Chi Router Guide](https://go-chi.io/#/pages/getting_started) +- [GORM Documentation](https://gorm.io/docs/) +- [Stripe Go SDK](https://github.com/stripe/stripe-go) + +--- + +**Remember**: The goal is to build this incrementally and learn along the way. Don't rush through phases. Take time to understand each pattern before moving on! diff --git a/MODELS.md b/MODELS.md new file mode 100644 index 0000000..d0cf25d --- /dev/null +++ b/MODELS.md @@ -0,0 +1,1214 @@ +# Technical Specifications & Architecture + +This document defines the technical stack, architecture patterns, code standards, and implementation details for the Terminal Coffee Shop project. **Read this before writing any code.** + +--- + +## Table of Contents + +1. [Development Workflow](#development-workflow) +2. [Technology Stack](#technology-stack) +3. [Database Schema](#database-schema) +4. [API Specifications](#api-specifications) +5. [Code Organization](#code-organization) +6. [Code Style Guide](#code-style-guide) +7. [Error Handling](#error-handling) +8. [Testing Standards](#testing-standards) +9. [Common Patterns](#common-patterns) + +--- + +## Development Workflow + +### Documentation-First Approach + +**IMPORTANT**: Before implementing features using any library or technology, **always fetch and read the official documentation first**. + +#### Process for Using New Libraries + +1. **Identify the library/technology** you need to use (e.g., Chi router, GORM, Stripe SDK) +2. **Fetch the documentation** using WebFetch: + ``` + WebFetch the official documentation URL for the library + Read through relevant sections (getting started, API reference, examples) + ``` +3. **Understand the current best practices** from the docs (not from memory/assumptions) +4. **Implement** following the documented patterns +5. **Reference the docs** if you encounter issues + +#### Why This Matters + +- Libraries evolve - documentation ensures you use current patterns +- Prevents deprecated patterns or outdated approaches +- Helps discover features you might not know about +- Ensures compatibility with the version in go.mod + +#### Example Workflow + +``` +Task: Implement Chi router middleware + +Step 1: Check go.mod for version +Step 2: WebFetch https://go-chi.io/#/pages/middleware +Step 3: Read middleware documentation +Step 4: Implement following documented patterns +Step 5: Test implementation +``` + +#### Documentation Sources + +- **Chi Router**: https://go-chi.io/ +- **GORM**: https://gorm.io/docs/ +- **Stripe Go**: https://stripe.com/docs/api?lang=go +- **Bubbletea**: https://github.com/charmbracelet/bubbletea +- **Lipgloss**: https://github.com/charmbracelet/lipgloss + +--- + +## Technology Stack + +### TUI (Terminal User Interface) + +| Package | Version | Purpose | Documentation | +|---------|---------|---------|---------------| +| `github.com/charmbracelet/bubbletea` | v1.3.10+ | Main TUI framework (The Elm Architecture) | https://github.com/charmbracelet/bubbletea | +| `github.com/charmbracelet/lipgloss` | v1.1.0+ | Styling and layout | https://github.com/charmbracelet/lipgloss | +| `github.com/charmbracelet/bubbles` | v0.21.0+ | Pre-built UI components (viewport, spinner, etc.) | https://github.com/charmbracelet/bubbles | +| `github.com/charmbracelet/huh` | v0.8.0+ | Forms and input validation | https://github.com/charmbracelet/huh | +| `github.com/charmbracelet/wish` | v1.4.7+ | SSH server framework | https://github.com/charmbracelet/wish | + +### Backend API + +| Package | Version | Purpose | Documentation | +|---------|---------|---------|---------------| +| `github.com/go-chi/chi/v5` | v5.0.0+ | HTTP router | https://go-chi.io/ | +| `gorm.io/gorm` | v1.25.0+ | ORM for database operations | https://gorm.io/docs/ | +| `gorm.io/driver/postgres` | v1.5.0+ | PostgreSQL driver | https://gorm.io/docs/connecting_to_the_database.html | +| `github.com/golang-jwt/jwt/v5` | v5.0.0+ | JWT token handling | https://github.com/golang-jwt/jwt | +| `github.com/go-playground/validator/v10` | v10.0.0+ | Struct validation | https://github.com/go-playground/validator | +| `github.com/joho/godotenv` | v1.5.0+ | Environment variables | https://github.com/joho/godotenv | + +### Payments & Services + +| Package | Version | Purpose | Documentation | +|---------|---------|---------|---------------| +| `github.com/stripe/stripe-go/v78` | v78.12.0+ | Payment processing | https://stripe.com/docs/api?lang=go | +| `rsc.io/qr` | v0.2.0 | QR code generation | https://pkg.go.dev/rsc.io/qr | + +### Utilities + +| Package | Version | Purpose | Documentation | +|---------|---------|---------|---------------| +| `github.com/google/uuid` | v1.6.0+ | UUID generation | https://github.com/google/uuid | +| `golang.org/x/crypto` | latest | Cryptography utilities | https://pkg.go.dev/golang.org/x/crypto | + +--- + +## Database Schema + +### Schema Design Principles +- Use UUIDs for primary keys (not auto-incrementing integers) +- Always include `created_at` and `updated_at` timestamps +- Use soft deletes (`deleted_at`) for important records +- Normalize data appropriately (3NF where possible) +- Add indexes on foreign keys and frequently queried columns + +### Complete Schema (PostgreSQL) + +```sql +-- Users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + fingerprint VARCHAR(255) UNIQUE, -- SSH public key fingerprint + email VARCHAR(255) UNIQUE, + name VARCHAR(255), + stripe_customer_id VARCHAR(255), -- Stripe customer ID + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL +); + +-- Products table +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + roast_type VARCHAR(100), -- "Dark Roast", "Medium Roast", etc. + ounces INTEGER, -- Size in ounces + bean_type VARCHAR(100), -- "Arabica", "Robusta", etc. + price INTEGER NOT NULL, -- Price in cents (e.g., 350 = $3.50) + color VARCHAR(7), -- Hex color for UI (e.g., "#8B4513") + image_url VARCHAR(500), + stock INTEGER DEFAULT 0, + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL +); + +-- Carts table +CREATE TABLE carts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Cart items table +CREATE TABLE cart_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cart_id UUID REFERENCES carts(id) ON DELETE CASCADE, + product_id UUID REFERENCES products(id), + quantity INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(cart_id, product_id) -- One entry per product per cart +); + +-- Addresses table +CREATE TABLE addresses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + name VARCHAR(255), -- Recipient name + street VARCHAR(255) NOT NULL, + street2 VARCHAR(255), -- Apt, suite, etc. + city VARCHAR(100) NOT NULL, + state VARCHAR(2) NOT NULL, -- US state code + zip VARCHAR(10) NOT NULL, + country VARCHAR(2) DEFAULT 'US', + is_default BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Payment methods table (stores Stripe tokens, not raw card data) +CREATE TABLE payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + stripe_payment_method_id VARCHAR(255) NOT NULL, -- Stripe PM ID + brand VARCHAR(50), -- "Visa", "Mastercard", etc. + last4 VARCHAR(4), -- Last 4 digits + exp_month INTEGER, + exp_year INTEGER, + is_default BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Orders table +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + address_id UUID REFERENCES addresses(id), + payment_method_id UUID REFERENCES payment_methods(id), + + -- Amounts in cents + subtotal INTEGER NOT NULL, + shipping INTEGER NOT NULL DEFAULT 0, + tax INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL, + + status VARCHAR(50) DEFAULT 'pending', -- pending, paid, shipped, delivered, cancelled + + stripe_payment_intent_id VARCHAR(255), -- Stripe Payment Intent ID + tracking_number VARCHAR(255), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Order items table (snapshot of products at time of order) +CREATE TABLE order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID REFERENCES products(id), + + -- Snapshot product details (in case product changes later) + product_name VARCHAR(255), + product_description TEXT, + price INTEGER NOT NULL, -- Price at time of order + quantity INTEGER NOT NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_fingerprint ON users(fingerprint); +CREATE INDEX idx_carts_user_id ON carts(user_id); +CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id); +CREATE INDEX idx_addresses_user_id ON addresses(user_id); +CREATE INDEX idx_payment_methods_user_id ON payment_methods(user_id); +CREATE INDEX idx_orders_user_id ON orders(user_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_order_items_order_id ON order_items(order_id); +``` + +### GORM Model Examples + +```go +// models/user.go +package models + +import ( + "time" + "gorm.io/gorm" + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + Fingerprint string `gorm:"uniqueIndex;size:255"` + Email string `gorm:"uniqueIndex;size:255"` + Name string `gorm:"size:255"` + StripeCustomerID string `gorm:"size:255"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +// models/product.go +type Product struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + Name string `gorm:"size:255;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + RoastType string `gorm:"size:100" json:"roast_type"` + Ounces int `json:"ounces"` + BeanType string `gorm:"size:100" json:"bean_type"` + Price int `gorm:"not null" json:"price"` // cents + Color string `gorm:"size:7" json:"color"` + ImageURL string `gorm:"size:500" json:"image_url"` + Stock int `gorm:"default:0" json:"stock"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// models/cart.go +type Cart struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + UserID uuid.UUID `gorm:"type:uuid;not null"` + User User `gorm:"foreignKey:UserID"` + Items []CartItem `gorm:"foreignKey:CartID"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type CartItem struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + CartID uuid.UUID `gorm:"type:uuid;not null"` + ProductID uuid.UUID `gorm:"type:uuid;not null"` + Product Product `gorm:"foreignKey:ProductID"` + Quantity int `gorm:"not null;default:1"` + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +--- + +## API Specifications + +### API Design Principles + +1. **RESTful routes** - Use standard HTTP methods (GET, POST, PUT, DELETE) +2. **JSON responses** - All responses in JSON format +3. **Consistent error format** - See Error Handling section +4. **JWT authentication** - Bearer token in Authorization header +5. **Versioning** - All routes prefixed with `/api/v1/` +6. **Pagination** - Use `page` and `limit` query params + +### API Routes + +#### Authentication +``` +POST /api/v1/auth/register - Create new user +POST /api/v1/auth/login - Get JWT token +POST /api/v1/auth/refresh - Refresh token +GET /api/v1/auth/me - Get current user (requires auth) +PUT /api/v1/auth/me - Update current user (requires auth) +``` + +#### Products +``` +GET /api/v1/products - List all products (paginated) +GET /api/v1/products/:id - Get single product +POST /api/v1/products - Create product (admin only) +PUT /api/v1/products/:id - Update product (admin only) +DELETE /api/v1/products/:id - Delete product (admin only) +``` + +#### Cart +``` +GET /api/v1/cart - Get current user's cart +POST /api/v1/cart/items - Add item to cart +PUT /api/v1/cart/items/:id - Update item quantity +DELETE /api/v1/cart/items/:id - Remove item from cart +DELETE /api/v1/cart - Clear entire cart +``` + +#### Payment Methods +``` +GET /api/v1/payment-methods - List saved payment methods +POST /api/v1/payment-methods - Save new payment method +DELETE /api/v1/payment-methods/:id - Remove payment method +``` + +#### Addresses +``` +GET /api/v1/addresses - List user's addresses +POST /api/v1/addresses - Create new address +PUT /api/v1/addresses/:id - Update address +DELETE /api/v1/addresses/:id - Delete address +``` + +#### Orders +``` +GET /api/v1/orders - List user's orders +GET /api/v1/orders/:id - Get order details +POST /api/v1/orders - Create new order (checkout) +``` + +### Request/Response Examples + +#### GET /api/v1/products +**Response (200 OK)**: +```json +{ + "success": true, + "data": { + "products": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Espresso", + "description": "Bold coffee...", + "roast_type": "Dark Roast", + "ounces": 2, + "bean_type": "Arabica", + "price": 350, + "color": "#8B4513", + "stock": 100, + "created_at": "2024-01-01T00:00:00Z" + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 6, + "total_pages": 1 + } + } +} +``` + +#### POST /api/v1/cart/items +**Request**: +```json +{ + "product_id": "123e4567-e89b-12d3-a456-426614174000", + "quantity": 2 +} +``` + +**Response (201 Created)**: +```json +{ + "success": true, + "data": { + "cart": { + "id": "cart-uuid", + "items": [ + { + "id": "item-uuid", + "product": { /* full product object */ }, + "quantity": 2 + } + ], + "subtotal": 700 + } + } +} +``` + +#### POST /api/v1/orders (Checkout) +**Request**: +```json +{ + "address_id": "address-uuid", + "payment_method_id": "pm-uuid" +} +``` + +**Response (201 Created)**: +```json +{ + "success": true, + "data": { + "order": { + "id": "order-uuid", + "subtotal": 700, + "shipping": 500, + "tax": 0, + "total": 1200, + "status": "pending", + "items": [ /* order items */ ] + } + } +} +``` + +--- + +## Code Organization + +### Directory Structure + +``` +terminalShop/ +├── main.go # Entry point (SSH server) +├── server.go # Current TUI entry point +├── model.go # Bubbletea TUI models and views +│ +├── api/ # Backend API server +│ ├── main.go # API server entry point +│ ├── routes/ +│ │ ├── auth.go # Auth routes +│ │ ├── products.go # Product routes +│ │ ├── cart.go # Cart routes +│ │ └── orders.go # Order routes +│ ├── middleware/ +│ │ ├── auth.go # JWT auth middleware +│ │ ├── logger.go # Request logging +│ │ └── cors.go # CORS middleware +│ └── handlers/ +│ ├── auth.go # Auth handler functions +│ ├── products.go # Product handlers +│ └── cart.go # Cart handlers +│ +├── pkg/ # Shared packages +│ ├── models/ # Database models (GORM) +│ │ ├── user.go +│ │ ├── product.go +│ │ ├── cart.go +│ │ └── order.go +│ ├── database/ # Database connection +│ │ └── db.go +│ ├── apiclient/ # API client for TUI +│ │ └── client.go +│ ├── auth/ # Auth utilities +│ │ ├── jwt.go # JWT generation/validation +│ │ └── password.go # Password hashing +│ └── utils/ # Helper functions +│ ├── response.go # Standard response helpers +│ └── validation.go # Validation helpers +│ +├── migrations/ # Database migrations +│ ├── 001_initial.sql +│ └── migrate.go +│ +├── config/ # Configuration +│ └── config.go # Load env vars +│ +├── tests/ # Tests +│ ├── api/ +│ └── integration/ +│ +├── .env # Environment variables (not in git) +├── .env.example # Example env file +├── go.mod +├── go.sum +├── README.md +└── MODELS.md # This file +``` + +### Package Organization Rules + +1. **One responsibility per package** - Each package should have a clear, single purpose +2. **No circular dependencies** - Organize imports in a clear hierarchy +3. **Shared code in pkg/** - Anything used by multiple packages goes in `pkg/` +4. **Keep handlers thin** - Business logic goes in separate service functions +5. **Models package is database-only** - No business logic in model structs + +--- + +## Code Style Guide + +### General Go Style + +Follow the official [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) and [Effective Go](https://go.dev/doc/effective_go). + +### Specific Rules for This Project + +#### 1. Imports Organization +Always organize imports in three groups, separated by blank lines: + +```go +import ( + // Standard library + "context" + "fmt" + "net/http" + + // External dependencies + "github.com/go-chi/chi/v5" + "gorm.io/gorm" + + // Internal packages + "github.com/yourusername/terminalshop/pkg/models" + "github.com/yourusername/terminalshop/pkg/utils" +) +``` + +#### 2. Naming Conventions + +- **Variables**: `camelCase` for local variables, `PascalCase` for exported +- **Constants**: `PascalCase` or `SCREAMING_SNAKE_CASE` for groups +- **Functions**: `PascalCase` for exported, `camelCase` for private +- **Interfaces**: Suffix with `-er` if single method (e.g., `Reader`, `Writer`) +- **Structs**: `PascalCase`, descriptive nouns +- **Files**: `snake_case.go` + +```go +// Good +type UserService struct { + db *gorm.DB +} + +func (s *UserService) CreateUser(ctx context.Context, name string) (*models.User, error) { + // ... +} + +// Bad +type userservice struct { } // should be PascalCase if exported +func (s *UserService) create_user() { } // should be camelCase if private +``` + +#### 3. Error Messages + +- Start with lowercase +- No punctuation at the end +- Include context about what failed + +```go +// Good +return nil, fmt.Errorf("failed to create user: %w", err) +return nil, errors.New("user not found") + +// Bad +return nil, fmt.Errorf("Error creating user: %v", err) // no caps, use %w +return nil, errors.New("User not found.") // no caps, no period +``` + +#### 4. Function Organization + +Order functions in files logically: +1. Exported types/constants +2. Constructor functions (`New...`) +3. Public methods (alphabetically) +4. Private methods (alphabetically) +5. Helper functions + +```go +// 1. Types +type UserHandler struct { + db *gorm.DB +} + +// 2. Constructor +func NewUserHandler(db *gorm.DB) *UserHandler { + return &UserHandler{db: db} +} + +// 3. Public methods +func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { + // ... +} + +func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { + // ... +} + +// 4. Private methods +func (h *UserHandler) validateUser(user *models.User) error { + // ... +} + +// 5. Helper functions +func hashPassword(password string) (string, error) { + // ... +} +``` + +#### 5. Comment Style + +- Use complete sentences for package and function comments +- Start comments with the name of the thing being described +- Use `//` for single-line, `/* */` for multi-line only when needed + +```go +// UserHandler handles HTTP requests related to users. +type UserHandler struct { + db *gorm.DB +} + +// CreateUser creates a new user in the database and returns it. +// Returns an error if validation fails or database operation fails. +func (h *UserHandler) CreateUser(ctx context.Context, user *models.User) error { + // Validate user input + if err := h.validateUser(user); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Create user in database + if err := h.db.Create(user).Error; err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} +``` + +#### 6. Struct Tags + +Always include JSON tags and use snake_case for JSON field names: + +```go +type Product struct { + ID uuid.UUID `gorm:"type:uuid;primary_key" json:"id"` + Name string `gorm:"size:255" json:"name"` + Price int `gorm:"not null" json:"price"` + RoastType string `gorm:"size:100" json:"roast_type"` // snake_case! + CreatedAt time.Time `json:"created_at"` +} +``` + +#### 7. Context Usage + +Always pass `context.Context` as the first parameter: + +```go +// Good +func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) { + return s.db.WithContext(ctx).First(&user, id).Error +} + +// Bad +func (s *UserService) GetUser(id uuid.UUID) (*models.User, error) { + // No context! +} +``` + +--- + +## Error Handling + +### Principles + +1. **Always handle errors** - Never ignore errors with `_` +2. **Wrap errors** - Use `fmt.Errorf("context: %w", err)` to add context +3. **Don't panic** - Only panic in `main()` or `init()` for unrecoverable errors +4. **Return early** - Use guard clauses and early returns + +### Standard Error Response Format (API) + +All API errors should follow this format: + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input data", + "details": { + "email": "must be a valid email address", + "password": "must be at least 8 characters" + } + } +} +``` + +### Error Response Helper + +```go +// pkg/utils/response.go +package utils + +import ( + "encoding/json" + "net/http" +) + +type ErrorResponse struct { + Success bool `json:"success"` + Error Error `json:"error"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} + +func RespondError(w http.ResponseWriter, code int, errCode, message string, details map[string]interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(ErrorResponse{ + Success: false, + Error: Error{ + Code: errCode, + Message: message, + Details: details, + }, + }) +} + +func RespondSuccess(w http.ResponseWriter, code int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": data, + }) +} +``` + +### Common Error Codes + +```go +const ( + ErrCodeValidation = "VALIDATION_ERROR" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeConflict = "CONFLICT" + ErrCodeInternal = "INTERNAL_ERROR" + ErrCodeBadRequest = "BAD_REQUEST" + ErrCodePaymentFailed = "PAYMENT_FAILED" +) +``` + +### Error Handling Pattern (API Handlers) + +```go +func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { + // Extract ID from URL + productID := chi.URLParam(r, "id") + if productID == "" { + utils.RespondError(w, http.StatusBadRequest, ErrCodeBadRequest, "product ID required", nil) + return + } + + // Parse UUID + id, err := uuid.Parse(productID) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, ErrCodeValidation, "invalid product ID", nil) + return + } + + // Get product from database + product, err := h.productService.GetProduct(r.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.RespondError(w, http.StatusNotFound, ErrCodeNotFound, "product not found", nil) + return + } + // Log the actual error internally + log.Printf("failed to get product: %v", err) + utils.RespondError(w, http.StatusInternalServerError, ErrCodeInternal, "internal server error", nil) + return + } + + // Success response + utils.RespondSuccess(w, http.StatusOK, product) +} +``` + +### Error Handling in TUI + +For the TUI, errors should be displayed to the user in a friendly way: + +```go +// In model.go Update() function +switch msg := msg.(type) { +case error: + m.errorMessage = api.GetErrorMessage(msg) + m.showError = true + return m, nil +} + +// In View() function +func (m model) View() string { + if m.showError { + errorBox := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Bold(true). + Render("Error: " + m.errorMessage) + return errorBox + "\n" + m.normalView() + } + return m.normalView() +} +``` + +--- + +## Testing Standards + +### Testing Strategy + +1. **Unit tests** for business logic and utilities +2. **Integration tests** for API endpoints with database +3. **No tests for TUI initially** (focus on API) + +### Test File Organization + +- Test files live next to the code they test +- Name: `_test.go` +- Use table-driven tests for multiple cases + +### Example Test Structure + +```go +// api/handlers/product_test.go +package handlers + +import ( + "context" + "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProductHandler_GetProduct(t *testing.T) { + tests := []struct { + name string + productID string + setupMock func(*mockDB) + expectedStatus int + expectedError string + }{ + { + name: "success", + productID: "valid-uuid", + setupMock: func(m *mockDB) { /* setup */ }, + expectedStatus: 200, + }, + { + name: "not found", + productID: "nonexistent-uuid", + setupMock: func(m *mockDB) { /* setup */ }, + expectedStatus: 404, + expectedError: "NOT_FOUND", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test implementation + // Use require for setup assertions + // Use assert for test assertions + }) + } +} +``` + +### Testing Libraries + +- `testing` - Standard library +- `github.com/stretchr/testify` - Assertions and mocking +- Test database: Use in-memory SQLite or Docker PostgreSQL + +--- + +## Common Patterns + +### Pattern 1: Service Layer + +Separate business logic from HTTP handlers: + +```go +// pkg/services/user_service.go +type UserService struct { + db *gorm.DB +} + +func NewUserService(db *gorm.DB) *UserService { + return &UserService{db: db} +} + +func (s *UserService) CreateUser(ctx context.Context, name, email string) (*models.User, error) { + user := &models.User{ + Name: name, + Email: email, + } + + if err := s.db.WithContext(ctx).Create(user).Error; err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return user, nil +} + +// api/handlers/user_handler.go +type UserHandler struct { + userService *services.UserService +} + +func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { + // Parse request + var req CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, 400, ErrCodeBadRequest, "invalid request body", nil) + return + } + + // Call service + user, err := h.userService.CreateUser(r.Context(), req.Name, req.Email) + if err != nil { + // Handle error + return + } + + utils.RespondSuccess(w, 201, user) +} +``` + +### Pattern 2: Middleware for Auth + +```go +// api/middleware/auth.go +func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + utils.RespondError(w, 401, ErrCodeUnauthorized, "missing auth token", nil) + return + } + + // Validate token + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + + if err != nil || !token.Valid { + utils.RespondError(w, 401, ErrCodeUnauthorized, "invalid token", nil) + return + } + + // Extract user ID from claims + claims := token.Claims.(jwt.MapClaims) + userID := claims["user_id"].(string) + + // Add to context + ctx := context.WithValue(r.Context(), "user_id", userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// Usage in routes +r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware(jwtSecret)) + r.Get("/api/v1/cart", handlers.GetCart) + r.Post("/api/v1/cart/items", handlers.AddCartItem) +}) +``` + +### Pattern 3: Repository Pattern (Optional) + +For more complex queries, separate database access: + +```go +// pkg/repositories/product_repository.go +type ProductRepository struct { + db *gorm.DB +} + +func (r *ProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Product, error) { + var product models.Product + err := r.db.WithContext(ctx).Where("id = ? AND active = ?", id, true).First(&product).Error + return &product, err +} + +func (r *ProductRepository) List(ctx context.Context, page, limit int) ([]models.Product, int64, error) { + var products []models.Product + var total int64 + + offset := (page - 1) * limit + + r.db.Model(&models.Product{}).Where("active = ?", true).Count(&total) + err := r.db.WithContext(ctx). + Where("active = ?", true). + Limit(limit). + Offset(offset). + Find(&products).Error + + return products, total, err +} +``` + +### Pattern 4: Bubbletea View Switching + +```go +// In model.go +type page int + +const ( + shopPage page = iota + cartPage + accountPage + paymentPage +) + +type model struct { + currentPage page + // ... other fields +} + +func (m model) SwitchToPage(p page) (model, tea.Cmd) { + m.currentPage = p + // Reset page-specific state + switch p { + case shopPage: + m.cursor = 0 + case cartPage: + m.cartCursor = 0 + // ... + } + return m, nil +} + +func (m model) View() string { + header := m.buildHeader() + footer := m.buildFooter() + + var content string + switch m.currentPage { + case shopPage: + content = m.buildShopView() + case cartPage: + content = m.buildCartView() + case accountPage: + content = m.buildAccountView() + default: + content = "Unknown page" + } + + return lipgloss.JoinVertical(lipgloss.Left, header, content, footer) +} +``` + +--- + +## Configuration Management + +### Environment Variables + +Use a `.env` file for local development: + +```bash +# .env (DO NOT COMMIT) +DATABASE_URL=postgresql://user:password@localhost:5432/terminalshop +JWT_SECRET=your-super-secret-jwt-key-change-this +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLIC_KEY=pk_test_... +API_PORT=8080 +SSH_PORT=2222 +ENVIRONMENT=development +``` + +### Config Package + +```go +// config/config.go +package config + +import ( + "os" + "github.com/joho/godotenv" +) + +type Config struct { + DatabaseURL string + JWTSecret string + StripeSecretKey string + StripePublicKey string + APIPort string + SSHPort string + Environment string +} + +func Load() (*Config, error) { + // Load .env file in development + if os.Getenv("ENVIRONMENT") != "production" { + godotenv.Load() + } + + return &Config{ + DatabaseURL: os.Getenv("DATABASE_URL"), + JWTSecret: os.Getenv("JWT_SECRET"), + StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), + StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), + APIPort: getEnvOrDefault("API_PORT", "8080"), + SSHPort: getEnvOrDefault("SSH_PORT", "2222"), + Environment: getEnvOrDefault("ENVIRONMENT", "development"), + }, nil +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} +``` + +--- + +## Security Best Practices + +1. **Never commit secrets** - Use `.env` and add to `.gitignore` +2. **Hash passwords** - Use `bcrypt` with cost >= 12 +3. **Validate all inputs** - Never trust user input +4. **Use parameterized queries** - GORM does this automatically +5. **HTTPS in production** - Always use TLS +6. **Rate limiting** - Add to API endpoints +7. **CORS configuration** - Only allow trusted origins + +--- + +## Reference Implementation + +The `terminal-shop-source/` directory contains the complete terminal.shop codebase. When in doubt: + +1. **Check similar functionality** in their code +2. **Copy patterns, not code verbatim** - adapt to our stack +3. **Understand before implementing** - don't blindly copy + +Key files to reference: +- `packages/go/pkg/tui/*.go` - TUI views and components +- `packages/go/pkg/api/api.go` - API client integration +- `packages/core/src/` - Backend business logic (TypeScript, but concepts apply) + +--- + +## Quick Reference Checklist + +Before writing new code, ask: +- [ ] Does this follow the file organization structure? +- [ ] Are imports organized correctly? +- [ ] Are errors wrapped with context? +- [ ] Are all errors handled? +- [ ] Does this need a test? +- [ ] Are naming conventions followed? +- [ ] Is the API response in the standard format? +- [ ] Are database queries using context? +- [ ] Is sensitive data in environment variables? +- [ ] Does this match patterns from terminal.shop reference code? + +--- + +**Remember**: Consistency is more important than perfection. Follow these patterns throughout the codebase, and reference this document frequently! diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbd474f --- /dev/null +++ b/README.md @@ -0,0 +1,413 @@ +# Terminal Coffee Shop + +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 + +```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 +``` + +## Environment Variables + +```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 +``` + +## Resources + +- **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/ + +## Contributing Guidelines for Claude Code + +When working on this project: + +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 + +## License + +MIT (or your preferred license) diff --git a/REFACTORING.md b/REFACTORING.md new file mode 100644 index 0000000..523232a --- /dev/null +++ b/REFACTORING.md @@ -0,0 +1,141 @@ +# Refactoring Complete! + +The codebase has been refactored from a single `model.go` file (~650 lines) into a clean, modular structure. + +## New Structure + +``` +terminalShop/ +├── main.go # Entry point (~20 lines) +├── pkg/ +│ ├── models/ +│ │ └── coffee.go # Data models (~25 lines) +│ └── tui/ +│ ├── model.go # Core model & state (~160 lines) +│ ├── views.go # Main View() function (~60 lines) +│ ├── shop.go # Shop view (~90 lines) +│ ├── cart.go # Cart view (~90 lines) +│ ├── account.go # Account view (~75 lines) +│ ├── header.go # Header rendering (~95 lines) +│ └── footer.go # Footer rendering (~75 lines) +├── model.go.old # Backup of old file +└── server.go.old # Backup of old file +``` + +## What Changed + +### Before +- **1 file**: `model.go` with 650+ lines +- All code mixed together +- Hard to navigate and maintain +- Difficult to find specific features + +### After +- **8 focused files** averaging ~70 lines each +- Clear separation of concerns +- Easy to find and modify features +- Follows Go best practices + +## File Responsibilities + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `main.go` | App entry point | `main()` | +| `pkg/models/coffee.go` | Data structures | `Coffee`, `CartItem`, `AccountMenuItems` | +| `pkg/tui/model.go` | State management | `Model`, `Init()`, `Update()`, `NewModel()` | +| `pkg/tui/views.go` | Layout assembly | `View()` | +| `pkg/tui/shop.go` | Shop view | `BuildShopView()` | +| `pkg/tui/cart.go` | Cart view | `BuildCartView()` | +| `pkg/tui/account.go` | Account views | `BuildAccountView()`, `buildAccountSubpage()` | +| `pkg/tui/header.go` | Header/tabs | `BuildHeader()` | +| `pkg/tui/footer.go` | Footer/keybinds | `BuildFooter()` | + +## Benefits + +1. **Easier to Navigate**: Find shop code in `shop.go`, cart code in `cart.go`, etc. +2. **Easier to Test**: Each component can be tested independently +3. **Easier to Extend**: Add new views by creating new files +4. **Better Organization**: Related code stays together +5. **Cleaner Git Diffs**: Changes to cart don't affect shop code +6. **Follows Standards**: Matches Go project conventions + +## Running the App + +Same as before: + +```bash +# Run the app +go run main.go + +# Or build and run +go build -o terminalshop main.go +./terminalshop +``` + +## Adding New Features + +### Adding a New View (e.g., "Settings") + +1. Create `pkg/tui/settings.go`: +```go +package tui + +func (m Model) BuildSettingsView() string { + // Your settings view code +} +``` + +2. Update `model.go`: +- Add `viewingSettings bool` to Model struct +- Add keybind case in Update() + +3. Update `views.go`: +- Add condition for settings view + +4. Update `header.go` and `footer.go`: +- Add settings tab/keybinds + +### Adding New Data Models + +Add to `pkg/models/` directory: +```go +// pkg/models/order.go +package models + +type Order struct { + ID string + Items []CartItem + Total float64 +} +``` + +## Migration Notes + +- Old files backed up as `.old` +- All functionality preserved +- No behavior changes +- Imports use `terminalShop/pkg/...` + +## Next Steps + +Now that the code is organized, you can easily: +- Add new views (checkout, payment, orders) +- Implement API client in `pkg/api/` +- Add database models in `pkg/models/` +- Create tests for each component +- Continue with Phase 2 of the roadmap (Backend API) + +## Rollback + +If needed, restore the old structure: +```bash +mv model.go.old model.go +mv server.go.old server.go +rm -rf pkg/ +# Edit main.go to use InitialModel again +``` + +--- + +**Refactored on**: 2026-02-12 +**Lines of code**: Same functionality, better organized! diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d9b4869 --- /dev/null +++ b/TODO.md @@ -0,0 +1,72 @@ +# Current Work - Quick TODO + +> This is your working scratchpad. Update this frequently as you work on features. +> For the complete roadmap, see README.md + +## 🔥 Currently Working On + +**Phase**: Phase 1 - Enhanced UI & Navigation +**Task**: Setting up project documentation +**Status**: ✅ Complete + +### Completed Today +- [x] Created comprehensive README.md with full roadmap +- [x] Created MODELS.md with technical specs +- [x] Created CONTRIBUTING.md for Claude Code workflow +- [x] Set up .env.example +- [x] Created .gitignore + +## 📋 Next Up + +### Immediate Next Task +**Phase 1.1 - Add Account Page View** + +Steps: +1. [ ] Add `viewingAccount bool` to model struct +2. [ ] Add "a" keybind to switch to account view +3. [ ] Create `buildAccountView()` function +4. [ ] Add mock user data (username, email) +5. [ ] Update header to include account tab +6. [ ] Update footer with account keybind +7. [ ] Test navigation between shop, cart, and account + +### After That +**Phase 1.2 - Implement Breadcrumb Navigation** +- Reference: `terminal-shop-source/packages/go/pkg/tui/breadcrumbs.go` + +## 🐛 Known Issues + +_None currently_ + +## 💡 Ideas / Notes + +- Consider adding a loading spinner for future API calls +- Might want to add a help screen (press 'h' or '?') +- Color scheme could be customizable in future + +## 📝 Questions / Decisions Needed + +_None currently_ + +--- + +## Quick Commands Reference + +```bash +# Run the app +go run server.go model.go + +# Clean build artifacts +rm terminalshop server_bin + +# View git status +git status + +# Check TODO items +cat TODO.md +``` + +--- + +**Last Updated**: 2026-02-12 +**By**: Setup session with Claude Code diff --git a/api/api-server b/api/api-server new file mode 100755 index 0000000..6d20470 Binary files /dev/null and b/api/api-server differ diff --git a/api/handlers/auth.go b/api/handlers/auth.go new file mode 100644 index 0000000..37c9a18 --- /dev/null +++ b/api/handlers/auth.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" + "terminalShop/pkg/validation" +) + +// AuthHandler handles authentication-related requests +type AuthHandler struct{} + +// NewAuthHandler creates a new auth handler +func NewAuthHandler() *AuthHandler { + return &AuthHandler{} +} + +// RegisterRequest represents a user registration request +type RegisterRequest struct { + SSHPublicKey string `json:"ssh_public_key"` + SSHKeyFingerprint string `json:"ssh_key_fingerprint"` + Name string `json:"name,omitempty"` // Optional + Email string `json:"email,omitempty"` // Optional +} + +// RegisterWithSSHKey registers a new user with their SSH public key +// No username required - following terminal.shop pattern +func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + // Validate required fields + errors := make(validation.ProductErrors) + if req.SSHPublicKey == "" { + errors["ssh_public_key"] = "SSH public key is required" + } + + if req.SSHKeyFingerprint == "" { + errors["ssh_key_fingerprint"] = "SSH key fingerprint is required" + } + + if errors.HasErrors() { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ + "errors": errors, + }) + return + } + + // Check if SSH key is already registered + var existing models.User + err := db.Where("ssh_key_fingerprint = ?", req.SSHKeyFingerprint).First(&existing).Error + if err == nil { + utils.RespondError(w, http.StatusConflict, "SSH_KEY_REGISTERED", "SSH key already registered", nil) + return + } + + // Create new user (no username required!) + user := models.User{ + SSHPublicKey: req.SSHPublicKey, + SSHKeyFingerprint: req.SSHKeyFingerprint, + Name: req.Name, + Email: req.Email, + } + + if err := db.Create(&user).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil) + return + } + + utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + "user": user.ToPublic(), + }) +} + +// GetUserBySSHKey retrieves a user by their SSH key fingerprint +func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + fingerprint := r.URL.Query().Get("fingerprint") + if fingerprint == "" { + utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint query parameter is required", nil) + return + } + + var user models.User + err := db.Where("ssh_key_fingerprint = ?", fingerprint).First(&user).Error + if err != nil { + utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "user": user.ToPublic(), + }) +} diff --git a/api/handlers/health.go b/api/handlers/health.go new file mode 100644 index 0000000..2c56aca --- /dev/null +++ b/api/handlers/health.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + "time" + + "terminalShop/pkg/utils" +) + +// HealthHandler handles health check requests +type HealthHandler struct { + version string +} + +// NewHealthHandler creates a new health handler +func NewHealthHandler(version string) *HealthHandler { + return &HealthHandler{ + version: version, + } +} + +// GetHealth returns the health status of the API +func (h *HealthHandler) GetHealth(w http.ResponseWriter, r *http.Request) { + data := utils.HealthData{ + Status: "ok", + Timestamp: time.Now(), + Version: h.version, + } + + utils.RespondSuccess(w, http.StatusOK, data) +} + +// Ping returns a simple pong response for testing +func (h *HealthHandler) Ping(w http.ResponseWriter, r *http.Request) { + utils.RespondSuccess(w, http.StatusOK, map[string]string{ + "message": "pong", + }) +} diff --git a/api/handlers/health_test.go b/api/handlers/health_test.go new file mode 100644 index 0000000..dba1619 --- /dev/null +++ b/api/handlers/health_test.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "terminalShop/pkg/utils" +) + +func TestGetHealth(t *testing.T) { + handler := NewHealthHandler("v1.0.0-test") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + w := httptest.NewRecorder() + + handler.GetHealth(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Parse response + var response struct { + Success bool `json:"success"` + Data utils.HealthData `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if response.Data.Status != "ok" { + t.Errorf("Expected status 'ok', got %s", response.Data.Status) + } + + if response.Data.Version != "v1.0.0-test" { + t.Errorf("Expected version 'v1.0.0-test', got %s", response.Data.Version) + } + + // Verify timestamp is recent (within last 5 seconds) + now := time.Now() + diff := now.Sub(response.Data.Timestamp) + if diff > 5*time.Second || diff < 0 { + t.Errorf("Timestamp seems incorrect: %v (diff: %v)", response.Data.Timestamp, diff) + } + + // Verify Content-Type header + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", contentType) + } +} + +func TestPing(t *testing.T) { + handler := NewHealthHandler("v1.0.0-test") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) + w := httptest.NewRecorder() + + handler.Ping(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Parse response + var response struct { + Success bool `json:"success"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if response.Data.Message != "pong" { + t.Errorf("Expected 'pong', got %s", response.Data.Message) + } + + // Verify Content-Type header + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", contentType) + } +} diff --git a/api/handlers/products.go b/api/handlers/products.go new file mode 100644 index 0000000..947e376 --- /dev/null +++ b/api/handlers/products.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" + "terminalShop/pkg/validation" +) + +// ProductHandler handles product-related requests +type ProductHandler struct{} + +// NewProductHandler creates a new product handler +func NewProductHandler() *ProductHandler { + return &ProductHandler{} +} + +// GetProducts returns all products from the database +func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + var products []models.Coffee + + // Query all products from database + if err := db.Find(&products).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to fetch products", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "products": products, + }) +} + +// GetProduct returns a single product by ID +func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + // Get ID from URL parameter + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil) + return + } + + var product models.Coffee + if err := db.First(&product, id).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "product": product, + }) +} + +// CreateProduct creates a new product +func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + // Parse request body + var req validation.ProductRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + // Validate request + if validationErrors := validation.ValidateProductRequest(req, false); validationErrors.HasErrors() { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ + "errors": validationErrors, + }) + return + } + + // Create product + product := models.Coffee{ + Name: req.Name, + RoastType: req.RoastType, + Ounces: req.Ounces, + BeanType: req.BeanType, + Price: req.Price, + Color: req.Color, + Description: req.Description, + } + + if err := db.Create(&product).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create product", nil) + return + } + + utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + "product": product, + }) +} + +// UpdateProduct updates an existing product +func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + // Get ID from URL parameter + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil) + return + } + + // Check if product exists + var product models.Coffee + if err := db.First(&product, id).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil) + return + } + + // Parse request body + var req validation.ProductRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + // Validate request (isUpdate = true for partial updates) + if validationErrors := validation.ValidateProductRequest(req, true); validationErrors.HasErrors() { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ + "errors": validationErrors, + }) + return + } + + // Update only provided fields + updates := make(map[string]interface{}) + if req.Name != "" { + updates["name"] = req.Name + } + if req.RoastType != "" { + updates["roast_type"] = req.RoastType + } + if req.Ounces != 0 { + updates["ounces"] = req.Ounces + } + if req.BeanType != "" { + updates["bean_type"] = req.BeanType + } + if req.Price != 0 { + updates["price"] = req.Price + } + if req.Color != "" { + updates["color"] = req.Color + } + if req.Description != "" { + updates["description"] = req.Description + } + + if err := db.Model(&product).Updates(updates).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update product", nil) + return + } + + // Reload product to get updated values + db.First(&product, id) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "product": product, + }) +} + +// DeleteProduct soft deletes a product +func (h *ProductHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + + // Get ID from URL parameter + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil) + return + } + + // Check if product exists + var product models.Coffee + if err := db.First(&product, id).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil) + return + } + + // Soft delete (sets deleted_at timestamp) + if err := db.Delete(&product).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to delete product", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "message": "product deleted successfully", + }) +} diff --git a/api/handlers/products_crud_test.go b/api/handlers/products_crud_test.go new file mode 100644 index 0000000..d56eb60 --- /dev/null +++ b/api/handlers/products_crud_test.go @@ -0,0 +1,417 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/validation" +) + +func TestGetProduct(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewProductHandler() + + // Create a router with chi to test URL parameters + r := chi.NewRouter() + r.Get("/products/{id}", handler.GetProduct) + + // Test getting existing product + req := httptest.NewRequest(http.MethodGet, "/products/1", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response struct { + Success bool `json:"success"` + Data struct { + Product models.Coffee `json:"product"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if response.Data.Product.Name != "Espresso" { + t.Errorf("Expected Espresso, got %s", response.Data.Product.Name) + } +} + +func TestGetProductNotFound(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewProductHandler() + + r := chi.NewRouter() + r.Get("/products/{id}", handler.GetProduct) + + req := httptest.NewRequest(http.MethodGet, "/products/999", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestCreateProduct(t *testing.T) { + testDB := "test_create.db" + defer os.Remove(testDB) + defer database.ResetForTesting() + + database.ResetForTesting() + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + if err := database.Migrate(db); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + handler := NewProductHandler() + + productReq := validation.ProductRequest{ + Name: "Test Coffee", + RoastType: "Medium Roast", + Ounces: 10, + BeanType: "Test Beans", + Price: 6.99, + Color: "#FF0000", + Description: "A test coffee", + } + + body, _ := json.Marshal(productReq) + req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handler.CreateProduct(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response struct { + Success bool `json:"success"` + Data struct { + Product models.Coffee `json:"product"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if response.Data.Product.Name != "Test Coffee" { + t.Errorf("Expected Test Coffee, got %s", response.Data.Product.Name) + } + + if response.Data.Product.ID == 0 { + t.Error("Expected product ID to be set") + } +} + +func TestCreateProductValidationError(t *testing.T) { + testDB := "test_create_validation.db" + defer os.Remove(testDB) + defer database.ResetForTesting() + + database.ResetForTesting() + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + if err := database.Migrate(db); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + handler := NewProductHandler() + + // Invalid product (missing name, negative price) + productReq := validation.ProductRequest{ + Name: "", + Price: -5.00, + } + + body, _ := json.Marshal(productReq) + req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handler.CreateProduct(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +func TestUpdateProduct(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewProductHandler() + + r := chi.NewRouter() + r.Put("/products/{id}", handler.UpdateProduct) + + // Update price of Espresso + updateReq := validation.ProductRequest{ + Price: 4.50, + } + + body, _ := json.Marshal(updateReq) + req := httptest.NewRequest(http.MethodPut, "/products/1", bytes.NewReader(body)) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response struct { + Success bool `json:"success"` + Data struct { + Product models.Coffee `json:"product"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if response.Data.Product.Price != 4.50 { + t.Errorf("Expected price 4.50, got %.2f", response.Data.Product.Price) + } + + // Name should still be Espresso + if response.Data.Product.Name != "Espresso" { + t.Errorf("Expected name Espresso, got %s", response.Data.Product.Name) + } +} + +func TestUpdateProductNotFound(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewProductHandler() + + r := chi.NewRouter() + r.Put("/products/{id}", handler.UpdateProduct) + + updateReq := validation.ProductRequest{ + Price: 4.50, + } + + body, _ := json.Marshal(updateReq) + req := httptest.NewRequest(http.MethodPut, "/products/999", bytes.NewReader(body)) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestDeleteProduct(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewProductHandler() + + r := chi.NewRouter() + r.Delete("/products/{id}", handler.DeleteProduct) + + // Delete product 1 + req := httptest.NewRequest(http.MethodDelete, "/products/1", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify product is soft deleted + db := database.GetDB() + var product models.Coffee + err := db.First(&product, 1).Error + if err == nil { + t.Error("Expected product to be soft deleted (not found in normal query)") + } + + // Verify it exists in unscoped query + err = db.Unscoped().First(&product, 1).Error + if err != nil { + t.Error("Expected product to still exist in database (soft delete)") + } + + if product.DeletedAt.Time.IsZero() { + t.Error("Expected DeletedAt to be set") + } +} + +func TestDeleteProductNotFound(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewProductHandler() + + r := chi.NewRouter() + r.Delete("/products/{id}", handler.DeleteProduct) + + req := httptest.NewRequest(http.MethodDelete, "/products/999", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestCRUDFlow(t *testing.T) { + testDB := "test_crud_flow.db" + defer os.Remove(testDB) + defer database.ResetForTesting() + + database.ResetForTesting() + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + if err := database.Migrate(db); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + handler := NewProductHandler() + + // 1. Create a product + productReq := validation.ProductRequest{ + Name: "Flow Test Coffee", + RoastType: "Dark Roast", + Ounces: 8, + BeanType: "Colombian", + Price: 7.99, + Color: "#000000", + Description: "Testing full CRUD flow", + } + + body, _ := json.Marshal(productReq) + req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) + w := httptest.NewRecorder() + handler.CreateProduct(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("Failed to create product: %d", w.Code) + } + + var createResp struct { + Success bool `json:"success"` + Data struct { + Product models.Coffee `json:"product"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&createResp) + productID := createResp.Data.Product.ID + + // 2. Get the product + r := chi.NewRouter() + r.Get("/products/{id}", handler.GetProduct) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/products/%d", productID), nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Failed to get product: %d", w.Code) + } + + // 3. Update the product + r = chi.NewRouter() + r.Put("/products/{id}", handler.UpdateProduct) + + updateReq := validation.ProductRequest{ + Price: 8.99, + Name: "Updated Flow Test Coffee", + } + body, _ = json.Marshal(updateReq) + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/products/%d", productID), bytes.NewReader(body)) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Failed to update product: %d", w.Code) + } + + var updateResp struct { + Success bool `json:"success"` + Data struct { + Product models.Coffee `json:"product"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&updateResp) + + if updateResp.Data.Product.Price != 8.99 { + t.Errorf("Expected price 8.99, got %.2f", updateResp.Data.Product.Price) + } + + if updateResp.Data.Product.Name != "Updated Flow Test Coffee" { + t.Errorf("Expected name Updated Flow Test Coffee, got %s", updateResp.Data.Product.Name) + } + + // 4. Delete the product + r = chi.NewRouter() + r.Delete("/products/{id}", handler.DeleteProduct) + + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/products/%d", productID), nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Failed to delete product: %d", w.Code) + } + + // 5. Verify product is gone + var product models.Coffee + err = db.First(&product, productID).Error + if err == nil { + t.Error("Expected product to be soft deleted") + } +} diff --git a/api/handlers/products_test.go b/api/handlers/products_test.go new file mode 100644 index 0000000..026d5e6 --- /dev/null +++ b/api/handlers/products_test.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +func setupTestDB(t *testing.T) string { + testDB := "test_products.db" + + // Remove if exists + os.Remove(testDB) + + // Reset singleton + database.ResetForTesting() + + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect to test database: %v", err) + } + + if err := database.Migrate(db); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + if err := database.Seed(db); err != nil { + t.Fatalf("Failed to seed: %v", err) + } + + return testDB +} + +func TestGetProducts(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + + handler := NewProductHandler() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil) + w := httptest.NewRecorder() + + handler.GetProducts(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Parse response + var response struct { + Success bool `json:"success"` + Data struct { + Products []models.Coffee `json:"products"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if len(response.Data.Products) != 6 { + t.Errorf("Expected 6 products, got %d", len(response.Data.Products)) + } + + // Verify product fields + for _, p := range response.Data.Products { + if p.ID == 0 { + t.Error("Product ID should not be 0") + } + if p.Name == "" { + t.Error("Product name should not be empty") + } + if p.Price <= 0 { + t.Errorf("Product %s has invalid price: %f", p.Name, p.Price) + } + } + + // Verify Content-Type header + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", contentType) + } +} + +func TestGetProductsEmptyDatabase(t *testing.T) { + testDB := "test_empty.db" + defer os.Remove(testDB) + defer database.ResetForTesting() + + // Create database but don't seed + database.ResetForTesting() + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + if err := database.Migrate(db); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + handler := NewProductHandler() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil) + w := httptest.NewRecorder() + + handler.GetProducts(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response struct { + Success bool `json:"success"` + Data struct { + Products []models.Coffee `json:"products"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if !response.Success { + t.Error("Expected success to be true") + } + + if len(response.Data.Products) != 0 { + t.Errorf("Expected 0 products, got %d", len(response.Data.Products)) + } +} diff --git a/api/main.go b/api/main.go new file mode 100644 index 0000000..e2c298a --- /dev/null +++ b/api/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "terminalShop/api/routes" + "terminalShop/pkg/config" + "terminalShop/pkg/database" +) + +const version = "v0.1.0" + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Initialize database connection + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() + + // Run migrations + if err := database.Migrate(db); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Seed database with initial data + if err := database.Seed(db); err != nil { + log.Fatalf("Failed to seed database: %v", err) + } + + // Setup routes + router := routes.SetupRoutes(version) + + // Create HTTP server + server := &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.APIPort), + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Channel to listen for interrupt signals + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + // Start server in a goroutine + go func() { + log.Printf("Starting API server on port %s", cfg.APIPort) + log.Printf("Environment: %s", cfg.Environment) + log.Printf("Database: %s", cfg.DatabaseURL) + log.Printf("Health check: http://localhost:%s/api/v1/health", cfg.APIPort) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed to start: %v", err) + } + }() + + log.Println("API server started. Press Ctrl+C to shutdown.") + + // Block until signal received + <-done + log.Println("Server is shutting down...") + + // Graceful shutdown with 30 second timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited gracefully") +} diff --git a/api/middleware/cors.go b/api/middleware/cors.go new file mode 100644 index 0000000..4a35db4 --- /dev/null +++ b/api/middleware/cors.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "net/http" + + "github.com/go-chi/cors" +) + +// CORS returns a configured CORS middleware +func CORS() func(http.Handler) http.Handler { + return cors.Handler(cors.Options{ + AllowedOrigins: []string{"http://localhost:*", "https://localhost:*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, // 5 minutes + }) +} diff --git a/api/middleware/logger.go b/api/middleware/logger.go new file mode 100644 index 0000000..a6d1c2c --- /dev/null +++ b/api/middleware/logger.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "log" + "net/http" + "time" +) + +// responseWriter wraps http.ResponseWriter to capture status code +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func newResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{w, http.StatusOK} +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// Logger logs HTTP requests +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := newResponseWriter(w) + next.ServeHTTP(wrapped, r) + + duration := time.Since(start) + + log.Printf( + "%s %s %d %s", + r.Method, + r.RequestURI, + wrapped.statusCode, + duration, + ) + }) +} diff --git a/api/middleware/recovery.go b/api/middleware/recovery.go new file mode 100644 index 0000000..69cc79e --- /dev/null +++ b/api/middleware/recovery.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" + + "terminalShop/pkg/utils" +) + +// Recovery recovers from panics and returns a 500 error +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("PANIC: %v\n%s", err, debug.Stack()) + + utils.RespondError( + w, + http.StatusInternalServerError, + "INTERNAL_ERROR", + "an unexpected error occurred", + nil, + ) + } + }() + + next.ServeHTTP(w, r) + }) +} diff --git a/api/routes/routes.go b/api/routes/routes.go new file mode 100644 index 0000000..e5dfa35 --- /dev/null +++ b/api/routes/routes.go @@ -0,0 +1,45 @@ +package routes + +import ( + "github.com/go-chi/chi/v5" + + "terminalShop/api/handlers" + "terminalShop/api/middleware" +) + +// SetupRoutes configures all API routes +func SetupRoutes(version string) *chi.Mux { + r := chi.NewRouter() + + // Apply global middleware + r.Use(middleware.Logger) + r.Use(middleware.CORS()) + r.Use(middleware.Recovery) + + // Initialize handlers + healthHandler := handlers.NewHealthHandler(version) + productHandler := handlers.NewProductHandler() + authHandler := handlers.NewAuthHandler() + + // API v1 routes + r.Route("/api/v1", func(r chi.Router) { + // Health & testing endpoints + r.Get("/health", healthHandler.GetHealth) + r.Get("/ping", healthHandler.Ping) + + // Authentication endpoints (SSH key-based) + r.Post("/auth/register", authHandler.RegisterWithSSHKey) // POST /api/v1/auth/register + r.Get("/auth/user", authHandler.GetUserBySSHKey) // GET /api/v1/auth/user?fingerprint=xxx + + // Product endpoints (full CRUD) + r.Route("/products", func(r chi.Router) { + r.Get("/", productHandler.GetProducts) // GET /api/v1/products + r.Post("/", productHandler.CreateProduct) // POST /api/v1/products + r.Get("/{id}", productHandler.GetProduct) // GET /api/v1/products/:id + r.Put("/{id}", productHandler.UpdateProduct) // PUT /api/v1/products/:id + r.Delete("/{id}", productHandler.DeleteProduct) // DELETE /api/v1/products/:id + }) + }) + + return r +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..afe2a24 --- /dev/null +++ b/go.mod @@ -0,0 +1,62 @@ +module terminalShop + +go 1.25.7 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 + github.com/charmbracelet/wish v1.4.7 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.48.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/log v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/conpty v0.1.0 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stripe/stripe-go/v78 v78.12.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ebccf5 --- /dev/null +++ b/go.sum @@ -0,0 +1,137 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4= +github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/main b/main new file mode 100755 index 0000000..d414120 Binary files /dev/null and b/main differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..6394668 --- /dev/null +++ b/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" + + "terminalShop/pkg/auth" + "terminalShop/pkg/config" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/tui" +) + +const ( + host = "localhost" + port = "23456" +) + +var authService *auth.SSHAuthService + +func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { + // Extract SSH public key from session + pubKey := s.PublicKey() + + var user *models.User + + if pubKey != nil { + // Authenticate with SSH key (auto-creates user on first connect!) + authenticatedUser, _, err := authService.AuthenticateSSHKey(pubKey) + if err != nil { + log.Printf("Auth error: %v", err) + // Continue as guest + } else { + user = authenticatedUser + displayName := user.SSHKeyFingerprint[:16] + if user.Name != "" { + displayName = user.Name + } + log.Printf("User authenticated: %s (ID: %d)", displayName, user.ID) + } + } + + // Create TUI model with user context (no registration screen needed!) + username := "guest" + if user != nil && user.Name != "" { + username = user.Name + } else if user != nil { + username = user.SSHKeyFingerprint[:16] + } + m := tui.NewModel(username) + return m, []tea.ProgramOption{tea.WithAltScreen()} +} + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Initialize database connection (for user management) + // Use same database as API server + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() + + // Run migrations (includes users table) + if err := database.Migrate(db); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Initialize JWT manager (uses same secret as API) + jwtManager := auth.NewJWTManager(cfg.JWTSecret, 24*time.Hour) + + // Initialize SSH auth service + authService = auth.NewSSHAuthService(db, jwtManager) + + // Create SSH server + s, err := wish.NewServer( + wish.WithAddress(fmt.Sprintf("%s:%s", host, port)), + wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + // Allow all public keys (we handle auth in the tea handler) + return true + }), + wish.WithMiddleware( + bubbletea.Middleware(teaHandler), + logging.Middleware(), + ), + ) + if err != nil { + log.Fatalln(err) + } + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + log.Printf("Starting SSH server on %s:%s", host, port) + log.Printf("Connect with: ssh %s -p %s", host, port) + + go func() { + if err = s.ListenAndServe(); err != nil { + log.Fatalln(err) + } + }() + + <-done + log.Println("Stopping SSH server") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + log.Fatalln(err) + } +} diff --git a/model.go.old b/model.go.old new file mode 100644 index 0000000..36826d3 --- /dev/null +++ b/model.go.old @@ -0,0 +1,710 @@ +package main + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + + +// Account menu items struct. +var accountMenuItems = []string { + "order history", + "faq", + "about", +} + +// Coffee represents a coffee product +type Coffee struct { + Name string + RoastType string + Ounces int + BeanType string + Price float64 + Color string // Hex color for background + Description string +} + +// CartItem represents an item in the cart +type CartItem struct { + Coffee Coffee + Quantity int +} + +type model struct { + username string + coffees []Coffee + cursor int + cart map[int]*CartItem // maps coffee index to cart item + cartCursor int // cursor position in cart view + accountCursor int + accountPage string + windowWidth int + windowHeight int + viewingCart bool // true when viewing cart details + viewingAccount bool +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowWidth = msg.Width + m.windowHeight = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "s": + m.viewingCart = false + m.viewingAccount = false + m.accountPage = "" + + case "c": + m.viewingCart = true + m.viewingAccount = false + m.accountPage = "" + + case "a": + m.viewingCart = false + m.viewingAccount = true + + case "up", "k": + if m.viewingAccount && m.accountPage == "" { + if m.accountCursor > 0 { + m.accountCursor-- + } + } else if !m.viewingCart && !m.viewingAccount && m.cursor > 0 { + m.cursor-- + } else if m.viewingCart && m.cartCursor > 0 { + m.cartCursor-- + } + + case "down", "j": + if m.viewingAccount && m.accountPage == "" { + if m.accountCursor < len(accountMenuItems)-1 { + m.accountCursor++ + } + } else if !m.viewingCart && !m.viewingAccount && m.cursor < len(m.coffees)-1 { + m.cursor++ + } else if m.viewingCart && m.cartCursor < len(m.cart)-1 { + m.cartCursor++ + } + + case "+", "=": + if !m.viewingCart { + // Increment quantity in shop view + if item, exists := m.cart[m.cursor]; exists { + item.Quantity++ + } else { + m.cart[m.cursor] = &CartItem{ + Coffee: m.coffees[m.cursor], + Quantity: 1, + } + } + } else { + // Increment quantity in cart view + cartItems := m.getCartItemsSlice() + if m.cartCursor >= 0 && m.cartCursor < len(cartItems) { + cartItems[m.cartCursor].Quantity++ + } + } + + case "enter": + if m.viewingAccount && m.accountPage == "" { + switch accountMenuItems[m.accountCursor]{ + case "order history": + m.accountPage = "orders" + case "faq": + m.accountPage = "faq" + case "about": + m.accountPage = "about" + } + } + + case "esc": + if m.viewingAccount && m.accountPage != "" { + m.accountPage = "" + } + + case "-", "_": + if !m.viewingCart { + // Decrement quantity in shop view + if item, exists := m.cart[m.cursor]; exists { + item.Quantity-- + if item.Quantity <= 0 { + delete(m.cart, m.cursor) + // Reset cart cursor if we deleted the last item + if len(m.cart) == 0 { + m.cartCursor = 0 + } else if m.cartCursor >= len(m.cart) { + m.cartCursor = len(m.cart) - 1 + } + } + } + } else { + // Decrement quantity in cart view + cartItems := m.getCartItemsSlice() + if m.cartCursor >= 0 && m.cartCursor < len(cartItems) { + cartItems[m.cartCursor].Quantity-- + if cartItems[m.cartCursor].Quantity <= 0 { + // Find and delete this item from the cart + for idx, item := range m.cart { + if item == cartItems[m.cartCursor] { + delete(m.cart, idx) + break + } + } + // Reset cart cursor + if len(m.cart) == 0 { + m.cartCursor = 0 + } else if m.cartCursor >= len(m.cart) { + m.cartCursor = len(m.cart) - 1 + } + } + } + } + } + } + return m, nil +} + +func (m model) View() string { + if m.windowWidth == 0 { + m.windowWidth = 120 + m.windowHeight = 30 + } + + // Build header with cart tab + header := m.buildHeader() + + // Build main content based on view + var content string + if m.viewingAccount { + content = m.buildAccountView() + } else if m.viewingCart { + content = m.buildCartView() + } else { + content = m.buildShopView() + } + + // Add vertical spacing around content + contentWithPadding := lipgloss.NewStyle(). + MarginTop(1). + MarginBottom(4). + Render(content) + + // Build footer + footer := m.buildFooter() + + // Combine all app content + appContent := lipgloss.JoinVertical( + lipgloss.Left, + header, + contentWithPadding, + footer, + ) + + // Calculate vertical centering + appHeight := lipgloss.Height(appContent) + verticalPadding := 2 // Minimum padding from top and bottom + availableSpace := m.windowHeight - appHeight - (verticalPadding * 2) + + if availableSpace < 0 { + availableSpace = 0 + verticalPadding = 0 + } + + // Split remaining space between top and bottom + topPadding := verticalPadding + (availableSpace / 2) + + // Create top spacing + topSpacing := "" + for i := 0; i < topPadding; i++ { + topSpacing += "\n" + } + + return topSpacing + appContent +} + +func (m model) buildHeader() string { + // Calculate cart total and item count + total := 0.0 + itemCount := 0 + for _, item := range m.cart { + total += float64(item.Quantity) * item.Coffee.Price + itemCount += item.Quantity + } + + // Define colors + whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + + // Build shop tab + var shopKeybind, shopName string + if !m.viewingCart { + // Active: bold white keybind and name + shopKeybind = boldWhiteStyle.Render("s") + shopName = boldWhiteStyle.Render("shop") + } else { + // Inactive: bold gray keybind, gray name + shopKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("s") + shopName = grayStyle.Render("shop") + } + + // Build cart tab + var cartKeybind, cartName, cartInfo string + itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount)) + + if m.viewingCart { + // Active: bold white keybind and name + cartKeybind = boldWhiteStyle.Render("c") + cartName = boldWhiteStyle.Render("cart") + cartInfo = fmt.Sprintf(" %s %s", + boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)), + itemCountText) + } else { + // Inactive: bold gray keybind, gray name + boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true) + boldWhitePriceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + cartKeybind = boldGrayStyle.Render("c") + cartName = grayStyle.Render("cart") + cartInfo = fmt.Sprintf(" %s %s", + boldWhitePriceStyle.Render(fmt.Sprintf("$%.2f", total)), + itemCountText) + } + + // Build Account Tabs + var accountKeybind, accountName string + if m.viewingAccount { + accountKeybind = boldWhiteStyle.Render("a") + accountName = boldWhiteStyle.Render("account") + } else { + accountKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("a") + accountName = grayStyle.Render("account") + } + + // Build the tabs with separator only in the middle + separator := whiteStyle.Render("|") + tabsContent := fmt.Sprintf("%s %s %s %s %s%s %s %s %s", + shopKeybind, + shopName, + separator, + cartKeybind, + cartName, + cartInfo, + separator, + accountKeybind, + accountName, + ) + + // Add box with padding (no vertical padding, just horizontal) + tabBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(0, 2). + MarginBottom(1) + + // Center the whole thing + centered := lipgloss.NewStyle(). + Width(m.windowWidth). + Align(lipgloss.Center) + + return centered.Render(tabBox.Render(tabsContent)) +} + +func (m model) buildShopView() string { + leftWidth := 18 // Narrower list + leftMargin := 4 // Minimal left margin + rightWidth := m.windowWidth - leftWidth - leftMargin - 4 + + // Build the left panel (coffee list) + leftPanel := "" + for i, coffee := range m.coffees { + // Apply background color when selected, white text + if m.cursor == i { + style := lipgloss.NewStyle(). + Background(lipgloss.Color(coffee.Color)). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(coffee.Name) + "\n" + } else { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(coffee.Name) + "\n" + } + } + + // Add left margin to the list + leftContainer := lipgloss.NewStyle(). + Width(leftWidth). + MarginLeft(leftMargin) + + // Build the detail view (right panel) + detailView := "" + if m.cursor >= 0 && m.cursor < len(m.coffees) { + selectedCoffee := m.coffees[m.cursor] + + nameStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")) + + infoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")) + + priceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(selectedCoffee.Color)). + Bold(true) + + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Width(rightWidth - 4) + + quantityStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + + // Get current quantity + quantity := 0 + if item, exists := m.cart[m.cursor]; exists { + quantity = item.Quantity + } + + detailView += nameStyle.Render(selectedCoffee.Name) + "\n" + detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s", + selectedCoffee.RoastType, + selectedCoffee.Ounces, + selectedCoffee.BeanType)) + "\n\n" + detailView += priceStyle.Render(fmt.Sprintf("$%.2f", selectedCoffee.Price)) + "\n\n" + detailView += descStyle.Render(selectedCoffee.Description) + "\n\n" + detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n" + } + + detailContainer := lipgloss.NewStyle(). + Width(rightWidth) + + // Layout the panels side by side + mainContent := lipgloss.JoinHorizontal( + lipgloss.Top, + leftContainer.Render(leftPanel), + detailContainer.Render(detailView), + ) + + return mainContent +} + +func (m model) buildCartView() string { + if len(m.cart) == 0 { + emptyStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Align(lipgloss.Center). + Width(m.windowWidth). + Padding(4, 0) + return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop") + } + + cartItems := "" + boxWidth := m.windowWidth - 20 // Leave some margin + itemSlice := m.getCartItemsSlice() + + for idx, item := range itemSlice { + itemTotal := float64(item.Quantity) * item.Coffee.Price + + // Left side: name and info + nameStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")) + + infoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")) + + // Right side: quantity controls and price + quantityStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + + priceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + + // Build content with proper spacing + nameText := nameStyle.Render(item.Coffee.Name) + infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s", + item.Coffee.RoastType, + item.Coffee.Ounces, + item.Coffee.BeanType)) + quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity)) + priceText := priceStyle.Render(fmt.Sprintf("$%.2f", itemTotal)) + + // Calculate spacing + nameWidth := lipgloss.Width(nameText) + quantityWidth := lipgloss.Width(quantityText) + priceWidth := lipgloss.Width(priceText) + spacing := boxWidth - nameWidth - quantityWidth - priceWidth - 8 // 8 for padding and gaps + + if spacing < 1 { + spacing = 1 + } + + contentLine1 := nameText + + lipgloss.NewStyle().Width(spacing).Render("") + + quantityText + " " + priceText + + contentLine2 := infoText + + boxContent := contentLine1 + "\n" + contentLine2 + + // Create box for this item + itemBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(0, 2). + Width(boxWidth). + Foreground(lipgloss.Color("#FFFFFF")) + + // Center the box + centered := lipgloss.NewStyle(). + Width(m.windowWidth). + Align(lipgloss.Center) + + cartItems += centered.Render(itemBox.Render(boxContent)) + + // Add spacing between items + if idx < len(itemSlice)-1 { + cartItems += "\n\n" + } + } + + return cartItems +} + +func (m model) buildAccountView() string { + // If we're on a subpage, show that content + if m.accountPage != "" { + return m.buildAccountSubpage() + } + + // Otherwise show the account menu + leftWidth := 18 + leftMargin := 4 + + leftPanel := "" + for i, item := range accountMenuItems { + // Apply background color when selected, white text + if m.accountCursor == i { + style := lipgloss.NewStyle(). + Background(lipgloss.Color("#4682B4")). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(item) + "\n" + } else { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(item) + "\n" + } + } + + leftContainer := lipgloss.NewStyle(). + Width(leftWidth). + MarginLeft(leftMargin) + + return leftContainer.Render(leftPanel) +} + +func (m model) buildAccountSubpage() string { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + MarginBottom(2) + + contentStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Width(m.windowWidth - 20). + MarginLeft(4) + + var content string + switch m.accountPage { + case "orders": + content = titleStyle.Render("Order History") + "\n\n" + + contentStyle.Render("No orders yet. Your order history will appear here once you make a purchase.") + case "faq": + content = titleStyle.Render("FAQ") + "\n\n" + + contentStyle.Render("Q: How do I place an order?\nA: Browse products with j/k, adjust quantity with +/-, and checkout via the cart.\n\nQ: What payment methods do you accept?\nA: We accept all major credit cards via Stripe.\n\nPress ESC to go back.") + case "about": + content = titleStyle.Render("About Terminal Coffee Shop") + "\n\n" + + contentStyle.Render("A terminal-based coffee ordering experience.\nBuilt with Go and Bubbletea.\n\nPress ESC to go back.") + default: + content = "Unknown page" + } + + return content +} + +// getCartItemsSlice converts the cart map to a sorted slice for consistent iteration +func (m model) getCartItemsSlice() []*CartItem { + items := make([]*CartItem, 0, len(m.cart)) + for _, item := range m.cart { + items = append(items, item) + } + return items +} + +func (m model) buildFooter() string { + // Style for keybinds (bold) + keybindStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Bold(true) + + // Style for descriptions (normal) + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")) + + var footerText string + if m.viewingAccount { + if m.accountPage != "" { + // In a subpage + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", + keybindStyle.Render("esc"), + descStyle.Render("back"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("c"), + descStyle.Render("cart"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else { + // In account menu + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("menu"), + keybindStyle.Render("enter"), + descStyle.Render("select"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("c"), + descStyle.Render("cart"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } + } else if m.viewingCart { + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("items"), + keybindStyle.Render("+/-"), + descStyle.Render("qty"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("a"), + descStyle.Render("account"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else { + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("products"), + keybindStyle.Render("+/-"), + descStyle.Render("qty"), + keybindStyle.Render("c"), + descStyle.Render("cart"), + keybindStyle.Render("a"), + descStyle.Render("account"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } + + return lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(m.windowWidth). + Render(footerText) +} + +// InitialModel creates a new model with default coffee options +func InitialModel(username string) model { + return model{ + username: username, + coffees: []Coffee{ + { + Name: "Espresso", + RoastType: "Dark Roast", + Ounces: 2, + BeanType: "Arabica", + Price: 3.50, + Color: "#8B4513", + Description: "A bold, concentrated shot of pure coffee bliss. Perfect for those who need an immediate caffeine injection to survive the day.", + }, + { + Name: "Latte", + RoastType: "Medium Roast", + Ounces: 12, + BeanType: "Arabica Blend", + Price: 5.00, + Color: "#D2691E", + Description: "Smooth espresso paired with steamed milk and a light layer of foam. For when you want coffee but also want to feel fancy about it.", + }, + { + Name: "Cappuccino", + RoastType: "Medium Roast", + Ounces: 8, + BeanType: "Italian Blend", + Price: 4.50, + Color: "#CD853F", + Description: "Equal parts espresso, steamed milk, and foam. The classic Italian choice for people who know what they're doing.", + }, + { + Name: "Americano", + RoastType: "Dark Roast", + Ounces: 16, + BeanType: "Arabica", + Price: 4.00, + Color: "#A0522D", + Description: "Espresso with hot water. Simple, strong, and no-nonsense. This is coffee for people who actually like the taste of coffee.", + }, + { + Name: "Cold Brew", + RoastType: "Medium Roast", + Ounces: 16, + BeanType: "Colombian", + Price: 5.50, + Color: "#4682B4", + Description: "Steeped for 12 hours to create a smooth, less acidic coffee experience. Served cold because sometimes hot coffee is just too much effort.", + }, + { + Name: "Macchiato", + RoastType: "Dark Roast", + Ounces: 3, + BeanType: "Robusta Blend", + Price: 4.25, + Color: "#DAA520", + Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", + }, + }, + cursor: 0, + cart: make(map[int]*CartItem), + cartCursor: 0, + accountCursor: 0, + accountPage: "", + windowWidth: 120, + windowHeight: 30, + viewingCart: false, + viewingAccount: false, + } +} diff --git a/pkg/api/client.go b/pkg/api/client.go new file mode 100644 index 0000000..4b049a2 --- /dev/null +++ b/pkg/api/client.go @@ -0,0 +1,144 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "terminalShop/pkg/models" +) + +// Client handles communication with the API +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +// NewClient creates a new API client +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// APIResponse represents the standard API response format +type APIResponse struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +// APIError represents an error from the API +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// ProductsResponse represents the products API response +type ProductsResponse struct { + Success bool `json:"success"` + Data struct { + Products []models.Coffee `json:"products"` + } `json:"data"` +} + +// GetProducts fetches all products from the API +func (c *Client) GetProducts() ([]models.Coffee, error) { + url := fmt.Sprintf("%s/api/v1/products", c.BaseURL) + + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch products: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var productsResp ProductsResponse + if err := json.NewDecoder(resp.Body).Decode(&productsResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !productsResp.Success { + return nil, fmt.Errorf("API request was not successful") + } + + return productsResp.Data.Products, nil +} + +// Health checks if the API is healthy +func (c *Client) Health() error { + url := fmt.Sprintf("%s/api/v1/health", c.BaseURL) + + resp, err := c.HTTPClient.Get(url) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API returned status %d", resp.StatusCode) + } + + return nil +} + +// RegisterRequest represents a user registration request +type RegisterRequest struct { + Username string `json:"username"` + SSHPublicKey string `json:"ssh_public_key"` + SSHKeyFingerprint string `json:"ssh_key_fingerprint"` +} + +// RegisterResponse represents the registration API response +type RegisterResponse struct { + Success bool `json:"success"` + Data struct { + User models.User `json:"user"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +// RegisterUser registers a new user with SSH key authentication +func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string) (*models.User, error) { + url := fmt.Sprintf("%s/api/v1/auth/register", c.BaseURL) + + reqBody := RegisterRequest{ + Username: username, + SSHPublicKey: sshPublicKey, + SSHKeyFingerprint: sshKeyFingerprint, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := c.HTTPClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to register user: %w", err) + } + defer resp.Body.Close() + + var registerResp RegisterResponse + if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !registerResp.Success { + if registerResp.Error != nil { + return nil, fmt.Errorf("%s: %s", registerResp.Error.Code, registerResp.Error.Message) + } + return nil, fmt.Errorf("registration failed") + } + + return ®isterResp.Data.User, nil +} diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go new file mode 100644 index 0000000..e70a411 --- /dev/null +++ b/pkg/auth/jwt.go @@ -0,0 +1,85 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + gossh "golang.org/x/crypto/ssh" +) + +// Claims represents the JWT claims +type Claims struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +// JWTManager handles JWT token generation and validation +type JWTManager struct { + secretKey string + tokenDuration time.Duration +} + +// NewJWTManager creates a new JWT manager +func NewJWTManager(secretKey string, tokenDuration time.Duration) *JWTManager { + return &JWTManager{ + secretKey: secretKey, + tokenDuration: tokenDuration, + } +} + +// GenerateToken generates a new JWT token for a user +func (m *JWTManager) GenerateToken(userID uint, email, username string) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.tokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "terminalshop", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +// ValidateToken validates a JWT token and returns the claims +func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Verify signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(m.secretKey), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +// GetSSHKeyFingerprint returns the MD5 fingerprint of an SSH public key +// This matches the format shown by `ssh-keygen -lf ~/.ssh/id_rsa.pub` +func GetSSHKeyFingerprint(pubKey gossh.PublicKey) string { + hash := sha256.Sum256(pubKey.Marshal()) + return "SHA256:" + hex.EncodeToString(hash[:]) +} + +// FormatSSHPublicKey formats an SSH public key in authorized_keys format +func FormatSSHPublicKey(pubKey gossh.PublicKey) string { + return string(gossh.MarshalAuthorizedKey(pubKey)) +} diff --git a/pkg/auth/ssh_auth.go b/pkg/auth/ssh_auth.go new file mode 100644 index 0000000..0808d6d --- /dev/null +++ b/pkg/auth/ssh_auth.go @@ -0,0 +1,112 @@ +package auth + +import ( + "fmt" + + gossh "golang.org/x/crypto/ssh" + "gorm.io/gorm" + "terminalShop/pkg/models" +) + +// SSHAuthService handles SSH key-based authentication +type SSHAuthService struct { + db *gorm.DB + jwtManager *JWTManager +} + +// NewSSHAuthService creates a new SSH auth service +func NewSSHAuthService(db *gorm.DB, jwtManager *JWTManager) *SSHAuthService { + return &SSHAuthService{ + db: db, + jwtManager: jwtManager, + } +} + +// AuthenticateSSHKey authenticates a user by their SSH public key +// Auto-creates user on first connect (following terminal.shop pattern) +// Returns the user and a JWT token +func (s *SSHAuthService) AuthenticateSSHKey(publicKey gossh.PublicKey) (*models.User, string, error) { + fingerprint := GetSSHKeyFingerprint(publicKey) + sshKey := FormatSSHPublicKey(publicKey) + + var user models.User + err := s.db.Where("ssh_key_fingerprint = ?", fingerprint).First(&user).Error + + if err == gorm.ErrRecordNotFound { + // User not found - auto-create (no registration screen needed!) + user = models.User{ + SSHKeyFingerprint: fingerprint, + SSHPublicKey: sshKey, + Anonymous: false, + } + + if err := s.db.Create(&user).Error; err != nil { + return nil, "", fmt.Errorf("failed to create user: %w", err) + } + } else if err != nil { + return nil, "", fmt.Errorf("database error: %w", err) + } + + // Generate JWT token for this session + token, err := s.jwtManager.GenerateToken(user.ID, "", fingerprint[:8]) + if err != nil { + return nil, "", fmt.Errorf("failed to generate token: %w", err) + } + + return &user, token, nil +} + +// RegisterWithSSHKey creates a new user account with the given SSH key +// No username required - matches terminal.shop pattern +func (s *SSHAuthService) RegisterWithSSHKey(publicKey gossh.PublicKey, name, email string) (*models.User, string, error) { + fingerprint := GetSSHKeyFingerprint(publicKey) + sshKeyFormatted := FormatSSHPublicKey(publicKey) + + // Check if SSH key is already registered + var existing models.User + err := s.db.Where("ssh_key_fingerprint = ?", fingerprint).First(&existing).Error + if err == nil { + return nil, "", fmt.Errorf("SSH key already registered") + } + if err != gorm.ErrRecordNotFound { + return nil, "", fmt.Errorf("database error: %w", err) + } + + // Create new user (no username required!) + user := models.User{ + SSHPublicKey: sshKeyFormatted, + SSHKeyFingerprint: fingerprint, + Name: name, + Email: email, + } + + if err := s.db.Create(&user).Error; err != nil { + return nil, "", fmt.Errorf("failed to create user: %w", err) + } + + // Generate JWT token + token, err := s.jwtManager.GenerateToken(user.ID, "", fingerprint[:8]) + if err != nil { + return nil, "", fmt.Errorf("failed to generate token: %w", err) + } + + return &user, token, nil +} + +// IsSSHKeyRegistered checks if an SSH key is already registered +func (s *SSHAuthService) IsSSHKeyRegistered(publicKey gossh.PublicKey) (bool, error) { + fingerprint := GetSSHKeyFingerprint(publicKey) + + var user models.User + err := s.db.Where("ssh_key_fingerprint = ?", fingerprint).First(&user).Error + + if err == gorm.ErrRecordNotFound { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("database error: %w", err) + } + + return true, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..a11b590 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +// Config holds all application configuration +type Config struct { + APIPort string + SSHPort string + Environment string + DatabaseURL string + JWTSecret string + StripeSecretKey string + StripePublicKey string + ShippoAPIKey string +} + +// Load reads configuration from environment variables +func Load() (*Config, error) { + // Load .env file in non-production environments + if os.Getenv("ENVIRONMENT") != "production" { + if err := godotenv.Load(); err != nil { + // .env is optional in production + fmt.Println("No .env file found, using environment variables") + } + } + + cfg := &Config{ + APIPort: getEnvOrDefault("API_PORT", "8080"), + SSHPort: getEnvOrDefault("SSH_PORT", "23457"), + Environment: getEnvOrDefault("ENVIRONMENT", "development"), + DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"), + JWTSecret: os.Getenv("JWT_SECRET"), + StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), + StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), + ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), + } + + return cfg, nil +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/pkg/database/db.go b/pkg/database/db.go new file mode 100644 index 0000000..817f05f --- /dev/null +++ b/pkg/database/db.go @@ -0,0 +1,76 @@ +package database + +import ( + "fmt" + "log" + "sync" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var ( + db *gorm.DB + once sync.Once +) + +// Connect initializes the database connection (singleton pattern) +func Connect(dbPath string) (*gorm.DB, error) { + var err error + + once.Do(func() { + // Configure GORM logger for development + config := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + // Connect to SQLite database + db, err = gorm.Open(sqlite.Open(dbPath), config) + if err != nil { + err = fmt.Errorf("failed to connect to database: %w", err) + return + } + + log.Println("Database connection established") + }) + + return db, err +} + +// GetDB returns the database instance (must call Connect first) +func GetDB() *gorm.DB { + if db == nil { + log.Fatal("Database not initialized. Call Connect() first.") + } + return db +} + +// Close closes the database connection +func Close() error { + if db == nil { + return nil + } + + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("failed to get database instance: %w", err) + } + + if err := sqlDB.Close(); err != nil { + return fmt.Errorf("failed to close database: %w", err) + } + + log.Println("Database connection closed") + return nil +} + +// ResetForTesting resets the singleton for testing purposes +// WARNING: Only use this in tests! +func ResetForTesting() { + if db != nil { + Close() + db = nil + once = sync.Once{} + } +} diff --git a/pkg/database/db_test.go b/pkg/database/db_test.go new file mode 100644 index 0000000..7fe6e49 --- /dev/null +++ b/pkg/database/db_test.go @@ -0,0 +1,134 @@ +package database + +import ( + "os" + "testing" + + "terminalShop/pkg/models" +) + +func TestConnect(t *testing.T) { + // Use a temporary test database + testDB := "test_terminalshop.db" + defer os.Remove(testDB) + defer ResetForTesting() + + db, err := Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + if db == nil { + t.Fatal("Database connection is nil") + } + + // Verify we can execute queries + var result int + if err := db.Raw("SELECT 1").Scan(&result).Error; err != nil { + t.Fatalf("Failed to execute query: %v", err) + } + + if result != 1 { + t.Errorf("Expected 1, got %d", result) + } +} + +func TestMigrate(t *testing.T) { + testDB := "test_migrate.db" + defer os.Remove(testDB) + defer ResetForTesting() + + db, err := Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + if err := Migrate(db); err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Verify table exists + var count int64 + db.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='coffees'").Scan(&count) + if count != 1 { + t.Errorf("Expected coffees table to exist, got count: %d", count) + } +} + +func TestSeed(t *testing.T) { + testDB := "test_seed.db" + defer os.Remove(testDB) + defer ResetForTesting() + + db, err := Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + if err := Migrate(db); err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // First seed should insert products + if err := Seed(db); err != nil { + t.Fatalf("First seed failed: %v", err) + } + + var count int64 + db.Model(&models.Coffee{}).Count(&count) + if count != 6 { + t.Errorf("Expected 6 products, got %d", count) + } + + // Second seed should be idempotent (no duplicates) + if err := Seed(db); err != nil { + t.Fatalf("Second seed failed: %v", err) + } + + db.Model(&models.Coffee{}).Count(&count) + if count != 6 { + t.Errorf("Expected 6 products after second seed, got %d", count) + } + + // Verify product data + var products []models.Coffee + db.Find(&products) + + expectedNames := map[string]bool{ + "Espresso": true, + "Americano": true, + "Cappuccino": true, + "Latte": true, + "Mocha": true, + "Macchiato": true, + } + + for _, p := range products { + if !expectedNames[p.Name] { + t.Errorf("Unexpected product name: %s", p.Name) + } + if p.Price <= 0 { + t.Errorf("Product %s has invalid price: %f", p.Name, p.Price) + } + } +} + +func TestGetDB(t *testing.T) { + testDB := "test_getdb.db" + defer os.Remove(testDB) + defer ResetForTesting() + + // Initialize connection + _, err := Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + + // GetDB should return the same instance + db1 := GetDB() + db2 := GetDB() + + if db1 != db2 { + t.Error("GetDB should return the same singleton instance") + } +} diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go new file mode 100644 index 0000000..846af7c --- /dev/null +++ b/pkg/database/migrate.go @@ -0,0 +1,29 @@ +package database + +import ( + "fmt" + "log" + + "gorm.io/gorm" + "terminalShop/pkg/models" +) + +// Migrate runs database migrations +func Migrate(db *gorm.DB) error { + log.Println("Running database migrations...") + + // AutoMigrate will create tables, missing columns, and indexes + // It will NOT delete unused columns or change column types + if err := db.AutoMigrate( + &models.Coffee{}, + &models.User{}, // SSH key-based authentication + // Add other models here as they're created + // &models.Cart{}, + // &models.Order{}, + ); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + log.Println("Database migrations completed successfully") + return nil +} diff --git a/pkg/database/seed.go b/pkg/database/seed.go new file mode 100644 index 0000000..1b1102e --- /dev/null +++ b/pkg/database/seed.go @@ -0,0 +1,88 @@ +package database + +import ( + "fmt" + "log" + + "gorm.io/gorm" + "terminalShop/pkg/models" +) + +// Seed populates the database with initial data +func Seed(db *gorm.DB) error { + log.Println("Seeding database...") + + // Check if products already exist + var count int64 + db.Model(&models.Coffee{}).Count(&count) + if count > 0 { + log.Printf("Database already has %d products, skipping seed", count) + return nil + } + + // Define seed products (from handlers/products.go) + products := []models.Coffee{ + { + Name: "Espresso", + RoastType: "Dark Roast", + Ounces: 2, + BeanType: "Arabica", + Price: 3.50, + Color: "#8B4513", + Description: "Bold and intense coffee with a rich crema", + }, + { + Name: "Americano", + RoastType: "Medium Roast", + Ounces: 8, + BeanType: "Arabica", + Price: 4.00, + Color: "#A0522D", + Description: "Smooth and balanced espresso diluted with hot water", + }, + { + Name: "Cappuccino", + RoastType: "Medium Roast", + Ounces: 6, + BeanType: "Arabica", + Price: 4.50, + Color: "#C4A574", + Description: "Espresso with steamed milk and a thick layer of foam", + }, + { + Name: "Latte", + RoastType: "Medium Roast", + Ounces: 12, + BeanType: "Arabica", + Price: 5.00, + Color: "#D2B48C", + Description: "Creamy espresso with plenty of steamed milk and a touch of foam", + }, + { + Name: "Mocha", + RoastType: "Dark Roast", + Ounces: 12, + BeanType: "Arabica", + Price: 5.50, + Color: "#8B4513", + Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.", + }, + { + Name: "Macchiato", + RoastType: "Dark Roast", + Ounces: 3, + BeanType: "Robusta Blend", + Price: 4.25, + Color: "#DAA520", + Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", + }, + } + + // Insert all products in a transaction + if err := db.Create(&products).Error; err != nil { + return fmt.Errorf("failed to seed products: %w", err) + } + + log.Printf("Successfully seeded %d products", len(products)) + return nil +} diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go new file mode 100644 index 0000000..d0115aa --- /dev/null +++ b/pkg/models/coffee.go @@ -0,0 +1,57 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Coffee represents a coffee product +type Coffee struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + RoastType string `gorm:"size:100" json:"roast_type"` + Ounces int `json:"ounces"` + BeanType string `gorm:"size:100" json:"bean_type"` + Price float64 `gorm:"not null" json:"price"` + Color string `gorm:"size:7" json:"color"` // Hex color for background + Description string `gorm:"type:text" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +type Socks struct { + ID uint + Name string + Color string + Size uint + Price float64 + Description string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt time.Time +} + +// TableName specifies the table name for the Coffee model +func (Coffee) TableName() string { + return "coffees" +} + +func (Socks) TableNameSocks() string { + return "socks" +} + +// CartItem represents an item in the cart +type CartItem struct { + Coffee Coffee + Socks Socks + Quantity int +} + +// Account menu items +var AccountMenuItems = []string{ + "order history", + "faq", + "about", +} diff --git a/pkg/models/user.go b/pkg/models/user.go new file mode 100644 index 0000000..53d3778 --- /dev/null +++ b/pkg/models/user.go @@ -0,0 +1,46 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// User represents a user authenticated via SSH public key +// No passwords - SSH key is the ONLY authentication method (matches terminal.shop exactly) +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier + SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key + Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels) + Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts) + Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for the User model +func (User) TableName() string { + return "users" +} + +// PublicUser returns a user object safe for public consumption +type PublicUser struct { + ID uint `json:"id"` + SSHKeyFingerprint string `json:"ssh_key_fingerprint"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ToPublic converts a User to a PublicUser +func (u *User) ToPublic() PublicUser { + return PublicUser{ + ID: u.ID, + SSHKeyFingerprint: u.SSHKeyFingerprint, + Name: u.Name, + Email: u.Email, + CreatedAt: u.CreatedAt, + } +} diff --git a/pkg/shippo/shippo.go b/pkg/shippo/shippo.go new file mode 100644 index 0000000..6c7562e --- /dev/null +++ b/pkg/shippo/shippo.go @@ -0,0 +1,144 @@ +package shippo + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const baseURL = "https://api.goshippo.com" + +// Client handles communication with the Shippo API. +type Client struct { + apiKey string + httpClient *http.Client +} + +// NewClient creates a new Shippo API client. +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Address represents a shipping address for validation. +type Address struct { + Name string `json:"name"` + Street1 string `json:"street1"` + Street2 string `json:"street2,omitempty"` + City string `json:"city"` + State string `json:"state,omitempty"` + Country string `json:"country"` + Zip string `json:"zip"` + Phone string `json:"phone,omitempty"` +} + +// addressRequest is the payload sent to Shippo's address validation endpoint. +type addressRequest struct { + Name string `json:"name"` + Street1 string `json:"street1"` + Street2 string `json:"street2,omitempty"` + City string `json:"city"` + State string `json:"state,omitempty"` + Country string `json:"country"` + Zip string `json:"zip"` + Phone string `json:"phone,omitempty"` + Validate bool `json:"validate"` +} + +// validationResult is Shippo's validation response nested under validation_results. +type validationResult struct { + IsValid bool `json:"is_valid"` +} + +// addressResponse is the response from Shippo's address endpoint. +type addressResponse struct { + Street1 string `json:"street1"` + Street2 string `json:"street2"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Zip string `json:"zip"` + Phone string `json:"phone"` + Test bool `json:"test"` + ValidationResults *validationResult `json:"validation_results"` +} + +// ValidateAddress sends an address to Shippo for validation. +// Returns the normalized/corrected address if valid, or an error if invalid. +func (c *Client) ValidateAddress(addr Address) (*Address, error) { + reqBody := addressRequest{ + Name: addr.Name, + Street1: addr.Street1, + Street2: addr.Street2, + City: addr.City, + State: addr.State, + Country: addr.Country, + Zip: addr.Zip, + Phone: addr.Phone, + Validate: true, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal address: %w", err) + } + + req, err := http.NewRequest("POST", baseURL+"/v1/addresses", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "ShippoToken "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("shippo request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("shippo returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var result addressResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Accept if test mode or validation says valid + if !result.Test && (result.ValidationResults == nil || !result.ValidationResults.IsValid) { + return nil, fmt.Errorf("address is invalid") + } + + // Return the normalized address from Shippo + validated := &Address{ + Name: addr.Name, + Street1: result.Street1, + Street2: result.Street2, + City: result.City, + State: result.State, + Country: result.Country, + Zip: result.Zip, + Phone: result.Phone, + } + + // If Shippo returned empty phone, keep the original + if validated.Phone == "" { + validated.Phone = addr.Phone + } + + return validated, nil +} diff --git a/pkg/tui/account.go b/pkg/tui/account.go new file mode 100644 index 0000000..43a8760 --- /dev/null +++ b/pkg/tui/account.go @@ -0,0 +1,78 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" + + "terminalShop/pkg/models" +) + +func (m Model) BuildAccountView() string { + leftWidth := 18 + leftMargin := 4 + rightWidth := m.WindowWidth - leftWidth - leftMargin - 4 + + // Build the left panel (menu items) + leftPanel := "" + for i, item := range models.AccountMenuItems { + // Apply background color when selected, white text + if m.AccountCursor == i { + style := lipgloss.NewStyle(). + Background(lipgloss.Color("#4682B4")). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(item) + "\n" + } else { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(item) + "\n" + } + } + + // Add left margin to the list + leftContainer := lipgloss.NewStyle(). + Width(leftWidth). + MarginLeft(leftMargin) + + // Build the detail view (right panel) based on cursor position + detailView := "" + if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + MarginBottom(2) + + contentStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Width(rightWidth - 4) + + selectedItem := models.AccountMenuItems[m.AccountCursor] + switch selectedItem { + case "order history": + detailView = titleStyle.Render("Order History") + "\n\n" + + contentStyle.Render("No orders yet. Your order history will appear here once you make a purchase.") + case "faq": + detailView = titleStyle.Render("FAQ") + "\n\n" + + contentStyle.Render("Q: How do I place an order?\nA: Browse products with j/k, adjust quantity with +/-, and checkout via the cart.\n\nQ: What payment methods do you accept?\nA: We accept all major credit cards via Stripe.") + case "about": + detailView = titleStyle.Render("About Terminal Coffee Shop") + "\n\n" + + contentStyle.Render("A terminal-based coffee ordering experience.\nBuilt with Go and Bubbletea.") + } + } + + detailContainer := lipgloss.NewStyle(). + Width(rightWidth) + + // Layout the panels side by side + mainContent := lipgloss.JoinHorizontal( + lipgloss.Top, + leftContainer.Render(leftPanel), + detailContainer.Render(detailView), + ) + + return mainContent +} diff --git a/pkg/tui/breadcrumbs.go b/pkg/tui/breadcrumbs.go new file mode 100644 index 0000000..cef311f --- /dev/null +++ b/pkg/tui/breadcrumbs.go @@ -0,0 +1,55 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +func (m Model) BuildBreadcrumbs() string { + // Only show breadcrumbs in cart view + if !m.ViewingCart { + return "" + } + + activeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + + inactiveStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")) + + sepStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")) + + sep := sepStyle.Render(" / ") + + // Checkout flow steps + labels := []string{"cart", "shipping", "payment", "confirmation"} + + // Get current checkout step from model + currentStep := m.CheckoutStep + + var items []string + for i, label := range labels { + if i == currentStep { + items = append(items, activeStyle.Render(label)) + } else { + items = append(items, inactiveStyle.Render(label)) + } + + // Add separator except after last item + if i < len(labels)-1 { + items = append(items, sep) + } + } + + breadcrumbsContainer := lipgloss.NewStyle(). + MarginBottom(1). + PaddingLeft(4) + + result := "" + for _, item := range items { + result += item + } + + return breadcrumbsContainer.Render(result) +} diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go new file mode 100644 index 0000000..02a9b2a --- /dev/null +++ b/pkg/tui/cart.go @@ -0,0 +1,113 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) BuildCartView() string { + if len(m.Cart) == 0 { + emptyStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Align(lipgloss.Center). + Width(m.WindowWidth). + Padding(2, 0) + return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop") + } + + cartItems := "" + boxWidth := m.WindowWidth - 20 // Leave some margin + itemSlice := m.GetCartItemsSlice() + + // Adjust padding based on window height + boxPadding := 0 + if m.WindowHeight >= 25 { + boxPadding = 1 + } + + for idx, item := range itemSlice { + itemTotal := float64(item.Quantity) * item.Coffee.Price + + // Left side: name and individual price + nameStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")) + + individualPriceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(item.Coffee.Color)). + Bold(true) + + infoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")) + + // Right side: quantity controls and total price + quantityStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + + totalPriceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + + // Build content with proper spacing + nameText := nameStyle.Render(item.Coffee.Name) + individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", item.Coffee.Price)) + infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s", + item.Coffee.RoastType, + item.Coffee.Ounces, + item.Coffee.BeanType)) + quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity)) + totalPriceText := totalPriceStyle.Render(fmt.Sprintf("$%.2f", itemTotal)) + + // Calculate spacing + nameAndPriceWidth := lipgloss.Width(nameText) + lipgloss.Width(individualPriceText) + quantityWidth := lipgloss.Width(quantityText) + totalPriceWidth := lipgloss.Width(totalPriceText) + spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8 // 8 for padding and gaps + + if spacing < 1 { + spacing = 1 + } + + contentLine1 := nameText + individualPriceText + + lipgloss.NewStyle().Width(spacing).Render("") + + quantityText + " " + totalPriceText + + contentLine2 := infoText + + boxContent := contentLine1 + "\n" + contentLine2 + + // Create box for this item - highlight if it's the selected item + var itemBox lipgloss.Style + if idx == m.CartCursor { + itemBox = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), true). + BorderForeground(lipgloss.Color("#FFFFFF")). + Padding(boxPadding, 2). + Width(boxWidth). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + } else { + itemBox = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#666666")). + Padding(boxPadding, 2). + Width(boxWidth). + Foreground(lipgloss.Color("#FFFFFF")) + } + + // Center the box + centered := lipgloss.NewStyle(). + Width(m.WindowWidth). + Align(lipgloss.Center) + + cartItems += centered.Render(itemBox.Render(boxContent)) + + // Add spacing between items + if idx < len(itemSlice)-1 { + cartItems += "\n" + } + } + + return cartItems +} diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go new file mode 100644 index 0000000..08f619c --- /dev/null +++ b/pkg/tui/confirmation.go @@ -0,0 +1,77 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// RenderConfirmation renders the order confirmation view +func (m Model) RenderConfirmation() string { + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 0, 1, 0) + + sectionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#CCCCCC")). + Padding(0, 0, 1, 2) + + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")) + + valueStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + + successStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF00")). + Bold(true). + Padding(1, 0) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Padding(1, 0, 0, 0) + + title := titleStyle.Render("Order Confirmation") + + // Shipping summary + shippingSection := "" + if m.ShippingInfo != nil { + shippingSection = sectionStyle.Render( + labelStyle.Render("Ship to: ") + + valueStyle.Render(m.ShippingInfo.Name) + "\n" + + labelStyle.Render(" ") + + valueStyle.Render(m.ShippingInfo.Street1) + "\n" + + labelStyle.Render(" ") + + valueStyle.Render(fmt.Sprintf("%s, %s %s", m.ShippingInfo.City, m.ShippingInfo.Country, m.ShippingInfo.Zip)), + ) + } + + // Cart summary + cartSection := "" + total := 0.0 + items := m.GetCartItemsSlice() + for _, item := range items { + line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, item.Coffee.Price*float64(item.Quantity)) + cartSection += sectionStyle.Render(line) + "\n" + total += item.Coffee.Price * float64(item.Quantity) + } + cartSection += sectionStyle.Render( + labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", total)), + ) + + success := successStyle.Render("Order placed successfully!") + help := helpStyle.Render("press esc to return to shop") + + return lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + shippingSection, + "", + cartSection, + "", + success, + help, + ) +} diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go new file mode 100644 index 0000000..179efdd --- /dev/null +++ b/pkg/tui/footer.go @@ -0,0 +1,109 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) BuildFooter() string { + // Style for keybinds (bold) + keybindStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Bold(true) + + // Style for descriptions (normal) + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")) + + var footerText string + if m.ViewingAccount { + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("menu"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("c"), + descStyle.Render("cart"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else if m.ViewingCart && m.CheckoutStep == 3 { + // Confirmation screen + footerText = fmt.Sprintf("%s %s %s %s", + keybindStyle.Render("esc"), + descStyle.Render("back to shop"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else if m.ViewingCart && (m.CheckoutStep == 1 || m.CheckoutStep == 2) { + // In shipping/payment form + footerText = fmt.Sprintf("%s %s %s %s", + keybindStyle.Render("esc"), + descStyle.Render("back"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else if m.ViewingCart && m.CheckoutStep == 0 { + // In cart view, show proceed option + if len(m.Cart) > 0 { + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("items"), + keybindStyle.Render("+/-"), + descStyle.Render("qty"), + keybindStyle.Render("p/enter"), + descStyle.Render("checkout"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("a"), + descStyle.Render("account"), + keybindStyle.Render("pgup/pgdn"), + descStyle.Render("scroll"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else { + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("items"), + keybindStyle.Render("+/-"), + descStyle.Render("qty"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("a"), + descStyle.Render("account"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } + } else if m.ViewingCart { + // Other checkout steps + footerText = fmt.Sprintf("%s %s %s %s %s %s", + keybindStyle.Render("esc"), + descStyle.Render("back"), + keybindStyle.Render("s"), + descStyle.Render("shop"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } else { + footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", + keybindStyle.Render("j/k"), + descStyle.Render("products"), + keybindStyle.Render("+/-"), + descStyle.Render("qty"), + keybindStyle.Render("c"), + descStyle.Render("cart"), + keybindStyle.Render("a"), + descStyle.Render("account"), + keybindStyle.Render("q"), + descStyle.Render("quit"), + ) + } + + return lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(m.WindowWidth). + Render(footerText) +} diff --git a/pkg/tui/header.go b/pkg/tui/header.go new file mode 100644 index 0000000..ef40679 --- /dev/null +++ b/pkg/tui/header.go @@ -0,0 +1,93 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) BuildHeader() string { + // Calculate cart total and item count + total := 0.0 + itemCount := 0 + for _, item := range m.Cart { + total += float64(item.Quantity) * item.Coffee.Price + itemCount += item.Quantity + } + + // Define colors + whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) + boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + + // Build shop tab + var shopKeybind, shopName string + if !m.ViewingCart && !m.ViewingAccount { + // Active: bold white keybind and name + shopKeybind = boldWhiteStyle.Render("s") + shopName = boldWhiteStyle.Render("shop") + } else { + // Inactive: bold gray keybind, gray name + shopKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("s") + shopName = grayStyle.Render("shop") + } + + // Build cart tab + var cartKeybind, cartName, cartInfo string + itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount)) + + if m.ViewingCart { + // Active: bold white keybind and name + cartKeybind = boldWhiteStyle.Render("c") + cartName = boldWhiteStyle.Render("cart") + cartInfo = fmt.Sprintf(" %s %s", + boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)), + itemCountText) + } else { + // Inactive: bold gray keybind, gray name + boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true) + boldWhitePriceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + cartKeybind = boldGrayStyle.Render("c") + cartName = grayStyle.Render("cart") + cartInfo = fmt.Sprintf(" %s %s", + boldWhitePriceStyle.Render(fmt.Sprintf("$%.2f", total)), + itemCountText) + } + + // Build Account Tabs + var accountKeybind, accountName string + if m.ViewingAccount { + accountKeybind = boldWhiteStyle.Render("a") + accountName = boldWhiteStyle.Render("account") + } else { + accountKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("a") + accountName = grayStyle.Render("account") + } + + // Build the tabs with separator + separator := whiteStyle.Render("|") + tabsContent := fmt.Sprintf("%s %s %s %s %s %s %s %s%s", + shopKeybind, + shopName, + separator, + accountKeybind, + accountName, + separator, + cartKeybind, + cartName, + cartInfo, + ) + + // Add box with padding (no vertical padding, just horizontal) + tabBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(0, 2). + MarginBottom(1) + + // Center the whole thing + centered := lipgloss.NewStyle(). + Width(m.WindowWidth). + Align(lipgloss.Center) + + return centered.Render(tabBox.Render(tabsContent)) +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000..5701a23 --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,661 @@ +package tui + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + gossh "golang.org/x/crypto/ssh" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/token" + + "terminalShop/pkg/api" + "terminalShop/pkg/models" + "terminalShop/pkg/shippo" +) + +type Model struct { + // User authentication + User *models.User // Authenticated user (nil if not logged in) + IsNewUser bool // True if user needs to register + SSHPublicKey gossh.PublicKey // SSH public key for registration + UsernameInput string // Input for username during registration + + // Shop state + Username string + Coffees []models.Coffee + Cursor int + Cart map[int]*models.CartItem // maps coffee index to cart item + CartCursor int // cursor position in cart view + AccountCursor int + CheckoutStep int // 0=cart, 1=shipping, 2=payment, 3=confirmation + ScrollOffset int // scroll position for content + WindowWidth int + WindowHeight int + ViewingCart bool // true when viewing cart details + ViewingAccount bool + Loading bool // true when fetching data from API + ErrorMsg string // error message if API fetch fails + APIClient *api.Client + + // Checkout state + ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step) + PaymentForm *PaymentFormState // Payment form state (nil when not in payment step) + ShippingInfo *ShippingFormCompleteMsg // Saved shipping info from completed form + SavedAddresses []ShippingFormCompleteMsg // Previously used shipping addresses + ShippingView int + AddressCursor int + StripeKey string // Stripe publishable key for client-side tokenization + ShippoKey string // Shippo API key for address validation +} + +// ProductsMsg is sent when products are fetched from API +type ProductsMsg struct { + Products []models.Coffee + Err error +} + +// fetchProductsCmd fetches products from the API +func (m Model) fetchProductsCmd() tea.Msg { + if m.APIClient == nil { + return ProductsMsg{Err: nil} // No API client, use fallback + } + + products, err := m.APIClient.GetProducts() + return ProductsMsg{Products: products, Err: err} +} + +func (m Model) Init() tea.Cmd { + // Fetch products from API on startup + return m.fetchProductsCmd +} + +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 m.ViewingCart && m.CheckoutStep == 1 { + switch msg := msg.(type) { + case ShippingFormCompleteMsg: + return m, m.validateAddress(msg) + case ShippoValidatedMsg: + m.SavedAddresses = append(m.SavedAddresses, msg.Address) + m.ShippingInfo = &msg.Address + m.ShippingForm = nil + m.CheckoutStep = 2 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + case ShippoValidationErrMsg: + if m.ShippingForm != nil { + m.ShippingForm.submitting = false + m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) + m.ErrorMsg = fmt.Sprintf("Invalid address: %v", msg.Err) + return m, m.ShippingForm.form.Init() + } + m.ErrorMsg = fmt.Sprintf("Invalid address: %v", msg.Err) + return m, nil + case ShippingFormErrorMsg: + m.ErrorMsg = msg.Message + return m, nil + case tea.WindowSizeMsg: + m.WindowWidth = msg.Width + m.WindowHeight = msg.Height + } + + // 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.CheckoutStep = 0 + 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] + m.ShippingInfo = &addr + m.CheckoutStep = 2 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + } + m.ShippingView = 1 + m.ShippingForm = m.InitShippingForm() + return m, m.ShippingForm.form.Init() + case "d", "x": + if m.AddressCursor < len(m.SavedAddresses) { + m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AccountCursor+1:]...) + if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 { + m.AddressCursor-- + } + } + } + } + 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.CheckoutStep = 0 + m.ShippingForm = nil + 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.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil { + switch msg := msg.(type) { + case PaymentFormCompleteMsg: + // kick off Stripe tokenization (runs async) + return m, m.tokenizeCard(msg) + case StripeTokenMsg: + // Tokenization completed, move to confirmation + m.CheckoutStep = 3 + m.PaymentForm = nil + return m, nil + case StripeTokenErrMsg: + // Tokenization failed, show error and let user retry + 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() + case PaymentFormErrorMsg: + m.ErrorMsg = msg.Message + return m, nil + case tea.WindowSizeMsg: + m.WindowWidth = msg.Width + m.WindowHeight = msg.Height + case tea.KeyMsg: + if msg.String() == "esc" { + // Go back to shipping + m.CheckoutStep = 1 + m.PaymentForm = nil + m.ErrorMsg = "" + m.ShippingForm = m.InitShippingForm() + // Restore shipping values if we have them + if m.ShippingInfo != nil { + m.ShippingForm.Name = m.ShippingInfo.Name + m.ShippingForm.Street1 = m.ShippingInfo.Street1 + m.ShippingForm.Street2 = m.ShippingInfo.Street2 + m.ShippingForm.City = m.ShippingInfo.City + m.ShippingForm.State = m.ShippingInfo.State + m.ShippingForm.Country = m.ShippingInfo.Country + m.ShippingForm.Zip = m.ShippingInfo.Zip + m.ShippingForm.Phone = m.ShippingInfo.Phone + // Rebuild form with restored values + m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) + } + return m, m.ShippingForm.form.Init() + } + m.ErrorMsg = "" + } + + cmd := m.UpdatePaymentForm(msg, m.PaymentForm) + return m, cmd + } + + switch msg := msg.(type) { + case ShippingFormCompleteMsg: + return m, m.validateAddress(msg) + + case ShippoValidatedMsg: + m.SavedAddresses = append(m.SavedAddresses, msg.Address) + m.ShippingInfo = &msg.Address + m.ShippingForm = nil + m.CheckoutStep = 2 + m.PaymentForm = m.InitPaymentForm() + return m, m.PaymentForm.form.Init() + + case ShippoValidationErrMsg: + m.ErrorMsg = fmt.Sprintf("invalid address: %v", msg.Err) + return m, nil + + case ShippingFormErrorMsg: + m.ErrorMsg = msg.Message + return m, nil + + case PaymentFormCompleteMsg: + m.CheckoutStep = 3 + m.PaymentForm = nil + return m, nil + + case PaymentFormErrorMsg: + m.ErrorMsg = msg.Message + return m, nil + + case StripeTokenMsg: + m.CheckoutStep = 3 + m.PaymentForm = nil + 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 tea.WindowSizeMsg: + m.WindowWidth = msg.Width + m.WindowHeight = msg.Height + return m, nil + + case tea.KeyMsg: + // Normal shop mode + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "s": + m.ViewingCart = false + m.ViewingAccount = false + m.CheckoutStep = 0 + m.ShippingForm = nil + m.PaymentForm = nil + m.ScrollOffset = 0 + + case "c": + m.ViewingCart = true + m.ViewingAccount = false + m.CheckoutStep = 0 + m.ShippingForm = nil + m.PaymentForm = nil + m.ScrollOffset = 0 + + case "a": + m.ViewingCart = false + m.ViewingAccount = true + m.CheckoutStep = 0 + m.ShippingForm = nil + m.PaymentForm = nil + m.ScrollOffset = 0 + + case "p", "enter": + // Proceed to checkout from cart + if m.ViewingCart && m.CheckoutStep == 0 && len(m.Cart) > 0 { + m.CheckoutStep = 1 + if len(m.SavedAddresses) > 0 { + m.ShippingView = 0 + m.AddressCursor = 0 + m.ShippingForm = nil + return m, nil + } + m.ShippingView = 1 + m.ShippingForm = m.InitShippingForm() + return m, m.ShippingForm.form.Init() + + } + + case "esc": + // From confirmation, go back to shop + if m.ViewingCart && m.CheckoutStep == 3 { + m.ViewingCart = false + m.CheckoutStep = 0 + m.ShippingForm = nil + m.PaymentForm = nil + m.ShippingInfo = nil + m.Cart = make(map[int]*models.CartItem) + m.CartCursor = 0 + m.ScrollOffset = 0 + return m, nil + } + // Go back in checkout flow + if m.ViewingCart && m.CheckoutStep > 0 { + m.CheckoutStep-- + m.ShippingForm = nil + m.PaymentForm = nil + return m, nil + } + + case "up", "k": + if m.ViewingAccount { + if m.AccountCursor > 0 { + m.AccountCursor-- + } + } else if !m.ViewingCart && !m.ViewingAccount && m.Cursor > 0 { + m.Cursor-- + } else if m.ViewingCart && m.CartCursor > 0 { + m.CartCursor-- + // 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.ViewingAccount { + if m.AccountCursor < len(models.AccountMenuItems)-1 { + m.AccountCursor++ + } + } else if !m.ViewingCart && !m.ViewingAccount && m.Cursor < len(m.Coffees)-1 { + m.Cursor++ + } else if m.ViewingCart && m.CartCursor < len(m.Cart)-1 { + m.CartCursor++ + // 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.WindowHeight - 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": + // Scroll up + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + + case "pgdown", "ctrl+d": + // Scroll down + m.ScrollOffset += 3 + + case "+", "=": + if !m.ViewingCart && !m.ViewingAccount { + // Increment quantity in shop view + if item, exists := m.Cart[m.Cursor]; exists { + item.Quantity++ + } else { + m.Cart[m.Cursor] = &models.CartItem{ + Coffee: m.Coffees[m.Cursor], + Quantity: 1, + } + } + } else if m.ViewingCart { + // Increment quantity in cart view + cartItems := m.GetCartItemsSlice() + if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { + cartItems[m.CartCursor].Quantity++ + } + } + + case "-", "_": + if !m.ViewingCart && !m.ViewingAccount { + // Decrement quantity in shop view + if item, exists := m.Cart[m.Cursor]; exists { + item.Quantity-- + if item.Quantity <= 0 { + delete(m.Cart, m.Cursor) + // Reset cart cursor if we deleted the last item + if len(m.Cart) == 0 { + m.CartCursor = 0 + } else if m.CartCursor >= len(m.Cart) { + m.CartCursor = len(m.Cart) - 1 + } + } + } + } else if m.ViewingCart { + // Decrement quantity in cart view + cartItems := m.GetCartItemsSlice() + if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { + cartItems[m.CartCursor].Quantity-- + if cartItems[m.CartCursor].Quantity <= 0 { + // Find and delete this item from the cart + for idx, item := range m.Cart { + if item == cartItems[m.CartCursor] { + delete(m.Cart, idx) + break + } + } + // Reset cart cursor + if len(m.Cart) == 0 { + m.CartCursor = 0 + } else if m.CartCursor >= len(m.Cart) { + m.CartCursor = len(m.Cart) - 1 + } + } + } + } + } + } + return m, nil +} + +// ShippoValidatedMsg is sent when Shippo address validation succeeds +type ShippoValidatedMsg struct { + Address ShippingFormCompleteMsg +} + +// ShippoValidationErrMsg is sent when Shippo address validation fails +type ShippoValidationErrMsg struct { + Err error +} + +// validateAddress sends the shipping address to Shippo for validation +func (m Model) validateAddress(info ShippingFormCompleteMsg) tea.Cmd { + return func() tea.Msg { + if m.ShippoKey == "" { + // No Shippo key configured, skip validation + return ShippoValidatedMsg{Address: info} + } + + client := shippo.NewClient(m.ShippoKey) + validated, err := client.ValidateAddress(shippo.Address{ + Name: info.Name, + Street1: info.Street1, + Street2: info.Street2, + City: info.City, + State: info.State, + Country: info.Country, + Zip: info.Zip, + Phone: info.Phone, + }) + if err != nil { + return ShippoValidationErrMsg{Err: err} + } + + return ShippoValidatedMsg{ + Address: ShippingFormCompleteMsg{ + Name: validated.Name, + Street1: validated.Street1, + Street2: validated.Street2, + City: validated.City, + State: validated.State, + Country: validated.Country, + Zip: validated.Zip, + Phone: validated.Phone, + }, + } + } +} + +type StripeTokenMsg struct { + TokenID string + Last4 string +} + +type StripeTokenErrMsg struct { + Err error +} + +func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd { + return func() tea.Msg { + stripe.Key = m.StripeKey + + params := &stripe.TokenParams{ + Card: &stripe.CardParams{ + Number: stripe.String(card.CardNumber), + ExpMonth: stripe.String(card.ExpiryMonth), + ExpYear: stripe.String(card.ExpiryYear), + CVC: stripe.String(card.CVC), + }, + } + + tok, err := token.New(params) + if err != nil { + return StripeTokenErrMsg{Err: err} + } + last4 := "" + if tok.Card != nil { + last4 = tok.Card.Last4 + } + + return StripeTokenMsg{ + TokenID: tok.ID, + Last4: last4, + } + + } +} + +// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration +func (m Model) GetCartItemsSlice() []*models.CartItem { + // Get keys and sort them for stable ordering + keys := make([]int, 0, len(m.Cart)) + for key := range m.Cart { + keys = append(keys, key) + } + + // Sort keys to ensure consistent order + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + if keys[i] > keys[j] { + keys[i], keys[j] = keys[j], keys[i] + } + } + } + + // Build items slice in sorted order + items := make([]*models.CartItem, 0, len(m.Cart)) + for _, key := range keys { + items = append(items, m.Cart[key]) + } + return items +} + +// NewModel creates a new model with default coffee options +func NewModel(username string) Model { + return Model{ + Username: username, + Coffees: []models.Coffee{ + { + Name: "Espresso", + RoastType: "Dark Roast", + Ounces: 2, + BeanType: "Arabica", + Price: 3.50, + Color: "#8B4513", + Description: "A bold, concentrated shot of pure coffee bliss. Perfect for those who need an immediate caffeine injection to survive the day.", + }, + { + Name: "Latte", + RoastType: "Medium Roast", + Ounces: 12, + BeanType: "Arabica Blend", + Price: 5.00, + Color: "#D2691E", + Description: "Smooth espresso paired with steamed milk and a light layer of foam. For when you want coffee but also want to feel fancy about it.", + }, + { + Name: "Cappuccino", + RoastType: "Medium Roast", + Ounces: 8, + BeanType: "Italian Blend", + Price: 4.50, + Color: "#CD853F", + Description: "Equal parts espresso, steamed milk, and foam. The classic Italian choice for people who know what they're doing.", + }, + { + Name: "Americano", + RoastType: "Dark Roast", + Ounces: 16, + BeanType: "Arabica", + Price: 4.00, + Color: "#A0522D", + Description: "Espresso with hot water. Simple, strong, and no-nonsense. This is coffee for people who actually like the taste of coffee.", + }, + { + Name: "Cold Brew", + RoastType: "Medium Roast", + Ounces: 16, + BeanType: "Colombian", + Price: 5.50, + Color: "#4682B4", + Description: "Steeped for 12 hours to create a smooth, less acidic coffee experience. Served cold because sometimes hot coffee is just too much effort.", + }, + { + Name: "Macchiato", + RoastType: "Dark Roast", + Ounces: 3, + BeanType: "Robusta Blend", + Price: 4.25, + Color: "#DAA520", + Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", + }, + }, + Cursor: 0, + Cart: make(map[int]*models.CartItem), + CartCursor: 0, + AccountCursor: 0, + WindowWidth: 120, + WindowHeight: 30, + ViewingCart: false, + ViewingAccount: false, + Loading: true, + APIClient: api.NewClient("http://localhost:8080"), + StripeKey: os.Getenv("STRIPE_PUBLIC_KEY"), + ShippoKey: os.Getenv("SHIPPO_API_KEY"), + } +} + +// NewModelWithAuth creates a new model with user authentication context +func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey) Model { + username := "guest" + if user != nil { + username = user.Name + } + + m := NewModel(username) + m.User = user + m.IsNewUser = isNewUser + m.SSHPublicKey = pubKey + + return m +} diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go new file mode 100644 index 0000000..1a6bc0e --- /dev/null +++ b/pkg/tui/payment_form.go @@ -0,0 +1,251 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "terminalShop/pkg/validate" +) + +// PaymentFormState holds the payment form state. +// Always heap-allocated so huh's Value() bindings stay valid. +type PaymentFormState struct { + form *huh.Form + CardName string + Email string + CardNumber string + ExpiryMonth string + ExpiryYear string + CVC string + BillingZip string + submitting bool +} + +// PaymentFormCompleteMsg is sent when the payment form passes validation +type PaymentFormCompleteMsg struct { + CardName string + Email string + CardNumber string + ExpiryMonth string + ExpiryYear string + CVC string + BillingZip string +} + +// PaymentFormErrorMsg is sent when payment form validation fails +type PaymentFormErrorMsg struct { + Message string +} + +// buildPaymentForm creates the huh form bound to state's fields. +// No field-level validators so Tab/Enter move freely. +func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form { + f := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Name on Card"). + Key("card_name"). + Value(&state.CardName), + huh.NewInput(). + Title("Email Address"). + Key("email"). + Value(&state.Email), + huh.NewInput(). + Title("Card Number"). + Key("card_number"). + Value(&state.CardNumber), + ), + huh.NewGroup( + huh.NewInput(). + Title("Expiry Month (MM)"). + Key("exp_month"). + Value(&state.ExpiryMonth), + huh.NewInput(). + Title("Expiry Year (YY)"). + Key("exp_year"). + Value(&state.ExpiryYear), + huh.NewInput(). + Title("CVC"). + Key("cvc"). + Value(&state.CVC), + huh.NewInput(). + Title("Billing ZIP"). + Key("billing_zip"). + Value(&state.BillingZip), + ), + ). + WithShowErrors(false). + WithShowHelp(false) + + if m.WindowWidth < 80 { + f = f.WithLayout(huh.LayoutStack).WithWidth(m.WindowWidth - 10) + } else { + f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.WindowWidth - 20) + } + + return f +} + +// InitPaymentForm creates a new heap-allocated payment form state. +func (m Model) InitPaymentForm() *PaymentFormState { + state := &PaymentFormState{} + + // Pre-fill name and email from user if available + if m.User != nil { + if m.User.Name != "" { + state.CardName = m.User.Name + } + if m.User.Email != "" { + state.Email = m.User.Email + } + } + + state.form = m.buildPaymentForm(state) + return state +} + +// validatePaymentForm checks all required fields at submission time. +// Returns an error message or empty string if valid. +func validatePaymentForm(state *PaymentFormState) string { + if strings.TrimSpace(state.CardName) == "" { + return "name on card cannot be empty" + } + + email := strings.TrimSpace(state.Email) + if email == "" { + return "email address cannot be empty" + } + if err := validate.EmailValidator(email); err != nil { + return err.Error() + } + + cardNum := strings.TrimSpace(state.CardNumber) + if cardNum == "" { + return "card number cannot be empty" + } + if err := validate.CcnValidator(cardNum); err != nil { + return err.Error() + } + + month := strings.TrimSpace(state.ExpiryMonth) + if month == "" { + return "expiry month cannot be empty" + } + if err := validate.Compose( + validate.IsDigits("expiry month"), + validate.MustBeLen(2, "expiry month"), + )(month); err != nil { + return err.Error() + } + + year := strings.TrimSpace(state.ExpiryYear) + if year == "" { + return "expiry year cannot be empty" + } + if err := validate.Compose( + validate.IsDigits("expiry year"), + validate.MustBeLen(2, "expiry year"), + )(year); err != nil { + return err.Error() + } + + cvc := strings.TrimSpace(state.CVC) + if cvc == "" { + return "CVC cannot be empty" + } + if err := validate.Compose( + validate.IsDigits("CVC"), + validate.WithinLen(3, 4, "CVC"), + )(cvc); err != nil { + return err.Error() + } + + if strings.TrimSpace(state.BillingZip) == "" { + return "billing ZIP cannot be empty" + } + + return "" +} + +// UpdatePaymentForm handles updates to the payment form. +// Mutates state in place, returns only the tea.Cmd. +func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if msg.Width < 80 { + state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(msg.Width - 10) + } else { + state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(msg.Width - 20) + } + } + + form, cmd := state.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + state.form = f + } + + if state.form.State == huh.StateCompleted && !state.submitting { + if errMsg := validatePaymentForm(state); errMsg != "" { + state.form = m.buildPaymentForm(state) + return tea.Batch( + state.form.Init(), + func() tea.Msg { return PaymentFormErrorMsg{Message: errMsg} }, + ) + } + + state.submitting = true + return func() tea.Msg { + return PaymentFormCompleteMsg{ + CardName: state.CardName, + Email: state.Email, + CardNumber: state.CardNumber, + ExpiryMonth: state.ExpiryMonth, + ExpiryYear: state.ExpiryYear, + CVC: state.CVC, + BillingZip: state.BillingZip, + } + } + } + + return cmd +} + +// RenderPaymentForm renders the payment form view +func (m Model) RenderPaymentForm(state *PaymentFormState) string { + if state.submitting { + loadingStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(1) + return loadingStyle.Render("Processing payment...") + } + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 0, 1, 0) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Padding(1, 0, 0, 0) + + cardLast4 := "" + if len(state.CardNumber) >= 4 { + cardLast4 = fmt.Sprintf(" (ending %s)", state.CardNumber[len(state.CardNumber)-4:]) + } + + title := titleStyle.Render("Payment Details" + cardLast4) + form := state.form.View() + help := helpStyle.Render("enter/tab next • shift+tab prev • esc back") + + return lipgloss.JoinVertical( + lipgloss.Left, + title, + form, + help, + ) +} diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go new file mode 100644 index 0000000..d6e7d76 --- /dev/null +++ b/pkg/tui/shipping_form.go @@ -0,0 +1,321 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// scandinavianCountries defines the available countries with their dial codes. +var scandinavianCountries = []struct { + Name string + Code string + DialCode string +}{ + {Name: "Denmark", Code: "DK", DialCode: "+45"}, + {Name: "Finland", Code: "FI", DialCode: "+358"}, + {Name: "Iceland", Code: "IS", DialCode: "+354"}, + {Name: "Norway", Code: "NO", DialCode: "+47"}, + {Name: "Sweden", Code: "SE", DialCode: "+46"}, +} + +// countryDialCodes maps country ISO codes to their dialing prefixes. +var countryDialCodes = map[string]string{ + "DK": "+45", + "FI": "+358", + "IS": "+354", + "NO": "+47", + "SE": "+46", +} + +// ShippingFormState holds the shipping form state. +// Always allocate on the heap (use &ShippingFormState{}) so that huh's +// Value() pointers to the string fields remain valid. +type ShippingFormState struct { + form *huh.Form + Name string + Street1 string + Street2 string + City string + State string + Country string + Zip string + Phone string + submitting bool +} + +// ShippingFormCompleteMsg is sent when the shipping form is completed +type ShippingFormCompleteMsg struct { + Name string + Street1 string + Street2 string + City string + State string + Country string + Zip string + Phone string +} + +// ShippingFormErrorMsg is sent when form validation fails at submission time +type ShippingFormErrorMsg struct { + Message string +} + +// buildShippingForm creates the huh form bound to state's fields. +// No field-level validators so Tab/Enter move freely between fields. +// Validation happens at submission time instead. +func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form { + countryOptions := make([]huh.Option[string], 0, len(scandinavianCountries)) + for _, c := range scandinavianCountries { + label := fmt.Sprintf("%s (%s)", c.Name, c.DialCode) + countryOptions = append(countryOptions, huh.NewOption(label, c.Code)) + } + + f := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Full Name"). + Key("name"). + Value(&state.Name), + huh.NewInput(). + Title("Street Address"). + Key("street1"). + Value(&state.Street1), + huh.NewInput(). + Title("Apt, Suite, etc. (optional)"). + Key("street2"). + Value(&state.Street2), + huh.NewInput(). + Title("City"). + Key("city"). + Value(&state.City), + ), + huh.NewGroup( + huh.NewInput(). + Title("Region (optional)"). + Key("state"). + Value(&state.State), + huh.NewInput(). + Title("Postal Code"). + Key("zip"). + Value(&state.Zip), + huh.NewSelect[string](). + Title("Country"). + Key("country"). + Options(countryOptions...). + Value(&state.Country). + Height(5). + Filtering(true), + huh.NewInput(). + Title("Phone"). + Key("phone"). + Value(&state.Phone), + ), + ). + WithShowErrors(false). + WithShowHelp(false) + + if m.WindowWidth < 80 { + f = f.WithLayout(huh.LayoutStack).WithWidth(m.WindowWidth - 10) + } else { + f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.WindowWidth - 20) + } + + return f +} + +// InitShippingForm creates a new heap-allocated shipping form state. +// The returned pointer must be stored directly in Model.ShippingForm +// so that huh's Value() bindings point to stable memory. +func (m Model) InitShippingForm() *ShippingFormState { + state := &ShippingFormState{ + Country: "SE", + } + + if m.User != nil && m.User.Name != "" { + state.Name = m.User.Name + } + + state.form = m.buildShippingForm(state) + return state +} + +// validateShippingForm checks required fields after the user submits. +// Returns an error message describing the first missing field, or empty string if valid. +func validateShippingForm(state *ShippingFormState) string { + if strings.TrimSpace(state.Name) == "" { + return "name cannot be empty" + } + if strings.TrimSpace(state.Street1) == "" { + return "street address cannot be empty" + } + if strings.TrimSpace(state.City) == "" { + return "city cannot be empty" + } + if strings.TrimSpace(state.Zip) == "" { + return "postal code cannot be empty" + } + if strings.TrimSpace(state.Phone) == "" { + return "phone number cannot be empty" + } + return "" +} + +// formatPhoneWithDialCode prepends the country dial code to the phone number +// if the user hasn't already included it. +func formatPhoneWithDialCode(phone, countryCode string) string { + phone = strings.TrimSpace(phone) + if phone == "" { + return phone + } + + if strings.HasPrefix(phone, "+") { + return phone + } + + // Strip leading "0" (common in Scandinavian local numbers) + phone = strings.TrimLeft(phone, "0") + + dialCode, ok := countryDialCodes[countryCode] + if !ok { + return phone + } + + return dialCode + phone +} + +// UpdateShippingForm handles updates to the shipping form. +// It mutates state in place (no struct copying) so huh's Value() +// pointers remain valid. +func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if msg.Width < 80 { + state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(msg.Width - 10) + } else { + state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(msg.Width - 20) + } + } + + // Pass message to huh form + form, cmd := state.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + state.form = f + } + + // Check if form is completed (user pressed Enter on last field) + if state.form.State == huh.StateCompleted && !state.submitting { + if errMsg := validateShippingForm(state); errMsg != "" { + // Validation failed -- rebuild the form to reset huh's + // unexported quitting flag so it renders again. + // Values are preserved because state is heap-allocated + // and buildShippingForm binds to the same pointer. + state.form = m.buildShippingForm(state) + return tea.Batch( + state.form.Init(), + func() tea.Msg { return ShippingFormErrorMsg{Message: errMsg} }, + ) + } + + state.submitting = true + phone := formatPhoneWithDialCode(state.Phone, state.Country) + + return func() tea.Msg { + return ShippingFormCompleteMsg{ + Name: state.Name, + Street1: state.Street1, + Street2: state.Street2, + City: state.City, + State: state.State, + Country: state.Country, + Zip: state.Zip, + Phone: phone, + } + } + } + + return cmd +} + +// RenderShippingForm renders the shipping form view +func (m Model) RenderShippingForm(state *ShippingFormState) string { + if state.submitting { + loadingStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(1) + return loadingStyle.Render("Processing shipping information...") + } + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 0, 1, 0) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Padding(1, 0, 0, 0) + + title := titleStyle.Render("Shipping Address") + form := state.form.View() + help := helpStyle.Render("enter/tab next • shift+tab prev • esc back") + + return lipgloss.JoinVertical( + lipgloss.Left, + title, + form, + help, + ) +} + +func (m Model) RenderAddressList() string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0) + activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Padding(1, 0, 0, 0) + + title := titleStyle.Render("Selected Shipping Address") + + var lines []string + for i, addr := range m.SavedAddresses { + cursor := " " + style := inactiveStyle + if i == m.AddressCursor { + cursor = "> " + style = activeStyle + } + + name := style.Render(addr.Name) + street := labelStyle.Render(addr.Street1) + if addr.Street2 != "" { + street += ", " + labelStyle.Render(addr.Street2) + } + city := labelStyle.Render(fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip)) + + lines = append(lines, cursor+name) + lines = append(lines, " "+street) + lines = append(lines, " "+city) + lines = append(lines, "") + } + + addCursor := " " + addStyle := inactiveStyle + if m.AddressCursor == len(m.SavedAddresses) { + addCursor = "> " + addStyle = activeStyle + } + + lines = append(lines, addCursor+addStyle.Render("+ Add new address")) + + help := helpStyle.Render("j/k, enter select, d delete, esc back") + + parts := []string{title} + parts = append(parts, strings.Join(lines, "\n")) + parts = append(parts, help) + + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go new file mode 100644 index 0000000..2ab88ef --- /dev/null +++ b/pkg/tui/shop.go @@ -0,0 +1,92 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) BuildShopView() string { + leftWidth := 18 // Narrower list + leftMargin := 4 // Minimal left margin + rightWidth := m.WindowWidth - leftWidth - leftMargin - 4 + + // Build the left panel (coffee list) + leftPanel := "" + for i, coffee := range m.Coffees { + // Apply background color when selected, white text + if m.Cursor == i { + style := lipgloss.NewStyle(). + Background(lipgloss.Color(coffee.Color)). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(coffee.Name) + "\n" + } else { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Padding(0, 1). + Width(leftWidth - 2). + Align(lipgloss.Left) + leftPanel += style.Render(coffee.Name) + "\n" + } + } + + // Add left margin to the list + leftContainer := lipgloss.NewStyle(). + Width(leftWidth). + MarginLeft(leftMargin) + + // Build the detail view (right panel) + detailView := "" + if m.Cursor >= 0 && m.Cursor < len(m.Coffees) { + selectedCoffee := m.Coffees[m.Cursor] + + nameStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")) + + infoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")) + + priceStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(selectedCoffee.Color)). + Bold(true) + + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Width(rightWidth - 4) + + quantityStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + + // Get current quantity + quantity := 0 + if item, exists := m.Cart[m.Cursor]; exists { + quantity = item.Quantity + } + + detailView += nameStyle.Render(selectedCoffee.Name) + "\n" + detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s", + selectedCoffee.RoastType, + selectedCoffee.Ounces, + selectedCoffee.BeanType)) + "\n\n" + detailView += priceStyle.Render(fmt.Sprintf("$%.2f", selectedCoffee.Price)) + "\n\n" + detailView += descStyle.Render(selectedCoffee.Description) + "\n\n" + detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n" + } + + detailContainer := lipgloss.NewStyle(). + Width(rightWidth) + + // Layout the panels side by side + mainContent := lipgloss.JoinHorizontal( + lipgloss.Top, + leftContainer.Render(leftPanel), + detailContainer.Render(detailView), + ) + + return mainContent +} diff --git a/pkg/tui/views.go b/pkg/tui/views.go new file mode 100644 index 0000000..e9cac27 --- /dev/null +++ b/pkg/tui/views.go @@ -0,0 +1,170 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) View() string { + if m.WindowWidth == 0 { + m.WindowWidth = 120 + m.WindowHeight = 30 + } + + // Build header with cart tab + header := m.BuildHeader() + + // Build breadcrumbs + breadcrumbs := m.BuildBreadcrumbs() + + // Build main content based on view + var content string + if m.Loading { + // Show loading message + loadingStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + Padding(2, 4) + content = loadingStyle.Render("Loading products from API...") + } else if m.ErrorMsg != "" { + // Show error message at top of shop view + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Background(lipgloss.Color("52")). + Padding(0, 1). + MarginBottom(1) + errorBanner := errorStyle.Render("⚠ " + m.ErrorMsg) + + if m.ViewingAccount { + content = errorBanner + "\n" + m.BuildAccountView() + } else if m.ViewingCart && m.CheckoutStep == 1 { + if m.ShippingView == 0 && m.ShippingForm == nil { + content = errorBanner + "\n" + m.RenderAddressList() + } else if m.ShippingForm != nil { + content = errorBanner + "\n" + m.RenderShippingForm(m.ShippingForm) + } + } else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil { + content = errorBanner + "\n" + m.RenderPaymentForm(m.PaymentForm) + } else if m.ViewingCart && m.CheckoutStep == 3 { + content = errorBanner + "\n" + m.RenderConfirmation() + } else if m.ViewingCart { + content = errorBanner + "\n" + m.BuildCartView() + } else { + content = errorBanner + "\n" + m.BuildShopView() + } + } else if m.ViewingAccount { + content = m.BuildAccountView() + } else if m.ViewingCart && m.CheckoutStep == 1 { + if m.ShippingView == 0 && m.ShippingForm == nil { + content = m.RenderAddressList() + } else if m.ShippingForm != nil { + content = m.RenderShippingForm(m.ShippingForm) + } + } else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil { + content = m.RenderPaymentForm(m.PaymentForm) + } else if m.ViewingCart && m.CheckoutStep == 3 { + content = m.RenderConfirmation() + } else if m.ViewingCart { + content = m.BuildCartView() + } else { + content = m.BuildShopView() + } + + // Calculate content height based on window size + headerHeight := lipgloss.Height(header) + breadcrumbsHeight := 0 + if breadcrumbs != "" { + breadcrumbsHeight = lipgloss.Height(breadcrumbs) + } + footerHeight := 1 + + // Scale margins based on window height + marginTop := 1 + marginBottom := 1 + + // Reserve space for header, breadcrumbs (if present), footer, and margins + // Add extra buffer to prevent footer from cutting off last item + bufferSpace := 1 + reservedHeight := headerHeight + breadcrumbsHeight + footerHeight + marginTop + marginBottom + bufferSpace + availableContentHeight := m.WindowHeight - reservedHeight + + // Ensure minimum content height + if availableContentHeight < 3 { + availableContentHeight = 3 + marginTop = 0 + marginBottom = 0 + } + + // Split content into lines and handle scrolling + contentLines := strings.Split(content, "\n") + totalLines := len(contentLines) + + // Ensure scroll offset is within bounds + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + maxScroll := totalLines - availableContentHeight + if maxScroll < 0 { + maxScroll = 0 + } + if m.ScrollOffset > maxScroll { + m.ScrollOffset = maxScroll + } + + // Apply scroll offset and truncate to fit available height + if totalLines > availableContentHeight { + start := m.ScrollOffset + end := start + availableContentHeight + if end > totalLines { + end = totalLines + } + contentLines = contentLines[start:end] + } + content = strings.Join(contentLines, "\n") + + // Add vertical spacing around content with fixed height + contentWithPadding := lipgloss.NewStyle(). + MarginTop(marginTop). + MarginBottom(marginBottom). + Height(availableContentHeight). + Render(content) + + // Build footer + footer := m.BuildFooter() + + // Combine all app content + var appContent string + if breadcrumbs != "" { + appContent = lipgloss.JoinVertical( + lipgloss.Left, + header, + breadcrumbs, + contentWithPadding, + footer, + ) + } else { + appContent = lipgloss.JoinVertical( + lipgloss.Left, + header, + contentWithPadding, + footer, + ) + } + + // Check if content fits in window + appHeight := lipgloss.Height(appContent) + + if appHeight <= m.WindowHeight { + // Content fits, center it vertically + return lipgloss.Place( + m.WindowWidth, + m.WindowHeight, + lipgloss.Left, + lipgloss.Center, + appContent, + ) + } else { + // Content doesn't fit, align to top (no centering) + return appContent + } +} diff --git a/pkg/utils/response.go b/pkg/utils/response.go new file mode 100644 index 0000000..c10a775 --- /dev/null +++ b/pkg/utils/response.go @@ -0,0 +1,63 @@ +package utils + +import ( + "encoding/json" + "net/http" + "time" +) + +// SuccessResponse represents a successful API response +type SuccessResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data"` +} + +// ErrorResponse represents an error API response +type ErrorResponse struct { + Success bool `json:"success"` + Error Error `json:"error"` +} + +// Error contains error details +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// RespondSuccess sends a successful JSON response +func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + response := SuccessResponse{ + Success: true, + Data: data, + } + + json.NewEncoder(w).Encode(response) +} + +// RespondError sends an error JSON response +func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + response := ErrorResponse{ + Success: false, + Error: Error{ + Code: code, + Message: message, + Details: details, + }, + } + + json.NewEncoder(w).Encode(response) +} + +// HealthData represents health check data +type HealthData struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version"` +} diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go new file mode 100644 index 0000000..d8b97d6 --- /dev/null +++ b/pkg/validate/validate.go @@ -0,0 +1,117 @@ +package validate + +import ( + "fmt" + "net/mail" +) + +type ErrorHandler func(str string) error + +func WithinLen(min, max int, name string) ErrorHandler { + return func(str string) error { + if len(str) >= min && len(str) <= max { + return nil + } + return fmt.Errorf( + "expected %s to be between %d and %d, but got %d", + name, + min, + max, + len(str), + ) + } +} + +func CcnValidator(cardNumber string) error { + if len(cardNumber) < 13 { + return fmt.Errorf("invalid credit card number") + } + + total := 0 + isSecondDigit := false + + // Iterate through the card number digits in reverse order + for i := len(cardNumber) - 1; i >= 0; i-- { + // Skip spaces or any other non-digit characters + if cardNumber[i] < '0' || cardNumber[i] > '9' { + continue + } + + // Convert the digit character to an integer + digit := int(cardNumber[i] - '0') + + if isSecondDigit { + // Double the digit for each second digit from the right + digit *= 2 + if digit > 9 { + // If doubling the digit results in a two-digit number, subtract 9 to get the sum of digits + digit -= 9 + } + } + + // Add the current digit to the total sum + total += digit + + // Toggle the flag for the next iteration + isSecondDigit = !isSecondDigit + } + + // Check if the total sum mod 10 is not zero (valid card numbers should result in total mod 10 == 0) + if total%10 != 0 { + return fmt.Errorf("invalid credit card number") + } + return nil +} + +func IsDigits(name string) ErrorHandler { + return func(str string) error { + for _, c := range str { + if !(c >= '0' && c <= '9') { + return fmt.Errorf("[%s] expected only digits but got %s", name, str) + } + } + + return nil + } +} + +func MustBeLen(length int, name string) ErrorHandler { + return func(str string) error { + if str == "" { + return nil + } + if len(str) != length { + return fmt.Errorf("Expected %s to be length %d but got %d", name, length, len(str)) + } + return nil + } +} + +func NotEmpty(name string) ErrorHandler { + return func(str string) error { + if len(str) == 0 { + return fmt.Errorf("%s cannot be empty", name) + } + return nil + } +} + +func EmailValidator(str string) error { + _, err := mail.ParseAddress(str) + if err != nil { + return fmt.Errorf("not a valid email address") + } + return nil +} + +func Compose(input ...ErrorHandler) ErrorHandler { + return func(str string) error { + for _, f := range input { + err := f(str) + if err != nil { + return err + } + } + return nil + } +} diff --git a/pkg/validation/product.go b/pkg/validation/product.go new file mode 100644 index 0000000..b037c33 --- /dev/null +++ b/pkg/validation/product.go @@ -0,0 +1,98 @@ +package validation + +import ( + "fmt" + "strings" +) + +// ProductRequest represents the request body for creating/updating products +type ProductRequest struct { + Name string `json:"name"` + RoastType string `json:"roast_type"` + Ounces int `json:"ounces"` + BeanType string `json:"bean_type"` + Price float64 `json:"price"` + Color string `json:"color"` + Description string `json:"description"` +} + +// ProductErrors holds validation errors for a product request +type ProductErrors map[string]string + +// ValidateProductRequest validates a product creation/update request +func ValidateProductRequest(req ProductRequest, isUpdate bool) ProductErrors { + errors := make(ProductErrors) + + // Name validation (required for create, optional for update) + if !isUpdate || req.Name != "" { + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" && !isUpdate { + errors["name"] = "Name is required" + } else if len(req.Name) > 255 { + errors["name"] = "Name must be less than 255 characters" + } + } + + // Roast type validation + if req.RoastType != "" { + validRoastTypes := map[string]bool{ + "Light Roast": true, + "Medium Roast": true, + "Dark Roast": true, + } + if !validRoastTypes[req.RoastType] { + errors["roast_type"] = "Roast type must be Light Roast, Medium Roast, or Dark Roast" + } + } + + // Ounces validation + if req.Ounces != 0 { + if req.Ounces < 0 { + errors["ounces"] = "Ounces must be positive" + } else if req.Ounces > 64 { + errors["ounces"] = "Ounces must be less than or equal to 64" + } + } + + // Bean type validation + if req.BeanType != "" && len(req.BeanType) > 100 { + errors["bean_type"] = "Bean type must be less than 100 characters" + } + + // Price validation (required for create, optional for update) + if !isUpdate || req.Price != 0 { + if req.Price <= 0 && !isUpdate { + errors["price"] = "Price must be greater than 0" + } else if req.Price > 1000 { + errors["price"] = "Price must be less than $1000" + } + } + + // Color validation (hex color) + if req.Color != "" { + if !strings.HasPrefix(req.Color, "#") || len(req.Color) != 7 { + errors["color"] = "Color must be a valid hex color (e.g., #8B4513)" + } + } + + // Description validation + if req.Description != "" && len(req.Description) > 1000 { + errors["description"] = "Description must be less than 1000 characters" + } + + return errors +} + +// HasErrors returns true if there are any validation errors +func (e ProductErrors) HasErrors() bool { + return len(e) > 0 +} + +// Error implements the error interface +func (e ProductErrors) Error() string { + var messages []string + for field, msg := range e { + messages = append(messages, fmt.Sprintf("%s: %s", field, msg)) + } + return strings.Join(messages, "; ") +} diff --git a/server.go.old b/server.go.old new file mode 100644 index 0000000..90143f0 --- /dev/null +++ b/server.go.old @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" +) + +const ( + host = "localhost" + port = "23457" +) + +func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { + m := InitialModel(s.User()) + return m, []tea.ProgramOption{tea.WithAltScreen()} +} + +func main() { + s, err := wish.NewServer( + wish.WithAddress(fmt.Sprintf("%s:%s", host, port)), + wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithMiddleware( + bubbletea.Middleware(teaHandler), + logging.Middleware(), + ), + ) + if err != nil { + log.Fatalln(err) + } + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + log.Printf("Starting SSH server on %s:%s", host, port) + log.Printf("Connect with: ssh %s -p %s", host, port) + + go func() { + if err = s.ListenAndServe(); err != nil { + log.Fatalln(err) + } + }() + + <-done + log.Println("Stopping SSH server") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + log.Fatalln(err) + } +} diff --git a/terminal-shop-source b/terminal-shop-source new file mode 160000 index 0000000..caa24e7 --- /dev/null +++ b/terminal-shop-source @@ -0,0 +1 @@ +Subproject commit caa24e7ffaaf6fa49289ebaf08a1b8c116fd6b0c