34 Commits

Author SHA1 Message Date
abe11853f6 refactor: add helper functions for subtotal, cart empty, and cart item count 2026-04-24 10:29:15 +02:00
280585c2d0 sdfsdf 2026-04-24 10:17:46 +02:00
8a86965981 update .gitignore 2026-04-24 10:07:03 +02:00
edc032d6d4 bug fixes: various small bug fixes and typos 2026-04-24 10:04:31 +02:00
53c1b5e254 update: removed stale errors
when encountering an error, the error would never be removed, i just reset the error msg in the switchPage function, this will have to be redone later to make error messages dissapear after a while even on the same page
2026-04-24 09:45:32 +02:00
1bc03822b1 security: remove SSH public key from TUI memory
replace SSHPublicKeyStr field on Model with a closure-based authCmd that
fires, the public key lives only in the goroutine stack not on the
long-lived Model struct

Prevents key material exposure in heap dumps if the process crashed
2026-04-24 09:39:26 +02:00
6006f9617d refactor: add dedicated *Switch() methods
added dedicated *Switch() methods (ShopSwitch, CartSwitc, AccountSwitch, ...) for page transitions and remove the catch-all resetPageState(). Each switch owns its pages zero-init, viewport setup, and async data fetching
2026-04-24 09:25:31 +02:00
1c3ebf789c refactor: update the flat model struct with nested structs 2026-04-24 08:57:04 +02:00
bef7f85bda rewrite: update the slice sort to use built-in lib sort 2026-04-19 14:15:14 +02:00
fb5881f26f docs: make README actually sound human
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:40:36 +02:00
7a774ceee7 docs: rewrite README as a project intro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:39:17 +02:00
a624037f65 docs: update README with live URL and hosting info
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:35:29 +02:00
d111dafd62 chore: untrack terminal-shop-source reference repo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:33:12 +02:00
cb048bc3dc chore: untrack binaries and update README
Remove compiled binaries (main, terminalShop, api/main, api/api-server)
and server.go.old from git tracking. Add them to .gitignore. Update
README to reflect current project state accurately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:32:21 +02:00
22ab7438e1 yay 2026-04-17 11:28:52 +02:00
4403138f74 just some updates to gitignore 2026-04-17 11:26:39 +02:00
2d86e1d23f chore: bump version to trigger CI 2026-04-16 18:00:38 +02:00
b92132474b various error checks and formatting 2026-04-14 15:26:36 +02:00
8157789e66 update: some updates to the deploy action 2026-04-13 14:48:20 +02:00
7a8f7e9c2a update: add some config to the deploy action 2026-04-13 14:39:12 +02:00
44a9de0209 ci: add github actions deploy 2026-04-13 14:29:51 +02:00
7882667e35 update: commented out unused country codes 2026-04-13 14:08:47 +02:00
a22ba382c2 feat: add ssh key management to account page 2026-04-13 13:33:19 +02:00
7cbefeae40 fix: some fixes 2026-04-12 12:35:05 +02:00
e11e708095 fix: shit was NOT working from last commit 2026-04-12 12:22:06 +02:00
6559c4dd8f fix and feature: made checkout page cleaner, and added card and address modification to account page
the checkout flow was a bit wonky because it would display the same info twice which looked weird and confusing. this is now changed to only show once and update one of the pages independantly.

Now added the possibility to modify card and address list in the account page and not in the checkout flow
2026-04-12 12:11:04 +02:00
157a752540 fix: added user verification to continue checkout after adding card
when the user added their card it would instantly complete the order. this could be unwanted so now i have added an extra input requirement to confirm the payment after adding the card
2026-04-11 20:03:17 +02:00
de9120d1eb fix: Caddyfile domain and Stripe webhook API version mismatch
- Update Caddyfile from placeholder api.yourdomain.com to api.sshops.uk
- Use ConstructEventWithOptions with IgnoreAPIVersionMismatch to handle
  Stripe account API version (2026-01-28.clover) vs stripe-go v78 (2024-04-10)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:10:06 +02:00
77a044e11d bruh 2026-04-11 11:37:11 +02:00
40dccc8139 something 2026-04-11 11:10:37 +02:00
ac04676abb adding catppuccin theme 2026-04-11 11:05:16 +02:00
53e443e93d updating 2026-04-09 19:31:55 +02:00
6dba7c6dc6 fix: output api binary to /tmp to avoid conflict with api/ directory 2026-04-09 19:15:58 +02:00
c12b0dcbc2 update 2026-04-09 19:11:39 +02:00
58 changed files with 1378 additions and 3596 deletions

35
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Deploy
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
restore-keys: ${{ runner.os }}-go-
- name: Run tests
run: go test ./...
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
cd ~/terminalShop
git pull
docker compose -f docker-compose.prod.yml up -d --build

