mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
first commit after chaning git structure on pc
This commit is contained in:
70
.gitignore
vendored
Normal file
70
.gitignore
vendored
Normal file
@@ -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
|
||||
153
AGENTS.md
Normal file
153
AGENTS.md
Normal file
@@ -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<Type>` (`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<FunctionName>` or `Test<FunctionName><Scenario>`
|
||||
|
||||
## 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
|
||||
202
CLAUDE.md
Normal file
202
CLAUDE.md
Normal file
@@ -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 <package>
|
||||
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
|
||||
265
CONTRIBUTING.md
Normal file
265
CONTRIBUTING.md
Normal file
@@ -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!
|
||||
413
README.md
Normal file
413
README.md
Normal file
@@ -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)
|
||||
141
REFACTORING.md
Normal file
141
REFACTORING.md
Normal file
@@ -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!
|
||||
72
TODO.md
Normal file
72
TODO.md
Normal file
@@ -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
|
||||
BIN
api/api-server
Executable file
BIN
api/api-server
Executable file
Binary file not shown.
103
api/handlers/auth.go
Normal file
103
api/handlers/auth.go
Normal file
@@ -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(),
|
||||
})
|
||||
}
|
||||
38
api/handlers/health.go
Normal file
38
api/handlers/health.go
Normal file
@@ -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",
|
||||
})
|
||||
}
|
||||
98
api/handlers/health_test.go
Normal file
98
api/handlers/health_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
202
api/handlers/products.go
Normal file
202
api/handlers/products.go
Normal file
@@ -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",
|
||||
})
|
||||
}
|
||||
417
api/handlers/products_crud_test.go
Normal file
417
api/handlers/products_crud_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
139
api/handlers/products_test.go
Normal file
139
api/handlers/products_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
87
api/main.go
Normal file
87
api/main.go
Normal file
@@ -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")
|
||||
}
|
||||
19
api/middleware/cors.go
Normal file
19
api/middleware/cors.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
42
api/middleware/logger.go
Normal file
42
api/middleware/logger.go
Normal file
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
30
api/middleware/recovery.go
Normal file
30
api/middleware/recovery.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
45
api/routes/routes.go
Normal file
45
api/routes/routes.go
Normal file
@@ -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
|
||||
}
|
||||
62
go.mod
Normal file
62
go.mod
Normal file
@@ -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
|
||||
)
|
||||
137
go.sum
Normal file
137
go.sum
Normal file
@@ -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=
|
||||
127
main.go
Normal file
127
main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
710
model.go.old
Normal file
710
model.go.old
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
144
pkg/api/client.go
Normal file
144
pkg/api/client.go
Normal file
@@ -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
|
||||
}
|
||||
85
pkg/auth/jwt.go
Normal file
85
pkg/auth/jwt.go
Normal file
@@ -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))
|
||||
}
|
||||
112
pkg/auth/ssh_auth.go
Normal file
112
pkg/auth/ssh_auth.go
Normal file
@@ -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
|
||||
}
|
||||
51
pkg/config/config.go
Normal file
51
pkg/config/config.go
Normal file
@@ -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
|
||||
}
|
||||
76
pkg/database/db.go
Normal file
76
pkg/database/db.go
Normal file
@@ -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{}
|
||||
}
|
||||
}
|
||||
134
pkg/database/db_test.go
Normal file
134
pkg/database/db_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
29
pkg/database/migrate.go
Normal file
29
pkg/database/migrate.go
Normal file
@@ -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
|
||||
}
|
||||
88
pkg/database/seed.go
Normal file
88
pkg/database/seed.go
Normal file
@@ -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
|
||||
}
|
||||
57
pkg/models/coffee.go
Normal file
57
pkg/models/coffee.go
Normal file
@@ -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",
|
||||
}
|
||||
46
pkg/models/user.go
Normal file
46
pkg/models/user.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
144
pkg/shippo/shippo.go
Normal file
144
pkg/shippo/shippo.go
Normal file
@@ -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
|
||||
}
|
||||
78
pkg/tui/account.go
Normal file
78
pkg/tui/account.go
Normal file
@@ -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
|
||||
}
|
||||
55
pkg/tui/breadcrumbs.go
Normal file
55
pkg/tui/breadcrumbs.go
Normal file
@@ -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)
|
||||
}
|
||||
113
pkg/tui/cart.go
Normal file
113
pkg/tui/cart.go
Normal file
@@ -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
|
||||
}
|
||||
77
pkg/tui/confirmation.go
Normal file
77
pkg/tui/confirmation.go
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
109
pkg/tui/footer.go
Normal file
109
pkg/tui/footer.go
Normal file
@@ -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)
|
||||
}
|
||||
93
pkg/tui/header.go
Normal file
93
pkg/tui/header.go
Normal file
@@ -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))
|
||||
}
|
||||
661
pkg/tui/model.go
Normal file
661
pkg/tui/model.go
Normal file
@@ -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
|
||||
}
|
||||
251
pkg/tui/payment_form.go
Normal file
251
pkg/tui/payment_form.go
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
321
pkg/tui/shipping_form.go
Normal file
321
pkg/tui/shipping_form.go
Normal file
@@ -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...)
|
||||
}
|
||||
92
pkg/tui/shop.go
Normal file
92
pkg/tui/shop.go
Normal file
@@ -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
|
||||
}
|
||||
170
pkg/tui/views.go
Normal file
170
pkg/tui/views.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
63
pkg/utils/response.go
Normal file
63
pkg/utils/response.go
Normal file
@@ -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"`
|
||||
}
|
||||
117
pkg/validate/validate.go
Normal file
117
pkg/validate/validate.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
98
pkg/validation/product.go
Normal file
98
pkg/validation/product.go
Normal file
@@ -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, "; ")
|
||||
}
|
||||
61
server.go.old
Normal file
61
server.go.old
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
1
terminal-shop-source
Submodule
1
terminal-shop-source
Submodule
Submodule terminal-shop-source added at caa24e7ffa
Reference in New Issue
Block a user