first commit after chaning git structure on pc

This commit is contained in:
2026-02-16 11:42:43 +01:00
commit f62b28d29b
52 changed files with 8112 additions and 0 deletions

70
.gitignore vendored Normal file
View 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
View 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
View 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
View 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!

1214
MODELS.md Normal file

File diff suppressed because it is too large Load Diff

413
README.md Normal file
View 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
View 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
View 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

Binary file not shown.

103
api/handlers/auth.go Normal file
View 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
View 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",
})
}

View 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
View 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",
})
}

View 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")
}
}

View 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
View 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
View 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
View 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,
)
})
}

View 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
View 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
View 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
View 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=

BIN
main Executable file

Binary file not shown.

127
main.go Normal file
View 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
View 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
View 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(&registerResp); 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 &registerResp.Data.User, nil
}

85
pkg/auth/jwt.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Submodule terminal-shop-source added at caa24e7ffa