17
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.env.local .env.local
.env.production .env.production
.env.example .env.example
.env.prod
# Build artifacts # Build artifacts
*.exe *.exe
@@ -11,8 +12,13 @@
*.so *.so
*.dylib *.dylib
terminalshop terminalshop
terminalShop
server_bin server_bin
terminalshop_* terminalshop_*
server.go.old
main
api/main
api/api-server
# Go test/coverage files # Go test/coverage files
*.test *.test
@@ -60,6 +66,9 @@ temp/
Thumbs.db Thumbs.db
.DS_Store .DS_Store
CONTRIBUTING.md
# random stuff like ai and shit # random stuff like ai and shit
CLAUDE.md CLAUDE.md
@@ -67,14 +76,18 @@ AGENTS.md
MODELS.md MODELS.md
REFACTORING.md REFACTORING.md
TODO.md TODO.md
NEXT.md
test_stripe.go
REFACTOR_PLAN.md
codebase_graph.html codebase_graph.html
# Don't commit the cloned reference repo (optional - you might want to keep it) terminal-shop-source/
# terminal-shop-source/
# API documentation (auto-generated) # API documentation (auto-generated)
api/docs/ api/docs/
docs/
# Migration state (if using a migration tool that creates state files) # Migration state (if using a migration tool that creates state files)
migrations/*.state migrations/*.state

153
AGENTS.md
View File

@@ -1,153 +0,0 @@
# 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
View File

@@ -1,202 +0,0 @@
# 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

View File

@@ -1,265 +0,0 @@
# 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!

View File

@@ -1,3 +1,3 @@
api.yourdomain.com { api.sshops.uk {
reverse_proxy api:8080 reverse_proxy api:8080
} }

View File

@@ -4,10 +4,10 @@ RUN apk add --no-cache gcc musl-dev
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o api ./api/main.go RUN CGO_ENABLED=1 GOOS=linux go build -o /tmp/api ./api/main.go
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /root/ WORKDIR /root/
COPY --from=builder /app/api . COPY --from=builder /tmp/api ./api
CMD ["./api"] CMD ["./api"]

1214
MODELS.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +1,13 @@
# Terminal Coffee Shop # Terminal Coffee Shop
A terminal-based coffee shop, accessible over SSH. Built with Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, and Shippo.
```bash ```bash
ssh localhost -p 23456 ssh sshops.uk
``` ```
## Running locally it's a coffee shop but in your terminal. you ssh in, browse the menu, chuck stuff in your cart, put in your address and card, and buy coffee without ever touching a browser. your ssh key is your account so there's no login or signup or any of that.
```bash made it because i thought it would be fun to see if you could build a real e-commerce flow that lives entirely in a terminal. turns out you can. heavily inspired by [terminal.shop](https://terminal.shop).
# Copy and fill in env vars
cp .env.example .env
# Start the API server running on Hetzner, behind Cloudflare.
go run api/main.go
# Start the SSH server **tech:** Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, Shippo, Bring
go run main.go
```
## Stack
| Layer | Tech |
|-------|------|
| TUI | Bubbletea + Wish (SSH) |
| HTTP API | Chi router |
| Database | PostgreSQL + GORM |
| Auth | SSH key fingerprint + JWT |
| Payments | Stripe |
| Shipping | Shippo |
## What's working
- SSH server — key-based auth, auto user creation
- Shop page — browse products with live detail panel
- Cart — add/remove items, synced to API
- Checkout flow — shipping address → payment → confirmation
- Saved addresses and cards
- Order history in account view
- FAQ page
- Responsive layout (small/medium/large terminal sizes)
- Token refresh
## TODO
**Features missing vs reference (terminal.shop):**
- [ ] Splash/loading screen on connect
- [ ] Animated logo with blinking cursor
- [ ] API token management page
- [ ] OAuth app management page
- [ ] About page
- [ ] Theme system — currently 40+ hardcoded hex colors scattered across files
- [ ] Viewport-based scrolling — currently manual scroll math with hardcoded heights
**Known bugs:**
- [ ] Address delete slice bug in `shipping_form.go:386` — `[m.AddressCursor+1:]...` missing
- [ ] Account page has a placeholder "something" menu item in `models/coffee.go`
**Code quality:**
- [ ] Nested state structs (reference uses `state.shop`, `state.cart`, etc. vs flat Model)
- [ ] Centralized styles file

View File

@@ -1,141 +0,0 @@
# 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!

105
REFERENCE_PATTERNS.md Normal file
View File

@@ -0,0 +1,105 @@
# Reference Patterns to Adopt
Patterns from `terminal-shop-source` that we're copying into this project.
---
## 1. Footer: Command Array Pattern
### Structs (in model.go or footer.go)
```go
type footerState struct {
commands []footerCommand
}
type footerCommand struct {
key string
value string
}
```
### Setting commands (in each *Switch method)
```go
func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage)
m.footer.commands = []footerCommand{
{key: "esc", value: "back"},
{key: "↑/↓", value: "items"},
{key: "+/-", value: "qty"},
{key: "c", value: "checkout"},
}
m = m.updateCartViewport()
return m, nil
}
```
### Rendering (footer.go)
```go
// Iterate commands instead of if/else chain
commands := []string{}
for _, cmd := range m.footer.commands {
commands = append(commands, bold(" "+cmd.key+" ")+base(cmd.value+" "))
}
// Error display replaces default content when m.ErrorMsg != ""
```
### Small screen: only show "m menu"
```go
if m.size == small {
return table.Render(bold("m") + base(" menu"))
}
```
---
## 2. Helper Methods
### Cart helpers (add to model.go or cart.go)
```go
func (m Model) IsCartEmpty() bool {
return len(m.Cart) == 0
}
func (m Model) CartItemCount() int {
return len(m.Cart)
}
func (m Model) CalculateSubtotal() int {
subtotal := 0
for _, item := range m.Cart {
subtotal += item.Coffee.Price * item.Quantity
}
return subtotal
}
```
### Used in reference for: header cart badge, footer conditional commands, checkout validation, review page totals.
---
## 3. Account View Split
### Reference splits into separate files per sub-view:
- `account.go` — parent: menu + delegation (~80 lines)
- `orders.go` — OrdersView() + OrdersUpdate()
- `subscriptions.go` — SubscriptionsView() + SubscriptionsUpdate()
- `tokens.go` — TokensView() + TokensUpdate()
- `apps.go` — AppsView() + AppsUpdate()
- `faq.go` — FaqView()
- `about.go` — AboutView() (in account.go, inline)
### Parent dispatches via:
```go
func (m model) GetAccountPageContent(accountPage page, width int) string {
switch accountPage {
case ordersPage:
return m.OrdersView(width, m.state.account.focused)
case addressesPage:
return m.AddressesView(width, m.state.account.focused)
// ...
}
}
```
### Each sub-view owns its own state, cursor, and deletion logic.

80
TODO.md
View File

@@ -1,80 +0,0 @@
# TODO
## Done
- [x] Cart persistence across sessions
- [x] Token refresh — auto-refresh JWT before expiry
- [x] Help screen — press ? to show keybindings
- [x] Order detail view — press Enter on an order to see full details
- [x] FAQ page
- [x] About page
- [x] Splash screen — logo + auth + data preload on connect
- [x] Bulk data preload — /view/init endpoint fetches all user data at once
- [x] Stripe webhook — payment_intent.succeeded/failed sync order status
- [x] PCI-safe card tokenization — publishable key in SSH process, raw card data never reaches backend
- [x] Rate limiting — per-IP and per-user limits on sensitive endpoints
- [x] Audit logging — structured JSON logs for all financial events
- [x] Shop page — dual viewport (menu left, detail right), matches reference shop.go
- [x] Cart page — single viewport with proper scroll
- [x] Shipping page — single viewport
- [x] Payment page — single viewport
- [x] Confirm page — single viewport (uses DefaultKeyMap for pgup/pgdn scrolling)
---
## Do First (meaningful gaps vs reference)
### Product variants
The reference supports multiple variants per product (roast type, grind, size) with
separate prices. We treat every product as single-variant. Without this the shop is
very limited once you have real products.
- [ ] Variant model + DB migration
- [ ] API: return variants in /products response
- [ ] TUI: show variant selector in shop detail panel
### User profile editing
Users currently cannot update their name or email from inside the app.
- [ ] PUT /api/v1/profile endpoint
- [ ] TUI: editable name/email fields in account view
### Missing API endpoints
- [ ] PUT /api/v1/addresses/:id — update a saved address
- [ ] GET /api/v1/orders/:id — single order (needed for better order detail view)
---
## Polish (noticeable but not blocking)
- [ ] Animated cursor — reference has a blinking block cursor on the splash/logo
(cursor.go, CursorTickMsg, 700ms interval). Small but gives the app personality.
- [ ] Themed UI — reference has a theme/ package; we use hardcoded hex colors everywhere.
Makes it hard to change the look later.
- [ ] Shipping timeframe — Shippo returns estimated delivery window; show it on confirm page
- [ ] Loading spinner — replace plain "loading..." text with animated spinner
- [ ] HTTPS/QR payment option — third card-entry method, generates a one-time URL + QR code
so users can enter card details in their browser instead of over SSH.
Reference: payment.go paymentHttpsView, PollPaymentInitMsg/StatusMsg/CompleteMsg.
- [ ] Per-field card validation — use huh .Validate() on each form field so errors
show inline as user tabs through, instead of a single error on submit.
---
## Testing
- [ ] API client tests — pkg/api/client.go has no tests
- [ ] Card handler tests — api/handlers/cards.go
- [ ] Order handler tests — api/handlers/orders.go
- [ ] Address handler tests — api/handlers/addresses.go (partial)
- [ ] Webhook handler tests — api/handlers/webhook.go
- [ ] TUI payment form tests — validatePaymentForm() is pure and easy to unit test
---
## Post-Launch
- [ ] Shippo webhook — order tracking status updates pushed from Shippo
- [ ] Email system — order confirmation + shipped notification
- [ ] Subscription model — DB table, CRUD endpoints, TUI flow (select → shipping → payment → confirm)
- [ ] Subscription management — list, detail, cancel with confirmation in account view
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle
- [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts

Binary file not shown.

View File

@@ -76,7 +76,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
switch req.Country { switch req.Country {
case "US": case "US":
if h.shippoKey == "" { if h.shippoKey == "" {
utils.RespondError(w, http.StatusBadRequest, "CONFIG_ERROR", "shippo not configured for US address validation", nil) utils.RespondError(w, http.StatusInternalServerError, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
return return
} }
client := shippo.NewClient(h.shippoKey) client := shippo.NewClient(h.shippoKey)
@@ -166,12 +166,14 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
var address models.Address var address models.Address
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil { if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ADDRESS NOT FOUND", "address not found", nil) utils.RespondError(w, http.StatusNotFound, "ADDRESS_NOT_FOUND", "address not found", nil)
return return
} }
// ownership is guaranteed by the WHERE clause above if err := db.Delete(&address).Error; err != nil {
db.Delete(&address) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address successfully deleted", "message": "address successfully deleted",
@@ -197,10 +199,16 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
return return
} }
db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false) if err := db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update address", nil)
return
}
address.IsDefault = true address.IsDefault = true
db.Save(&address) if err := db.Save(&address).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"address": address, "address": address,

View File

@@ -158,4 +158,3 @@ func TestGetAddressesWithData(t *testing.T) {
t.Errorf("expected 2 addresses, got: %d", len(resp.Data.Addresses)) t.Errorf("expected 2 addresses, got: %d", len(resp.Data.Addresses))
} }
} }

View File

@@ -79,10 +79,25 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
return return
} }
} else if err != nil { } else if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "database error", nil) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to look up user", nil)
return return
} }
if req.SSHPublicKey != "" {
sshKey := models.SSHKey{
UserID: user.ID,
Fingerprint: req.Fingerprint,
PublicKey: req.SSHPublicKey,
}
if err := db.Where(models.SSHKey{Fingerprint: req.Fingerprint}).FirstOrCreate(&sshKey).Error; err != nil {
// we specifically want to block users whos ssh key failed to save. this is to prevent downstream errors which i dont want to deal with now
// !TODO add a way to just log the errror and app continue without trying to save carts, cards and addresses. this way the users wouldnt
// just be kicked, they would shop normally but on their next login they would need to add all their data again.
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save ssh key", nil)
return
}
}
token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name) token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil) utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)

View File

@@ -31,7 +31,7 @@ var (
) )
func storeRedirect(stripeURL string) string { func storeRedirect(stripeURL string) string {
b := make([]byte, 4) b := make([]byte, 16)
rand.Read(b) rand.Read(b)
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
payRedirectsMu.Lock() payRedirectsMu.Lock()
@@ -119,10 +119,16 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false) if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
return
}
card.IsDefault = true card.IsDefault = true
db.Save(&card) if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card, "card": card,
@@ -285,7 +291,10 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// Clear the card from any cart that references it. // Clear the card from any cart that references it.
cardID := uint(id) cardID := uint(id)
db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil) if err := db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to clear card from cart", nil)
return
}
// Detach from Stripe (best-effort; don't block deletion on Stripe errors). // Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
@@ -294,7 +303,10 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
} }
// Soft-delete the local record. // Soft-delete the local record.
db.Delete(&card) if err := db.Delete(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete card", nil)
return
}
audit.CardDeleted(userID, card.ID) audit.CardDeleted(userID, card.ID)
@@ -325,6 +337,8 @@ func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error {
return err return err
} }
user.StripeCustomerID = cust.ID user.StripeCustomerID = cust.ID
gdb.Save(user) if err := gdb.Save(user).Error; err != nil {
return fmt.Errorf("Failed to save stripe customer: %w", err)
}
return nil return nil
} }

View File

@@ -5,10 +5,10 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"gorm.io/gorm"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/paymentmethod"
"gorm.io/gorm"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
@@ -117,7 +117,10 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
} }
if req.Quantity <= 0 { if req.Quantity <= 0 {
db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}) if err := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
return
}
} else { } else {
var item models.CartItem var item models.CartItem
result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item) result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item)
@@ -127,9 +130,15 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
CoffeeID: req.CoffeeID, CoffeeID: req.CoffeeID,
Quantity: req.Quantity, Quantity: req.Quantity,
} }
db.Create(&item) if err := db.Create(&item).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create item", nil)
return
}
} else { } else {
db.Model(&item).Update("quantity", req.Quantity) if err := db.Model(&item).Update("quantity", req.Quantity).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update quantity", nil)
return
}
} }
} }
@@ -164,7 +173,10 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
db.Model(cart).Update("address_id", req.AddressID) if err := db.Model(cart).Update("address_id", req.AddressID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address set", "message": "address set",
@@ -197,7 +209,10 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
db.Model(cart).Update("card_id", req.CardID) if err := db.Model(cart).Update("card_id", req.CardID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set card", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card set", "message": "card set",
@@ -214,7 +229,10 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return return
} }
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}) if err := db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "cart cleared", "message": "cart cleared",
@@ -297,7 +315,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
paymentMethodID = pm.ID paymentMethodID = pm.ID
card.StripePaymentID = pm.ID card.StripePaymentID = pm.ID
db.Save(&card) if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
} }
var subtotal int var subtotal int
@@ -427,10 +448,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
audit.OrderPaid(userID, order.ID, total, pi.ID) audit.OrderPaid(userID, order.ID, total, pi.ID)
db.Preload("Items").First(&order, order.ID) if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order": order, "order": order,
}) })
} }

View File

@@ -57,4 +57,3 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
"product": product, "product": product,
}) })
} }

58
api/handlers/ssh_keys.go Normal file
View File

@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"strconv"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"github.com/go-chi/chi/v5"
)
type SSHKeyHandler struct{}
func NewSSHKeyHandler() *SSHKeyHandler {
return &SSHKeyHandler{}
}
// GetSSHKeys returns all ssh keys for the authenticated user.
func (h *SSHKeyHandler) GetSSHKeys(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var keys []models.SSHKey
db.Where("user_id = ?", userID).Order("created_at ASC").Find(&keys)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"ssh_keys": keys,
})
}
// DeleteSSHKeys removes one of the users ssh keys by ID
func (h *SSHKeyHandler) DeleteSSHKeys(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid ssh key id", nil)
return
}
var key models.SSHKey
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&key).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "SSH_KEY_NOT_FOUND", "ssh key not found", nil)
return
}
if err := db.Delete(&key).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete ssh key", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "ssh key deleted",
})
}

View File

@@ -37,6 +37,9 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
var cards []models.Card var cards []models.Card
db.Where("user_id = ?", userID).Find(&cards) db.Where("user_id = ?", userID).Find(&cards)
var sshKeys []models.SSHKey
db.Where("user_id = ?", userID).Order("created_at ASC").Find(&sshKeys)
var orders []models.Order var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
var cartItems []models.CartItem var cartItems []models.CartItem
@@ -50,6 +53,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
"cart": cartItems, "cart": cartItems,
"addresses": addresses, "addresses": addresses,
"cards": cards, "cards": cards,
"ssh_keys": sshKeys,
"orders": orders, "orders": orders,
}) })
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -48,7 +49,10 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification") log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification")
} else { } else {
sig := r.Header.Get("Stripe-Signature") sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEvent(body, sig, h.webhookSecret); err != nil { if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
}); err != nil {
log.Printf("[webhook] signature verification failed: %v", err)
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil) utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
return return
} }
@@ -125,7 +129,9 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil { if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
return nil // Cart already gone. return nil // Cart already gone.
} }
tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}) if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err)
}
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
@@ -153,8 +159,10 @@ func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
} }
db := database.GetDB() db := database.GetDB()
db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed) Update("status", models.OrderStatusFailed).Error; err != nil {
log.Printf("[webhook] failed to mark order %s as failed: %v", orderIDStr, err)
}
errMsg := "unknown" errMsg := "unknown"
if pi.LastPaymentError != nil { if pi.LastPaymentError != nil {

BIN
api/main

Binary file not shown.

View File

@@ -4,6 +4,8 @@ import (
"net/http" "net/http"
"time" "time"
"strconv"
"github.com/go-chi/httprate" "github.com/go-chi/httprate"
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
) )
@@ -29,7 +31,7 @@ func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Ha
// Fall back to IP if no user context (shouldn't happen on protected routes). // Fall back to IP if no user context (shouldn't happen on protected routes).
return httprate.KeyByIP(r) return httprate.KeyByIP(r)
} }
return http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), nil return "user:" + strconv.FormatUint(uint64(userID), 10), nil
}), }),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil) utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)

View File

@@ -43,6 +43,7 @@ func SetupRoutes(
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler() viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey) webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey)
sshKeyHandler := handlers.NewSSHKeyHandler()
// Short payment redirect and success page — no auth required. // Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect) r.Get("/pay/{token}", handlers.PayRedirect)
@@ -114,6 +115,14 @@ func SetupRoutes(
r.Delete("/{id}", addressHandler.DeleteAddress) r.Delete("/{id}", addressHandler.DeleteAddress)
r.Put("/{id}/default", addressHandler.SetDefaultAddress) r.Put("/{id}/default", addressHandler.SetDefaultAddress)
}) })
// SSH Keys
r.Route("/ssh-keys", func(r chi.Router) {
// GET doesnt need id because it gets every ssh key
r.Get("/", sshKeyHandler.GetSSHKeys)
// Delete needs an id to delete only one key
r.Delete("/{id}", sshKeyHandler.DeleteSSHKeys)
})
}) })
}) })

View File

@@ -1,83 +0,0 @@
# Checkout FLow
## The Problemo
Charging a card and recording the order are two different operations accross
two seperate systems (Stripe and the database). If anything fails betweenj
them, you can end up in a state where a customer was charged but no
order exists in the database.
## How It Works
The ´ConvertCart´ handler (´api/handlers/cart.go´) avoids this by following
a specific order of operations:
1. **Create the order first** written to the DB with ´status = "pending"´
and no Stripe ID yet. This means there is always a DB record before any
money moves.
+2. **Charge Stripe** — using an idempotency key of `order-{id}`. If the
request is retried for any reason, Stripe will return the original result
instead of charging twice.
+3. **On charge failure** — the order is marked `status = "failed"` and the
error is returned to the client. The cart is left untouched.
+4. **On charge success** — a single DB transaction atomically:
- Sets `status = "paid"` and writes the Stripe PaymentIntent ID
- Deletes the cart items
- Clears the address and card from the cart
+5. **If the transaction fails after a successful charge** — this is the
worst case. The charge went through but the DB update failed. A
`[CRITICAL]` log line is emitted with the order ID and PaymentIntent ID
for manual reconciliation. See below.
## Reconciliation
If a `[CRITICAL]` log appears, find the order and patch it manually:
```sql
UPDATE orders
SET status = 'paid', stripe_payment_id = '<pi_id_from_log>'
WHERE id = <order_id_from_log>;
```
Then verify the PaymentIntent status in the Stripe dashboard matches.
A pending order with no ´stripe_payment_id´ after more than a few minutes
indicates a failed charge (status should be ´failed´). A pending order
with a ´stripe_payment_id´ indicates a reconciliation gap.
## Testing
Use Stripe´s test mode with the following card numbers (any future expiry,
any CVC, any ZIP):
the folling table is constructed by ["Card number"]["Behaviour"]
4242 4242 4242 4242 - Success
4000 0000 0000 0002 - Generic decline
4000 0000 0000 9995 - Insufficient funds
4000 0000 0000 0069 - Expired card
4000 0000 0000 0127 - Incorrect CVC
### What to verify on success
- Response is ´200´ with an order object
- Order in DB has ´status = "paid"´ and a non-empty ´stripe_payment_id´
- Cart items for that user are deleted
- Cart ´address_id´ and ´card_id´ are cleared
### What to verify on decline
- Response is `402` with `CARD_ERROR`
- Order in DB has `status = "failed"`
- Cart items are untouched
## Known Limitations
A server crash (not an error — a literal process kill) between the Stripe
charge succeeding and the DB transaction committing will leave the order as
`pending` with no `stripe_payment_id`. The `[CRITICAL]` log won't fire
because the process is dead. The fix is a reconciliation job that
periodically queries Stripe for PaymentIntents and cross-checks them
against orders — not implemented yet.

View File

@@ -1,342 +0,0 @@
# Deployment Plan: Docker + VPS + Domain
**Goal:** Get the app running publicly so anyone can connect with `ssh yourdomain.com`
---
## Overview
Reference uses AWS ECS + SST (overkill). We use:
- **Hetzner VPS** (~€4/month) — Linux server with a real public IP
- **Docker Compose** — runs all services, replaces `dev.sh`/Procfile
- **Caddy** — reverse proxy for the API, handles HTTPS automatically (free Let's Encrypt)
- **Cloudflare** — DNS (free tier, fast propagation)
- **Your domain** — e.g. `yourcoffee.shop`
Port layout on the VPS:
- `:22` → SSH TUI server (users connect here)
- `:443` → API (Caddy terminates TLS, proxies to API container on :8080)
- `:80` → Caddy (redirects to HTTPS)
---
## Step 1: Prerequisites
### 1a. Buy a domain
- Recommended: Cloudflare Registrar (cheapest, DNS included, no upsells)
- Or: Namecheap, then point nameservers to Cloudflare
### 1b. Get a VPS
- **Hetzner Cloud** → Create account → New Server
- Location: closest to you (Falkenstein/Helsinki for EU)
- OS: Ubuntu 24.04
- Type: CX22 (2 vCPU, 4GB RAM, €3.79/month) — plenty for this
- Add your SSH public key during setup so you can SSH in
- Note the public IP address
### 1c. DNS (Cloudflare)
Add these A records pointing to your VPS IP:
| Type | Name | Value |
|------|------|-------|
| A | `@` | `<VPS IP>` |
| A | `www` | `<VPS IP>` |
| A | `api` | `<VPS IP>` |
Set proxy status to **DNS only** (grey cloud) — not proxied. Cloudflare proxying breaks raw TCP (SSH).
---
## Step 2: Files to Create
### `Dockerfile.ssh`
Directly based on reference's `packages/go/Dockerfile`. Two-stage build: compile in Go Alpine, run in minimal Alpine.
The reference uses `CGO_ENABLED=0` but our SQLite driver needs CGO. Fix: switch to the pure-Go SQLite driver (`modernc.org/sqlite`) so we can keep `CGO_ENABLED=0` and get tiny images.
```dockerfile
FROM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o ssh ./main.go
FROM alpine:3.21
WORKDIR /root/
COPY --from=builder /app/ssh .
CMD TERM=xterm-256color ./ssh
```
### `Dockerfile.api`
```dockerfile
FROM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./api/main.go
FROM alpine:3.21
WORKDIR /root/
COPY --from=builder /app/api .
CMD ["./api"]
```
### `docker-compose.prod.yml`
```yaml
services:
api:
build:
context: .
dockerfile: Dockerfile.api
restart: unless-stopped
env_file: .env.prod
volumes:
- ./data:/data
expose:
- "8080"
ssh:
build:
context: .
dockerfile: Dockerfile.ssh
restart: unless-stopped
env_file: .env.prod
ports:
- "22:22"
volumes:
- ./.ssh:/root/.ssh
depends_on:
- api
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
```
### `Caddyfile`
```
api.yourdomain.com {
reverse_proxy api:8080
}
```
Caddy automatically gets a Let's Encrypt cert for the domain. No manual SSL setup.
### `.env.prod`
```
# API
API_PORT=8080
DATABASE_URL=/data/terminalshop.db
JWT_SECRET=<generate with: openssl rand -hex 32>
AUTH_FINGERPRINT_KEY=<generate with: openssl rand -hex 32>
ENVIRONMENT=production
APP_URL=https://api.yourdomain.com
# Stripe (live keys in production)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLIC_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=<from stripe dashboard>
# Shipping
SHIPPO_API_KEY=...
BRING_API_UID=...
BRING_API_KEY=...
# SSH server
API_URL=http://api:8080
```
Note: `API_URL` uses `api` (the Docker Compose service name) not `localhost` — containers talk to each other by service name.
### `.dockerignore`
```
.git
.env
.env.*
!.env.example
terminal-shop-source/
*.db
.ssh/
docs/
```
---
## Step 3: Code Changes
### 3a. SSH server host binding
`main.go` currently listens on `localhost`. In Docker it must listen on `0.0.0.0` to accept connections from outside the container.
```go
// Change:
host = "localhost"
port = "23456"
// To (read from env):
host = "0.0.0.0"
port = os.Getenv("SSH_PORT") // default "22" in prod, "23456" in dev
```
Or just hardcode `0.0.0.0` and keep port as env var.
### 3b. SSH host key path
Currently hardcoded to `.ssh/term_info_ed25519`. In Docker this needs to be an absolute path or a path relative to the working directory that gets volume-mounted. The volume mount in `docker-compose.prod.yml` handles this — generate the key once on the server and it persists.
Generate on the VPS before first run:
```bash
mkdir -p .ssh
ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N ""
```
### 3c. Switch SQLite driver to pure Go
In `pkg/database/db.go`, swap the import:
```go
// Remove:
_ "github.com/mattn/go-sqlite3" // or however it's imported
// Add:
_ "modernc.org/sqlite"
```
And update go.mod:
```bash
go get modernc.org/sqlite
go mod tidy
```
This lets us use `CGO_ENABLED=0` in both Dockerfiles, keeping images small and the build simple.
---
## Step 4: VPS Setup
SSH into your new server:
```bash
ssh root@<VPS IP>
```
Install Docker:
```bash
curl -fsSL https://get.docker.com | sh
```
Clone your repo:
```bash
git clone https://github.com/yourusername/terminalShop.git
cd terminalShop
```
Create data directory and generate SSH host key:
```bash
mkdir -p data .ssh
ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N ""
```
Create `.env.prod` with your production secrets (copy template above, fill in real values).
Open firewall:
```bash
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
**Important:** Port 22 is now used by your app. Change admin SSH to a different port first (e.g. 2222) so you don't lock yourself out:
```bash
# In /etc/ssh/sshd_config, change:
Port 2222
systemctl restart sshd
# Open new terminal, verify: ssh root@<VPS IP> -p 2222
# Then: ufw allow 2222/tcp
```
---
## Step 5: First Deploy
On the VPS:
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
Check logs:
```bash
docker compose -f docker-compose.prod.yml logs -f
```
Test:
```bash
ssh yourdomain.com
```
---
## Step 6: Stripe Webhook
In Stripe Dashboard → Developers → Webhooks:
- Add endpoint: `https://api.yourdomain.com/api/v1/webhooks/stripe`
- Copy the signing secret into `.env.prod` as `STRIPE_WEBHOOK_SECRET`
- No more ngrok needed in production
---
## Step 7: Ongoing Deploys
When you push changes:
```bash
# On VPS:
git pull
docker compose -f docker-compose.prod.yml up -d --build
```
Or automate with a GitHub Action that SSHes into the VPS and runs this on every push to main.
---
## Dev vs Prod Summary
| | Dev | Prod |
|--|-----|------|
| Process manager | Goreman + Procfile | Docker Compose |
| SSH port | 23456 | 22 |
| API URL | localhost:8080 | api.yourdomain.com (HTTPS) |
| Database | `terminalshop.db` (local) | `/data/terminalshop.db` (volume) |
| Stripe | ngrok + test keys | real domain + live keys |
| Webhooks | stripe CLI listener | Stripe dashboard endpoint |
---
## TODO After Deployment
- [ ] Switch from SQLite to PostgreSQL (add `postgres` service to compose, update connection string)
- [ ] Set up GitHub Actions for automatic deploy on push to main
- [ ] Add health check endpoint to Docker Compose so containers restart if app hangs
- [ ] Structured logging (write to file + stdout in prod)

View File

@@ -1,93 +0,0 @@
# Gap Analysis: Terminal Coffee Shop vs Reference (terminal.shop)
**Date:** 2026-04-08
---
## What's Already Working
- SSH server with key-based auth + auto user creation
- Shop page with live detail panel
- Cart (add/remove, synced to API)
- Full checkout flow: shipping → payment → confirmation
- Saved addresses and saved cards
- Order history in account view
- FAQ page
- About page
- Splash screen with blinking cursor animation
- Responsive layout (small/medium/large)
- Token refresh
- Full backend API (Chi router, PostgreSQL, GORM, Stripe webhooks)
- Shippo shipping integration
---
## Missing Features (vs reference)
### 1. API Token Management
- **Reference:** Create, view, and revoke personal API tokens
- **Current:** Not implemented
- **Reference file:** `tokens.go`
- **Task:** Add tokens section under account view with list + create/revoke actions
### 2. OAuth App Management
- **Reference:** Create, edit, delete OAuth apps with redirect URIs; uses `huh` form
- **Current:** Not implemented
- **Reference file:** `apps.go`
- **Task:** Add apps section under account view with form-based CRUD
### 3. Keyboard Interactive SSH Auth (Anonymous Sessions)
- **Reference:** Supports keyboard interactive auth in addition to public key, generates UUID fingerprints for anonymous users
- **Current:** Public key auth only (`WithPublicKeyAuth` in `main.go`)
- **Task:** Add `WithKeyboardInteractiveAuth` to Wish options; generate UUID fingerprint for anonymous sessions
---
## Code Quality Issues
### Theme System
- **Reference:** Centralized `theme.Theme` struct with methods (`Base()`, `TextAccent()`, `Border()`, `Brand()`, `Accent()`)
- **Current:** 40+ hardcoded hex colors scattered across view files (e.g. `#4682B4`, `#FFFFFF`, `#666666`)
- **Task:** Create `pkg/tui/theme/theme.go`, extract all colors and styles, update all view files to reference theme
### State Management
- **Reference:** Nested state substruct: `state.shop`, `state.cart`, `state.account`, etc. — each feature owns its state
- **Current:** Flat `Model` struct with 80+ fields, all mixed together
- **Task:** Refactor into nested substructs per feature area (breaking change — do after features are stable)
### Viewport-Based Scrolling
- **Reference:** Uses `charmbracelet/bubbles/viewport` throughout for scrollable content
- **Current:** Manual scroll offset math with hardcoded heights (e.g. `-9` for cart viewport height)
- **Task:** Replace manual scroll math with `viewport.Model` in cart, account, and FAQ views
---
## Architecture Comparison
| Area | Current | Reference |
|------|---------|-----------|
| State management | Flat `Model` struct | Nested `state` substructs per feature |
| Theme/styling | 40+ hardcoded colors | Centralized `theme.Theme` |
| Scrolling | Manual offset math | `viewport.Model` |
| Backend | Full custom API (Chi + GORM) | External SDK (`terminal-sdk-go`) |
| Auth | SSH pubkey + JWT | SSH pubkey + keyboard interactive |
| Product model | Custom `Coffee` struct | SDK `terminal.Product` with variants |
| Forms | `huh` (partial) | `huh` (comprehensive, with huh theme) |
---
## Recommended Order of Work
1. **Fix known bugs** — address delete bug and placeholder item (quick wins, low risk)
2. **Theme system** — extract colors; unblocks all visual polish
3. **Viewport-based scrolling** — replace manual math, fixes responsiveness
4. **API token management** — useful feature, account section
5. **OAuth app management** — lowest priority unless API access is a goal
6. **Keyboard interactive SSH auth** — enables anonymous browsing
7. **Nested state structs** — last, after features are stable (pure refactor)

View File

@@ -1,80 +0,0 @@
# Custom Card Form with Stripe.js
## Why
The reference (terminal.shop) uses their own branded card input page with Stripe.js
instead of Stripe Hosted Checkout. This means:
- Full control over the UI/branding
- No webhooks needed for card saving (token POSTed directly to API)
- No Stripe Checkout Session required
- Cleaner flow, fewer moving parts
## How it would work
1. TUI calls `POST /api/v1/cards/collect` → gets `https://yourdomain.com/pay/TOKEN`
2. User opens URL → sees your own card form (HTML + Stripe.js)
3. Stripe.js tokenizes card in the browser (client-side, no raw card data APIs needed)
4. Page POSTs token + TOKEN to your API: `POST /api/v1/cards/collect/TOKEN`
5. API saves the card to the database
6. TUI polling detects new card and auto-proceeds
## What to build
### 1. HTML page served at `GET /pay/{token}`
Instead of redirecting to Stripe, serve an HTML page with Stripe Elements.
The TOKEN from the URL is used to associate the card with the right user.
```html
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="card-form">
<div id="card-element"></div>
<button type="submit">Save Card</button>
</form>
<script>
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
const elements = stripe.elements();
const card = elements.create('card');
card.mount('#card-element');
document.getElementById('card-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { token } = await stripe.createToken(card);
await fetch('/api/v1/cards/collect/TOKEN_FROM_URL', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.id })
});
window.location.href = '/card-added';
});
</script>
</body>
</html>
```
### 2. New API endpoint: `POST /api/v1/cards/collect/{token}`
- Looks up the user from the token (stored in DB or memory map)
- Calls Stripe to convert token → PaymentMethod
- Attaches to customer
- Saves card to DB
- No webhook needed
### 3. Remove Stripe Checkout Session logic from CollectCard
`CollectCard` just generates a token, stores user association, returns the URL.
No `stripeSession.New()` call needed.
## Notes
- Requires a publicly accessible HTTPS domain (same as current approach)
- The in-memory token→userID map works fine (same TTL concerns as current approach)
- Could remove the `checkout.session.completed` webhook handler entirely
- Would need to remove the Stripe Checkout Session dependency from `cards.go`

View File

@@ -1,225 +0,0 @@
# Theme System Implementation Reference
> Use this document when implementing or modifying the theme system.
> Every decision and pattern is explained so context loss doesn't stall progress.
---
## What We're Doing
Replacing 40+ scattered `lipgloss.Color("#XXXXXX")` calls across 13 TUI files with a
centralized `Theme` struct in `pkg/tui/theme/`. Copied closely from the reference
implementation in `terminal-shop-source/packages/go/pkg/tui/theme/`.
---
## New Files to Create
```
pkg/tui/theme/theme.go — Theme struct, BasicTheme(), all color/style methods
pkg/tui/theme/huh.go — HuhTheme(), copyFieldStyles(), copyTextStyles()
```
---
## Files to Modify (in order)
| Step | File | Type of change |
|------|------|----------------|
| 1 | `pkg/tui/theme/theme.go` | Create |
| 2 | `pkg/tui/theme/huh.go` | Create |
| 3 | `pkg/tui/model.go` | Add `renderer` + `theme` fields; update constructors |
| 4 | `main.go` | Create renderer from SSH session; pass to NewModelWithAuth |
| 5 | `pkg/tui/views.go` | 3 color swaps |
| 6 | `pkg/tui/splash.go` | 2 color swaps |
| 7 | `pkg/tui/header.go` | 4 color swaps |
| 8 | `pkg/tui/footer.go` | 2 color swaps |
| 9 | `pkg/tui/breadcrumbs.go` | 3 color swaps |
| 10 | `pkg/tui/shop.go` | 6 color swaps |
| 11 | `pkg/tui/cart.go` | 9 color swaps |
| 12 | `pkg/tui/account.go` | 14 color swaps |
| 13 | `pkg/tui/menu.go` | 4 color swaps |
| 14 | `pkg/tui/help.go` | 5 color swaps |
| 15 | `pkg/tui/confirmation.go` | 5 color swaps |
| 16 | `pkg/tui/shipping_form.go` | 4 color swaps + `.WithTheme()` |
| 17 | `pkg/tui/payment_form.go` | 5 color swaps + `.WithTheme()` |
---
## Color Mapping (hardcoded → theme)
| Hardcoded | Semantic role | Text style method | Color accessor |
|-----------|--------------|-------------------|----------------|
| `#FFFFFF` | active / selected text | `m.theme.TextAccent()` | `m.theme.Accent()` |
| `#AAAAAA` | body / regular text | `m.theme.TextBody()` | `m.theme.Body()` |
| `#666666` | hints / inactive / dim text | `m.theme.TextDim()` | `m.theme.Dim()` |
| `#666666` | border foreground | — | `m.theme.Border()` |
| `#4682B4` | highlight / brand / steel blue | `m.theme.TextHighlight()` | `m.theme.Highlight()` |
| `#999999` | form label text | `m.theme.TextLabel()` | `m.theme.Label()` |
| `#00FF00` | success / order placed | `m.theme.TextSuccess()` | `m.theme.Success()` |
| `"196"` | error text | `m.theme.TextError()` | `m.theme.Error()` |
| `"205"` | loading / spinner | `m.theme.TextLoading()` | — |
| `"196"` + `"52"` | error banner bg+fg | `m.theme.PanelError()` | — |
| `"C6DDF0"` | user name (light blue) | `m.theme.TextHighlight()` | — |
| `#CCCCCC` | muted subtext | `m.theme.TextBody()` | — |
**Note:** `#666666` is used for BOTH dim text AND border foreground in this project.
Both `Dim()` and `Border()` return the same value. Use `Border()` in `BorderForeground()`,
use `Dim()` / `TextDim()` for text styling.
**Note:** Dynamic product colors (`coffee.Color` = `"#8B4513"` etc.) are NOT replaced.
Keep those as `lipgloss.Color(coffee.Color)`.
---
## Theme Colors (what BasicTheme sets)
```go
background = AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
border = AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
body = Color("#AAAAAA")
dim = Color("#666666")
accent = AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
highlight = Color("#4682B4")
brand = Color("#4682B4") // same as highlight for this project
label = Color("#999999")
success = Color("#00FF00")
error = Color("196")
```
---
## How the Renderer Works
The reference implementation creates a `*lipgloss.Renderer` **per SSH session**:
```go
// main.go — SSH handler
renderer := bubbletea.MakeRenderer(s) // s = ssh.Session
```
This lets lipgloss detect the correct color profile for each client's terminal
(e.g. TrueColor for Ghostty vs 256-color for basic terminals). All style creation
inside the theme uses `renderer.NewStyle()` instead of the global `lipgloss.NewStyle()`.
In this project:
- SSH sessions → renderer from `bubbletea.MakeRenderer(s)`
- Local / test mode → `lipgloss.DefaultRenderer()`
---
## Usage Pattern in Views
All view functions are methods on `Model`, so `m.theme` is always available.
**Text-only style (before → after):**
```go
// before
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(18)
// after
m.theme.TextAccent().Bold(true).Width(18)
```
**Border foreground (before → after):**
```go
// before
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666"))
// after
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border())
```
**Selected item background (before → after):**
```go
// before
lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Foreground(lipgloss.Color("#FFFFFF"))
// after
lipgloss.NewStyle().Background(m.theme.Highlight()).Foreground(m.theme.Accent())
```
**`lipgloss.NewStyle()` stays for layout-only styles** (Width, Margin, Padding, Align
with no color). Only color-setting calls move to the theme.
---
## Huh Form Theme
`HuhTheme()` creates a `*huh.Theme` applied to shipping and payment forms.
Applied by chaining `.WithTheme(m.theme.Form())` on the `huh.NewForm(...)` call:
```go
// shipping_form.go and payment_form.go — in buildShippingForm / buildPaymentForm
f := huh.NewForm(...).
WithShowErrors(false).
WithShowHelp(false).
WithTheme(m.theme.Form()) // ← add this line
```
Form theme styles:
- Focused border: thick left border in accent color (`#FFFFFF`)
- Field title + body text: body color (`#AAAAAA`)
- Cursor: highlight color (`#4682B4`)
- Placeholder text: dim color (`#666666`)
- Error: color `"196"`
- Blurred fields: hidden border (same styles, border invisible)
---
## Constructor Changes (model.go)
**Before:** `NewModel(username string)` initializes everything.
`NewModelWithAuth(fingerprint, pubKey, apiURL, secret, stripeKey)` calls NewModel.
**After:** A private `newModelWithRenderer(username, renderer)` does the heavy init.
Both public constructors call it:
```go
func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
}
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint, pubKey, apiURL, secret, stripeKey string) Model {
m := newModelWithRenderer("", renderer)
m.Fingerprint = fingerprint
// ... etc
return m
}
```
`NewModelWithAuth` signature change: **renderer moves to first parameter**.
Update the call in `main.go` accordingly.
---
## Theme Methods Quick Reference
```go
// Color accessors (lipgloss.TerminalColor — use in .Foreground() / .Background())
m.theme.Accent() // #FFFFFF / #11181C adaptive
m.theme.Body() // #AAAAAA
m.theme.Dim() // #666666
m.theme.Border() // #666666 / #D7DBDF adaptive
m.theme.Highlight() // #4682B4
m.theme.Brand() // #4682B4
m.theme.Label() // #999999
m.theme.Success() // #00FF00
m.theme.Error() // "196"
m.theme.Background() // #000000 / #FBFCFD adaptive
// Style helpers (lipgloss.Style — chain .Bold(), .Width(), .Padding() etc.)
m.theme.Base() // base style, body foreground
m.theme.TextAccent() // white text
m.theme.TextBody() // gray text (#AAAAAA)
m.theme.TextDim() // dim text (#666666)
m.theme.TextHighlight() // steel blue text (#4682B4)
m.theme.TextBrand() // same as TextHighlight
m.theme.TextLabel() // label text (#999999)
m.theme.TextSuccess() // green text (#00FF00)
m.theme.TextError() // error text ("196")
m.theme.TextLoading() // loading pink text ("205")
m.theme.PanelError() // error banner: dark red bg + red fg
m.theme.Form() // *huh.Theme for form styling
```

BIN
main

Binary file not shown.

View File

@@ -1,3 +1,4 @@
// version: 0.1.1
package main package main
import ( import (

View File

@@ -61,6 +61,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
reqBody := TokenRequest{ reqBody := TokenRequest{
Fingerprint: fingerprint, Fingerprint: fingerprint,
SSHPublicKey: pubKeyStr,
ClientSecret: clientSecret, ClientSecret: clientSecret,
} }
@@ -593,7 +594,7 @@ func (c *Client) CollectCard() (string, error) {
var collectResp CollectCardResponse var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
return "", fmt.Errorf("failed to devode collect response: %w", err) return "", fmt.Errorf("failed to decode collect response: %w", err)
} }
if !collectResp.Success { if !collectResp.Success {
@@ -627,6 +628,64 @@ func (c *Client) DeleteCard(id uint) error {
return nil return nil
} }
type SSHKeyResponse struct {
Success bool `json:"success"`
Data struct {
SSHKeys []models.SSHKey `json:"ssh_keys"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetSSHKeys() ([]models.SSHKey, error) {
url := fmt.Sprintf("%s/api/v1/ssh-keys", c.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch ssh keys: %w", err)
}
defer resp.Body.Close()
var keysResp SSHKeyResponse
if err := json.NewDecoder(resp.Body).Decode(&keysResp); err != nil {
return nil, fmt.Errorf("failed to decode ssh keys response: %w", err)
}
if !keysResp.Success {
if keysResp.Error != nil {
return nil, fmt.Errorf("%s: %s", keysResp.Error.Code, keysResp.Error.Message)
}
return nil, fmt.Errorf("failed to fetch ssh keys")
}
return keysResp.Data.SSHKeys, nil
}
func (c *Client) DeleteSSHKey(id uint) error {
url := fmt.Sprintf("%s/api/v1/ssh-keys/%d", c.BaseURL, id)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to delete ssh key: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete ssh key failed with status %d", resp.StatusCode)
}
return nil
}
type OrderResponse struct { type OrderResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
@@ -783,6 +842,7 @@ type ViewInitData struct {
Cart []CartItemData `json:"cart"` Cart []CartItemData `json:"cart"`
Addresses []models.Address `json:"addresses"` Addresses []models.Address `json:"addresses"`
Cards []models.Card `json:"cards"` Cards []models.Card `json:"cards"`
SSHKeys []models.SSHKey `json:"ssh_keys"`
Orders []models.Order `json:"orders"` Orders []models.Order `json:"orders"`
} }

View File

@@ -42,7 +42,6 @@ type Address struct {
Phone string Phone string
} }
type validationMatch struct { type validationMatch struct {
StreetName string `json:"street_name"` StreetName string `json:"street_name"`
HouseNumber int `json:"house_number"` HouseNumber int `json:"house_number"`

View File

@@ -23,6 +23,7 @@ func Migrate(db *gorm.DB) error {
&models.Order{}, // Completed purchases &models.Order{}, // Completed purchases
&models.OrderItem{}, // Line items within orders &models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses &models.Address{}, // Saved addresses
&models.SSHKey{}, // One of the users ssh keys
); err != nil { ); err != nil {
return fmt.Errorf("migration failed: %w", err) return fmt.Errorf("migration failed: %w", err)
} }

View File

@@ -29,6 +29,9 @@ func (Coffee) TableName() string {
// AccountMenuItems // AccountMenuItems
var AccountMenuItems = []string{ var AccountMenuItems = []string{
"order history", "order history",
"addresses",
"cards",
"ssh keys",
"faq", "faq",
"about", "about",
} }

28
pkg/models/ssh_key.go Normal file
View File

@@ -0,0 +1,28 @@
package models
import (
"time"
"gorm.io/gorm"
)
// SSHKey represents one of a users registered SSH public keys.
// A user may have multiple keys, any of them grants access.
type SSHKey struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Fingerprint string `gorm:"uniqueIndex;size:100;not null" json:"fingerprint"`
PublicKey string `gorm:"type:text" json:"-"`
Comment string `gorm:"size:255" json:"comment"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
}
// TableName specifies the table name for the SSHKey model.
func (SSHKey) TableName() string {
return "ssh_keys"
}

View File

@@ -17,30 +17,21 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Build the left panel (menu items) // Build the left panel (menu items)
leftPanel := "" leftPanel := ""
for i, item := range models.AccountMenuItems { for i, item := range models.AccountMenuItems {
if m.AccountCursor == i { if m.account.cursor == i {
style := lipgloss.NewStyle(). style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
Background(m.theme.Highlight()).
Foreground(m.theme.Accent()).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n" leftPanel += style.Render(item) + "\n"
} else { } else {
style := lipgloss.NewStyle(). style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
Foreground(m.theme.Body()).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n" leftPanel += style.Render(item) + "\n"
} }
} }
leftContainer := lipgloss.NewStyle(). leftContainer := m.theme.Base().
Width(leftWidth) Width(leftWidth)
// Build the detail view (right panel) based on cursor position // Build the detail view (right panel) based on cursor position
detailView := "" detailView := ""
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) { if m.account.cursor >= 0 && m.account.cursor < len(models.AccountMenuItems) {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2) titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
// On small terminals, use full content width // On small terminals, use full content width
@@ -51,16 +42,16 @@ func (m Model) BuildAccountView(availableHeight int) string {
contentStyle := m.theme.TextBody().Width(detailContentWidth) contentStyle := m.theme.TextBody().Width(detailContentWidth)
selectedItem := models.AccountMenuItems[m.AccountCursor] selectedItem := models.AccountMenuItems[m.account.cursor]
switch selectedItem { switch selectedItem {
case "order history": case "order history":
if !m.OrdersLoaded { if !m.OrdersLoaded {
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...") detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
} else if len(m.Orders) == 0 { } else if len(m.Orders) == 0 {
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.") detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.")
} else if m.OrderViewState == 2 { } else if m.account.orderViewState == 2 {
// State 2: full detail for the selected order // State 2: full detail for the selected order
detailView = m.buildOrderDetailView(m.Orders[m.OrderCursor], detailContentWidth) detailView = m.buildOrderDetailView(m.Orders[m.account.orderCursor], detailContentWidth)
} else { } else {
// State 0 (preview) or State 1 (browsing with cursor) // State 0 (preview) or State 1 (browsing with cursor)
boxWidth := detailContentWidth - 4 boxWidth := detailContentWidth - 4
@@ -71,19 +62,104 @@ func (m Model) BuildAccountView(availableHeight int) string {
lines := titleStyle.Render("Order History") + "\n\n" lines := titleStyle.Render("Order History") + "\n\n"
for i, order := range m.Orders { for i, order := range m.Orders {
isSelected := m.OrderViewState == 1 && i == m.OrderCursor isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, detailContentWidth) lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, detailContentWidth)
if i < len(m.Orders)-1 { if i < len(m.Orders)-1 {
lines += "\n" lines += "\n"
} }
} }
if m.OrderViewState == 0 { if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim() hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders") lines += "\n" + hintStyle.Render("enter: browse orders")
} }
detailView = lines detailView = lines
} }
case "addresses":
lines := titleStyle.Render("Address") + "\n\n"
if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses")
} else {
for i, addr := range m.SavedAddresses {
isSelected := m.account.addressListFocused && i == m.account.addressCursor
label := addr.Name + " " + addr.Street + ", " + addr.City
if len(label) > detailContentWidth-4 {
label = label[:detailContentWidth-4]
}
if m.account.addressDeleting != nil && *m.account.addressDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" deletes? (y/n)") + "\n"
} else if isSelected {
lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.addressListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
detailView = lines
case "cards":
lines := titleStyle.Render("Cards") + "\n\n"
if len(m.SavedCards) == 0 {
lines += contentStyle.Render("No saved cards.")
} else {
for i, card := range m.SavedCards {
isSelected := m.account.cardListFocused && i == m.account.cardCursor
label := fmt.Sprintf("**** **** **** %s %s exp %02d/%02d",
card.Last4, card.Brand, card.ExpMonth, card.ExpYear%100)
if m.account.cardDeleting != nil && *m.account.cardDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n"
} else if isSelected {
lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.cardListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
detailView = lines
case "ssh keys":
lines := titleStyle.Render("SSH keys") + "\n\n"
if !m.SSHKeysLoaded {
lines += contentStyle.Render("Loading ssh keys...")
} else if len(m.SSHKeys) == 0 {
lines += contentStyle.Render("No saved ssh keys.")
} else {
for i, key := range m.SSHKeys {
isSelected := m.account.sshKeyListFocused && i == m.account.sshKeyCursor
label := key.Fingerprint
if key.Comment != "" {
label += " " + key.Comment
}
if len(label) > detailContentWidth-4 {
label = label[:detailContentWidth-4]
}
if m.account.sshKeyDeleting != nil && *m.account.sshKeyDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n"
} else if isSelected {
lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n"
} else {
lines += contentStyle.Render(" "+label) + "\n"
}
}
}
if m.account.sshKeyListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
detailView = lines
case "faq": case "faq":
questionStyle := m.theme.TextHighlight().Bold(true) questionStyle := m.theme.TextHighlight().Bold(true)
@@ -96,7 +172,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
} }
} }
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.FaqFocused { if !m.account.faqFocused {
hintStyle := m.theme.TextDim() hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq") detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
} }
@@ -126,7 +202,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Apply internal scrolling to just the detail view // Apply internal scrolling to just the detail view
// Scrolling is needed for: order detail/list and FAQ when focused // Scrolling is needed for: order detail/list and FAQ when focused
if m.OrderViewState >= 1 || m.FaqFocused { if m.account.orderViewState >= 1 || m.account.faqFocused {
detailLines := strings.Split(detailView, "\n") detailLines := strings.Split(detailView, "\n")
totalLines := len(detailLines) totalLines := len(detailLines)
@@ -141,7 +217,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
} }
// Clamp scroll offset locally (don't mutate m since value receiver) // Clamp scroll offset locally (don't mutate m since value receiver)
offset := m.ScrollOffset offset := m.account.scrollOffset
maxScroll := totalLines - scrollHeight maxScroll := totalLines - scrollHeight
if maxScroll < 0 { if maxScroll < 0 {
maxScroll = 0 maxScroll = 0
@@ -380,36 +456,67 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
if !ok { if !ok {
return m, nil return m, nil
} }
selectedItem := ""
if m.account.cursor < len(models.AccountMenuItems) {
selectedItem = models.AccountMenuItems[m.account.cursor]
}
switch keyMsg.String() { switch keyMsg.String() {
case "up", "k": case "up", "k":
if m.FaqFocused { if m.account.addressListFocused && m.account.addressDeleting == nil {
m.ScrollOffset -= 3 if m.account.addressCursor > 0 {
if m.ScrollOffset < 0 { m.account.addressCursor--
m.ScrollOffset = 0
} }
} else if m.OrderViewState == 1 && m.OrderCursor > 0 { } else if m.account.cardListFocused && m.account.cardDeleting == nil {
m.OrderCursor-- if m.account.cardCursor > 0 {
m.account.cardCursor--
}
} else if m.account.sshKeyListFocused && m.account.sshKeyDeleting == nil {
if m.account.sshKeyCursor > 0 {
m.account.sshKeyCursor--
}
} else if m.account.faqFocused {
m.account.scrollOffset -= 3
if m.account.scrollOffset < 0 {
m.account.scrollOffset = 0
}
} else if m.account.orderViewState == 1 && m.account.orderCursor > 0 {
m.account.orderCursor--
cardHeight := 5 cardHeight := 5
if m.heightContainer >= 25 { if m.heightContainer >= 25 {
cardHeight = 7 cardHeight = 7
} }
targetTop := 3 + m.OrderCursor*cardHeight targetTop := 3 + m.account.orderCursor*cardHeight
if targetTop < m.ScrollOffset { if targetTop < m.account.scrollOffset {
m.ScrollOffset = targetTop m.account.scrollOffset = targetTop
} }
} else if m.OrderViewState == 0 && m.AccountCursor > 0 { } else if m.account.orderViewState == 0 && m.account.cursor > 0 {
m.AccountCursor-- m.account.cursor--
m.ScrollOffset = 0 m.account.scrollOffset = 0
} }
case "down", "j": case "down", "j":
if m.FaqFocused { if m.account.addressListFocused && m.account.addressDeleting == nil {
m.ScrollOffset += 3 if m.account.addressCursor < len(m.SavedAddresses)-1 {
maxScroll := m.computeFaqScrollMax() m.account.addressCursor++
if m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
} }
} else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 { } else if m.account.cardListFocused && m.account.cardDeleting == nil {
m.OrderCursor++ if m.account.cardCursor < len(m.SavedCards)-1 {
m.account.cardCursor++
}
} else if m.account.sshKeyListFocused && m.account.sshKeyDeleting == nil {
if m.account.sshKeyCursor < len(m.SSHKeys)-1 {
m.account.sshKeyCursor++
}
} else if m.account.faqFocused {
m.account.scrollOffset += 3
maxScroll := m.computeFaqScrollMax()
if m.account.scrollOffset > maxScroll {
m.account.scrollOffset = maxScroll
}
} else if m.account.orderViewState == 1 && m.account.orderCursor < len(m.Orders)-1 {
m.account.orderCursor++
cardHeight := 5 cardHeight := 5
if m.heightContainer >= 25 { if m.heightContainer >= 25 {
cardHeight = 7 cardHeight = 7
@@ -421,50 +528,133 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
if viewportHeight < 5 { if viewportHeight < 5 {
viewportHeight = 5 viewportHeight = 5
} }
targetBottom := 3 + (m.OrderCursor+1)*cardHeight targetBottom := 3 + (m.account.orderCursor+1)*cardHeight
if targetBottom > m.ScrollOffset+viewportHeight { if targetBottom > m.account.scrollOffset+viewportHeight {
m.ScrollOffset = targetBottom - viewportHeight m.account.scrollOffset = targetBottom - viewportHeight
} }
} else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 { } else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && !m.account.sshKeyListFocused && m.account.cursor < len(models.AccountMenuItems)-1 {
m.AccountCursor++ m.account.cursor++
m.ScrollOffset = 0 m.account.scrollOffset = 0
} }
case "pgup", "ctrl+u": case "pgup", "ctrl+u":
if m.FaqFocused || m.OrderViewState >= 2 { if m.account.faqFocused || m.account.orderViewState >= 2 {
m.ScrollOffset -= 3 m.account.scrollOffset -= 3
if m.ScrollOffset < 0 { if m.account.scrollOffset < 0 {
m.ScrollOffset = 0 m.account.scrollOffset = 0
} }
} }
case "pgdown", "ctrl+d": case "pgdown", "ctrl+d":
if m.FaqFocused || m.OrderViewState >= 2 { if m.account.faqFocused || m.account.orderViewState >= 2 {
m.ScrollOffset += 3 m.account.scrollOffset += 3
maxScroll := m.computeFaqScrollMax() maxScroll := m.computeFaqScrollMax()
if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll { if m.account.faqFocused && maxScroll >= 0 && m.account.scrollOffset > maxScroll {
m.ScrollOffset = maxScroll m.account.scrollOffset = maxScroll
} }
} }
case "x", "d":
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
m.account.addressDeleting = &m.account.addressCursor
} else if m.account.cardListFocused && m.account.cardDeleting == nil && m.account.cardCursor < len(m.SavedCards) {
m.account.cardDeleting = &m.account.cardCursor
} else if m.account.sshKeyListFocused && m.account.sshKeyDeleting == nil && m.account.sshKeyCursor < len(m.SSHKeys) {
m.account.sshKeyDeleting = &m.account.sshKeyCursor
}
case "y":
if m.account.addressDeleting != nil {
cursor := *m.account.addressDeleting
addr := m.SavedAddresses[cursor]
m.account.addressDeleting = nil
m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...)
if m.account.addressCursor >= len(m.SavedAddresses) && m.account.addressCursor > 0 {
m.account.addressCursor--
}
return m, m.deleteAddressCmd(addr.ID)
} else if m.account.cardDeleting != nil {
cursor := *m.account.cardDeleting
card := m.SavedCards[cursor]
m.account.cardDeleting = nil
m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...)
if m.account.cardCursor >= len(m.SavedCards) && m.account.cardCursor > 0 {
m.account.cardCursor--
}
return m, m.deleteCardCmd(card.ID)
} else if m.account.sshKeyDeleting != nil {
cursor := *m.account.sshKeyDeleting
key := m.SSHKeys[cursor]
m.account.sshKeyDeleting = nil
m.SSHKeys = append(m.SSHKeys[:cursor], m.SSHKeys[cursor+1:]...)
if m.account.sshKeyCursor >= len(m.SSHKeys) && m.account.sshKeyCursor > 0 {
m.account.sshKeyCursor--
}
return m, m.deleteSSHKeyCmd(key.ID)
}
case "n":
m.account.addressDeleting = nil
m.account.cardDeleting = nil
m.account.sshKeyDeleting = nil
case "p", "enter": case "p", "enter":
if m.AccountCursor == 0 && len(m.Orders) > 0 { switch selectedItem {
if m.OrderViewState == 0 { case "order history":
m.OrderViewState = 1 if len(m.Orders) > 0 {
m.OrderCursor = 0 if m.account.orderViewState == 0 {
m.ScrollOffset = 0 m.account.orderViewState = 1
} else if m.OrderViewState == 1 { m.account.orderCursor = 0
m.OrderViewState = 2 m.account.scrollOffset = 0
m.ScrollOffset = 0 } else if m.account.orderViewState == 1 {
m.account.orderViewState = 2
m.account.scrollOffset = 0
} }
} else if m.AccountCursor == 1 && !m.FaqFocused {
m.FaqFocused = true
m.ScrollOffset = 0
} }
case "ssh keys":
if !m.account.sshKeyListFocused {
m.account.sshKeyListFocused = true
m.account.sshKeyCursor = 0
}
case "addresses":
if !m.account.addressListFocused {
m.account.addressListFocused = true
m.account.addressCursor = 0
}
case "cards":
if !m.account.cardListFocused {
m.account.cardListFocused = true
m.account.cardCursor = 0
}
case "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
m.account.scrollOffset = 0
}
}
case "esc": case "esc":
if m.FaqFocused { if m.account.addressDeleting != nil {
m.FaqFocused = false m.account.addressDeleting = nil
m.ScrollOffset = 0 } else if m.account.addressListFocused {
} else if m.OrderViewState > 0 { m.account.addressListFocused = false
m.OrderViewState-- m.account.addressCursor = 0
m.ScrollOffset = 0 } else if m.account.cardDeleting != nil {
m.account.cardDeleting = nil
} else if m.account.cardListFocused {
m.account.cardListFocused = false
m.account.cardCursor = 0
} else if m.account.sshKeyDeleting != nil {
m.account.sshKeyDeleting = nil
} else if m.account.sshKeyListFocused {
m.account.sshKeyListFocused = false
m.account.sshKeyCursor = 0
} else if m.account.faqFocused {
m.account.faqFocused = false
m.account.scrollOffset = 0
} else if m.account.orderViewState > 0 {
m.account.orderViewState--
m.account.scrollOffset = 0
} }
} }
return m, nil return m, nil

View File

@@ -16,19 +16,19 @@ func (m Model) updateCartViewport() Model {
if availH < 1 { if availH < 1 {
availH = 1 availH = 1
} }
if !m.cartVPReady { if !m.cart.viewportReady {
m.cartVP = viewport.New(m.widthContent, availH) m.cart.viewport = viewport.New(m.widthContent, availH)
m.cartVP.KeyMap = viewport.KeyMap{} m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cartVPReady = true m.cart.viewportReady = true
} else { } else {
m.cartVP.Width = m.widthContent m.cart.viewport.Width = m.widthContent
m.cartVP.Height = availH m.cart.viewport.Height = availH
} }
return m return m
} }
func (m Model) generateCartContent() string { func (m Model) generateCartContent() string {
if len(m.Cart) == 0 { if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim(). emptyStyle := m.theme.TextDim().
Align(lipgloss.Center). Align(lipgloss.Center).
Width(m.widthContent). Width(m.widthContent).
@@ -87,7 +87,7 @@ func (m Model) generateCartContent() string {
boxContent := contentLine1 + "\n" + contentLine2 boxContent := contentLine1 + "\n" + contentLine2
var itemBox lipgloss.Style var itemBox lipgloss.Style
if idx == m.CartCursor { if idx == m.cart.cursor {
itemBox = lipgloss.NewStyle(). itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true). Border(lipgloss.NormalBorder(), true).
BorderForeground(m.theme.Accent()). BorderForeground(m.theme.Accent()).
@@ -118,26 +118,26 @@ func (m Model) generateCartContent() string {
} }
func (m Model) CartView() string { func (m Model) CartView() string {
if !m.cartVPReady { if !m.cart.viewportReady {
m = m.updateCartViewport() m = m.updateCartViewport()
} }
content := m.generateCartContent() content := m.generateCartContent()
m.cartVP.SetContent(content) m.cart.viewport.SetContent(content)
if len(m.Cart) > 0 { if len(m.Cart) > 0 {
itemHeight := 5 itemHeight := 5
targetY := m.CartCursor * itemHeight targetY := m.cart.cursor * itemHeight
if targetY < m.cartVP.YOffset { if targetY < m.cart.viewport.YOffset {
m.cartVP.SetYOffset(targetY) m.cart.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.cartVP.YOffset+m.cartVP.Height { if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
m.cartVP.SetYOffset(targetY - m.cartVP.Height + itemHeight) m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
} }
} }
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
lipgloss.Height(m.cartVP.View()), lipgloss.Height(m.cart.viewport.View()),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.cartVP.View(), m.cart.viewport.View(),
) )
} }
@@ -148,41 +148,39 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
switch keyMsg.String() { switch keyMsg.String() {
case "up", "k": case "up", "k":
if m.CartCursor > 0 { if m.cart.cursor > 0 {
m.CartCursor-- m.cart.cursor--
} }
case "down", "j": case "down", "j":
if m.CartCursor < len(m.Cart)-1 { if m.cart.cursor < len(m.Cart)-1 {
m.CartCursor++ m.cart.cursor++
} }
case "+", "=": case "+", "=":
cartItems := m.GetCartItemsSlice() cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
cartItems[m.CartCursor].Quantity++ cartItems[m.cart.cursor].Quantity++
return m, m.syncCartItemCmd(cartItems[m.CartCursor].CoffeeID, cartItems[m.CartCursor].Quantity) return m, m.syncCartItemCmd(cartItems[m.cart.cursor].CoffeeID, cartItems[m.cart.cursor].Quantity)
} }
case "-", "_": case "-", "_":
cartItems := m.GetCartItemsSlice() cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
coffeeID := cartItems[m.CartCursor].CoffeeID coffeeID := cartItems[m.cart.cursor].CoffeeID
cartItems[m.CartCursor].Quantity-- cartItems[m.cart.cursor].Quantity--
newQty := cartItems[m.CartCursor].Quantity newQty := cartItems[m.cart.cursor].Quantity
if cartItems[m.CartCursor].Quantity <= 0 { if cartItems[m.cart.cursor].Quantity <= 0 {
delete(m.Cart, coffeeID) delete(m.Cart, coffeeID)
if len(m.Cart) == 0 { if len(m.Cart) == 0 {
m.CartCursor = 0 m.cart.cursor = 0
} else if m.CartCursor >= len(m.Cart) { } else if m.cart.cursor >= len(m.Cart) {
m.CartCursor = len(m.Cart) - 1 m.cart.cursor = len(m.Cart) - 1
} }
newQty = 0 newQty = 0
} }
return m, m.syncCartItemCmd(coffeeID, newQty) return m, m.syncCartItemCmd(coffeeID, newQty)
} }
case "p", "enter": case "p", "enter":
if len(m.Cart) > 0 { if !m.IsCartEmpty() {
m = m.SwitchPage(shippingPage) return m.ShippingSwitch()
m = m.updateShippingViewport()
return m, m.fetchAddressesCmd()
} }
} }
return m, nil return m, nil

View File

@@ -2,6 +2,8 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -24,20 +26,20 @@ func (m Model) RenderConfirmation() string {
// Shipping summary // Shipping summary
shippingSection := "" shippingSection := ""
if m.ConfirmShipping != nil { if m.confirm.shipping != nil {
shippingSection = sectionStyle.Render( shippingSection = sectionStyle.Render(
labelStyle.Render("Ship to: ") + labelStyle.Render("Ship to: ") +
valueStyle.Render(m.ConfirmShipping.Name) + "\n" + valueStyle.Render(m.confirm.shipping.Name) + "\n" +
labelStyle.Render(" ") + labelStyle.Render(" ") +
valueStyle.Render(m.ConfirmShipping.Street) + "\n" + valueStyle.Render(m.confirm.shipping.Street) + "\n" +
labelStyle.Render(" ") + labelStyle.Render(" ") +
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.Country)), valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.confirm.shipping.City, m.confirm.shipping.State, m.confirm.shipping.Zip, m.confirm.shipping.Country)),
) )
} }
// Items summary // Items summary
cartSection := "" cartSection := ""
for _, item := range m.ConfirmItems { for _, item := range m.confirm.items {
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100) line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
cartSection += sectionStyle.Render(line) + "\n" cartSection += sectionStyle.Render(line) + "\n"
} }
@@ -45,7 +47,7 @@ func (m Model) RenderConfirmation() string {
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"), labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
) + "\n" ) + "\n"
cartSection += sectionStyle.Render( cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)), labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.confirm.total)/100)),
) )
success := successStyle.Render("Order placed successfully!") success := successStyle.Render("Order placed successfully!")
@@ -70,34 +72,34 @@ func (m Model) updateConfirmViewport() Model {
if availH < 1 { if availH < 1 {
availH = 1 availH = 1
} }
if !m.confirmVPReady { if !m.confirm.viewportReady {
m.confirmVP = viewport.New(m.widthContent, availH) m.confirm.viewport = viewport.New(m.widthContent, availH)
m.confirmVP.KeyMap = viewport.DefaultKeyMap() m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
m.confirmVPReady = true m.confirm.viewportReady = true
} else { } else {
m.confirmVP.Width = m.widthContent m.confirm.viewport.Width = m.widthContent
m.confirmVP.Height = availH m.confirm.viewport.Height = availH
} }
return m return m
} }
func (m Model) ConfirmView() string { func (m Model) ConfirmView() string {
if !m.confirmVPReady { if !m.confirm.viewportReady {
m = m.updateConfirmViewport() m = m.updateConfirmViewport()
} }
m.confirmVP.SetContent(m.RenderConfirmation()) m.confirm.viewport.SetContent(m.RenderConfirmation())
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
lipgloss.Height(m.confirmVP.View()), lipgloss.Height(m.confirm.viewport.View()),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.confirmVP.View(), m.confirm.viewport.View(),
) )
} }
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) { func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
if m.confirmVPReady { if m.confirm.viewportReady {
m.confirmVP, cmd = m.confirmVP.Update(msg) m.confirm.viewport, cmd = m.confirm.viewport.Update(msg)
} }
keyMsg, ok := msg.(tea.KeyMsg) keyMsg, ok := msg.(tea.KeyMsg)
@@ -109,12 +111,11 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd return m, cmd
} }
m = m.SwitchPage(shopPage)
m.CheckingOut = false m.CheckingOut = false
m.ConfirmTotal = 0 m.confirm.total = 0
m.ConfirmItems = nil m.confirm.items = nil
m.ConfirmShipping = nil m.confirm.shipping = nil
m = m.resetPageState() m, _ = m.ShopSwitch()
return m, func() tea.Msg { return m, func() tea.Msg {
if m.APIClient != nil { if m.APIClient != nil {
_ = m.APIClient.ClearCart() _ = m.APIClient.ClearCart()
@@ -122,3 +123,107 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
return nil return nil
} }
} }
func formatUSD(cents int) string {
return fmt.Sprintf("$%d.%02d", cents/100, cents%100)
}
func (m Model) generateReviewContent() string {
view := strings.Builder{}
if m.review.cardJustAdded {
view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n")
}
if m.ShippingInfo != nil {
view.WriteString(m.ShippingInfo.Name + "\n")
view.WriteString(m.ShippingInfo.Street + "\n")
if m.ShippingInfo.Street2 != "" {
view.WriteString(m.ShippingInfo.Street2 + "\n")
}
view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country))
}
view.WriteString("\n")
if m.SelectedCard != nil {
view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4))
}
view.WriteString("\n")
subtotal := m.CalculateSubtotal()
view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal)))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n")
view.WriteString(m.theme.TextDim().Render("press enter to confirm") + "\n")
return m.theme.Base().Padding(0, 1).Render(view.String())
}
func (m Model) ReviewView() string {
if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
}
if m.review.success {
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
content := m.generateReviewContent()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m.review.success = true
return m, nil
case tea.KeyMsg:
if m.CheckingOut {
return m, nil
}
if m.review.success {
m.review.success = false
m.review.cardJustAdded = false
m, _ = m.ShopSwitch()
return m, func() tea.Msg {
if m.APIClient != nil {
_ = m.APIClient.ClearCart()
}
return nil
}
}
switch msg.String() {
case "esc":
m = m.SwitchPage(paymentPage)
return m, nil
case "enter":
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
}
return m, nil
}

View File

@@ -20,7 +20,7 @@ func (m Model) BuildFooter() string {
} }
var footerText string var footerText string
if m.FaqFocused { if m.account.faqFocused {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"), keybindStyle.Render("j/k"),
descStyle.Render("scroll"), descStyle.Render("scroll"),
@@ -34,7 +34,7 @@ func (m Model) BuildFooter() string {
descStyle.Render("quit"), descStyle.Render("quit"),
) )
} else if m.currentPage == accountPage { } else if m.currentPage == accountPage {
switch m.OrderViewState { switch m.account.orderViewState {
case 2: case 2:
// Viewing single order detail // Viewing single order detail
footerText = fmt.Sprintf("%s %s %s %s %s %s", footerText = fmt.Sprintf("%s %s %s %s %s %s",
@@ -83,7 +83,7 @@ func (m Model) BuildFooter() string {
descStyle.Render("quit"), descStyle.Render("quit"),
) )
} else if m.currentPage == shippingPage { } else if m.currentPage == shippingPage {
if m.ShippingView == 0 && m.ShippingForm == nil { if m.shipping.view == 0 && m.shipping.form == nil {
// Address list // Address list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"), keybindStyle.Render("j/k"),
@@ -107,7 +107,7 @@ func (m Model) BuildFooter() string {
) )
} }
} else if m.currentPage == paymentPage { } else if m.currentPage == paymentPage {
if m.PaymentView == 0 && m.PaymentForm == nil { if m.payment.view == 0 && m.payment.form == nil {
// Card list // Card list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s", footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"), keybindStyle.Render("j/k"),
@@ -135,7 +135,7 @@ func (m Model) BuildFooter() string {
} }
} else if m.currentPage == cartPage { } else if m.currentPage == cartPage {
// In cart view, show proceed option // In cart view, show proceed option
if len(m.Cart) > 0 { if !m.IsCartEmpty() {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s", footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"), keybindStyle.Render("j/k"),
descStyle.Render("items"), descStyle.Render("items"),

View File

@@ -8,12 +8,8 @@ import (
func (m Model) BuildHeader() string { func (m Model) BuildHeader() string {
// Calculate cart total and item count // Calculate cart total and item count
total := 0 total := m.CalculateSubtotal()
itemCount := 0 itemCount := m.CartItemCount()
for _, item := range m.Cart {
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
// Define colors // Define colors
grayStyle := m.theme.TextBody() grayStyle := m.theme.TextBody()

View File

@@ -2,11 +2,10 @@ package tui
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
func (m Model) BuildMenuView() string { func (m Model) BuildMenuView() string {
bold := m.theme.TextAccent().Bold(true) bold := m.theme.TextAccent().Bold(true)
@@ -21,13 +20,11 @@ func (m Model) BuildMenuView() string {
rowStyle.Render(bold.Render("q") + " " + base.Render("quit")), rowStyle.Render(bold.Render("q") + " " + base.Render("quit")),
} }
menuContent := "" menuContent := ""
for _, row := range rows { for _, row := range rows {
menuContent += row + "\n" menuContent += row + "\n"
} }
logoStyle := m.theme.TextAccent().Bold(true) logoStyle := m.theme.TextAccent().Bold(true)
logo := logoStyle.Render("terminal coffee") logo := logoStyle.Render("terminal coffee")
@@ -35,12 +32,8 @@ func (m Model) BuildMenuView() string {
hint := base.Render("press esc to close") hint := base.Render("press esc to close")
itemCount := 0 total := m.CalculateSubtotal()
total := 0 itemCount := m.CartItemCount()
for _, item := range m.Cart {
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount)) cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount))
@@ -72,17 +65,15 @@ func (m Model) MenuUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch keyMsg.String() { switch keyMsg.String() {
case "s": case "s":
m.ShowingMenu = false m.ShowingMenu = false
return m.SwitchPage(shopPage).resetPageState(), nil m, cmd := m.ShopSwitch()
return m, cmd
case "a": case "a":
m.ShowingMenu = false m.ShowingMenu = false
m = m.SwitchPage(accountPage).resetPageState() return m.AccountSwitch()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
case "c": case "c":
m.ShowingMenu = false m.ShowingMenu = false
return m.SwitchPage(cartPage).resetPageState(), nil m, cmd := m.CartSwitch()
return m, cmd
case "esc": case "esc":
m.ShowingMenu = false m.ShowingMenu = false
case "?": case "?":

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"math" "math"
"sort"
"time" "time"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@@ -28,7 +29,7 @@ const (
large // viewportWidth >= 80 large // viewportWidth >= 80
) )
// page represetnts the currently active TUI screen // page represents the currently active TUI screen
type page int type page int
const ( const (
@@ -36,6 +37,7 @@ const (
cartPage cartPage
shippingPage shippingPage
paymentPage paymentPage
reviewPage
confirmPage confirmPage
accountPage accountPage
splashPage splashPage
@@ -55,31 +57,28 @@ type Model struct {
// User authentication // User authentication
User *models.User User *models.User
Fingerprint string Fingerprint string
SSHPublicKeyStr string
AccessToken string AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string StripePublicKey string
// Shop state // One-shop auth command - capture pubKeyStr in closure, not stored on Model
authCmd tea.Cmd
// Shared data
Username string Username string
Coffees []models.Coffee Coffees []models.Coffee
shop shopState
cartVP viewport.Model
cartVPReady bool
shippingVP viewport.Model
shippingVPReady bool
paymentVP viewport.Model
paymentVPReady bool
confirmVP viewport.Model
confirmVPReady bool
Cart map[uint]*models.CartItem Cart map[uint]*models.CartItem
CartCursor int // cursor position in cart view SavedAddresses []models.Address
AccountCursor int SavedCards []models.Card
Orders []models.Order
OrdersLoaded bool
SSHKeys []models.SSHKey
SSHKeysLoaded bool
FAQs []FAQ
// Page navigation
currentPage page // currently active screen currentPage page // currently active screen
switched bool // true when the page just changed (for state resets) switched bool // true when the page just changed (for state resets)
ScrollOffset int // scroll position for content
CheckingOut bool // true while saveCardAndConvert is running
lastCartUpdateID int64 // debounce ID for optimistic cart sync
// Menu modal state // Menu modal state
ShowingMenu bool // true when full-screen menu is showing ShowingMenu bool // true when full-screen menu is showing
@@ -107,40 +106,21 @@ type Model struct {
renderer *lipgloss.Renderer renderer *lipgloss.Renderer
theme theme.Theme theme theme.Theme
// Checkout state // Checkout flow state (spans multiple pages)
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step) CheckingOut bool
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step) ShippingInfo *models.Address
ShippingInfo *models.Address // Selected shipping address for current order SelectedCard *models.Card
SavedAddresses []models.Address // Saved addresses from database lastCartUpdateID int64
ShippingView int
AddressCursor int
SavedCards []models.Card // Saved cards from database
SelectedCard *models.Card // Card selected from saved list (nil = new card)
CardCursor int // cursor position in the card list
PaymentView int // 0=card list, 1=new card form, 2 = browser payment
CollectURL *string // URL for browser-based card entry
CollectCardCount int // card count when browser polling started
// Order history
Orders []models.Order
OrdersLoaded bool
OrderCursor int // which order is selected in the list
OrderViewState int // 0=preview, 1=browsing list, 2=viewing detail
// Static content
FAQs []FAQ // loaded from embedded faq.json
FaqFocused bool // true when FAQ detail is focused
// Splash screen
splashDataReady bool // true when products have loaded
splashDelayDone bool // true when minimum display time has elapsed
splashCursor bool // toggles for blinking cursor animation
// Confirmation screen — snapshot populated on checkout, cleared when leaving confirm page
ConfirmTotal int // server-confirmed order total in cents
ConfirmItems []models.CartItem // cart items at time of purchase
ConfirmShipping *models.Address // shipping address at time of purchase
// Per-page State
splash splashState
shop shopState
cart cartState
shipping shippingState
payment paymentState
account accountState
review reviewState
confirm confirmState
} }
type shopState struct { type shopState struct {
@@ -150,6 +130,66 @@ type shopState struct {
viewportsReady bool viewportsReady bool
} }
type splashState struct {
dataReady bool
delayDone bool
cursor bool
}
type cartState struct {
cursor int
viewport viewport.Model
viewportReady bool
}
type shippingState struct {
form *ShippingFormState
view int
addressCursor int
viewport viewport.Model
viewportReady bool
}
type paymentState struct {
form *PaymentFormState
view int
cardCursor int
collectURL *string
collectCardCount int
viewport viewport.Model
viewportReady bool
}
type accountState struct {
cursor int
scrollOffset int
orderViewState int
orderCursor int
faqFocused bool
addressListFocused bool
cardListFocused bool
addressCursor int
cardCursor int
addressDeleting *int
cardDeleting *int
sshKeyListFocused bool
sshKeyCursor int
sshKeyDeleting *int
}
type reviewState struct {
cardJustAdded bool
success bool
}
type confirmState struct {
total int
items []models.CartItem
shipping *models.Address
viewport viewport.Model
viewportReady bool
}
var modifiedKeyMap = viewport.KeyMap{ var modifiedKeyMap = viewport.KeyMap{
PageDown: key.NewBinding(key.WithKeys("pgdown")), PageDown: key.NewBinding(key.WithKeys("pgdown")),
PageUp: key.NewBinding(key.WithKeys("pgup")), PageUp: key.NewBinding(key.WithKeys("pgup")),
@@ -161,15 +201,63 @@ var modifiedKeyMap = viewport.KeyMap{
func (m Model) SwitchPage(p page) Model { func (m Model) SwitchPage(p page) Model {
m.currentPage = p m.currentPage = p
m.switched = true m.switched = true
m.ErrorMsg = ""
return m return m
} }
func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
}
func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage)
m = m.updateCartViewport()
return m, nil
}
func (m Model) AccountSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(accountPage)
m.account = accountState{}
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
}
func (m Model) ShippingSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shippingPage)
m.shipping = shippingState{}
m = m.updateShippingViewport()
return m, m.fetchAddressesCmd()
}
func (m Model) PaymentSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(paymentPage)
m.payment = paymentState{}
m.SelectedCard = nil
return m, m.fetchCardsCmd()
}
func (m Model) ReviewSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(reviewPage)
return m, nil
}
func (m Model) ConfirmSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(confirmPage)
m.confirm = confirmState{}
m = m.updateConfirmViewport()
return m, nil
}
// inCartFlow returns true when the user is anywhere in the cart or checkout flow // inCartFlow returns true when the user is anywhere in the cart or checkout flow
func (m Model) inCartFlow() bool { func (m Model) inCartFlow() bool {
return m.currentPage == cartPage || return m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == shippingPage ||
m.currentPage == paymentPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage m.currentPage == reviewPage
} }
func (m Model) checkoutStep() int { func (m Model) checkoutStep() int {
@@ -178,7 +266,7 @@ func (m Model) checkoutStep() int {
return 1 return 1
case paymentPage: case paymentPage:
return 2 return 2
case confirmPage: case reviewPage:
return 3 return 3
default: default:
return 0 return 0
@@ -254,7 +342,7 @@ func (m Model) fetchProductsCmd() tea.Msg {
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.splashAuthCmd, m.authCmd,
tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
return DelayCompleteMsg{} return DelayCompleteMsg{}
}), }),
@@ -264,15 +352,6 @@ func (m Model) Init() tea.Cmd {
) )
} }
func (m Model) resetPageState() Model {
m.ShippingForm = nil
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
return m
}
type CheckoutResultMsg struct { type CheckoutResultMsg struct {
OrderID uint OrderID uint
Total int Total int
@@ -375,6 +454,17 @@ type CardDeletedMsg struct {
Err error Err error
} }
// SSHKeysMsg is sent when ssh keys are fetched from the API
type SSHKeysMsg struct {
Keys []models.SSHKey
Err error
}
// SSHKeyDeletedMsg is sent when an ssh key is deleted
type SSHKeyDeletedMsg struct {
Err error
}
func (m Model) fetchAddressesCmd() tea.Cmd { func (m Model) fetchAddressesCmd() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if m.APIClient == nil || m.User == nil { if m.APIClient == nil || m.User == nil {
@@ -437,6 +527,26 @@ func (m Model) deleteCardCmd(id uint) tea.Cmd {
} }
} }
func (m Model) fetchSSHKeysCmd() tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return SSHKeysMsg{Err: fmt.Errorf("not authenticated")}
}
keys, err := m.APIClient.GetSSHKeys()
return SSHKeysMsg{Keys: keys, Err: err}
}
}
func (m Model) deleteSSHKeyCmd(id uint) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return SSHKeyDeletedMsg{Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.DeleteSSHKey(id)
return SSHKeyDeletedMsg{Err: err}
}
}
// checkoutWithSavedCard sets address and saved card on the cart, then converts. // checkoutWithSavedCard sets address and saved card on the cart, then converts.
// Used when the user selects an existing card from the list (no tokenization needed). // Used when the user selects an existing card from the list (no tokenization needed).
func (m Model) checkoutWithSavedCard() tea.Cmd { func (m Model) checkoutWithSavedCard() tea.Cmd {
@@ -575,6 +685,36 @@ func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
} }
} }
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
}
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
Name: stripe.String(form.CardName),
Number: stripe.String(form.CardNumber),
ExpMonth: stripe.String(form.ExpiryMonth),
ExpYear: stripe.String(form.ExpiryYear),
CVC: stripe.String(form.CVC),
AddressZip: stripe.String(form.BillingZip),
},
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
return CardSavedForReviewMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
return CardSavedForReviewMsg{Card: *card}
}
}
// loadCartFromAPI populates the in-memory cart map from an API CartData response // loadCartFromAPI populates the in-memory cart map from an API CartData response
// It matches cart items to local Coffees by ID so the Coffee struct is populated // It matches cart items to local Coffees by ID so the Coffee struct is populated
func (m *Model) loadCartFromAPI(cartData *api.CartData) { func (m *Model) loadCartFromAPI(cartData *api.CartData) {
@@ -591,8 +731,8 @@ func (m *Model) loadCartFromAPI(cartData *api.CartData) {
} }
} }
if m.CartCursor >= len(m.Cart) { if m.cart.cursor >= len(m.Cart) {
m.CartCursor = 0 m.cart.cursor = 0
} }
} }
@@ -605,13 +745,7 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
} }
// Sort keys to ensure consistent order // Sort keys to ensure consistent order
for i := 0; i < len(keys); i++ { sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
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 // Build items slice in sorted order
items := make([]*models.CartItem, 0, len(m.Cart)) items := make([]*models.CartItem, 0, len(m.Cart))
@@ -621,6 +755,29 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
return items return items
} }
// IsCartEmpty returns true when the cart has no items
func (m Model) IsCartEmpty() bool {
return len(m.Cart) == 0
}
// CartItemCount returns the total number of items (sum of quanities) in the cart
func (m Model) CartItemCount() int {
count := 0
for _, item := range m.Cart {
count += item.Quantity
}
return count
}
// CalculateSubtotal returns the cart subtotal in cents
func (m Model) CalculateSubtotal() int {
subtotal := 0
for _, item := range m.Cart {
subtotal += item.Coffee.Price * item.Quantity
}
return subtotal
}
// NewModel creates a new model using the default renderer. // NewModel creates a new model using the default renderer.
func NewModel(username string) Model { func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer()) return newModelWithRenderer(username, lipgloss.DefaultRenderer())
@@ -690,10 +847,6 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
}, },
}, },
Cart: make(map[uint]*models.CartItem), Cart: make(map[uint]*models.CartItem),
CartCursor: 0,
AccountCursor: 0,
OrderCursor: 0,
OrderViewState: 0,
Loading: true, Loading: true,
APIClient: api.NewClient("http://localhost:8080", ""), APIClient: api.NewClient("http://localhost:8080", ""),
FAQs: LoadFaqs(), FAQs: LoadFaqs(),
@@ -706,7 +859,6 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := newModelWithRenderer("", renderer) m := newModelWithRenderer("", renderer)
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.SSHPublicKeyStr = pubKeyStr
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey m.StripePublicKey = stripePublicKey
m.currentPage = splashPage m.currentPage = splashPage
@@ -715,6 +867,18 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
} else { } else {
m.APIClient = api.NewClient("http://localhost:8000", "") m.APIClient = api.NewClient("http://localhost:8000", "")
} }
m.authCmd = func() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken(
fingerprint,
pubKeyStr,
clientSecret,
)
if err != nil {
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
}
return SplashAuthMsg{Token: token, User: user}
}
return m return m
} }

View File

@@ -39,6 +39,13 @@ type PaymentFormCompleteMsg struct {
BillingZip string BillingZip string
} }
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
// The user is taken to the review page to confirm before checkout
type CardSavedForReviewMsg struct {
Card models.Card
Err error
}
// PaymentFormErrorMsg is sent when payment form validation fails // PaymentFormErrorMsg is sent when payment form validation fails
type PaymentFormErrorMsg struct { type PaymentFormErrorMsg struct {
Message string Message string
@@ -265,61 +272,61 @@ func (m Model) updatePaymentViewport() Model {
if availH < 1 { if availH < 1 {
availH = 1 availH = 1
} }
if !m.paymentVPReady { if !m.payment.viewportReady {
m.paymentVP = viewport.New(m.widthContent, availH) m.payment.viewport = viewport.New(m.widthContent, availH)
m.paymentVP.KeyMap = viewport.KeyMap{} m.payment.viewport.KeyMap = viewport.KeyMap{}
m.paymentVPReady = true m.payment.viewportReady = true
} else { } else {
m.paymentVP.Width = m.widthContent m.payment.viewport.Width = m.widthContent
m.paymentVP.Height = availH m.payment.viewport.Height = availH
} }
return m return m
} }
func (m Model) PaymentPageView() string { func (m Model) PaymentPageView() string {
if !m.paymentVPReady { if !m.payment.viewportReady {
m = m.updatePaymentViewport() m = m.updatePaymentViewport()
} }
var content string var content string
if m.CheckingOut { if m.CheckingOut {
content = " submitting order..." content = " submitting order..."
} else if m.PaymentView == 2 { } else if m.payment.view == 2 {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits. // Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
m.paymentVP.Height, m.payment.viewport.Height,
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(), m.renderPaymentHttpsView(),
) )
} else if m.PaymentView == 0 && m.PaymentForm == nil { } else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList() content = m.RenderCardList()
m.paymentVP.SetContent(content) m.payment.viewport.SetContent(content)
itemHeight := 3 itemHeight := 3
targetY := m.CardCursor * itemHeight targetY := m.payment.cardCursor * itemHeight
if targetY < m.paymentVP.YOffset { if targetY < m.payment.viewport.YOffset {
m.paymentVP.SetYOffset(targetY) m.payment.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height { if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1) m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
} }
if m.CardCursor == len(m.SavedCards) { if m.payment.cardCursor == len(m.SavedCards) {
m.paymentVP.GotoBottom() m.payment.viewport.GotoBottom()
} }
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
lipgloss.Height(m.paymentVP.View()), lipgloss.Height(m.payment.viewport.View()),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.paymentVP.View(), m.payment.viewport.View(),
) )
} else if m.PaymentForm != nil { } else if m.payment.form != nil {
content = m.RenderPaymentForm(m.PaymentForm) content = m.RenderPaymentForm(m.payment.form)
} }
m.paymentVP.SetContent(content) m.payment.viewport.SetContent(content)
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
lipgloss.Height(m.paymentVP.View()), lipgloss.Height(m.payment.viewport.View()),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.paymentVP.View(), m.payment.viewport.View(),
) )
} }
@@ -335,7 +342,7 @@ func (m Model) RenderCardList() string {
for i, card := range m.SavedCards { for i, card := range m.SavedCards {
cursor := " " cursor := " "
style := inactiveStyle style := inactiveStyle
if i == m.CardCursor { if i == m.payment.cardCursor {
cursor = "> " cursor = "> "
style = activeStyle style = activeStyle
} }
@@ -351,7 +358,7 @@ func (m Model) RenderCardList() string {
sshCursor := " " sshCursor := " "
sshStyle := inactiveStyle sshStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards) { if m.payment.cardCursor == len(m.SavedCards) {
sshCursor = "> " sshCursor = "> "
sshStyle = activeStyle sshStyle = activeStyle
} }
@@ -359,7 +366,7 @@ func (m Model) RenderCardList() string {
httpsCursor := " " httpsCursor := " "
httpsStyle := inactiveStyle httpsStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards)+1 { if m.payment.cardCursor == len(m.SavedCards)+1 {
httpsCursor = "> " httpsCursor = "> "
httpsStyle = activeStyle httpsStyle = activeStyle
} }
@@ -375,22 +382,22 @@ func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel() baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight() accentStyle := m.theme.TextHighlight()
if m.CollectURL == nil { if m.payment.collectURL == nil {
return baseStyle.Render(" generating payment link...") return baseStyle.Render(" generating payment link...")
} }
qr, qrSize, err := qrfefe.Generate(0, *m.CollectURL) qr, qrSize, err := qrfefe.Generate(0, *m.payment.collectURL)
if err != nil || qrSize > m.widthContent { if err != nil || qrSize > m.widthContent {
// QR too wide for the terminal — just show the URL. // QR too wide for the terminal — just show the URL.
return baseStyle.Render("open in browser to add payment information:") + "\n\n" + return baseStyle.Render("open in browser to add payment information:") + "\n\n" +
accentStyle.Render(*m.CollectURL) + "\n" accentStyle.Render(*m.payment.collectURL) + "\n"
} }
// Concatenate manually — lipgloss layout functions (JoinVertical with Center) // Concatenate manually — lipgloss layout functions (JoinVertical with Center)
// measure string widths and add padding that corrupts qrfefe's raw ANSI codes. // measure string widths and add padding that corrupts qrfefe's raw ANSI codes.
return qr + return qr +
baseStyle.Render("scan or visit to add payment information") + "\n" + baseStyle.Render("scan or visit to add payment information") + "\n" +
accentStyle.Render(*m.CollectURL) + "\n" accentStyle.Render(*m.payment.collectURL) + "\n"
} }
// PaymentUpdate handles all messages while on the payment page. // PaymentUpdate handles all messages while on the payment page.
@@ -403,78 +410,65 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
} else { } else {
m.SavedCards = msg.Cards m.SavedCards = msg.Cards
} }
m.PaymentView = 0 m.payment.view = 0
m.CardCursor = 0 m.payment.cardCursor = 0
m.PaymentForm = nil m.payment.form = nil
return m, nil return m, nil
case PaymentFormCompleteMsg: case PaymentFormCompleteMsg:
if m.PaymentForm != nil { if m.payment.form != nil && !m.payment.form.submitting {
m.PaymentForm.submitting = true m.payment.form.submitting = true
m.CheckingOut = true return m, m.saveCardOnlyCmd(msg)
return m, m.saveCardAndConvert(msg)
} }
return m, nil return m, nil
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
if len(m.SavedCards) > 0 {
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
// Re-open the form so the user can retry with corrected details.
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
// Snapshot order details before clearing cart/shipping
m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
// Clear cart and shipping immediately so they don't appear stale elsewhere
m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil
case PaymentFormErrorMsg: case PaymentFormErrorMsg:
m.ErrorMsg = msg.Message m.ErrorMsg = msg.Message
return m, nil return m, nil
case CardSavedForReviewMsg:
if m.payment.form != nil {
m.payment.form.submitting = false
}
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to save card: %v", msg.Err)
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
return m, m.payment.form.form.Init()
}
m.SavedCards = append(m.SavedCards, msg.Card)
selected := msg.Card
m.SelectedCard = &selected
m.payment.form = nil
m.review.cardJustAdded = true
return m.ReviewSwitch()
case PollPaymentInitMsg: case PollPaymentInitMsg:
url := msg.URL url := msg.URL
m.CollectURL = &url m.payment.collectURL = &url
return m, m.pollCardsCmd(m.CollectCardCount) return m, m.pollCardsCmd(m.payment.collectCardCount)
case PollPaymentStatusMsg: case PollPaymentStatusMsg:
if m.PaymentView != 2 { if m.payment.view != 2 {
return m, nil // user navigated away, stop polling return m, nil // user navigated away, stop polling
} }
return m, m.pollCardsCmd(msg.CardCount) return m, m.pollCardsCmd(msg.CardCount)
case PollPaymentCompleteMsg: case PollPaymentCompleteMsg:
if m.PaymentView != 2 { if m.payment.view != 2 {
return m, nil return m, nil
} }
m.SavedCards = msg.Cards m.SavedCards = msg.Cards
m.PaymentView = 0 m.payment.view = 0
m.CollectURL = nil m.payment.collectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1] selected := m.SavedCards[len(m.SavedCards)-1]
m.SelectedCard = &selected m.SelectedCard = &selected
m.CheckingOut = true m.review.cardJustAdded = true
return m, m.checkoutWithSavedCard() return m.ReviewSwitch()
} }
// Card list navigation // Card list navigation
if m.PaymentView == 0 && m.PaymentForm == nil { if m.payment.view == 0 && m.payment.form == nil {
keyMsg, ok := msg.(tea.KeyMsg) keyMsg, ok := msg.(tea.KeyMsg)
if !ok || m.CheckingOut { if !ok || m.CheckingOut {
return m, nil return m, nil
@@ -482,46 +476,44 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.ErrorMsg = "" m.ErrorMsg = ""
switch keyMsg.String() { switch keyMsg.String() {
case "esc": case "esc":
m = m.SwitchPage(shippingPage) return m.ShippingSwitch()
return m, m.fetchAddressesCmd()
case "up", "k": case "up", "k":
if m.CardCursor > 0 { if m.payment.cardCursor > 0 {
m.CardCursor-- m.payment.cardCursor--
} }
case "down", "j": case "down", "j":
if m.CardCursor < len(m.SavedCards)+1 { if m.payment.cardCursor < len(m.SavedCards)+1 {
m.CardCursor++ m.payment.cardCursor++
} }
case "enter": case "enter":
if m.CardCursor < len(m.SavedCards) { if m.payment.cardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.CardCursor] selected := m.SavedCards[m.payment.cardCursor]
m.SelectedCard = &selected m.SelectedCard = &selected
m.CheckingOut = true return m.ReviewSwitch()
return m, m.checkoutWithSavedCard()
} }
if m.CardCursor == len(m.SavedCards) { if m.payment.cardCursor == len(m.SavedCards) {
m.PaymentView = 1 m.payment.view = 1
m.PaymentForm = m.InitPaymentForm() m.payment.form = m.InitPaymentForm()
return m, m.PaymentForm.form.Init() return m, m.payment.form.form.Init()
} }
// Browser payment // Browser payment
m.PaymentView = 2 m.payment.view = 2
m.CollectURL = nil m.payment.collectURL = nil
m.CollectCardCount = len(m.SavedCards) m.payment.collectCardCount = len(m.SavedCards)
return m, m.collectCardCmd() return m, m.collectCardCmd()
case "d", "x": case "d", "x":
if m.CardCursor < len(m.SavedCards) { if m.payment.cardCursor < len(m.SavedCards) {
card := m.SavedCards[m.CardCursor] card := m.SavedCards[m.payment.cardCursor]
m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...) m.SavedCards = append(m.SavedCards[:m.payment.cardCursor], m.SavedCards[m.payment.cardCursor+1:]...)
if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 { if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.CardCursor-- m.payment.cardCursor--
} }
if len(m.SavedCards) == 0 { if len(m.SavedCards) == 0 {
m.PaymentView = 1 m.payment.view = 1
m.PaymentForm = m.InitPaymentForm() m.payment.form = m.InitPaymentForm()
return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID)) return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
} }
return m, m.deleteCardCmd(card.ID) return m, m.deleteCardCmd(card.ID)
} }
@@ -530,33 +522,33 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
// HTTPS/browser view avigation // HTTPS/browser view avigation
if m.PaymentView == 2 { if m.payment.view == 2 {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
m.PaymentView = 0 m.payment.view = 0
m.CollectURL = nil m.payment.collectURL = nil
return m, nil return m, nil
} }
return m, nil return m, nil
} }
// Payment form navigation // Payment form navigation
if m.PaymentForm != nil { if m.payment.form != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" { if keyMsg.String() == "esc" {
m.ErrorMsg = "" m.ErrorMsg = ""
if len(m.SavedCards) > 0 { if len(m.SavedCards) > 0 {
m.PaymentView = 0 m.payment.view = 0
m.PaymentForm = nil m.payment.form = nil
return m, nil return m, nil
} }
m.PaymentForm = nil m.payment.form = nil
m = m.SwitchPage(shippingPage) m = m.SwitchPage(shippingPage)
return m, m.fetchAddressesCmd() return m, m.fetchAddressesCmd()
} }
// Clear error only when user starts typing, not on internal huh messages // Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = "" m.ErrorMsg = ""
} }
return m, m.UpdatePaymentForm(msg, m.PaymentForm) return m, m.UpdatePaymentForm(msg, m.payment.form)
} }
return m, nil return m, nil
} }

View File

@@ -18,21 +18,21 @@ var countries = []struct {
Code string Code string
DialCode string DialCode string
}{ }{
{Name: "Denmark", Code: "DK", DialCode: "+45"}, // {Name: "Denmark", Code: "DK", DialCode: "+45"},
{Name: "Finland", Code: "FI", DialCode: "+358"}, // {Name: "Finland", Code: "FI", DialCode: "+358"},
{Name: "Iceland", Code: "IS", DialCode: "+354"}, // {Name: "Iceland", Code: "IS", DialCode: "+354"},
{Name: "Norway", Code: "NO", DialCode: "+47"}, {Name: "Norway", Code: "NO", DialCode: "+47"},
{Name: "Sweden", Code: "SE", DialCode: "+46"}, // {Name: "Sweden", Code: "SE", DialCode: "+46"},
{Name: "USA", Code: "US", DialCode: "+1"}, {Name: "USA", Code: "US", DialCode: "+1"},
} }
// countryDialCodes maps country ISO codes to their dialing prefixes. // countryDialCodes maps country ISO codes to their dialing prefixes.
var countryDialCodes = map[string]string{ var countryDialCodes = map[string]string{
"DK": "+45", // "DK": "+45",
"FI": "+358", // "FI": "+358",
"IS": "+354", // "IS": "+354",
"NO": "+47", "NO": "+47",
"SE": "+46", // "SE": "+46",
"US": "+1", "US": "+1",
} }
@@ -112,8 +112,7 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
Key("country"). Key("country").
Options(countryOptions...). Options(countryOptions...).
Value(&state.Country). Value(&state.Country).
Height(5). Height(3),
Filtering(true),
huh.NewInput(). huh.NewInput().
Title("Phone"). Title("Phone").
Key("phone"). Key("phone").
@@ -138,7 +137,7 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
// so that huh's Value() bindings point to stable memory. // so that huh's Value() bindings point to stable memory.
func (m Model) InitShippingForm() *ShippingFormState { func (m Model) InitShippingForm() *ShippingFormState {
state := &ShippingFormState{ state := &ShippingFormState{
Country: "SE", Country: "NO",
} }
if m.User != nil && m.User.Name != "" { if m.User != nil && m.User.Name != "" {
@@ -263,43 +262,43 @@ func (m Model) updateShippingViewport() Model {
if availH < 1 { if availH < 1 {
availH = 1 availH = 1
} }
if !m.shippingVPReady { if !m.shipping.viewportReady {
m.shippingVP = viewport.New(m.widthContent, availH) m.shipping.viewport = viewport.New(m.widthContent, availH)
m.shippingVP.KeyMap = viewport.KeyMap{} m.shipping.viewport.KeyMap = viewport.KeyMap{}
m.shippingVPReady = true m.shipping.viewportReady = true
} else { } else {
m.shippingVP.Width = m.widthContent m.shipping.viewport.Width = m.widthContent
m.shippingVP.Height = availH m.shipping.viewport.Height = availH
} }
return m return m
} }
func (m Model) ShippingPageView() string { func (m Model) ShippingPageView() string {
if !m.shippingVPReady { if !m.shipping.viewportReady {
m = m.updateShippingViewport() m = m.updateShippingViewport()
} }
if m.ShippingView == 0 && m.ShippingForm == nil { if m.shipping.view == 0 && m.shipping.form == nil {
content := m.RenderAddressList() content := m.RenderAddressList()
m.shippingVP.SetContent(content) m.shipping.viewport.SetContent(content)
itemHeight := 4 itemHeight := 4
targetY := m.AddressCursor * itemHeight targetY := m.shipping.addressCursor * itemHeight
if targetY < m.shippingVP.YOffset { if targetY < m.shipping.viewport.YOffset {
m.shippingVP.SetYOffset(targetY) m.shipping.viewport.SetYOffset(targetY)
} }
if targetY+itemHeight > m.shippingVP.YOffset+m.shippingVP.Height { if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
m.shippingVP.SetYOffset(targetY - m.shippingVP.Height + itemHeight + 1) m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
} }
if m.AddressCursor == len(m.SavedAddresses) { if m.shipping.addressCursor == len(m.SavedAddresses) {
m.shippingVP.GotoBottom() m.shipping.viewport.GotoBottom()
} }
} else if m.ShippingForm != nil { } else if m.shipping.form != nil {
m.shippingVP.SetContent(m.RenderShippingForm(m.ShippingForm)) m.shipping.viewport.SetContent(m.RenderShippingForm(m.shipping.form))
} }
return lipgloss.Place( return lipgloss.Place(
m.widthContainer, m.widthContainer,
lipgloss.Height(m.shippingVP.View()), lipgloss.Height(m.shipping.viewport.View()),
lipgloss.Center, lipgloss.Center, lipgloss.Center, lipgloss.Center,
m.shippingVP.View(), m.shipping.viewport.View(),
) )
} }
@@ -315,7 +314,7 @@ func (m Model) RenderAddressList() string {
for i, addr := range m.SavedAddresses { for i, addr := range m.SavedAddresses {
cursor := " " cursor := " "
style := inactiveStyle style := inactiveStyle
if i == m.AddressCursor { if i == m.shipping.addressCursor {
cursor = "> " cursor = "> "
style = activeStyle style = activeStyle
} }
@@ -335,7 +334,7 @@ func (m Model) RenderAddressList() string {
addCursor := " " addCursor := " "
addStyle := inactiveStyle addStyle := inactiveStyle
if m.AddressCursor == len(m.SavedAddresses) { if m.shipping.addressCursor == len(m.SavedAddresses) {
addCursor = "> " addCursor = "> "
addStyle = activeStyle addStyle = activeStyle
} }
@@ -361,30 +360,26 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
case AddressesMsg: case AddressesMsg:
if msg.Err != nil || len(msg.Addresses) == 0 { if msg.Err != nil || len(msg.Addresses) == 0 {
m.SavedAddresses = nil m.SavedAddresses = nil
m.ShippingView = 1 m.shipping.view = 1
m.ShippingForm = m.InitShippingForm() m.shipping.form = m.InitShippingForm()
return m, m.ShippingForm.form.Init() return m, m.shipping.form.form.Init()
} }
m.SavedAddresses = msg.Addresses m.SavedAddresses = msg.Addresses
m.ShippingView = 0 m.shipping.view = 0
m.AddressCursor = 0 m.shipping.addressCursor = 0
m.ShippingForm = nil m.shipping.form = nil
return m, nil return m, nil
case AddressSavedMsg: case AddressSavedMsg:
if msg.Err != nil { if msg.Err != nil {
if m.ShippingForm != nil { if m.shipping.form != nil {
m.ShippingForm.submitting = false m.shipping.form.submitting = false
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) m.shipping.form.form = m.buildShippingForm(m.shipping.form)
} }
m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported." m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported."
return m, m.ShippingForm.form.Init() return m, m.shipping.form.form.Init()
} }
m.ShippingInfo = &msg.Address return m.PaymentSwitch()
m.ShippingForm = nil
m = m.SwitchPage(paymentPage)
m.SelectedCard = nil
return m, m.fetchCardsCmd()
case ShippingFormErrorMsg: case ShippingFormErrorMsg:
m.ErrorMsg = msg.Message m.ErrorMsg = msg.Message
@@ -392,7 +387,7 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
// Address list navigation // Address list navigation
if m.ShippingView == 0 && m.ShippingForm == nil { if m.shipping.view == 0 && m.shipping.form == nil {
keyMsg, ok := msg.(tea.KeyMsg) keyMsg, ok := msg.(tea.KeyMsg)
if !ok { if !ok {
return m, nil return m, nil
@@ -400,38 +395,35 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.ErrorMsg = "" m.ErrorMsg = ""
switch keyMsg.String() { switch keyMsg.String() {
case "esc": case "esc":
m = m.SwitchPage(cartPage) return m.CartSwitch()
return m, nil
case "up", "k": case "up", "k":
if m.AddressCursor > 0 { if m.shipping.addressCursor > 0 {
m.AddressCursor-- m.shipping.addressCursor--
} }
case "down", "j": case "down", "j":
if m.AddressCursor < len(m.SavedAddresses) { if m.shipping.addressCursor < len(m.SavedAddresses) {
m.AddressCursor++ m.shipping.addressCursor++
} }
case "enter": case "enter":
if m.AddressCursor < len(m.SavedAddresses) { if m.shipping.addressCursor < len(m.SavedAddresses) {
selected := m.SavedAddresses[m.AddressCursor] selected := m.SavedAddresses[m.shipping.addressCursor]
m.ShippingInfo = &selected m.ShippingInfo = &selected
m = m.SwitchPage(paymentPage) return m.PaymentSwitch()
m.SelectedCard = nil
return m, m.fetchCardsCmd()
} }
m.ShippingView = 1 m.shipping.view = 1
m.ShippingForm = m.InitShippingForm() m.shipping.form = m.InitShippingForm()
return m, m.ShippingForm.form.Init() return m, m.shipping.form.form.Init()
case "d", "x": case "d", "x":
if m.AddressCursor < len(m.SavedAddresses) { if m.shipping.addressCursor < len(m.SavedAddresses) {
addr := m.SavedAddresses[m.AddressCursor] addr := m.SavedAddresses[m.shipping.addressCursor]
m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor+1:]...) m.SavedAddresses = append(m.SavedAddresses[:m.shipping.addressCursor], m.SavedAddresses[m.shipping.addressCursor+1:]...)
if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 { if m.shipping.addressCursor >= len(m.SavedAddresses) && m.shipping.addressCursor > 0 {
m.AddressCursor-- m.shipping.addressCursor--
} }
if len(m.SavedAddresses) == 0 { if len(m.SavedAddresses) == 0 {
m.ShippingView = 1 m.shipping.view = 1
m.ShippingForm = m.InitShippingForm() m.shipping.form = m.InitShippingForm()
return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID)) return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID))
} }
return m, m.deleteAddressCmd(addr.ID) return m, m.deleteAddressCmd(addr.ID)
} }
@@ -440,23 +432,23 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
// Form navigation // Form navigation
if m.ShippingForm != nil { if m.shipping.form != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" { if keyMsg.String() == "esc" {
m.ErrorMsg = "" m.ErrorMsg = ""
if len(m.SavedAddresses) > 0 { if len(m.SavedAddresses) > 0 {
m.ShippingView = 0 m.shipping.view = 0
m.ShippingForm = nil m.shipping.form = nil
return m, nil return m, nil
} }
m.ShippingForm = nil m.shipping.form = nil
m = m.SwitchPage(cartPage) m, cmd := m.CartSwitch()
return m, nil return m, cmd
} }
// Clear error only when user starts typing, not on internal huh messages // Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = "" m.ErrorMsg = ""
} }
return m, m.UpdateShippingForm(msg, m.ShippingForm) return m, m.UpdateShippingForm(msg, m.shipping.form)
} }
return m, nil return m, nil
} }

View File

@@ -24,18 +24,6 @@ type ViewInitMsg struct {
Err error Err error
} }
func (m Model) splashAuthCmd() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken(
m.Fingerprint,
m.SSHPublicKeyStr,
m.AuthFingerprintKey,
)
if err != nil {
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
}
return SplashAuthMsg{Token: token, User: user}
}
func (m Model) splashViewInitCmd() tea.Msg { func (m Model) splashViewInitCmd() tea.Msg {
data, err := m.APIClient.GetViewInit() data, err := m.APIClient.GetViewInit()
if err != nil { if err != nil {
@@ -48,13 +36,13 @@ func (m Model) SplashView() string {
accent := m.theme.TextBody().Bold(true) accent := m.theme.TextBody().Bold(true)
var cursor string var cursor string
if m.splashCursor { if m.splash.cursor {
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ") cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
} else { } else {
cursor = " " cursor = " "
} }
logo := accent.Render("terminal coffe") + cursor logo := accent.Render("terminal coffee") + cursor
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Center, lipgloss.Center,

View File

@@ -45,6 +45,26 @@ func BasicTheme(renderer *lipgloss.Renderer) Theme {
return t return t
} }
// this should resemble something like catppuccin
func Catppuccin(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#6C7086")
t.accent = lipgloss.Color("#CBA6F7")
t.highlight = lipgloss.Color("#CBA6F7")
t.brand = lipgloss.Color("#CBA6F7")
t.label = lipgloss.Color("#A6ADC8")
t.success = lipgloss.Color("#A6E3A1")
t.error = lipgloss.Color("#F38BA8")
t.base = renderer.NewStyle().Foreground(t.body)
t.form = HuhTheme(t)
return t
}
// Color accessors — use in .Foreground() / .Background() calls. // Color accessors — use in .Foreground() / .Background() calls.
func (t Theme) Background() lipgloss.TerminalColor { return t.background } func (t Theme) Background() lipgloss.TerminalColor { return t.background }
func (t Theme) Border() lipgloss.TerminalColor { return t.border } func (t Theme) Border() lipgloss.TerminalColor { return t.border }

View File

@@ -25,12 +25,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.updatePaymentViewport() m = m.updatePaymentViewport()
case confirmPage: case confirmPage:
m = m.updateConfirmViewport() m = m.updateConfirmViewport()
case reviewPage:
// no viewport on review page
} }
if m.ShippingForm != nil { if m.shipping.form != nil {
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm) m.shipping.form.form = m.buildShippingForm(m.shipping.form)
} }
if m.PaymentForm != nil { if m.payment.form != nil {
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) m.payment.form.form = m.buildPaymentForm(m.payment.form)
} }
} }
return m, nil return m, nil
@@ -66,9 +68,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd()) return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd())
case ViewInitMsg: case ViewInitMsg:
if msg.Err != nil { if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to load: %v", msg.Err) return m.ShopSwitch()
m.Loading = false
return m, nil
} }
m.Loading = false m.Loading = false
if len(msg.Data.Products) > 0 { if len(msg.Data.Products) > 0 {
@@ -87,10 +87,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loadCartFromAPI(cart) m.loadCartFromAPI(cart)
m.SavedAddresses = msg.Data.Addresses m.SavedAddresses = msg.Data.Addresses
m.SavedCards = msg.Data.Cards m.SavedCards = msg.Data.Cards
m.SSHKeys = msg.Data.SSHKeys
m.SSHKeysLoaded = true
m.Orders = msg.Data.Orders m.Orders = msg.Data.Orders
m.OrdersLoaded = true m.OrdersLoaded = true
m.splashDataReady = true m.splash.dataReady = true
if m.splashDelayDone { if m.splash.delayDone {
m = m.SwitchPage(shopPage) m = m.SwitchPage(shopPage)
m = m.updateShopViewports() m = m.updateShopViewports()
return m, nil return m, nil
@@ -131,22 +133,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err) m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err)
} }
return m, nil return m, nil
case DelayCompleteMsg: case SSHKeysMsg:
m.splashDelayDone = true if msg.Err == nil {
if m.currentPage == splashPage && m.splashDataReady { m.SSHKeys = msg.Keys
m = m.SwitchPage(shopPage) m.SSHKeysLoaded = true
m = m.updateShopViewports() }
return m, nil return m, nil
case SSHKeyDeletedMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to delete ssh key: %v", msg.Err)
}
return m, nil
case DelayCompleteMsg:
m.splash.delayDone = true
if m.currentPage == splashPage && m.splash.dataReady {
return m.ShopSwitch()
} }
return m, nil return m, nil
case splashCursorTickMsg: case splashCursorTickMsg:
m.splashCursor = !m.splashCursor m.splash.cursor = !m.splash.cursor
return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg { return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg {
return splashCursorTickMsg{} return splashCursorTickMsg{}
}) })
case tea.KeyMsg: case tea.KeyMsg:
// If a form is active, skip global keys — let the page handler own input // If a form is active, skip global keys — let the page handler own input
if m.ShippingForm != nil || m.PaymentForm != nil { if m.shipping.form != nil || m.payment.form != nil {
break break
} }
// Global navigation // Global navigation
@@ -161,19 +172,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.menuLastPage = m.currentPage m.menuLastPage = m.currentPage
return m, nil return m, nil
case "s": case "s":
m = m.SwitchPage(shopPage).resetPageState() return m.ShopSwitch()
m = m.updateShopViewports()
return m, nil
case "c": case "c":
m = m.SwitchPage(cartPage).resetPageState() return m.CartSwitch()
m = m.updateCartViewport()
return m, nil
case "a": case "a":
m = m.SwitchPage(accountPage).resetPageState() return m.AccountSwitch()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
} }
// non-globals. fall through to page dispatch below // non-globals. fall through to page dispatch below
} }
@@ -187,6 +190,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.ShippingUpdate(msg) return m.ShippingUpdate(msg)
case paymentPage: case paymentPage:
return m.PaymentUpdate(msg) return m.PaymentUpdate(msg)
case reviewPage:
return m.ReviewUpdate(msg)
case confirmPage: case confirmPage:
return m.ConfirmUpdate(msg) return m.ConfirmUpdate(msg)
case accountPage: case accountPage:

View File

@@ -89,23 +89,23 @@ func (m Model) View() string {
// Skip global scroll when account view handles its own scrolling // Skip global scroll when account view handles its own scrolling
// (order list/detail and FAQ focused use internal scrolling in BuildAccountView) // (order list/detail and FAQ focused use internal scrolling in BuildAccountView)
accountHandlesScroll := m.currentPage == accountPage && (m.OrderViewState >= 1 || m.FaqFocused) accountHandlesScroll := m.currentPage == accountPage && (m.account.orderViewState >= 1 || m.account.faqFocused)
if !accountHandlesScroll { if !accountHandlesScroll {
// Ensure scroll offset is within bounds // Ensure scroll offset is within bounds
if m.ScrollOffset < 0 { if m.account.scrollOffset < 0 {
m.ScrollOffset = 0 m.account.scrollOffset = 0
} }
maxScroll := totalLines - availableContentHeight maxScroll := totalLines - availableContentHeight
if maxScroll < 0 { if maxScroll < 0 {
maxScroll = 0 maxScroll = 0
} }
if m.ScrollOffset > maxScroll { if m.account.scrollOffset > maxScroll {
m.ScrollOffset = maxScroll m.account.scrollOffset = maxScroll
} }
if totalLines > availableContentHeight { if totalLines > availableContentHeight {
start := m.ScrollOffset start := m.account.scrollOffset
end := start + availableContentHeight end := start + availableContentHeight
if end > totalLines { if end > totalLines {
end = totalLines end = totalLines
@@ -129,11 +129,11 @@ func (m Model) View() string {
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer) child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
} }
} else if breadcrumbs != "" { } else if breadcrumbs != "" {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content) contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, ccontentWithPadding, footer) child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
} else { } else {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content) contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, ccontentWithPadding, footer) child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
} }
// Constrain the assembled layout to the container dimensions // Constrain the assembled layout to the container dimensions
@@ -160,6 +160,8 @@ func (m Model) buildPageContent(height int) string {
return m.ShippingPageView() return m.ShippingPageView()
case paymentPage: case paymentPage:
return m.PaymentPageView() return m.PaymentPageView()
case reviewPage:
return m.ReviewView()
case confirmPage: case confirmPage:
return m.ConfirmView() return m.ConfirmView()
case cartPage: case cartPage:

View File

@@ -1,61 +0,0 @@
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)
}
}

Submodule terminal-shop-source deleted from 697730e73c

Binary file not shown.

View File

@@ -1,56 +0,0 @@
//go:build ignore
package main
import (
"fmt"
"os"
"github.com/joho/godotenv"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
)
func main() {
godotenv.Load()
secKey := os.Getenv("STRIPE_SECRET_KEY")
if secKey == "" {
fmt.Println("FAILED: missing STRIPE_SECRET_KEY in .env")
return
}
stripe.Key = secKey
// Step 1: Create a PaymentMethod using Stripe's test token (tok_visa = Visa 4242)
pm, err := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String("tok_visa"),
},
})
if err != nil {
fmt.Printf("FAILED payment method: %v\n", err)
return
}
fmt.Printf("1. PaymentMethod created: %s (brand: %s, last4: %s)\n", pm.ID, pm.Card.Brand, pm.Card.Last4)
// Step 2: Create a PaymentIntent and confirm it immediately
pi, err := paymentintent.New(&stripe.PaymentIntentParams{
Amount: stripe.Int64(999999), // $10.00 in cents
Currency: stripe.String("usd"),
PaymentMethod: stripe.String(pm.ID),
Confirm: stripe.Bool(true),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
AllowRedirects: stripe.String("never"),
},
})
if err != nil {
fmt.Printf("FAILED payment intent: %v\n", err)
return
}
fmt.Printf("2. PaymentIntent created: %s (status: %s, amount: $%.2f)\n", pi.ID, pi.Status, float64(pi.Amount)/100)
fmt.Println("\nSUCCESS! Stripe is fully working.")
}