53 Commits

Author SHA1 Message Date
d66b41eed3 refactor: refactor the footer handling 2026-04-24 12:13:26 +02:00
6db7748d9f change the BuildAccountView inline sub-views to use 4 different files to make it better 2026-04-24 11:37:42 +02:00
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
ef5c4ef554 update 2026-04-09 19:06:33 +02:00
7c00202929 add docker and deployment files 2026-04-09 19:05:29 +02:00
639b75578e theme system 2026-04-09 17:39:59 +02:00
062242e346 feat: add browser-based card collection via Stripe Checkout Session
adds a /pay/{token} redirect endpoint that generates a short URL
pointing to a stripe checkout sessions in setup mode. The TUI shows a QR
code and URL on the payment page so users can add a card via the browser
instead of entering raw card data over SSH.

the checkout.session.completed webhook saves the card to the database.
the TUI polls GET /api/v1/cards every second and auto-proceeds to
confirmation once the new card is detected.

also adds a dev.sh startup script that runs all four required processes.
(SSH server, API server, Stripe CLI listener, ngrok) in a single
terminal
2026-04-08 01:03:43 +02:00
6094e05b6f feat: shorten checkout URLs via in-memory redirect for smaller QR codes
Stripe checkout URLs are too long to produce a scannable QR code in a
terminal. Add an in-memory token→URL store and a /pay/{token} redirect
endpoint so the TUI shows a short URL (e.g. localhost:8080/pay/a3f1c2b4)
that fits in a QR. APP_URL config controls the base URL (defaults to
API_URL). Revert to returning sess.URL directly in CollectCard if this
approach needs to be abandoned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:55:52 +02:00
a4d5a1559a fix: resoce cart persistance, quantity increment and post-order state bugs
Three bugs fixed, two of them from the last commit, one of them has been
long living.

- the cart query in the ViewInit handler used user_id on cart_items, but
that column does not exist, cart_items links to carts via cart_id. Fixed
by first finding the users cart and then querying items by cart_id,
which is how the reso of the cart handlers already do it.

- GetViewInit was missing the json.Decode call entirely. so the response
body was never parsed. This meant m.User was never set, blocking all
cart syncs and address saves, and the cart always loaded empty.

- after a successful order, the cart was never cleared locally even
though the server deleted the cart items. this meant that the user had
to quit and ssh back in to see the cart update. fixed by clreaing
m.Cart, m.CartCursor and m.ShippingInfo in the CheckoutResultMsg success
handler
2026-03-27 12:21:58 +01:00
296c052fa7 refactor: move SSH auth and initial data loading to splash screen
previously main.go handled auth directly, it connected to the database,
ran migrations, created a JWT manager and SSHAuthService, and
authenticated the SSH key before the TUI even started. this commit rips
all of that out

Auth now happens inside the TUI during the splash screen via
splashAuthCmd, which calls the /auth/token endpoint. if no user exists
or the fingerprint it auto-creates one using the SSH public ke, so there
is no longer a separate registartion flow. The token and user com back
in the response.

once authed splashViewInitCmd fires a single /view/init request that
loads everything needed to start the app, products, cart, addresses,
cards, and orders, before switching to the shop page

on the model side, User/*models.user and gossh.PublicKey were replaced
with plain Fingerprint and SSHPublicKeyStr strings, and NewModelWithAuth
was updated too
2026-03-27 12:18:05 +01:00
4eeb3eda9a feat: added a splash page 2026-03-26 18:34:19 +01:00
4bd4dfabd2 fix: deleting addresses didnt work. oops 2026-03-26 18:01:57 +01:00
3ff4aa9d50 fix: rewrote the huge 700+ line update call with modularized calls 2026-03-26 17:47:56 +01:00
93b257a612 fix: store coffee prices as int cents to prevent rounding errors
prices stored as float64 could lead to rounding errors when converting to cents for stripe (19.99 * 100 = 1998.99... truncating to 1998). canged Coffee.Price to int, storing cents directly. Updated the seed data, fallback products, API handlers, and all TUI display code to be in sync with this change.
2026-03-26 09:09:20 +01:00
f13bfd6572 feat: add order reconciliation job for crash recovery
Adds a background goruotine that runs every 5 minutes to find pending
orders with no Stripe ID older than 10 mins and patches them if stripe
shows a succeeded PaymentIntent. Covers the edge case where the server
crashes after charging but before the DB transaction commits. Also tags
PaymentIntents with order_id metadata at createion to make them
searchable
2026-03-25 21:00:30 +01:00
d5cb4259f5 fix: prevent charge-without-order by creating order before charging
When paying the application charges the card and then sends a payment
record to the database after, in a situation where something goes wrong
between those two steps, we could potentially end up where a users card
was charged but no record on what is was charged for. The fix is to
re-order ConvertCart to write a pending order record before calling
Stripe, this way a DB record always exists if anything fails
mid-operation. This implementation uses an idempotency key keyed to the
order ID to prevent double-charges on retry. Wraps the post-charge DB
updates in a transaction so marking paid andd clearing the cart are
atomic. Adds OrderStatusFailed and a CRITICAL log for the unrecoverable
case. Includes a checkout flow doc with test cards and reconcilation
steps.
2026-03-25 20:13:51 +01:00
1a49360191 feat: throw out the boolean navigation with page routing
replaced ViewingCart/ViewingAccount booleans and CheckoutStep int with a
single 'page' type and a SwitchPage() method, this is just almost
copying the reference implementation (terminalDotShop)'s routing
pattern. Adds inCartFlow(), chechoutStep(), and buildPageContent()
helpers to clean up those huge if/else chains in views, footer, header,
and breadcrumds. code is looking a lot better now!
2026-03-25 15:11:33 +01:00
93cc69d028 just updating the branch with current changes 2026-03-25 08:57:27 +01:00
c7fa292730 update: addeds some extra text in the account page and making some updates for the future for the setting default addresses and cards 2026-03-24 21:29:22 +01:00
0146fa3e76 update: updated README 2026-03-16 16:06:51 +01:00
77 changed files with 4654 additions and 5489 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.env
.env.*
!.env.example
terminal-shop-source/
*.db
data/
docs/

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.production
.env.example
.env.prod
# Build artifacts
*.exe
@@ -11,8 +12,13 @@
*.so
*.dylib
terminalshop
terminalShop
server_bin
terminalshop_*
server.go.old
main
api/main
api/api-server
# Go test/coverage files
*.test
@@ -60,6 +66,9 @@ temp/
Thumbs.db
.DS_Store
CONTRIBUTING.md
# random stuff like ai and shit
CLAUDE.md
@@ -67,14 +76,18 @@ AGENTS.md
MODELS.md
REFACTORING.md
TODO.md
NEXT.md
test_stripe.go
REFACTOR_PLAN.md
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/docs/
docs/
# Migration state (if using a migration tool that creates state files)
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!

3
Caddyfile Normal file
View File

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

13
Dockerfile.api Normal file
View File

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

11
Dockerfile.ssh Normal file
View File

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

1214
MODELS.md

File diff suppressed because it is too large Load Diff

4
Procfile Normal file
View File

@@ -0,0 +1,4 @@
api: bash -c 'cd api && go run main.go'
ssh: go run main.go
webhook: stripe listen --api-key $STRIPE_SECRET_KEY --forward-to localhost:8080/api/v1/webhooks/stripe
tunnel: ngrok http 8080 --log stdout

410
README.md
View File

@@ -1,413 +1,13 @@
# Terminal Coffee Shop
A terminal-based e-commerce application for ordering coffee, built with Go and Bubbletea TUI framework. Inspired by [terminal.shop](https://terminal.shop).
## Current Status
**Phase 1: Enhanced UI & Navigation** - 80% Complete
### What's Working
- ✅ Multi-view navigation (shop tab, cart tab)
- ✅ Product listing with left-aligned coffee names
- ✅ Product detail view with descriptions
- ✅ Cart management (add/remove items, quantity controls)
- ✅ Responsive vertical centering with padding
- ✅ Clean, minimal UI with proper spacing
### What's Next
- ⬜ Account page view (add `viewingAccount bool` to model)
- ⬜ Breadcrumb navigation system
- ⬜ Viewport scrolling for long content
- ⬜ Loading states and error displays
## Project Structure
```
terminalShop/
├── main.go # Entry point, SSH server (future)
├── server.go # Current main entry point for TUI
├── model.go # Bubbletea model, views, and state management
├── README.md # This file - project overview and roadmap
├── MODELS.md # Technical specs, architecture, code standards
├── api/ # (Future) REST API backend
├── db/ # (Future) Database migrations and schemas
├── config/ # (Future) Configuration files
└── terminal-shop-source/ # Reference implementation (terminal.shop clone)
```
## Development Roadmap
This roadmap is designed to be executed incrementally. Each phase builds on the previous one.
---
## **PHASE 1: Enhanced UI & Navigation** (Current Phase)
### Objective
Build a polished, navigable TUI with shop, cart, and account views.
### Tasks
#### 1.1 Add Account Page View
- [ ] Add `viewingAccount bool` to model struct in `model.go`
- [ ] Add keybind "a" to switch to account view in `Update()` function
- [ ] Create `buildAccountView()` function
- [ ] Show user info: username, email (mock data for now)
- [ ] Add account tab to header with proper active/inactive styling
- [ ] Update footer to show account navigation keybind
**Files to modify**: `model.go`
#### 1.2 Implement Breadcrumb Navigation
- [ ] Create breadcrumb component showing current page path
- [ ] Display breadcrumbs between header and content
- [ ] Style breadcrumbs to match terminal.shop aesthetic
**Reference**: `terminal-shop-source/packages/go/pkg/tui/breadcrumbs.go`
#### 1.3 Add Viewport Scrolling
- [ ] Import `github.com/charmbracelet/bubbles/viewport`
- [ ] Add viewport to cart view for long item lists
- [ ] Add viewport to shop view for product descriptions
- [ ] Handle scroll keybinds (pgup/pgdn, mouse wheel)
**Reference**: `terminal-shop-source/packages/go/pkg/tui/cart.go` (lines 15-20)
#### 1.4 Loading and Error States
- [ ] Create loading spinner component
- [ ] Add error message display system
- [ ] Show loading state when switching views
- [ ] Handle and display errors gracefully
---
## **PHASE 2: Backend API Foundation**
### Objective
Build a REST API backend in Go that the TUI can communicate with.
### Tech Stack
- **Framework**: Chi router (`github.com/go-chi/chi/v5`)
- **Database**: PostgreSQL with GORM (`gorm.io/gorm`)
- **Auth**: JWT tokens (`github.com/golang-jwt/jwt/v5`)
- **Validation**: go-playground/validator (`github.com/go-playground/validator/v10`)
### Tasks
#### 2.1 Project Setup
- [ ] Create `api/` directory structure
- [ ] Initialize Go modules for API
- [ ] Set up Chi router
- [ ] Add middleware: logging, CORS, recovery
- [ ] Create `.env` file for configuration
- [ ] Add `godotenv` for environment variables
**Directory structure**:
```
api/
├── main.go # API server entry point
├── routes/ # Route handlers
│ ├── products.go
│ ├── users.go
│ └── cart.go
├── middleware/ # Custom middleware
├── models/ # Database models
└── db/ # Database connection
```
#### 2.2 Database Setup
- [ ] Install PostgreSQL locally (or use Docker)
- [ ] Create database schema
- [ ] Set up GORM connection
- [ ] Create migration system
- [ ] Add seed data for coffee products
**Schema to create**:
```sql
-- See MODELS.md for complete schema
users, products, carts, cart_items, orders, order_items, addresses
```
#### 2.3 Product API Endpoints
- [ ] `GET /api/products` - List all products
- [ ] `GET /api/products/:id` - Get single product
- [ ] `POST /api/products` - Create product (admin only, future)
- [ ] Add pagination to product listing
- [ ] Add filtering by category/type
**Expected response format**:
```json
{
"products": [
{
"id": "uuid",
"name": "Espresso",
"description": "Bold coffee...",
"price": 350,
"color": "#8B4513",
"roast_type": "Dark Roast",
"ounces": 2,
"bean_type": "Arabica"
}
]
}
```
#### 2.4 Update TUI to Use API
- [ ] Create `pkg/api/client.go` - API client
- [ ] Replace hardcoded coffee data with API calls
- [ ] Add loading states during API calls
- [ ] Handle API errors in TUI
- [ ] Add retry logic for failed requests
---
## **PHASE 3: User Authentication**
### Objective
Implement user registration, login, and session management.
### Tasks
#### 3.1 Auth System Design
- [ ] Choose auth method: SSH fingerprint OR username/password
- [ ] Design user registration flow
- [ ] Design login flow
- [ ] Implement JWT token generation and validation
**Recommended**: Start with username/password, add SSH fingerprint later
#### 3.2 API Endpoints
- [ ] `POST /api/auth/register` - Create new user
- [ ] `POST /api/auth/login` - Get JWT token
- [ ] `POST /api/auth/refresh` - Refresh expired token
- [ ] `GET /api/auth/me` - Get current user info
- [ ] `PUT /api/auth/me` - Update user profile
#### 3.3 TUI Login Flow
- [ ] Create login view with username/password form
- [ ] Use `github.com/charmbracelet/huh` for form inputs
- [ ] Store JWT token in memory (or file for persistence)
- [ ] Send token with all API requests
- [ ] Handle token expiration and refresh
**Reference**: `terminal-shop-source/packages/go/pkg/tui/account.go`
#### 3.4 User Profile
- [ ] Show user info in account page
- [ ] Allow editing name, email
- [ ] Add password change functionality
---
## **PHASE 4: Cart Persistence**
### Objective
Save cart data to database and sync across sessions.
### Tasks
#### 4.1 Cart API
- [ ] `GET /api/cart` - Get current user's cart
- [ ] `POST /api/cart/items` - Add item to cart
- [ ] `PUT /api/cart/items/:id` - Update item quantity
- [ ] `DELETE /api/cart/items/:id` - Remove item
- [ ] Calculate totals on backend
#### 4.2 Update TUI Cart
- [ ] Fetch cart from API on startup
- [ ] Sync cart changes to API immediately
- [ ] Handle offline mode (local-only cart)
- [ ] Merge local cart with server cart on login
---
## **PHASE 5: Payment Integration**
### Objective
Accept real payments via Stripe.
### Tasks
#### 5.1 Stripe Setup
- [ ] Create Stripe account (test mode)
- [ ] Add `github.com/stripe/stripe-go/v78`
- [ ] Set up Stripe API keys in `.env`
- [ ] Create Stripe customer on user registration
#### 5.2 Payment Method Management
- [ ] Create payment form in TUI (like terminal.shop)
- [ ] Collect: card number, expiry, CVC, zip
- [ ] Validate card details client-side
- [ ] Tokenize card with Stripe API
- [ ] Save card token to database
**Reference**: `terminal-shop-source/packages/go/pkg/tui/payment.go`
#### 5.3 Payment API
- [ ] `POST /api/cards` - Save payment method
- [ ] `GET /api/cards` - List saved cards
- [ ] `DELETE /api/cards/:id` - Remove card
- [ ] `POST /api/orders/checkout` - Create order and charge
#### 5.4 Alternative Payment Flow
- [ ] Generate QR code for browser payment
- [ ] Create payment link with Stripe
- [ ] Poll for payment completion
- [ ] Update order status
**Reference**: `terminal-shop-source/packages/go/pkg/tui/qrfefe/qr.go`
---
## **PHASE 6: Shipping & Orders**
### Objective
Collect shipping addresses and create orders.
### Tasks
#### 6.1 Address Management
- [ ] Create address form in TUI
- [ ] Validate addresses (use Shippo or similar)
- [ ] Save addresses to database
- [ ] Allow selecting from saved addresses
#### 6.2 Shipping Calculation
- [ ] Integrate Shippo for shipping rates
- [ ] Calculate shipping based on address
- [ ] Show shipping options to user
- [ ] Add shipping cost to order total
#### 6.3 Order Creation
- [ ] Create checkout confirmation screen
- [ ] Show order summary: items, subtotal, shipping, total
- [ ] `POST /api/orders` - Create order
- [ ] Charge payment method
- [ ] Send confirmation email (optional)
#### 6.4 Order History
- [ ] `GET /api/orders` - List user's orders
- [ ] Show order history in account page
- [ ] Display order details and status
---
## **PHASE 7: SSH Server**
### Objective
Deploy as SSH server accessible via `ssh shop@yourserver.com`
### Tasks
#### 7.1 SSH Server Setup
- [ ] Add `github.com/charmbracelet/wish`
- [ ] Create SSH server in `main.go`
- [ ] Generate or load SSH host keys
- [ ] Handle concurrent user sessions
**Reference**: `terminal-shop-source/packages/go/cmd/ssh/main.go`
#### 7.2 Authentication
- [ ] Implement public key authentication
- [ ] Link SSH fingerprints to user accounts
- [ ] Auto-register new SSH keys
- [ ] Handle key-based login
#### 7.3 Deployment
- [ ] Set up VPS/cloud server (DigitalOcean, AWS, etc.)
- [ ] Configure firewall (port 22 or custom)
- [ ] Set up systemd service
- [ ] Add logging and monitoring
- [ ] Configure domain name
---
## **PHASE 8: Polish & Production**
### Objective
Make it production-ready.
### Tasks
#### 8.1 Testing
- [ ] Write unit tests for API handlers
- [ ] Write integration tests for critical flows
- [ ] Test payment flow thoroughly
- [ ] Load test with multiple concurrent users
#### 8.2 Admin Features
- [ ] Create admin dashboard (web or TUI)
- [ ] Manage products (CRUD operations)
- [ ] View orders and fulfill them
- [ ] Manage inventory
#### 8.3 Production Stripe
- [ ] Switch to Stripe production mode
- [ ] Handle webhooks for payment events
- [ ] Implement proper error handling
- [ ] Set up refund capability
#### 8.4 Monitoring & Logging
- [ ] Add structured logging
- [ ] Set up error tracking (Sentry)
- [ ] Add metrics (Prometheus)
- [ ] Create health check endpoint
---
## Quick Start Commands
```bash
# Run current TUI app
go run server.go model.go
# Run API server (future)
cd api && go run main.go
# Run tests (future)
go test ./...
# Database migrations (future)
go run migrations/migrate.go up
# Deploy (future)
make deploy
ssh sshops.uk
```
## Environment Variables
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
# .env file (create this)
DATABASE_URL=postgresql://user:password@localhost:5432/terminalshop
JWT_SECRET=your-secret-key-here
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
SSH_HOST_KEY_PATH=./ssh_host_key
API_PORT=8080
SSH_PORT=2222
```
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).
## Resources
running on Hetzner, behind Cloudflare.
- **Reference Implementation**: `terminal-shop-source/` directory
- **Technical Specs**: See `MODELS.md`
- **Bubbletea Tutorial**: https://github.com/charmbracelet/bubbletea/tree/master/tutorials
- **Stripe Go Docs**: https://stripe.com/docs/api?lang=go
- **Chi Router**: https://go-chi.io/
## Contributing Guidelines for Claude Code
When working on this project:
1. **Always check MODELS.md** before writing code to follow established patterns
2. **Read the current phase** in this README to understand what to build next
3. **Reference terminal.shop source code** in `terminal-shop-source/` when unsure
4. **Update this README** when completing tasks (check off boxes)
5. **Follow Go best practices** and the style guide in MODELS.md
6. **Write tests** for new API endpoints
7. **Keep commits focused** - one feature/fix per commit
## License
MIT (or your preferred license)
**tech:** Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, Shippo, Bring

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.

42
TODO.md
View File

@@ -1,42 +0,0 @@
# TODO
## Done
- [x] Cart persistence across sessions — sync cart to server, fetch on startup
- [x] Token refresh — auto-refresh JWT before expiry
- [x] Help screen — press ? to show keybindings
- [x] Order detail view — press Enter on an order in account view to see full details
## Up Next
- [x] FAQ page — replace hardcoded 2 Q&As with real content (embedded JSON, ~12 entries)
- [x] About page — replace placeholder with real content/credits
- [ ] Loading spinner — replace plain text with animated bubbles/spinner
- [ ] Address update — PUT /api/v1/addresses/:id endpoint
- [ ] Single order endpoint — GET /api/v1/orders/:id
- [ ] User profile editing — PUT /api/v1/profile, set name/email from account view
## Feature Gaps (vs Reference)
- [ ] Bulk data preload — single /view/init endpoint to fetch all user data at once
- [ ] Splash screen — logo + auth + data preload on connect
- [ ] Product variants — multiple variants per product (roast types) with separate prices
- [ ] Featured/originals product sections — categorize products in shop view
- [ ] Per-product theme colors — dynamic accent color based on selected product
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle region
- [ ] Stripe webhook — sync card updates from Stripe events
- [ ] Shippo webhook — order tracking status updates
- [ ] Email system — order confirmation, shipped notification emails
## 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
- [ ] TUI tests — model_test.go and menu_test.go are empty skeletons
- [ ] Shippo/Bring integration tests
## Post-Release
- [ ] Subscription model — database table, CRUD endpoints, scheduling
- [ ] Subscription creation flow — TUI: select variant → shipping → payment → confirm
- [ ] Subscription management — TUI: list, detail view, cancel with confirmation
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete in account view
- [ ] 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 {
case "US":
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
}
client := shippo.NewClient(h.shippoKey)
@@ -166,12 +166,14 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
var address models.Address
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
}
// ownership is guaranteed by the WHERE clause above
db.Delete(&address)
if err := db.Delete(&address).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address successfully deleted",
@@ -197,10 +199,16 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
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
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{}{
"address": address,

View File

@@ -0,0 +1,160 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"testing"
)
func setupAddressTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_addresses.db"
os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
if err := database.Seed(db); err != nil {
t.Fatalf("failed to seed: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:addresstestfingerprint",
SSHPublicKey: "ssh-ed25519 AAAA addresstestkey",
Name: "Address Test User",
Email: "address@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return testDB, user
}
func TestGetAddressesEmpty(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer database.ResetForTesting()
handler := NewAddressHandler("", "", "")
req := authRequest("GET", "/api/v1/addresses", nil, user.ID)
w := httptest.NewRecorder()
handler.GetAddresses(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp struct {
Success bool `json:"success"`
Data struct {
Addresses []interface{} `json:"addresses"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Errorf("expexted success to be true")
}
if len(resp.Data.Addresses) != 0 {
t.Errorf("expected + addresses, got %d", len(resp.Data.Addresses))
}
}
func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB)
defer database.ResetForTesting()
db := database.GetDB()
user2 := models.User{
SSHKeyFingerprint: "SHA256:otheraddressuser",
SSHPublicKey: "ssh-ed25519 AAAA otheraddressuser",
}
db.Create(&user2)
db.Create(&models.Address{
UserID: user1.ID, Name: "User1 Addr", Street: "111 St",
City: "Portland", State: "OR", Zip: "97201", Country: "US",
})
db.Create(&models.Address{
UserID: user2.ID, Name: "User2 Addr", Street: "222 St",
City: "Seattle", State: "WA", Zip: "98101", Country: "US",
})
handler := NewAddressHandler("", "", "")
req := authRequest("GET", "/api/v1/addresses", nil, user1.ID)
w := httptest.NewRecorder()
handler.GetAddresses(w, req)
var resp struct {
Success bool `json:"success"`
Data struct {
Addresses []models.Address `json:"addresses"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
}
if resp.Data.Addresses[0].Name != "User1 Addr" {
t.Errorf("expected 'User1 Addr', got %q", resp.Data.Addresses[0].Name)
}
}
func TestGetAddressesWithData(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer database.ResetForTesting()
db := database.GetDB()
address1 := &models.Address{
UserID: user.ID, Name: "Home", Street: "123 Main St",
City: "Portland", State: "OR", Zip: "97201", Country: "US",
}
address2 := &models.Address{
UserID: user.ID, Name: "Work", Street: "456 Office Ave",
City: "Portland", State: "OR", Zip: "97202", Country: "US",
}
db.Create(address1)
db.Create(address2)
handler := NewAddressHandler("", "", "")
req := authRequest("GET", "/api/v1/addresses", nil, user.ID)
w := httptest.NewRecorder()
handler.GetAddresses(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected code 200, got %d", w.Code)
}
var resp struct {
Success bool `json:"success"`
Data struct {
Addresses []models.Address `json:"addresses"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp.Data.Addresses) != 2 {
t.Errorf("expected 2 addresses, got: %d", len(resp.Data.Addresses))
}
}

View File

@@ -8,6 +8,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
)
// AuthHandler handles authentication-related requests
@@ -27,14 +29,16 @@ func NewAuthHandler(jwtManager *auth.JWTManager, authFingerprintKey string) *Aut
// Type to represent a request to exchange SSH fingerprint for JWT
type TokenRequest struct {
Fingerprint string `json:"fingerprint"`
SSHPublicKey string `json:"ssh_public_key"`
ClientSecret string `json:"client_secret"`
}
// Type to represent the JWT token response
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User models.PublicUser `json:"user"`
}
func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
@@ -60,11 +64,40 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
// look up user by his fingies
var user models.User
if err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error; err != nil {
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error
if err == gorm.ErrRecordNotFound {
if req.SSHPublicKey == "" {
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
return
}
user = models.User{
SSHKeyFingerprint: req.Fingerprint,
SSHPublicKey: req.SSHPublicKey,
}
if err := db.Create(&user).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil)
return
}
} else if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to look up user", nil)
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)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)
@@ -75,6 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
"access_token": token,
"token_type": "Bearer",
"expires_in": 1800, // 30 minutes in seconds
"user": user.ToPublic(),
})
}

View File

@@ -1,30 +1,67 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/customer"
"github.com/stripe/stripe-go/v78/paymentmethod"
"gorm.io/gorm"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
)
// payRedirects maps short tokens to full Stripe checkout URLs.
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL string) string {
b := make([]byte, 16)
rand.Read(b)
token := hex.EncodeToString(b)
payRedirectsMu.Lock()
payRedirects[token] = stripeURL
payRedirectsMu.Unlock()
return token
}
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
payRedirectsMu.Lock()
url, ok := payRedirects[token]
payRedirectsMu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
// CardHandler handles saved payment method CRUD.
type CardHandler struct {
stripeKey string
appURL string
}
// NewCardHandler creates a new card handler with the Stripe secret key.
func NewCardHandler(stripeSecretKey string) *CardHandler {
return &CardHandler{stripeKey: stripeSecretKey}
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL}
}
// GetCards returns all saved cards for the authenticated user.
@@ -33,13 +70,15 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
var cards []models.Card
db.Where("user_id = ?", userID).Order("created_at DESC").Find(&cards)
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cards": cards,
})
}
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
// GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
@@ -63,16 +102,92 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
})
}
// SaveCard creates a saved card from a Stripe token. The token is converted to
// a PaymentMethod and attached to the user's Stripe customer.
func (h *CardHandler) SetDefaultCard(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 card id", nil)
return
}
var card models.Card
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&card).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "CARD_NOT_FOUND", "card not found", nil)
return
}
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
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{}{
"card": card,
})
}
func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
stripe.Key = h.stripeKey
if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSetup)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
SuccessURL: stripe.String(h.appURL + "/card-added"),
CancelURL: stripe.String(h.appURL + "/card-added"),
}
sess, err := stripeSession.New(params)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create checkout session", nil)
return
}
token := storeRedirect(sess.URL)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
}
// SaveCardRequest holds the raw card fields submitted by the TUI.
// Tokenization happens server-side so the Stripe secret key never
// touches the SSH client or TUI layer.
type SaveCardRequest struct {
Token string `json:"token"`
}
// SaveCard accepts raw card params, tokenizes them via the stripe-go SDK
// (server-side), converts the token to a PaymentMethod, deduplicates by
// fingerprint, attaches to the user's Stripe customer, and saves the
// card metadata locally. No raw card data is stored at any point.
func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var req struct {
Token string `json:"token"`
}
var req SaveCardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
@@ -91,28 +206,13 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer
if user.StripeCustomerID == "" {
params := &stripe.CustomerParams{
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
}
if user.Email != "" {
params.Email = stripe.String(user.Email)
}
if user.Name != "" {
params.Name = stripe.String(user.Name)
}
cust, err := customer.New(params)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
user.StripeCustomerID = cust.ID
db.Save(&user)
// Ensure the user has a Stripe customer, creating one if needed.
if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
// Convert the token to a PaymentMethod
// Convert the single-use token to a reusable PaymentMethod.
pm, err := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
@@ -120,11 +220,11 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
},
})
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "invalid card token", nil)
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
// Check for duplicate cards by fingerprint
// Deduplicate: reject if the user already has a card with this fingerprint.
listParams := &stripe.PaymentMethodListParams{
Customer: stripe.String(user.StripeCustomerID),
Type: stripe.String("card"),
@@ -139,7 +239,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
}
}
// Attach the PaymentMethod to the Stripe customer
// Attach the PaymentMethod to the Stripe customer.
_, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
})
@@ -148,7 +248,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return
}
// Save the card locally
// Persist only the safe card metadata — no raw card data is stored.
card := models.Card{
UserID: user.ID,
StripePaymentID: pm.ID,
@@ -163,6 +263,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return
}
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
"card": card,
})
@@ -187,20 +289,56 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
return
}
// Clear the card from any cart that references it
// Clear the card from any cart that references it.
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 if it's a PaymentMethod (pm_)
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
stripe.Key = h.stripeKey
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
}
// Soft-delete the local record
db.Delete(&card)
// Soft-delete the local record.
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)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card deleted",
})
}
// ensureStripeCustomer creates a Stripe Customer for the user if they don't
// already have one and persists the customer ID. Safe to call concurrently —
// a duplicate Stripe customer is harmless; the local record reflects the first
// successful write.
func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error {
if user.StripeCustomerID != "" {
return nil
}
params := &stripe.CustomerParams{
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
}
if user.Email != "" {
params.Email = stripe.String(user.Email)
}
if user.Name != "" {
params.Name = stripe.String(user.Name)
}
cust, err := customer.New(params)
if err != nil {
return err
}
user.StripeCustomerID = cust.ID
if err := gdb.Save(user).Error; err != nil {
return fmt.Errorf("Failed to save stripe customer: %w", err)
}
return nil
}

View File

@@ -5,15 +5,16 @@ import (
"fmt"
"net/http"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
"gorm.io/gorm"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/customer"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
)
type CartHandler struct {
@@ -48,14 +49,13 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
subtotal := 0
itemsData := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
priceInCents := int(item.Coffee.Price * 100)
lineTotal := priceInCents * item.Quantity
lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal
itemsData = append(itemsData, map[string]interface{}{
"id": item.ID,
"coffee_id": item.CoffeeID,
"quantity": item.Quantity,
"subtotal": lineTotal,
"subtotal": lineTotal, // already in cents
"coffee": item.Coffee,
})
}
@@ -117,7 +117,10 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
}
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 {
var item models.CartItem
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,
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 {
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
}
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{}{
"message": "address set",
@@ -197,7 +209,10 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
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{}{
"message": "card set",
@@ -214,7 +229,10 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
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{}{
"message": "cart cleared",
@@ -234,7 +252,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
if len(items) <= 0 {
if len(items) == 0 {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
return
}
@@ -269,38 +287,49 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
stripe.Key = h.stripeKey
if user.StripeCustomerID == "" {
params := &stripe.CustomerParams{
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
}
// Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
if user.Email != "" {
params.Email = stripe.String(user.Email)
}
if user.Name != "" {
params.Name = stripe.String(user.Name)
}
cust, err := customer.New(params)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
// All cards are now saved as pm_ PaymentMethods. This branch handles any
// legacy tok_ records that predate the server-side tokenization refactor.
paymentMethodID := card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
}); attachErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
user.StripeCustomerID = cust.ID
db.Save(&user)
}
var subtotal int
var orderItems []models.OrderItem
for _, item := range items {
priceInCents := int(item.Coffee.Price * 100)
lineTotal := priceInCents * item.Quantity
lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal
orderItems = append(orderItems, models.OrderItem{
CoffeeID: item.CoffeeID,
Name: item.Coffee.Name,
Price: priceInCents,
Price: item.Coffee.Price,
Quantity: item.Quantity,
})
}
@@ -312,72 +341,13 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
// If the saved card has a token (tok_), convert to a PaymentMethod and attach
paymentMethodID := card.StripePaymentID
if len(paymentMethodID) >= 4 && paymentMethodID[:3] == "tok" {
pm, err := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
_, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
})
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
db.Save(&card)
}
// Charge via PaymentIntent (matching reference implementation)
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
AllowRedirects: stripe.String("never"),
},
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", user.ID)),
Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name),
Address: &stripe.AddressParams{
Line1: stripe.String(address.Street),
Line2: stripe.String(address.Street2),
City: stripe.String(address.City),
State: stripe.String(address.State),
PostalCode: stripe.String(address.Zip),
Country: stripe.String(address.Country),
},
},
}
pi, err := paymentintent.New(piParams)
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
utils.RespondError(w, http.StatusPaymentRequired, "CARD_ERROR", stripeErr.Msg, nil)
return
}
utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment failed", nil)
return
}
// Create the order record BEFORE charging so we always have something to
// reconcile against if the server crashes mid-flight.
order := models.Order{
UserID: user.ID,
CardID: card.ID,
StripePaymentID: pi.ID,
Status: models.OrderStatusPaid,
StripePaymentID: "",
Status: models.OrderStatusPending,
Subtotal: subtotal,
ShippingCost: cart.ShippingCost,
Total: total,
@@ -397,14 +367,91 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
return
}
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
audit.OrderCreated(userID, order.ID, total)
db.Model(cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
// Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge.
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
AllowRedirects: stripe.String("never"),
},
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name),
Address: &stripe.AddressParams{
Line1: stripe.String(address.Street),
Line2: stripe.String(address.Street2),
City: stripe.String(address.City),
State: stripe.String(address.State),
PostalCode: stripe.String(address.Zip),
Country: stripe.String(address.Country),
},
},
}
piParams.SetIdempotencyKey(fmt.Sprintf("order-%d", order.ID))
piParams.Metadata = map[string]string{
"order_id": fmt.Sprintf("%d", order.ID),
}
pi, err := paymentintent.New(piParams)
if err != nil {
db.Model(&order).Update("status", models.OrderStatusFailed)
if stripeErr, ok := err.(*stripe.Error); ok {
switch stripeErr.Type {
case stripe.ErrorTypeCard:
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
case stripe.ErrorTypeInvalidRequest:
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
default:
audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
}
return
}
audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
return
}
// Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error {
order.StripePaymentID = pi.ID
order.Status = models.OrderStatusPaid
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error
})
if txErr != nil {
// Card was charged but we could not persist the outcome. This is the
// most dangerous failure mode: the customer paid but has no order.
// The reconciliation job will catch this within 5 minutes.
// The audit log entry below must be treated as a critical alert.
audit.PaymentCritical(order.ID, pi.ID, txErr)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
return
}
db.Preload("Items").First(&order, order.ID)
audit.OrderPaid(userID, order.ID, total, pi.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{}{
"order": order,

View File

@@ -2,28 +2,31 @@ package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
type OrderHandler struct {
stripeKey string
}
type OrderHandler struct {}
func NewOrderHandler() *OrderHandler {
return &OrderHandler{}
}
func NewOrderHandler(stripeSecretKey string) *OrderHandler {
return &OrderHandler{stripeKey: stripeSecretKey}
}
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
@@ -31,3 +34,62 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
"orders": orders,
})
}
// RefundOrder issues a full refund for an order via Stripe and marks it as
// refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(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 order id", nil)
return
}
var order models.Order
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
// Only paid or shipped orders can be refunded.
if order.Status != models.OrderStatusPaid && order.Status != models.OrderStatusShipped {
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
"only paid or shipped orders can be refunded", nil)
return
}
if order.StripePaymentID == "" {
utils.RespondError(w, http.StatusBadRequest, "MISSING_PAYMENT_ID",
"order has no stripe payment id — contact support", nil)
return
}
stripe.Key = h.stripeKey
rf, err := refund.New(&stripe.RefundParams{
PaymentIntent: stripe.String(order.StripePaymentID),
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", stripeErr.Msg, nil)
return
}
utils.RespondError(w, http.StatusInternalServerError, "REFUND_FAILED", "refund failed", nil)
return
}
if err := db.Model(&order).Update("status", models.OrderStatusRefunded).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "refund issued but failed to update order status", nil)
return
}
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"refund_id": rf.ID,
"status": "refunded",
})
}

View File

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

74
api/handlers/reconcile.go Normal file
View File

@@ -0,0 +1,74 @@
package handlers
import (
"fmt"
"log"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"gorm.io/gorm"
)
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
// the case where the server crashed after charging but before the DB transaction
// completed.
func ReconcileOrders(stripeKey string) {
stripe.Key = stripeKey
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute)
db.Where("status = ? AND stripe_payment_id = '' AND created_at < ?",
models.OrderStatusPending, cutoff).Find(&orders)
if len(orders) == 0 {
return
}
log.Printf("[reconcile] checking %d pending orders", len(orders))
for _, order := range orders {
params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
},
}
iter := paymentintent.Search(params)
for iter.Next() {
pi := iter.PaymentIntent()
log.Printf("[reconcile] found succeeded PI %s for order %d — patching", pi.ID, order.ID)
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID,
}).Error; err != nil {
return err
}
var cart models.Cart
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
return nil // cart already gone, nothing to clear
}
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err
}
return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error
})
if txErr != nil {
log.Printf("[reconcile] failed to patch order %d: %v", order.ID, txErr)
}
}
if err := iter.Err(); err != nil {
log.Printf("[reconcile] stripe search error for order %d: %v", order.ID, err)
}
}
}

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

59
api/handlers/view.go Normal file
View File

@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
type ViewHandler struct{}
func NewViewHandler() *ViewHandler {
return &ViewHandler{}
}
func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "user not found", nil)
return
}
var products []models.Coffee
db.Find(&products)
var cart models.Cart
db.Where("user_id = ?", userID).First(&cart)
var addresses []models.Address
db.Where("user_id = ?", userID).Find(&addresses)
var cards []models.Card
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
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
var cartItems []models.CartItem
if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"user": user.ToPublic(),
"products": products,
"cart": cartItems,
"addresses": addresses,
"cards": cards,
"ssh_keys": sshKeys,
"orders": orders,
})
}

238
api/handlers/webhook.go Normal file
View File

@@ -0,0 +1,238 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentmethod"
"github.com/stripe/stripe-go/v78/setupintent"
"github.com/stripe/stripe-go/v78/webhook"
"gorm.io/gorm"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct {
webhookSecret string
stripeKey string
}
// NewWebhookHandler creates a new webhook handler.
func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler {
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey}
}
// HandleStripe validates the Stripe signature and routes the event.
// This endpoint is intentionally unauthenticated — authentication is
// provided by the webhook signature (HMAC-SHA256).
func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Read the entire body up front; webhook.ConstructEvent requires it.
const maxBodyBytes = 65536
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "READ_ERROR", "could not read request body", nil)
return
}
// Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" {
// No secret configured — log a loud warning but still process in dev.
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
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)
return
}
}
var event stripe.Event
if err := json.Unmarshal(body, &event); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil)
return
}
switch event.Type {
case "checkout.session.completed":
h.handleCheckoutSessionCompleted(event)
// payment_intent.succeeded fires when a charge completes asynchronously
// (e.g. 3DS, bank redirects). For confirm=true intents this is normally
// already handled by ConvertCart, but the webhook is the authoritative source.
case "payment_intent.succeeded":
h.handlePaymentIntentSucceeded(event)
// payment_intent.payment_failed fires when a payment fails after initial
// confirmation (e.g. bank decline on a deferred 3DS flow).
case "payment_intent.payment_failed":
h.handlePaymentIntentFailed(event)
// Sync local card state when Stripe's side changes.
case "payment_method.attached",
"payment_method.detached",
"payment_method.updated":
// Handled by the reconcile job and direct API calls; log only.
log.Printf("[webhook] %s received for payment method", event.Type)
}
// Always return 200 so Stripe doesn't retry.
w.WriteHeader(http.StatusOK)
}
func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
return
}
orderIDStr, ok := pi.Metadata["order_id"]
if !ok {
// PaymentIntent not tied to a local order — nothing to do.
return
}
db := database.GetDB()
var order models.Order
if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil {
log.Printf("[webhook] order %s not found for pi %s", orderIDStr, pi.ID)
return
}
if order.Status == models.OrderStatusPaid {
// Already recorded — idempotent.
return
}
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{
"status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID,
}).Error; err != nil {
return err
}
// Clear the user's cart if it hasn't been cleared yet.
var cart models.Cart
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
return nil // Cart already gone.
}
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{}{
"address_id": nil,
"card_id": nil,
}).Error
})
if txErr != nil {
log.Printf("[webhook] failed to record paid status for order %s (pi=%s): %v", orderIDStr, pi.ID, txErr)
return
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
log.Printf("[webhook] order %s marked paid via payment_intent.succeeded (pi=%s)", orderIDStr, pi.ID)
}
func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
return
}
orderIDStr, ok := pi.Metadata["order_id"]
if !ok {
return
}
db := database.GetDB()
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil {
log.Printf("[webhook] failed to mark order %s as failed: %v", orderIDStr, err)
}
errMsg := "unknown"
if pi.LastPaymentError != nil {
errMsg = pi.LastPaymentError.Msg
}
log.Printf("[webhook] payment failed for order %s (pi=%s): %s", orderIDStr, pi.ID, errMsg)
}
func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
log.Printf("[webhook] failed to unmarshal checkout session: %v", err)
return
}
if sess.Mode != stripe.CheckoutSessionModeSetup {
return
}
if sess.SetupIntent == nil || sess.Customer == nil {
log.Printf("[webhook] checkout session missing setup_intent or customer")
return
}
stripe.Key = h.stripeKey
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != nil {
log.Printf("[webhook] failed to get setup intent %s: %v", sess.SetupIntent.ID, err)
return
}
if si.PaymentMethod == nil {
log.Printf("[webhook] setup intent %s has no payment method", si.ID)
return
}
pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil)
if err != nil {
log.Printf("[webhook] failed to get payment method %s: %v", si.PaymentMethod.ID, err)
return
}
db := database.GetDB()
var user models.User
if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil {
log.Printf("[webhook] user not found for customer %s: %v", sess.Customer.ID, err)
return
}
// Idempotency: skip if this payment method is already saved.
var existing models.Card
if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil {
return
}
card := models.Card{
UserID: user.ID,
StripePaymentID: pm.ID,
Last4: pm.Card.Last4,
Brand: string(pm.Card.Brand),
ExpMonth: int(pm.Card.ExpMonth),
ExpYear: int(pm.Card.ExpYear),
}
if err := db.Create(&card).Error; err != nil {
log.Printf("[webhook] failed to save card for user %d: %v", user.ID, err)
return
}
audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4)
log.Printf("[webhook] card saved via checkout.session.completed for user %d", user.ID)
}

View File

@@ -10,8 +10,9 @@ import (
"syscall"
"time"
"terminalShop/pkg/auth"
"terminalShop/api/handlers"
"terminalShop/api/routes"
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
"terminalShop/pkg/database"
)
@@ -19,6 +20,8 @@ import (
const version = "v0.1.0"
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Load configuration
cfg, err := config.Load()
if err != nil {
@@ -46,7 +49,7 @@ func main() {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes
router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey)
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.AppURL)
// Create HTTP server
server := &http.Server{
@@ -65,7 +68,7 @@ func main() {
go func() {
log.Printf("Starting API server on port %s", cfg.APIPort)
log.Printf("Environment: %s", cfg.Environment)
log.Printf("Database: %s", cfg.DatabaseURL)
log.Printf("Database: %s", cfg.SafeDatabaseURL())
log.Printf("Health check: http://localhost:%s/api/v1/health", cfg.APIPort)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -73,14 +76,30 @@ func main() {
}
}()
// Start reconciliation job. runs every 5 minutes to catch any orders
// that were charged but not recorded due to a crash.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(cfg.StripeSecretKey)
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received
<-done
cancel()
log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {

View File

@@ -0,0 +1,40 @@
package middleware
import (
"net/http"
"time"
"strconv"
"github.com/go-chi/httprate"
"terminalShop/pkg/utils"
)
// RateLimitByIP returns a per-IP rate limiter for public endpoints.
func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Handler {
return httprate.Limit(
limit, window,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}),
)
}
// RateLimitByUser returns a per-authenticated-user rate limiter for protected endpoints.
func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Handler {
return httprate.Limit(
limit, window,
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
userID := UserIDFromContext(r.Context())
if userID == 0 {
// Fall back to IP if no user context (shouldn't happen on protected routes).
return httprate.KeyByIP(r)
}
return "user:" + strconv.FormatUint(uint64(userID), 10), nil
}),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}),
)
}

View File

@@ -1,6 +1,9 @@
package routes
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"terminalShop/api/handlers"
@@ -9,43 +12,74 @@ import (
)
// SetupRoutes configures all API routes
func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string, shippoAPIKey string, bringAPIUID string, bringAPIKey string) *chi.Mux {
func SetupRoutes(
version string,
stripeSecretKey string,
stripeWebhookSecret string,
jwtManager *auth.JWTManager,
authFingerprintKey string,
shippoAPIKey string,
bringAPIUID string,
bringAPIKey string,
appURL string,
) *chi.Mux {
r := chi.NewRouter()
// Apply global middleware
// Global middleware
r.Use(middleware.Logger)
r.Use(middleware.CORS())
r.Use(middleware.Recovery)
r.Use(middleware.Auth(jwtManager))
// Broad IP-level rate limit: 200 req/min per IP across all endpoints.
r.Use(middleware.RateLimitByIP(200, time.Minute))
// Initialize handlers
healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey)
cardHandler := handlers.NewCardHandler(stripeSecretKey)
orderHandler := handlers.NewOrderHandler()
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(stripeSecretKey)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey)
sshKeyHandler := handlers.NewSSHKeyHandler()
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
// API v1 routes
r.Route("/api/v1", func(r chi.Router) {
// Health & testing endpoints
// Health
r.Get("/health", healthHandler.GetHealth)
r.Get("/ping", healthHandler.Ping)
// Authentication endpoints (SSH key-based)
r.Post("/auth/register", authHandler.RegisterWithSSHKey) // POST /api/v1/auth/register
r.Get("/auth/user", authHandler.GetUserBySSHKey) // GET /api/v1/auth/user?fingerprint=xxx
r.Post("/auth/token", authHandler.GetToken)
// Stripe webhooks — unauthenticated, verified via HMAC signature.
// Apply a generous IP rate limit here (Stripe retries from many IPs).
r.With(middleware.RateLimitByIP(60, time.Minute)).
Post("/webhooks/stripe", webhookHandler.HandleStripe)
// Product endpoints (full CRUD)
r.Get("/products", productHandler.GetProducts) // Public
r.Get("/products/{id}", productHandler.GetProduct) // Public
// Auth — stricter IP rate limit to protect against brute-force.
r.Group(func(r chi.Router) {
r.Use(middleware.RateLimitByIP(20, time.Minute))
r.Post("/auth/register", authHandler.RegisterWithSSHKey)
r.Get("/auth/user", authHandler.GetUserBySSHKey)
r.Post("/auth/token", authHandler.GetToken)
})
// Protected routes - requires a valid JWT
// Public product listing
r.Get("/products", productHandler.GetProducts)
r.Get("/products/{id}", productHandler.GetProduct)
// Protected routes — require a valid JWT
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth)
r.Get("/view/init", viewHandler.GetViewInit)
// Cart
r.Route("/cart", func(r chi.Router) {
r.Get("/", cartHandler.GetCart)
@@ -53,19 +87,26 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
r.Put("/address", cartHandler.SetAddress)
r.Put("/card", cartHandler.SetCard)
r.Delete("/", cartHandler.ClearCart)
r.Post("/convert", cartHandler.ConvertCart)
// Tighter per-user limit on the checkout endpoint.
r.With(middleware.RateLimitByUser(10, time.Minute)).
Post("/convert", cartHandler.ConvertCart)
})
// Cards
// Cards — per-user limit on card creation to slow fraud probing.
r.Route("/cards", func(r chi.Router) {
r.Get("/", cardHandler.GetCards)
r.Get("/{id}", cardHandler.GetCard)
r.Post("/", cardHandler.SaveCard)
r.With(middleware.RateLimitByUser(15, time.Minute)).
Post("/", cardHandler.SaveCard)
r.With(middleware.RateLimitByUser(10, time.Minute)).
Post("/collect", cardHandler.CollectCard)
r.Delete("/{id}", cardHandler.DeleteCard)
r.Put("/{id}/default", cardHandler.SetDefaultCard)
})
// Orders
r.Get("/orders", orderHandler.GetOrders)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
// Addresses
r.Route("/addresses", func(r chi.Router) {
@@ -74,6 +115,14 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
r.Delete("/{id}", addressHandler.DeleteAddress)
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)
})
})
})

19
dev.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Load .env to get the Stripe key
set -a; source .env; set +a
# Kill all background jobs on exit
trap 'kill $(jobs -p) 2>/dev/null' EXIT
echo "Starting API server..."
(cd api && go run main.go) &
echo "Starting Stripe webhook listener..."
stripe listen --api-key "$STRIPE_SECRET_KEY" --forward-to localhost:8080/api/v1/webhooks/stripe &
echo "Starting ngrok..."
ngrok http 8080 --log stdout &
echo ""
echo "Starting SSH server (Ctrl+C to stop everything)..."
go run main.go

41
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,41 @@
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
depends_on:
- api
volumes:
caddy_data:
caddy_config:

11
go.mod
View File

@@ -41,9 +41,16 @@ require (
github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c // indirect
github.com/go-chi/httprate v0.15.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -56,7 +63,11 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/stripe/stripe-go/v78 v78.12.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
rsc.io/qr v0.2.0 // indirect
)

23
go.sum
View File

@@ -65,20 +65,34 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -102,6 +116,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -109,11 +124,15 @@ github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0
github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -131,7 +150,11 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

BIN
main

Binary file not shown.

73
main.go
View File

@@ -1,3 +1,4 @@
// version: 0.1.1
package main
import (
@@ -7,6 +8,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -17,48 +19,47 @@ import (
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/tui"
)
const (
host = "localhost"
port = "23456"
)
func listenHost() string {
if h := os.Getenv("SSH_HOST"); h != "" {
return h
}
return "localhost"
}
func listenPort() string {
if p := os.Getenv("SSH_PORT"); p != "" {
return p
}
return port
}
var (
authService *auth.SSHAuthService
apiURL string
authFingerprintKey string
stripePublicKey string
)
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session
pubKey := s.PublicKey()
var user *models.User
var token string
var fingerprint, pubKeyStr string
if pubKey != nil {
// Authenticate with SSH key (auto-creates user on first connect!)
authenticatedUser, jwtToken, err := authService.AuthenticateSSHKey(pubKey)
if err != nil {
log.Printf("Auth error: %v", err)
// Continue as guest
} else {
user = authenticatedUser
token = jwtToken
displayName := user.SSHKeyFingerprint[:16]
if user.Name != "" {
displayName = user.Name
}
log.Printf("User authenticated: %s (ID: %d)", displayName, user.ID)
}
fingerprint = auth.GetSSHKeyFingerprint(pubKey)
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
}
// Create TUI model with user context (no registration screen needed!)
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token, apiURL, authFingerprintKey)
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
@@ -69,33 +70,17 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize database connection (for user management)
// Use same database as API server
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
// Run migrations (includes users table)
if err := database.Migrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
// Initialize JWT manager (uses same secret as API)
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Initialize SSH auth service
authService = auth.NewSSHAuthService(db, jwtManager)
// Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey
stripePublicKey = cfg.StripePublicKey
log.Printf("TUI will connect to API at: %s", apiURL)
// Create SSH server
host := listenHost()
p := listenPort()
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
wish.WithAddress(fmt.Sprintf("%s:%s", host, p)),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
// Allow all public keys (we handle auth in the tea handler)
@@ -113,8 +98,8 @@ func main() {
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)
log.Printf("Starting SSH server on %s:%s", host, p)
log.Printf("Connect with: ssh %s -p %s", host, p)
go func() {
if err = s.ListenAndServe(); err != nil {

View File

@@ -1,710 +0,0 @@
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Account menu items struct.
var accountMenuItems = []string {
"order history",
"faq",
"about",
}
// Coffee represents a coffee product
type Coffee struct {
Name string
RoastType string
Ounces int
BeanType string
Price float64
Color string // Hex color for background
Description string
}
// CartItem represents an item in the cart
type CartItem struct {
Coffee Coffee
Quantity int
}
type model struct {
username string
coffees []Coffee
cursor int
cart map[int]*CartItem // maps coffee index to cart item
cartCursor int // cursor position in cart view
accountCursor int
accountPage string
windowWidth int
windowHeight int
viewingCart bool // true when viewing cart details
viewingAccount bool
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.windowWidth = msg.Width
m.windowHeight = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "s":
m.viewingCart = false
m.viewingAccount = false
m.accountPage = ""
case "c":
m.viewingCart = true
m.viewingAccount = false
m.accountPage = ""
case "a":
m.viewingCart = false
m.viewingAccount = true
case "up", "k":
if m.viewingAccount && m.accountPage == "" {
if m.accountCursor > 0 {
m.accountCursor--
}
} else if !m.viewingCart && !m.viewingAccount && m.cursor > 0 {
m.cursor--
} else if m.viewingCart && m.cartCursor > 0 {
m.cartCursor--
}
case "down", "j":
if m.viewingAccount && m.accountPage == "" {
if m.accountCursor < len(accountMenuItems)-1 {
m.accountCursor++
}
} else if !m.viewingCart && !m.viewingAccount && m.cursor < len(m.coffees)-1 {
m.cursor++
} else if m.viewingCart && m.cartCursor < len(m.cart)-1 {
m.cartCursor++
}
case "+", "=":
if !m.viewingCart {
// Increment quantity in shop view
if item, exists := m.cart[m.cursor]; exists {
item.Quantity++
} else {
m.cart[m.cursor] = &CartItem{
Coffee: m.coffees[m.cursor],
Quantity: 1,
}
}
} else {
// Increment quantity in cart view
cartItems := m.getCartItemsSlice()
if m.cartCursor >= 0 && m.cartCursor < len(cartItems) {
cartItems[m.cartCursor].Quantity++
}
}
case "enter":
if m.viewingAccount && m.accountPage == "" {
switch accountMenuItems[m.accountCursor]{
case "order history":
m.accountPage = "orders"
case "faq":
m.accountPage = "faq"
case "about":
m.accountPage = "about"
}
}
case "esc":
if m.viewingAccount && m.accountPage != "" {
m.accountPage = ""
}
case "-", "_":
if !m.viewingCart {
// Decrement quantity in shop view
if item, exists := m.cart[m.cursor]; exists {
item.Quantity--
if item.Quantity <= 0 {
delete(m.cart, m.cursor)
// Reset cart cursor if we deleted the last item
if len(m.cart) == 0 {
m.cartCursor = 0
} else if m.cartCursor >= len(m.cart) {
m.cartCursor = len(m.cart) - 1
}
}
}
} else {
// Decrement quantity in cart view
cartItems := m.getCartItemsSlice()
if m.cartCursor >= 0 && m.cartCursor < len(cartItems) {
cartItems[m.cartCursor].Quantity--
if cartItems[m.cartCursor].Quantity <= 0 {
// Find and delete this item from the cart
for idx, item := range m.cart {
if item == cartItems[m.cartCursor] {
delete(m.cart, idx)
break
}
}
// Reset cart cursor
if len(m.cart) == 0 {
m.cartCursor = 0
} else if m.cartCursor >= len(m.cart) {
m.cartCursor = len(m.cart) - 1
}
}
}
}
}
}
return m, nil
}
func (m model) View() string {
if m.windowWidth == 0 {
m.windowWidth = 120
m.windowHeight = 30
}
// Build header with cart tab
header := m.buildHeader()
// Build main content based on view
var content string
if m.viewingAccount {
content = m.buildAccountView()
} else if m.viewingCart {
content = m.buildCartView()
} else {
content = m.buildShopView()
}
// Add vertical spacing around content
contentWithPadding := lipgloss.NewStyle().
MarginTop(1).
MarginBottom(4).
Render(content)
// Build footer
footer := m.buildFooter()
// Combine all app content
appContent := lipgloss.JoinVertical(
lipgloss.Left,
header,
contentWithPadding,
footer,
)
// Calculate vertical centering
appHeight := lipgloss.Height(appContent)
verticalPadding := 2 // Minimum padding from top and bottom
availableSpace := m.windowHeight - appHeight - (verticalPadding * 2)
if availableSpace < 0 {
availableSpace = 0
verticalPadding = 0
}
// Split remaining space between top and bottom
topPadding := verticalPadding + (availableSpace / 2)
// Create top spacing
topSpacing := ""
for i := 0; i < topPadding; i++ {
topSpacing += "\n"
}
return topSpacing + appContent
}
func (m model) buildHeader() string {
// Calculate cart total and item count
total := 0.0
itemCount := 0
for _, item := range m.cart {
total += float64(item.Quantity) * item.Coffee.Price
itemCount += item.Quantity
}
// Define colors
whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
// Build shop tab
var shopKeybind, shopName string
if !m.viewingCart {
// Active: bold white keybind and name
shopKeybind = boldWhiteStyle.Render("s")
shopName = boldWhiteStyle.Render("shop")
} else {
// Inactive: bold gray keybind, gray name
shopKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("s")
shopName = grayStyle.Render("shop")
}
// Build cart tab
var cartKeybind, cartName, cartInfo string
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
if m.viewingCart {
// Active: bold white keybind and name
cartKeybind = boldWhiteStyle.Render("c")
cartName = boldWhiteStyle.Render("cart")
cartInfo = fmt.Sprintf(" %s %s",
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)),
itemCountText)
} else {
// Inactive: bold gray keybind, gray name
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
boldWhitePriceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
cartKeybind = boldGrayStyle.Render("c")
cartName = grayStyle.Render("cart")
cartInfo = fmt.Sprintf(" %s %s",
boldWhitePriceStyle.Render(fmt.Sprintf("$%.2f", total)),
itemCountText)
}
// Build Account Tabs
var accountKeybind, accountName string
if m.viewingAccount {
accountKeybind = boldWhiteStyle.Render("a")
accountName = boldWhiteStyle.Render("account")
} else {
accountKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("a")
accountName = grayStyle.Render("account")
}
// Build the tabs with separator only in the middle
separator := whiteStyle.Render("|")
tabsContent := fmt.Sprintf("%s %s %s %s %s%s %s %s %s",
shopKeybind,
shopName,
separator,
cartKeybind,
cartName,
cartInfo,
separator,
accountKeybind,
accountName,
)
// Add box with padding (no vertical padding, just horizontal)
tabBox := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 2).
MarginBottom(1)
// Center the whole thing
centered := lipgloss.NewStyle().
Width(m.windowWidth).
Align(lipgloss.Center)
return centered.Render(tabBox.Render(tabsContent))
}
func (m model) buildShopView() string {
leftWidth := 18 // Narrower list
leftMargin := 4 // Minimal left margin
rightWidth := m.windowWidth - leftWidth - leftMargin - 4
// Build the left panel (coffee list)
leftPanel := ""
for i, coffee := range m.coffees {
// Apply background color when selected, white text
if m.cursor == i {
style := lipgloss.NewStyle().
Background(lipgloss.Color(coffee.Color)).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(coffee.Name) + "\n"
} else {
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(coffee.Name) + "\n"
}
}
// Add left margin to the list
leftContainer := lipgloss.NewStyle().
Width(leftWidth).
MarginLeft(leftMargin)
// Build the detail view (right panel)
detailView := ""
if m.cursor >= 0 && m.cursor < len(m.coffees) {
selectedCoffee := m.coffees[m.cursor]
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
infoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
priceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(selectedCoffee.Color)).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Width(rightWidth - 4)
quantityStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
// Get current quantity
quantity := 0
if item, exists := m.cart[m.cursor]; exists {
quantity = item.Quantity
}
detailView += nameStyle.Render(selectedCoffee.Name) + "\n"
detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s",
selectedCoffee.RoastType,
selectedCoffee.Ounces,
selectedCoffee.BeanType)) + "\n\n"
detailView += priceStyle.Render(fmt.Sprintf("$%.2f", selectedCoffee.Price)) + "\n\n"
detailView += descStyle.Render(selectedCoffee.Description) + "\n\n"
detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n"
}
detailContainer := lipgloss.NewStyle().
Width(rightWidth)
// Layout the panels side by side
mainContent := lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
detailContainer.Render(detailView),
)
return mainContent
}
func (m model) buildCartView() string {
if len(m.cart) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Align(lipgloss.Center).
Width(m.windowWidth).
Padding(4, 0)
return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
}
cartItems := ""
boxWidth := m.windowWidth - 20 // Leave some margin
itemSlice := m.getCartItemsSlice()
for idx, item := range itemSlice {
itemTotal := float64(item.Quantity) * item.Coffee.Price
// Left side: name and info
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
infoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
// Right side: quantity controls and price
quantityStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
priceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
// Build content with proper spacing
nameText := nameStyle.Render(item.Coffee.Name)
infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s",
item.Coffee.RoastType,
item.Coffee.Ounces,
item.Coffee.BeanType))
quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity))
priceText := priceStyle.Render(fmt.Sprintf("$%.2f", itemTotal))
// Calculate spacing
nameWidth := lipgloss.Width(nameText)
quantityWidth := lipgloss.Width(quantityText)
priceWidth := lipgloss.Width(priceText)
spacing := boxWidth - nameWidth - quantityWidth - priceWidth - 8 // 8 for padding and gaps
if spacing < 1 {
spacing = 1
}
contentLine1 := nameText +
lipgloss.NewStyle().Width(spacing).Render("") +
quantityText + " " + priceText
contentLine2 := infoText
boxContent := contentLine1 + "\n" + contentLine2
// Create box for this item
itemBox := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 2).
Width(boxWidth).
Foreground(lipgloss.Color("#FFFFFF"))
// Center the box
centered := lipgloss.NewStyle().
Width(m.windowWidth).
Align(lipgloss.Center)
cartItems += centered.Render(itemBox.Render(boxContent))
// Add spacing between items
if idx < len(itemSlice)-1 {
cartItems += "\n\n"
}
}
return cartItems
}
func (m model) buildAccountView() string {
// If we're on a subpage, show that content
if m.accountPage != "" {
return m.buildAccountSubpage()
}
// Otherwise show the account menu
leftWidth := 18
leftMargin := 4
leftPanel := ""
for i, item := range accountMenuItems {
// Apply background color when selected, white text
if m.accountCursor == i {
style := lipgloss.NewStyle().
Background(lipgloss.Color("#4682B4")).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
} else {
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
leftContainer := lipgloss.NewStyle().
Width(leftWidth).
MarginLeft(leftMargin)
return leftContainer.Render(leftPanel)
}
func (m model) buildAccountSubpage() string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(2)
contentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Width(m.windowWidth - 20).
MarginLeft(4)
var content string
switch m.accountPage {
case "orders":
content = titleStyle.Render("Order History") + "\n\n" +
contentStyle.Render("No orders yet. Your order history will appear here once you make a purchase.")
case "faq":
content = titleStyle.Render("FAQ") + "\n\n" +
contentStyle.Render("Q: How do I place an order?\nA: Browse products with j/k, adjust quantity with +/-, and checkout via the cart.\n\nQ: What payment methods do you accept?\nA: We accept all major credit cards via Stripe.\n\nPress ESC to go back.")
case "about":
content = titleStyle.Render("About Terminal Coffee Shop") + "\n\n" +
contentStyle.Render("A terminal-based coffee ordering experience.\nBuilt with Go and Bubbletea.\n\nPress ESC to go back.")
default:
content = "Unknown page"
}
return content
}
// getCartItemsSlice converts the cart map to a sorted slice for consistent iteration
func (m model) getCartItemsSlice() []*CartItem {
items := make([]*CartItem, 0, len(m.cart))
for _, item := range m.cart {
items = append(items, item)
}
return items
}
func (m model) buildFooter() string {
// Style for keybinds (bold)
keybindStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Bold(true)
// Style for descriptions (normal)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
var footerText string
if m.viewingAccount {
if m.accountPage != "" {
// In a subpage
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
// In account menu
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("menu"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else if m.viewingCart {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("products"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
return lipgloss.NewStyle().
Align(lipgloss.Center).
Width(m.windowWidth).
Render(footerText)
}
// InitialModel creates a new model with default coffee options
func InitialModel(username string) model {
return model{
username: username,
coffees: []Coffee{
{
Name: "Espresso",
RoastType: "Dark Roast",
Ounces: 2,
BeanType: "Arabica",
Price: 3.50,
Color: "#8B4513",
Description: "A bold, concentrated shot of pure coffee bliss. Perfect for those who need an immediate caffeine injection to survive the day.",
},
{
Name: "Latte",
RoastType: "Medium Roast",
Ounces: 12,
BeanType: "Arabica Blend",
Price: 5.00,
Color: "#D2691E",
Description: "Smooth espresso paired with steamed milk and a light layer of foam. For when you want coffee but also want to feel fancy about it.",
},
{
Name: "Cappuccino",
RoastType: "Medium Roast",
Ounces: 8,
BeanType: "Italian Blend",
Price: 4.50,
Color: "#CD853F",
Description: "Equal parts espresso, steamed milk, and foam. The classic Italian choice for people who know what they're doing.",
},
{
Name: "Americano",
RoastType: "Dark Roast",
Ounces: 16,
BeanType: "Arabica",
Price: 4.00,
Color: "#A0522D",
Description: "Espresso with hot water. Simple, strong, and no-nonsense. This is coffee for people who actually like the taste of coffee.",
},
{
Name: "Cold Brew",
RoastType: "Medium Roast",
Ounces: 16,
BeanType: "Colombian",
Price: 5.50,
Color: "#4682B4",
Description: "Steeped for 12 hours to create a smooth, less acidic coffee experience. Served cold because sometimes hot coffee is just too much effort.",
},
{
Name: "Macchiato",
RoastType: "Dark Roast",
Ounces: 3,
BeanType: "Robusta Blend",
Price: 4.25,
Color: "#DAA520",
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
},
},
cursor: 0,
cart: make(map[int]*CartItem),
cartCursor: 0,
accountCursor: 0,
accountPage: "",
windowWidth: 120,
windowHeight: 30,
viewingCart: false,
viewingAccount: false,
}
}

View File

@@ -39,6 +39,7 @@ func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
// TokenRequest represents a request to exchange SSH fingerprint for JWT.
type TokenRequest struct {
Fingerprint string `json:"fingerprint"`
SSHPublicKey string `json:"ssh_public_key"`
ClientSecret string `json:"client_secret"`
}
@@ -46,54 +47,60 @@ type TokenRequest struct {
type TokenResponse struct {
Success bool `json:"success"`
Data struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User models.PublicUser `json:"user"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// RefreshToken exchanges an SSH fingerprint and client secret for a new JWT.
// It does not update c.Token — the caller is responsible for that.
func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) {
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{
Fingerprint: fingerprint,
SSHPublicKey: pubKeyStr,
ClientSecret: clientSecret,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal token request: %w", err)
return "", models.PublicUser{}, fmt.Errorf("failed to marshal token request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create token request: %w", err)
return "", models.PublicUser{}, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("token refresh request failed: %w", err)
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token refresh returned status %d", resp.StatusCode)
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
return "", models.PublicUser{}, fmt.Errorf("failed to decode token response: %w", err)
}
if !tokenResp.Success || tokenResp.Data.AccessToken == "" {
return "", fmt.Errorf("token refresh failed: no access token in response")
return "", models.PublicUser{}, fmt.Errorf("token refresh failed: no access token in response")
}
return tokenResp.Data.AccessToken, nil
return tokenResp.Data.AccessToken, tokenResp.Data.User, nil
}
func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) {
token, _, err := c.GetOrCreateToken(fingerprint, "", clientSecret)
return token, err
}
// APIResponse represents the standard API response format
@@ -520,12 +527,18 @@ func (c *Client) GetCards() ([]models.Card, error) {
return cardsResp.Data.Cards, nil
}
// SaveCard creates a saved card from a Stripe token.
func (c *Client) SaveCard(token string) (*models.Card, error) {
// SaveCardRequest holds the raw card fields sent to the backend for
// server-side tokenization. The Stripe secret key never leaves the server.
type SaveCardRequest struct {
Token string `json:"token"`
}
// SaveCard sends raw card fields to the backend, which tokenizes them
// server-side and returns the saved card metadata.
func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
body := map[string]string{"token": token}
jsonData, err := json.Marshal(body)
jsonData, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
@@ -557,6 +570,42 @@ func (c *Client) SaveCard(token string) (*models.Card, error) {
return &cardResp.Data.Card, nil
}
type CollectCardResponse struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) CollectCard() (string, error) {
url := fmt.Sprintf("%s/api/v1/cards/collect", c.BaseURL)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err)
}
defer resp.Body.Close()
var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
return "", fmt.Errorf("failed to decode collect response: %w", err)
}
if !collectResp.Success {
if collectResp.Error != nil {
return "", fmt.Errorf("%s: %s", collectResp.Error.Code, collectResp.Error.Message)
}
return "", fmt.Errorf("failed to collect card")
}
return collectResp.Data.URL, nil
}
// DeleteCard removes a saved card by ID.
func (c *Client) DeleteCard(id uint) error {
url := fmt.Sprintf("%s/api/v1/cards/%d", c.BaseURL, id)
@@ -579,6 +628,64 @@ func (c *Client) DeleteCard(id uint) error {
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 {
Success bool `json:"success"`
Data struct {
@@ -728,3 +835,42 @@ func (c *Client) DeleteAddress(id uint) error {
return nil
}
type ViewInitData struct {
User models.PublicUser `json:"user"`
Products []models.Coffee `json:"products"`
Cart []CartItemData `json:"cart"`
Addresses []models.Address `json:"addresses"`
Cards []models.Card `json:"cards"`
SSHKeys []models.SSHKey `json:"ssh_keys"`
Orders []models.Order `json:"orders"`
}
type ViewInitResponse struct {
Success bool `json:"success"`
Data ViewInitData `json:"data"`
Error *APIError `json:"error"`
}
// GetViewInit fetches all initial data in one request
func (c *Client) GetViewInit() (ViewInitData, error) {
url := fmt.Sprintf("%s/api/v1/view/init", c.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return ViewInitData{}, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
}
var viewResp ViewInitResponse
if err := json.NewDecoder(resp.Body).Decode(&viewResp); err != nil {
return ViewInitData{}, fmt.Errorf("failed to decode view init response: %w", err)
}
return viewResp.Data, nil
}

124
pkg/audit/audit.go Normal file
View File

@@ -0,0 +1,124 @@
// Package audit writes structured JSON logs for financial events.
// Every card addition, order creation, payment, and refund is recorded here.
// Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.).
package audit
import (
"encoding/json"
"log"
"time"
)
type eventType string
const (
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderFailed eventType = "order_failed"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
)
type event struct {
Time time.Time `json:"time"`
Event eventType `json:"event"`
UserID uint `json:"user_id,omitempty"`
OrderID uint `json:"order_id,omitempty"`
CardID uint `json:"card_id,omitempty"`
Amount int `json:"amount_cents,omitempty"`
StripeID string `json:"stripe_id,omitempty"`
CardLast4 string `json:"card_last4,omitempty"`
CardBrand string `json:"card_brand,omitempty"`
Error string `json:"error,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
func emit(e event) {
e.Time = time.Now().UTC()
b, err := json.Marshal(e)
if err != nil {
log.Printf("[audit] failed to marshal event: %v", err)
return
}
log.Printf("[AUDIT] %s", b)
}
// CardAdded records that a user saved a new payment method.
func CardAdded(userID, cardID uint, brand, last4 string) {
emit(event{
Event: eventCardAdded,
UserID: userID,
CardID: cardID,
CardBrand: brand,
CardLast4: last4,
})
}
// CardDeleted records that a user removed a saved payment method.
func CardDeleted(userID, cardID uint) {
emit(event{
Event: eventCardDeleted,
UserID: userID,
CardID: cardID,
})
}
// OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) {
emit(event{
Event: eventOrderCreated,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
})
}
// OrderPaid records a successful charge.
func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) {
emit(event{
Event: eventOrderPaid,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
StripeID: stripePaymentIntentID,
})
}
// OrderFailed records a failed charge attempt.
func OrderFailed(userID, orderID uint, amountCents int, reason string) {
emit(event{
Event: eventOrderFailed,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
Error: reason,
})
}
// OrderRefunded records a refund.
func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) {
emit(event{
Event: eventOrderRefunded,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
StripeID: stripeRefundID,
})
}
// PaymentCritical records the dangerous state where Stripe charged the card
// but the local database transaction failed. This MUST be investigated and
// manually reconciled. Emit a loud log line that monitoring can alert on.
func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
emit(event{
Event: eventPaymentCritical,
OrderID: orderID,
StripeID: stripePaymentIntentID,
Error: err.Error(),
})
// Second line so it's impossible to miss in plain-text log tails.
log.Printf("[AUDIT][CRITICAL] order %d was charged (pi=%s) but DB commit failed: %v — REQUIRES MANUAL RECONCILIATION",
orderID, stripePaymentIntentID, err)
}

View File

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

View File

@@ -1,29 +1,34 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"github.com/joho/godotenv"
)
// Config holds all application configuration
type Config struct {
APIPort string
APIURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
}
// Load reads configuration from environment variables
// Load reads configuration from environment variables and validates required secrets.
func Load() (*Config, error) {
// Load .env file in non-production environments.
// Try current directory first, then parent directory (for when
@@ -37,24 +42,80 @@ func Load() (*Config, error) {
}
apiPort := getEnvOrDefault("API_PORT", "8080")
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
cfg := &Config{
APIPort: apiPort,
APIURL: getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
APIPort: apiPort,
APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// validate checks that all required secrets are present and well-formed.
// In production, it is strict; in development it warns but does not fail.
func (c *Config) validate() error {
type check struct {
name string
value string
}
required := []check{
{"JWT_SECRET", c.JWTSecret},
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
}
var missing []string
for _, ch := range required {
if strings.TrimSpace(ch.value) == "" {
missing = append(missing, ch.name)
}
}
if len(missing) > 0 {
msg := fmt.Sprintf("missing required environment variables: %s", strings.Join(missing, ", "))
if c.Environment == "production" {
return errors.New(msg)
}
// Development: warn but continue so the server is still runnable without full config.
fmt.Printf("WARNING: %s\n", msg)
}
// Warn if the Stripe key looks like a test key in production.
if c.Environment == "production" && strings.HasPrefix(c.StripeSecretKey, "sk_test_") {
fmt.Println("WARNING: STRIPE_SECRET_KEY is a test key in a production environment")
}
return nil
}
// SafeDatabaseURL returns the database URL with the password masked for logging.
func (c *Config) SafeDatabaseURL() string {
parsed, err := url.Parse(c.DatabaseURL)
if err != nil || parsed.User == nil {
return c.DatabaseURL
}
if _, hasPassword := parsed.User.Password(); hasPassword {
parsed.User = url.UserPassword(parsed.User.Username(), "***")
}
return parsed.String()
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value

View File

@@ -3,8 +3,11 @@ package database
import (
"fmt"
"log"
"strings"
"sync"
"time"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -15,23 +18,42 @@ var (
once sync.Once
)
// Connect initializes the database connection (singleton pattern)
func Connect(dbPath string) (*gorm.DB, error) {
// Connect initializes the database connection (singleton pattern).
// If dsn starts with postgres:// or postgresql://, connects to PostgreSQL.
// Otherwise falls back to SQLite (development only).
func Connect(dsn string) (*gorm.DB, error) {
var err error
once.Do(func() {
// Configure GORM logger for development
config := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: logger.Default.LogMode(logger.Warn),
}
// Connect to SQLite database
db, err = gorm.Open(sqlite.Open(dbPath), config)
isPostgres := strings.HasPrefix(dsn, "postgres://") ||
strings.HasPrefix(dsn, "postgresql://")
if isPostgres {
db, err = gorm.Open(postgres.Open(dsn), config)
} else {
db, err = gorm.Open(sqlite.Open(dsn), config)
}
if err != nil {
err = fmt.Errorf("failed to connect to database: %w", err)
return
}
// Configure connection pool for PostgreSQL.
if isPostgres {
sqlDB, poolErr := db.DB()
if poolErr != nil {
err = fmt.Errorf("failed to get sql.DB: %w", poolErr)
return
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
}
log.Println("Database connection established")
})

View File

@@ -108,7 +108,7 @@ func TestSeed(t *testing.T) {
t.Errorf("Unexpected product name: %s", p.Name)
}
if p.Price <= 0 {
t.Errorf("Product %s has invalid price: %f", p.Name, p.Price)
t.Errorf("Product %s has invalid price: %d", p.Name, p.Price)
}
}
}

View File

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

View File

@@ -27,7 +27,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 2,
BeanType: "Arabica",
Price: 3.50,
Price: 350, // $3.50
Color: "#8B4513",
Description: "Bold and intense coffee with a rich crema",
},
@@ -36,7 +36,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 8,
BeanType: "Arabica",
Price: 4.00,
Price: 400, // $4.00
Color: "#A0522D",
Description: "Smooth and balanced espresso diluted with hot water",
},
@@ -45,7 +45,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 6,
BeanType: "Arabica",
Price: 4.50,
Price: 450, // $4.50
Color: "#C4A574",
Description: "Espresso with steamed milk and a thick layer of foam",
},
@@ -54,7 +54,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Medium Roast",
Ounces: 12,
BeanType: "Arabica",
Price: 5.00,
Price: 500, // $5.00
Color: "#D2B48C",
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
},
@@ -63,7 +63,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 12,
BeanType: "Arabica",
Price: 5.50,
Price: 550, // $5.50
Color: "#8B4513",
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
},
@@ -72,7 +72,7 @@ func Seed(db *gorm.DB) error {
RoastType: "Dark Roast",
Ounces: 3,
BeanType: "Robusta Blend",
Price: 4.25,
Price: 425, // $4.25
Color: "#DAA520",
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
},

View File

@@ -11,7 +11,7 @@ type Cart struct {
CardID *uint `json:"card_id,omitempty"`
ShippingCost int `gorm:"default:0" json:"shipping_cost"`
ShippingService string `gorm:"size:255" json:"shipping_service,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"-"`

View File

@@ -13,7 +13,7 @@ type Coffee struct {
RoastType string `gorm:"size:100" json:"roast_type"`
Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"`
Price float64 `gorm:"not null" json:"price"`
Price int `gorm:"not null" json:"price"`
Color string `gorm:"size:7" json:"color"` // Hex color for background
Description string `gorm:"type:text" json:"description"`
CreatedAt time.Time `json:"created_at"`
@@ -26,11 +26,12 @@ func (Coffee) TableName() string {
return "coffees"
}
// AccountMenuItems something
// AccountMenuItems
var AccountMenuItems = []string{
"order history",
"addresses",
"cards",
"ssh keys",
"faq",
"about",
"something",
}

View File

@@ -16,6 +16,7 @@ const (
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
)
// Order represents a completed purchase.

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

@@ -1,9 +1,9 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
@@ -16,34 +16,22 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Build the left panel (menu items)
leftPanel := ""
for i, item := range models.AccountMenuItems {
if m.AccountCursor == i {
style := lipgloss.NewStyle().
Background(lipgloss.Color("#4682B4")).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
if m.account.cursor == i {
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
} else {
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
leftContainer := lipgloss.NewStyle().
leftContainer := m.theme.Base().
Width(leftWidth)
// Build the detail view (right panel) based on cursor position
detailView := ""
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(2)
if m.account.cursor >= 0 && m.account.cursor < len(models.AccountMenuItems) {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
// On small terminals, use full content width
detailContentWidth := rightWidth - 2
@@ -51,48 +39,20 @@ func (m Model) BuildAccountView(availableHeight int) string {
detailContentWidth = m.widthContent - 2
}
contentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Width(detailContentWidth)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
selectedItem := models.AccountMenuItems[m.AccountCursor]
selectedItem := models.AccountMenuItems[m.account.cursor]
switch selectedItem {
case "order history":
if !m.OrdersLoaded {
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
} else if len(m.Orders) == 0 {
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.")
} else if m.OrderViewState == 2 {
// State 2: full detail for the selected order
detailView = m.buildOrderDetailView(m.Orders[m.OrderCursor], detailContentWidth)
} else {
// State 0 (preview) or State 1 (browsing with cursor)
boxWidth := detailContentWidth - 4
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
lines := titleStyle.Render("Order History") + "\n\n"
for i, order := range m.Orders {
isSelected := m.OrderViewState == 1 && i == m.OrderCursor
lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, detailContentWidth)
if i < len(m.Orders)-1 {
lines += "\n"
}
}
if m.OrderViewState == 0 {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
lines += "\n" + hintStyle.Render("enter: browse orders")
}
detailView = lines
}
detailView = m.OrdersView(detailContentWidth)
case "addresses":
detailView = m.AddressesView(detailContentWidth)
case "cards":
detailView = m.CardsView(detailContentWidth)
case "ssh keys":
detailView = m.SSHKeysView(detailContentWidth)
case "faq":
questionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
questionStyle := m.theme.TextHighlight().Bold(true)
faqContent := ""
for i, faq := range m.FAQs {
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
@@ -102,39 +62,18 @@ func (m Model) BuildAccountView(availableHeight int) string {
}
}
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.FaqFocused {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
if !m.account.faqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
}
case "about":
accentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
userStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("C6DDF0")).
Bold(true)
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
detailContentWidth,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
detailView = titleStyle.Render("About") + "\n\n" + aboutContent
detailView = m.AboutView(detailContentWidth)
}
}
// Apply internal scrolling to just the detail view
// 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")
totalLines := len(detailLines)
@@ -149,7 +88,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
}
// Clamp scroll offset locally (don't mutate m since value receiver)
offset := m.ScrollOffset
offset := m.account.scrollOffset
maxScroll := totalLines - scrollHeight
if maxScroll < 0 {
maxScroll = 0
@@ -192,164 +131,31 @@ func (m Model) BuildAccountView(availableHeight int) string {
)
}
// buildOrderCard renders a single order as a bordered box for the order list.
// Shows order number, date, status, total, and the first 2 items as a preview.
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
accentStyle := m.theme.TextHighlight().Bold(true)
userStyle := m.theme.TextAccent().Bold(true)
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
width,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += contentStyle.Render(wordWrap(
"This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
width,
)) + "\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
statusStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
return titleStyle.Render("About") + "\n\n" + aboutContent
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
rightSide := date + " " + status + " " + total
rightWidth := lipgloss.Width(rightSide)
leftWidth := lipgloss.Width(orderLabel)
spacing := boxWidth - leftWidth - rightWidth - 4 // 4 for padding
if spacing < 1 {
spacing = 1
}
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + rightSide
// Item preview lines (show first 2 items)
var lines []string
lines = append(lines, line1)
previewCount := 2
if len(order.Items) < previewCount {
previewCount = len(order.Items)
}
for i := 0; i < previewCount; i++ {
item := order.Items[i]
itemLine := dimStyle.Render(fmt.Sprintf("%dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0))
lines = append(lines, itemLine)
}
if len(order.Items) > 2 {
extra := len(order.Items) - 2
moreText := "item"
if extra > 1 {
moreText = "items"
}
lines = append(lines, dimStyle.Render(fmt.Sprintf("+ %d more %s", extra, moreText)))
}
boxContent := strings.Join(lines, "\n")
// Create bordered box — highlight if selected
var itemBox lipgloss.Style
if isSelected {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(lipgloss.Color("#FFFFFF")).
Padding(boxPadding, 2).
Width(boxWidth)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")).
Padding(boxPadding, 2).
Width(boxWidth)
}
centered := lipgloss.NewStyle().
Width(containerWidth).
Align(lipgloss.Center)
return centered.Render(itemBox.Render(boxContent))
}
// buildOrderDetailView renders the full detail view for a single order.
func (m Model) buildOrderDetailView(order models.Order, width int) string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(1)
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
var b strings.Builder
// Header
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
b.WriteString("\n\n")
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
b.WriteString("\n")
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
b.WriteString("\n\n")
// Items
b.WriteString(labelStyle.Render("Items"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
for _, item := range order.Items {
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
b.WriteString(valueStyle.Render(line))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
// Totals
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingName))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
b.WriteString("\n")
if order.ShippingStreet2 != "" {
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
b.WriteString("\n")
}
cityLine := order.ShippingCity
if order.ShippingState != "" {
cityLine += ", " + order.ShippingState
}
cityLine += " " + order.ShippingZip
b.WriteString(valueStyle.Render(" " + cityLine))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
b.WriteString("\n\n")
// Footer hint
b.WriteString(dimStyle.Render("esc: back to orders"))
return b.String()
}
// computeFaqScrollMax calculates the maximum scroll offset for the FAQ content.
@@ -363,12 +169,8 @@ func (m Model) computeFaqScrollMax() int {
detailContentWidth = m.widthContent - 2
}
questionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
contentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Width(detailContentWidth)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := ""
for i, faq := range m.FAQs {
@@ -379,10 +181,7 @@ func (m Model) computeFaqScrollMax() int {
}
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(2)
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
totalLines := len(strings.Split(detailView, "\n"))
@@ -401,3 +200,267 @@ func (m Model) computeFaqScrollMax() int {
}
return maxScroll
}
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
selectedItem := ""
if m.account.cursor < len(models.AccountMenuItems) {
selectedItem = models.AccountMenuItems[m.account.cursor]
}
switch keyMsg.String() {
case "up", "k":
if m.account.addressListFocused && m.account.addressDeleting == nil {
if m.account.addressCursor > 0 {
m.account.addressCursor--
}
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
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
if m.heightContainer >= 25 {
cardHeight = 7
}
targetTop := 3 + m.account.orderCursor*cardHeight
if targetTop < m.account.scrollOffset {
m.account.scrollOffset = targetTop
}
} else if m.account.orderViewState == 0 && m.account.cursor > 0 {
m.account.cursor--
m.account.scrollOffset = 0
}
case "down", "j":
if m.account.addressListFocused && m.account.addressDeleting == nil {
if m.account.addressCursor < len(m.SavedAddresses)-1 {
m.account.addressCursor++
}
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
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
if m.heightContainer >= 25 {
cardHeight = 7
}
viewportHeight := m.heightContainer - 7
if m.size < large {
viewportHeight -= len(models.AccountMenuItems) + 1
}
if viewportHeight < 5 {
viewportHeight = 5
}
targetBottom := 3 + (m.account.orderCursor+1)*cardHeight
if targetBottom > m.account.scrollOffset+viewportHeight {
m.account.scrollOffset = targetBottom - viewportHeight
}
} else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && !m.account.sshKeyListFocused && m.account.cursor < len(models.AccountMenuItems)-1 {
m.account.cursor++
m.account.scrollOffset = 0
}
case "pgup", "ctrl+u":
if m.account.faqFocused || m.account.orderViewState >= 2 {
m.account.scrollOffset -= 3
if m.account.scrollOffset < 0 {
m.account.scrollOffset = 0
}
}
case "pgdown", "ctrl+d":
if m.account.faqFocused || m.account.orderViewState >= 2 {
m.account.scrollOffset += 3
maxScroll := m.computeFaqScrollMax()
if m.account.faqFocused && maxScroll >= 0 && m.account.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":
switch selectedItem {
case "order history":
if len(m.Orders) > 0 {
if m.account.orderViewState == 0 {
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.scrollOffset = 0
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else if m.account.orderViewState == 1 {
m.account.orderViewState = 2
m.account.scrollOffset = 0
m.footer = []footerCommand{
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "q", value: "quit"},
}
}
}
case "ssh keys":
if !m.account.sshKeyListFocused {
m.account.sshKeyListFocused = true
m.account.sshKeyCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "keys"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "addresses":
if !m.account.addressListFocused {
m.account.addressListFocused = true
m.account.addressCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "cards":
if !m.account.cardListFocused {
m.account.cardListFocused = true
m.account.cardCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
m.account.scrollOffset = 0
m.footer = []footerCommand{
{key: "j/k", value: "scroll"},
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "q", value: "quit"},
}
}
}
case "esc":
accountDefaultFooter := []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
if m.account.addressDeleting != nil {
m.account.addressDeleting = nil
} else if m.account.addressListFocused {
m.account.addressListFocused = false
m.account.addressCursor = 0
m.footer = accountDefaultFooter
} else if m.account.cardDeleting != nil {
m.account.cardDeleting = nil
} else if m.account.cardListFocused {
m.account.cardListFocused = false
m.account.cardCursor = 0
m.footer = accountDefaultFooter
} else if m.account.sshKeyDeleting != nil {
m.account.sshKeyDeleting = nil
} else if m.account.sshKeyListFocused {
m.account.sshKeyListFocused = false
m.account.sshKeyCursor = 0
m.footer = accountDefaultFooter
} else if m.account.faqFocused {
m.account.faqFocused = false
m.account.scrollOffset = 0
m.footer = accountDefaultFooter
} else if m.account.orderViewState > 0 {
m.account.orderViewState--
m.account.scrollOffset = 0
if m.account.orderViewState == 1 {
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else {
m.footer = accountDefaultFooter
}
}
}
return m, nil
}

View File

@@ -0,0 +1,35 @@
package tui
func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
activeStyle := m.theme.TextAccent().Bold(true)
contentStyle := m.theme.TextBody().Width(width)
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) > width-4 {
label = label[:width-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 += activeStyle.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")
}
return lines
}

36
pkg/tui/account_cards.go Normal file
View File

@@ -0,0 +1,36 @@
package tui
import (
"fmt"
)
func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
activeStyle := m.theme.TextAccent().Bold(true)
lines := titleSTyle.Render("Cards") + "\n\n"
if m.SavedCardsIsEmpty() {
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/s)") + "\n"
} else if isSelected {
lines += activeStyle.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")
}
return lines
}

194
pkg/tui/account_orders.go Normal file
View File

@@ -0,0 +1,194 @@
package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) OrdersView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {
return titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
}
if len(m.Orders) == 0 {
return titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet")
}
if m.account.orderViewState == 2 {
return m.buildOrderDetailView(m.Orders[m.account.orderCursor], width)
}
boxWidth := width - 4
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
lines := titleStyle.Render("Order History") + "\n\n"
for i, order := range m.Orders {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, width)
if i < len(m.Orders)-1 {
lines += "\n"
}
}
if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders")
}
return lines
}
// buildOrderCard renders a single order as a bordered box for the order list.
// Shows order number, date, status, total, and the first 2 items as a preview.
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
nameStyle := m.theme.TextAccent().Bold(true)
dimStyle := m.theme.TextDim()
statusStyle := m.theme.TextHighlight().Bold(true)
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
rightSide := date + " " + status + " " + total
rightWidth := lipgloss.Width(rightSide)
leftWidth := lipgloss.Width(orderLabel)
spacing := boxWidth - leftWidth - rightWidth - 4 // 4 for padding
if spacing < 1 {
spacing = 1
}
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + rightSide
// Item preview lines (show first 2 items)
var lines []string
lines = append(lines, line1)
previewCount := 2
if len(order.Items) < previewCount {
previewCount = len(order.Items)
}
for i := 0; i < previewCount; i++ {
item := order.Items[i]
itemLine := dimStyle.Render(fmt.Sprintf("%dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0))
lines = append(lines, itemLine)
}
if len(order.Items) > 2 {
extra := len(order.Items) - 2
moreText := "item"
if extra > 1 {
moreText = "items"
}
lines = append(lines, dimStyle.Render(fmt.Sprintf("+ %d more %s", extra, moreText)))
}
boxContent := strings.Join(lines, "\n")
// Create bordered box — highlight if selected
var itemBox lipgloss.Style
if isSelected {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth)
}
centered := lipgloss.NewStyle().
Width(containerWidth).
Align(lipgloss.Center)
return centered.Render(itemBox.Render(boxContent))
}
// buildOrderDetailView renders the full detail view for a single order
func (m Model) buildOrderDetailView(order models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
labelStyle := m.theme.TextHighlight().Bold(true)
valueStyle := m.theme.TextBody()
dimStyle := m.theme.TextDim()
var b strings.Builder
// Header
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
b.WriteString("\n\n")
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
b.WriteString("\n")
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
b.WriteString("\n\n")
// Items
b.WriteString(labelStyle.Render("Items"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
for _, item := range order.Items {
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
b.WriteString(valueStyle.Render(line))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
// Totals
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingName))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
b.WriteString("\n")
if order.ShippingStreet2 != "" {
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
b.WriteString("\n")
}
cityLine := order.ShippingCity
if order.ShippingState != "" {
cityLine += ", " + order.ShippingState
}
cityLine += " " + order.ShippingZip
b.WriteString(valueStyle.Render(" " + cityLine))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
b.WriteString("\n\n")
// Footer hint
b.WriteString(dimStyle.Render("esc: back to orders"))
return b.String()
}

View File

@@ -0,0 +1,41 @@
package tui
func (m Model) SSHKeysView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
activeStyle := m.theme.TextAccent().Bold(true)
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) > width-4 {
label += label[:width-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 += activeStyle.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")
}
return lines
}

View File

@@ -6,19 +6,13 @@ import (
func (m Model) BuildBreadcrumbs() string {
// Only show breadcrumbs in cart view
if !m.ViewingCart {
if !m.inCartFlow() {
return ""
}
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
inactiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
sepStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := m.theme.TextDim()
sepStyle := m.theme.TextDim()
sep := sepStyle.Render(" / ")
@@ -31,7 +25,7 @@ func (m Model) BuildBreadcrumbs() string {
}
// Get current checkout step from model
currentStep := m.CheckoutStep
currentStep := m.checkoutStep()
var items []string
for i, label := range labels {

View File

@@ -3,13 +3,33 @@ package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildCartView() string {
if len(m.Cart) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
func (m Model) updateCartViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.cart.viewportReady {
m.cart.viewport = viewport.New(m.widthContent, availH)
m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true
} else {
m.cart.viewport.Width = m.widthContent
m.cart.viewport.Height = availH
}
return m
}
func (m Model) generateCartContent() string {
if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim().
Align(lipgloss.Center).
Width(m.widthContent).
Padding(2, 0)
@@ -17,53 +37,42 @@ func (m Model) BuildCartView() string {
}
cartItems := ""
boxWidth := m.widthContent - 4 // Leave some margin within the container
boxWidth := m.widthContent - 4
itemSlice := m.GetCartItemsSlice()
// Adjust padding based on container height
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
for idx, item := range itemSlice {
itemTotal := float64(item.Quantity) * item.Coffee.Price
itemTotal := item.Quantity * item.Coffee.Price
// Left side: name and individual price
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
nameStyle := m.theme.TextAccent().Bold(true)
individualPriceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(item.Coffee.Color)).
Bold(true)
infoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
infoStyle := m.theme.TextBody()
// Right side: quantity controls and total price
quantityStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
quantityStyle := m.theme.TextAccent()
totalPriceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
totalPriceStyle := m.theme.TextAccent().Bold(true)
// Build content with proper spacing
nameText := nameStyle.Render(item.Coffee.Name)
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", item.Coffee.Price))
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100))
infoText := infoStyle.Render(fmt.Sprintf("%s | %doz | %s",
item.Coffee.RoastType,
item.Coffee.Ounces,
item.Coffee.BeanType))
quantityText := quantityStyle.Render(fmt.Sprintf("- %d +", item.Quantity))
totalPriceText := totalPriceStyle.Render(fmt.Sprintf("$%.2f", itemTotal))
totalPriceText := totalPriceStyle.Render(fmt.Sprintf("$%.2f", float64(itemTotal)/100))
// Calculate spacing
nameAndPriceWidth := lipgloss.Width(nameText) + lipgloss.Width(individualPriceText)
quantityWidth := lipgloss.Width(quantityText)
totalPriceWidth := lipgloss.Width(totalPriceText)
spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8 // 8 for padding and gaps
spacing := boxWidth - nameAndPriceWidth - quantityWidth - totalPriceWidth - 8
if spacing < 1 {
spacing = 1
@@ -77,37 +86,102 @@ func (m Model) BuildCartView() string {
boxContent := contentLine1 + "\n" + contentLine2
// Create box for this item - highlight if it's the selected item
var itemBox lipgloss.Style
if idx == m.CartCursor {
if idx == m.cart.cursor {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(lipgloss.Color("#FFFFFF")).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth).
Foreground(lipgloss.Color("#FFFFFF")).
Foreground(m.theme.Accent()).
Bold(true)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth).
Foreground(lipgloss.Color("#FFFFFF"))
Foreground(m.theme.Accent())
}
// Center the box within the container
centered := lipgloss.NewStyle().
Width(m.widthContent).
Align(lipgloss.Center)
cartItems += centered.Render(itemBox.Render(boxContent))
// Add spacing between items
if idx < len(itemSlice)-1 {
cartItems += "\n"
}
}
return cartItems
}
func (m Model) CartView() string {
if !m.cart.viewportReady {
m = m.updateCartViewport()
}
content := m.generateCartContent()
m.cart.viewport.SetContent(content)
if len(m.Cart) > 0 {
itemHeight := 5
targetY := m.cart.cursor * itemHeight
if targetY < m.cart.viewport.YOffset {
m.cart.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
}
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.cart.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.cart.viewport.View(),
)
}
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "up", "k":
if m.cart.cursor > 0 {
m.cart.cursor--
}
case "down", "j":
if m.cart.cursor < len(m.Cart)-1 {
m.cart.cursor++
}
case "+", "=":
cartItems := m.GetCartItemsSlice()
if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
cartItems[m.cart.cursor].Quantity++
return m, m.syncCartItemCmd(cartItems[m.cart.cursor].CoffeeID, cartItems[m.cart.cursor].Quantity)
}
case "-", "_":
cartItems := m.GetCartItemsSlice()
if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
coffeeID := cartItems[m.cart.cursor].CoffeeID
cartItems[m.cart.cursor].Quantity--
newQty := cartItems[m.cart.cursor].Quantity
if cartItems[m.cart.cursor].Quantity <= 0 {
delete(m.Cart, coffeeID)
if len(m.Cart) == 0 {
m.cart.cursor = 0
} else if m.cart.cursor >= len(m.Cart) {
m.cart.cursor = len(m.Cart) - 1
}
newQty = 0
}
return m, m.syncCartItemCmd(coffeeID, newQty)
}
case "p", "enter":
if !m.IsCartEmpty() {
return m.ShippingSwitch()
}
}
return m, nil
}

View File

@@ -2,58 +2,52 @@ package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// RenderConfirmation renders the order confirmation view
func (m Model) RenderConfirmation() string {
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 0, 1, 0)
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
sectionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#CCCCCC")).
Padding(0, 0, 1, 2)
sectionStyle := m.theme.TextBody().Padding(0, 0, 1, 2)
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
labelStyle := m.theme.TextDim()
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
valueStyle := m.theme.TextAccent()
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FF00")).
Bold(true).
Padding(1, 0)
successStyle := m.theme.TextSuccess().Bold(true).Padding(1, 0)
title := titleStyle.Render("Order Confirmation")
// Shipping summary
shippingSection := ""
if m.ShippingInfo != nil {
if m.confirm.shipping != nil {
shippingSection = sectionStyle.Render(
labelStyle.Render("Ship to: ") +
valueStyle.Render(m.ShippingInfo.Name) + "\n" +
valueStyle.Render(m.confirm.shipping.Name) + "\n" +
labelStyle.Render(" ") +
valueStyle.Render(m.ShippingInfo.Street) + "\n" +
valueStyle.Render(m.confirm.shipping.Street) + "\n" +
labelStyle.Render(" ") +
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.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)),
)
}
// Cart summary
// Items summary
cartSection := ""
total := 0.0
items := m.GetCartItemsSlice()
for _, item := range items {
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, item.Coffee.Price*float64(item.Quantity))
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)
cartSection += sectionStyle.Render(line) + "\n"
total += item.Coffee.Price * float64(item.Quantity)
}
cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", total)),
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
) + "\n"
cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.confirm.total)/100)),
)
success := successStyle.Render("Order placed successfully!")
@@ -69,3 +63,167 @@ func (m Model) RenderConfirmation() string {
success,
)
}
func (m Model) updateConfirmViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.confirm.viewportReady {
m.confirm.viewport = viewport.New(m.widthContent, availH)
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
m.confirm.viewportReady = true
} else {
m.confirm.viewport.Width = m.widthContent
m.confirm.viewport.Height = availH
}
return m
}
func (m Model) ConfirmView() string {
if !m.confirm.viewportReady {
m = m.updateConfirmViewport()
}
m.confirm.viewport.SetContent(m.RenderConfirmation())
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.confirm.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.confirm.viewport.View(),
)
}
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
if m.confirm.viewportReady {
m.confirm.viewport, cmd = m.confirm.viewport.Update(msg)
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, cmd
}
if keyMsg.String() != "esc" && keyMsg.String() != "s" {
return m, cmd
}
m.CheckingOut = false
m.confirm.total = 0
m.confirm.items = nil
m.confirm.shipping = nil
m, _ = m.ShopSwitch()
return m, func() tea.Msg {
if m.APIClient != nil {
_ = m.APIClient.ClearCart()
}
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

@@ -1,207 +1,25 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildFooter() string {
// Style for keybinds (bold)
keybindStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Bold(true)
keybindStyle := m.theme.TextDim().Bold(true)
descStyle := m.theme.TextDim()
// Style for descriptions (normal)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
// On small terminals, only show the menu hint
if m.size == small {
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
}
var footerText string
if m.FaqFocused {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("scroll"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.ViewingAccount {
switch m.OrderViewState {
case 2:
// Viewing single order detail
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
case 1:
// Browsing order list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("orders"),
keybindStyle.Render("enter"),
descStyle.Render("details"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
default:
// Account tabs
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("navigate"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else if m.ViewingCart && m.CheckoutStep == 3 {
// Confirmation screen
footerText = fmt.Sprintf("%s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back to shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.ViewingCart && m.CheckoutStep == 1 {
if m.ShippingView == 0 && m.ShippingForm == nil {
// Address list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("addresses"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("d/x"),
descStyle.Render("delete"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
} else {
// Shipping form
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("tab"),
descStyle.Render("next"),
keybindStyle.Render("enter"),
descStyle.Render("submit"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
}
} else if m.ViewingCart && m.CheckoutStep == 2 {
if m.PaymentView == 0 && m.PaymentForm == nil {
// Card list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("cards"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("d/x"),
descStyle.Render("delete"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
} else if m.CheckingOut {
// Submitting order
footerText = descStyle.Render("submitting order...")
} else {
// Payment form
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("tab"),
descStyle.Render("next"),
keybindStyle.Render("enter"),
descStyle.Render("submit"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
}
} else if m.ViewingCart && m.CheckoutStep == 0 {
// In cart view, show proceed option
if len(m.Cart) > 0 {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("p/enter"),
descStyle.Render("checkout"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("pgup/pgdn"),
descStyle.Render("scroll"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else if m.ViewingCart {
// Other checkout steps
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("products"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
parts := []string{}
for _, cmd := range m.footer {
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
}
footerText := strings.Join(parts, " ")
return lipgloss.NewStyle().
Align(lipgloss.Center).
Width(m.widthContainer).
Render(footerText)
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
}

View File

@@ -8,35 +8,31 @@ import (
func (m Model) BuildHeader() string {
// Calculate cart total and item count
total := 0.0
itemCount := 0
for _, item := range m.Cart {
total += float64(item.Quantity) * item.Coffee.Price
itemCount += item.Quantity
}
total := m.CalculateSubtotal()
itemCount := m.CartItemCount()
// Define colors
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
grayStyle := m.theme.TextBody()
boldWhiteStyle := m.theme.TextAccent().Bold(true)
boldGrayStyle := m.theme.TextBody().Bold(true)
// Build cart tab (used in all sizes)
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
var cartKeybind, cartName, cartInfo string
if m.ViewingCart {
if m.inCartFlow() {
// Active: bold white keybind and name
cartKeybind = boldWhiteStyle.Render("c")
cartName = boldWhiteStyle.Render("cart")
cartInfo = fmt.Sprintf(" %s %s",
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)),
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", float64(total)/100)),
itemCountText)
} else {
// Inactive: bold gray keybind, gray name
cartKeybind = boldGrayStyle.Render("c")
cartName = grayStyle.Render("cart")
cartInfo = fmt.Sprintf(" %s %s",
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)),
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", float64(total)/100)),
itemCountText)
}
cartTab := cartKeybind + " " + cartName + cartInfo
@@ -51,13 +47,13 @@ func (m Model) BuildHeader() string {
case medium:
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
logo := boldWhiteStyle.Render("terminal coffee")
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
separator := m.theme.TextAccent().Render("|")
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
default:
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
separator := m.theme.TextAccent().Render("|")
var shopKeybind, shopName string
if !m.ViewingCart && !m.ViewingAccount {
if m.currentPage == shopPage {
shopKeybind = boldWhiteStyle.Render("s")
shopName = boldWhiteStyle.Render("shop")
} else {
@@ -66,7 +62,7 @@ func (m Model) BuildHeader() string {
}
var accountKeybind, accountName string
if m.ViewingAccount {
if m.currentPage == accountPage {
accountKeybind = boldWhiteStyle.Render("a")
accountName = boldWhiteStyle.Render("account")
} else {

View File

@@ -1,13 +1,14 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildHelpView() string {
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
header := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4")).Bold(true)
bold := m.theme.TextAccent().Bold(true)
base := m.theme.TextBody()
header := m.theme.TextHighlight().Bold(true)
rowStyle := lipgloss.NewStyle().Padding(0, 1)
sections := []struct {
@@ -75,12 +76,12 @@ func (m Model) BuildHelpView() string {
}
}
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
logoStyle := m.theme.TextAccent().Bold(true)
logo := logoStyle.Render("keybindings")
modal := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")).
BorderForeground(m.theme.Border()).
Padding(1, 2).
Render(content)
@@ -103,3 +104,17 @@ func (m Model) BuildHelpView() string {
assembled,
)
}
func (m Model) HelpUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "esc", "?":
m.ShowingHelp = false
case "q", "ctrl+c":
return m, tea.Quit
}
return m, nil
}

View File

@@ -2,16 +2,16 @@ package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildMenuView() string {
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
bold := m.theme.TextAccent().Bold(true)
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
base := m.theme.TextBody()
rowStyle := lipgloss.NewStyle().Padding(0,1)
rowStyle := lipgloss.NewStyle().Padding(0, 1)
rows := []string{
rowStyle.Render(bold.Render("s") + " " + base.Render("shop")),
rowStyle.Render(bold.Render("a") + " " + base.Render("account")),
@@ -20,28 +20,22 @@ func (m Model) BuildMenuView() string {
rowStyle.Render(bold.Render("q") + " " + base.Render("quit")),
}
menuContent := ""
for _, row := range rows {
menuContent += row + "\n"
}
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
logoStyle := m.theme.TextAccent().Bold(true)
logo := logoStyle.Render("terminal coffee")
modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666")).Padding(1,2).Render(menuContent)
modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border()).Padding(1, 2).Render(menuContent)
hint := base.Render("press esc to close")
itemCount := 0
total := 0.0
for _, item := range m.Cart {
total += float64(item.Quantity) * item.Coffee.Price
itemCount += item.Quantity
}
total := m.CalculateSubtotal()
itemCount := m.CartItemCount()
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", total, itemCount))
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount))
assembled := lipgloss.JoinVertical(
lipgloss.Center,
@@ -51,7 +45,7 @@ func (m Model) BuildMenuView() string {
"",
hint,
cartHint,
)
)
return lipgloss.Place(
m.viewportWidth,
@@ -59,6 +53,34 @@ func (m Model) BuildMenuView() string {
lipgloss.Center,
lipgloss.Center,
assembled,
)
)
}
func (m Model) MenuUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.String() {
case "s":
m.ShowingMenu = false
m, cmd := m.ShopSwitch()
return m, cmd
case "a":
m.ShowingMenu = false
return m.AccountSwitch()
case "c":
m.ShowingMenu = false
m, cmd := m.CartSwitch()
return m, cmd
case "esc":
m.ShowingMenu = false
case "?":
m.ShowingMenu = false
m.ShowingHelp = true
case "q", "ctrl+c":
return m, tea.Quit
}
return m, nil
}

View File

@@ -1,100 +1,20 @@
package tui
import (
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"testing"
gossh "golang.org/x/crypto/ssh"
)
func TestModel_BuildMenuView(t *testing.T) {
type fields struct {
User *models.User
IsNewUser bool
SSHPublicKey gossh.PublicKey
AccessToken string
UsernameInput string
Username string
Coffees []models.Coffee
Cursor int
Cart map[uint]*models.CartItem
CartCursor int
AccountCursor int
CheckoutStep int
ScrollOffset int
ViewingCart bool
ViewingAccount bool
viewportWidth int
viewportHeight int
widthContainer int
heightContainer int
widthContent int
size termSize
resizeSeq int
pendingWidth int
pendingHeight int
Loading bool
ErrorMsg string
APIClient *api.Client
ShippingForm *ShippingFormState
PaymentForm *PaymentFormState
ShippingInfo *models.Address
SavedAddresses []models.Address
ShippingView int
AddressCursor int
StripeKey string
Orders []models.Order
OrdersLoaded bool
}
tests := []struct {
name string
fields fields
want string
name string
m Model
want string
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := Model{
User: tt.fields.User,
IsNewUser: tt.fields.IsNewUser,
SSHPublicKey: tt.fields.SSHPublicKey,
AccessToken: tt.fields.AccessToken,
UsernameInput: tt.fields.UsernameInput,
Username: tt.fields.Username,
Coffees: tt.fields.Coffees,
Cursor: tt.fields.Cursor,
Cart: tt.fields.Cart,
CartCursor: tt.fields.CartCursor,
AccountCursor: tt.fields.AccountCursor,
CheckoutStep: tt.fields.CheckoutStep,
ScrollOffset: tt.fields.ScrollOffset,
ViewingCart: tt.fields.ViewingCart,
ViewingAccount: tt.fields.ViewingAccount,
viewportWidth: tt.fields.viewportWidth,
viewportHeight: tt.fields.viewportHeight,
widthContainer: tt.fields.widthContainer,
heightContainer: tt.fields.heightContainer,
widthContent: tt.fields.widthContent,
size: tt.fields.size,
resizeSeq: tt.fields.resizeSeq,
pendingWidth: tt.fields.pendingWidth,
pendingHeight: tt.fields.pendingHeight,
Loading: tt.fields.Loading,
ErrorMsg: tt.fields.ErrorMsg,
APIClient: tt.fields.APIClient,
ShippingForm: tt.fields.ShippingForm,
PaymentForm: tt.fields.PaymentForm,
ShippingInfo: tt.fields.ShippingInfo,
SavedAddresses: tt.fields.SavedAddresses,
ShippingView: tt.fields.ShippingView,
AddressCursor: tt.fields.AddressCursor,
StripeKey: tt.fields.StripeKey,
Orders: tt.fields.Orders,
OrdersLoaded: tt.fields.OrdersLoaded,
}
if got := m.BuildMenuView(); got != tt.want {
if got := tt.m.BuildMenuView(); got != tt.want {
t.Errorf("Model.BuildMenuView() = %v, want %v", got, tt.want)
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,104 +1,30 @@
package tui
import (
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"testing"
gossh "golang.org/x/crypto/ssh"
)
func TestModel_updateLayout(t *testing.T) {
type fields struct {
User *models.User
IsNewUser bool
SSHPublicKey gossh.PublicKey
AccessToken string
UsernameInput string
Username string
Coffees []models.Coffee
Cursor int
Cart map[uint]*models.CartItem
CartCursor int
AccountCursor int
CheckoutStep int
ScrollOffset int
ViewingCart bool
ViewingAccount bool
viewportWidth int
viewportHeight int
widthContainer int
heightContainer int
widthContent int
size termSize
resizeSeq int
pendingWidth int
pendingHeight int
Loading bool
ErrorMsg string
APIClient *api.Client
ShippingForm *ShippingFormState
PaymentForm *PaymentFormState
ShippingInfo *models.Address
SavedAddresses []models.Address
ShippingView int
AddressCursor int
StripeKey string
Orders []models.Order
OrdersLoaded bool
}
type args struct {
width int
height int
}
tests := []struct {
name string
fields fields
args args
width int
height int
}{
// TODO: Add test cases.
{"large terminal", 120, 40},
{"medium terminal", 70, 25},
{"small terminal", 45, 20},
{"undersized", 15, 8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Model{
User: tt.fields.User,
IsNewUser: tt.fields.IsNewUser,
SSHPublicKey: tt.fields.SSHPublicKey,
AccessToken: tt.fields.AccessToken,
UsernameInput: tt.fields.UsernameInput,
Username: tt.fields.Username,
Coffees: tt.fields.Coffees,
Cursor: tt.fields.Cursor,
Cart: tt.fields.Cart,
CartCursor: tt.fields.CartCursor,
AccountCursor: tt.fields.AccountCursor,
CheckoutStep: tt.fields.CheckoutStep,
ScrollOffset: tt.fields.ScrollOffset,
ViewingCart: tt.fields.ViewingCart,
ViewingAccount: tt.fields.ViewingAccount,
viewportWidth: tt.fields.viewportWidth,
viewportHeight: tt.fields.viewportHeight,
widthContainer: tt.fields.widthContainer,
heightContainer: tt.fields.heightContainer,
widthContent: tt.fields.widthContent,
size: tt.fields.size,
resizeSeq: tt.fields.resizeSeq,
pendingWidth: tt.fields.pendingWidth,
pendingHeight: tt.fields.pendingHeight,
Loading: tt.fields.Loading,
ErrorMsg: tt.fields.ErrorMsg,
APIClient: tt.fields.APIClient,
ShippingForm: tt.fields.ShippingForm,
PaymentForm: tt.fields.PaymentForm,
ShippingInfo: tt.fields.ShippingInfo,
SavedAddresses: tt.fields.SavedAddresses,
ShippingView: tt.fields.ShippingView,
AddressCursor: tt.fields.AddressCursor,
StripeKey: tt.fields.StripeKey,
Orders: tt.fields.Orders,
OrdersLoaded: tt.fields.OrdersLoaded,
m := NewModel("")
m.updateLayout(tt.width, tt.height)
if m.viewportWidth != tt.width {
t.Errorf("viewportWidth = %d, want %d", m.viewportWidth, tt.width)
}
if m.viewportHeight != tt.height {
t.Errorf("viewportHeight = %d, want %d", m.viewportHeight, tt.height)
}
m.updateLayout(tt.args.width, tt.args.height)
})
}
}

View File

@@ -4,10 +4,13 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"terminalShop/pkg/validate"
)
@@ -36,11 +39,35 @@ type PaymentFormCompleteMsg struct {
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
type PaymentFormErrorMsg struct {
Message string
}
// PollPaymentInitMsg is sent when the collect endpoint returns a URL
type PollPaymentInitMsg struct {
URL string
}
// PollPaymentStatusMsg is sent each polling tick while waiting for the browser
// card entry to complete.
type PollPaymentStatusMsg struct {
CardCount int
}
// PollPaymentCompleteMsg is sent when the card count increases, meaning the
// webhook saved the new card
type PollPaymentCompleteMsg struct {
Cards []models.Card
}
// buildPaymentForm creates the huh form bound to state's fields.
// No field-level validators so Tab/Enter move freely.
func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
@@ -79,7 +106,8 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
),
).
WithShowErrors(false).
WithShowHelp(false)
WithShowHelp(false).
WithTheme(m.theme.Form())
if m.size < medium {
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
@@ -216,17 +244,10 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
// RenderPaymentForm renders the payment form view
func (m Model) RenderPaymentForm(state *PaymentFormState) string {
if state.submitting {
loadingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(1)
return loadingStyle.Render("Processing payment...")
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing payment...")
}
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 0, 1, 0)
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
cardLast4 := ""
if len(state.CardNumber) >= 4 {
@@ -243,11 +264,77 @@ func (m Model) RenderPaymentForm(state *PaymentFormState) string {
)
}
func (m Model) updatePaymentViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(m.widthContent, availH)
m.payment.viewport.KeyMap = viewport.KeyMap{}
m.payment.viewportReady = true
} else {
m.payment.viewport.Width = m.widthContent
m.payment.viewport.Height = availH
}
return m
}
func (m Model) PaymentPageView() string {
if !m.payment.viewportReady {
m = m.updatePaymentViewport()
}
var content string
if m.CheckingOut {
content = " submitting order..."
} else if m.payment.view == 2 {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place(
m.widthContainer,
m.payment.viewport.Height,
lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(),
)
} else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList()
m.payment.viewport.SetContent(content)
itemHeight := 3
targetY := m.payment.cardCursor * itemHeight
if targetY < m.payment.viewport.YOffset {
m.payment.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
}
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.viewport.GotoBottom()
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.payment.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.payment.viewport.View(),
)
} else if m.payment.form != nil {
content = m.RenderPaymentForm(m.payment.form)
}
m.payment.viewport.SetContent(content)
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.payment.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.payment.viewport.View(),
)
}
func (m Model) RenderCardList() string {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := m.theme.TextDim()
labelStyle := m.theme.TextLabel()
title := titleStyle.Render("Select Payment Method")
@@ -255,7 +342,7 @@ func (m Model) RenderCardList() string {
for i, card := range m.SavedCards {
cursor := " "
style := inactiveStyle
if i == m.CardCursor {
if i == m.payment.cardCursor {
cursor = "> "
style = activeStyle
}
@@ -269,17 +356,229 @@ func (m Model) RenderCardList() string {
lines = append(lines, "")
}
addCursor := " "
addStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards) {
addCursor = "> "
addStyle = activeStyle
sshCursor := " "
sshStyle := inactiveStyle
if m.payment.cardCursor == len(m.SavedCards) {
sshCursor = "> "
sshStyle = activeStyle
}
lines = append(lines, sshCursor+sshStyle.Render("+ add card via ssh"))
lines = append(lines, addCursor+addStyle.Render("+ Add new card"))
httpsCursor := " "
httpsStyle := inactiveStyle
if m.payment.cardCursor == len(m.SavedCards)+1 {
httpsCursor = "> "
httpsStyle = activeStyle
}
lines = append(lines, httpsCursor+httpsStyle.Render("+ add payment via browser"))
parts := []string{title}
parts = append(parts, strings.Join(lines, "\n"))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight()
if m.payment.collectURL == nil {
return baseStyle.Render(" generating payment link...")
}
qr, qrSize, err := qrfefe.Generate(0, *m.payment.collectURL)
if err != nil || qrSize > m.widthContent {
// QR too wide for the terminal — just show the URL.
return baseStyle.Render("open in browser to add payment information:") + "\n\n" +
accentStyle.Render(*m.payment.collectURL) + "\n"
}
// Concatenate manually — lipgloss layout functions (JoinVertical with Center)
// measure string widths and add padding that corrupts qrfefe's raw ANSI codes.
return qr +
baseStyle.Render("scan or visit to add payment information") + "\n" +
accentStyle.Render(*m.payment.collectURL) + "\n"
}
// PaymentUpdate handles all messages while on the payment page.
// Moved from model.go Update() lines 429-578.
func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CardsMsg:
if msg.Err != nil {
m.SavedCards = nil
} else {
m.SavedCards = msg.Cards
}
m.payment.view = 0
m.payment.cardCursor = 0
m.payment.form = nil
return m, nil
case PaymentFormCompleteMsg:
if m.payment.form != nil && !m.payment.form.submitting {
m.payment.form.submitting = true
return m, m.saveCardOnlyCmd(msg)
}
return m, nil
case PaymentFormErrorMsg:
m.ErrorMsg = msg.Message
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()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
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:
url := msg.URL
m.payment.collectURL = &url
return m, m.pollCardsCmd(m.payment.collectCardCount)
case PollPaymentStatusMsg:
if m.payment.view != 2 {
return m, nil // user navigated away, stop polling
}
return m, m.pollCardsCmd(msg.CardCount)
case PollPaymentCompleteMsg:
if m.payment.view != 2 {
return m, nil
}
m.SavedCards = msg.Cards
m.payment.view = 0
m.payment.collectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1]
m.SelectedCard = &selected
m.review.cardJustAdded = true
return m.ReviewSwitch()
}
// Card list navigation
if m.payment.view == 0 && m.payment.form == nil {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok || m.CheckingOut {
return m, nil
}
m.ErrorMsg = ""
switch keyMsg.String() {
case "esc":
return m.ShippingSwitch()
case "up", "k":
if m.payment.cardCursor > 0 {
m.payment.cardCursor--
}
case "down", "j":
if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++
}
case "enter":
if m.payment.cardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.payment.cardCursor]
m.SelectedCard = &selected
return m.ReviewSwitch()
}
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
// Browser payment
m.payment.view = 2
m.payment.collectURL = nil
m.payment.collectCardCount = len(m.SavedCards)
m.footer = []footerCommand{
{key: "esc", value: "back"},
}
return m, m.collectCardCmd()
case "d", "x":
if m.payment.cardCursor < len(m.SavedCards) {
card := m.SavedCards[m.payment.cardCursor]
m.SavedCards = append(m.SavedCards[:m.payment.cardCursor], m.SavedCards[m.payment.cardCursor+1:]...)
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor--
}
if len(m.SavedCards) == 0 {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID)
}
}
return m, nil
}
// HTTPS/browser view avigation
if m.payment.view == 2 {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
m.payment.view = 0
m.payment.collectURL = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil
}
return m, nil
}
// Payment form navigation
if m.payment.form != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
m.ErrorMsg = ""
if len(m.SavedCards) > 0 {
m.payment.view = 0
m.payment.form = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil
}
m.payment.form = nil
m = m.SwitchPage(shippingPage)
return m, m.fetchAddressesCmd()
}
// Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = ""
}
return m, m.UpdatePaymentForm(msg, m.payment.form)
}
return m, nil
}

58
pkg/tui/qrfefe/qr.go Normal file
View File

@@ -0,0 +1,58 @@
package qrfefe
import (
sgr "github.com/foize/go.sgr"
"rsc.io/qr"
)
var tops_bottoms = []rune{' ', '▀', '▄', '█'}
func Generate(size int, text string) (string, int, error) {
return generate(qr.L, text)
}
func generate(level qr.Level, text string) (string, int, error) {
code, err := qr.Encode(text, qr.Level(level))
if err != nil {
return "", 0, err
}
qrRunes := make([]rune, 0)
for y := 0; y < code.Size-1; y += 2 {
qrRunes = append(qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...)
for x := 0; x < code.Size; x += 1 {
var num int8
if code.Black(x, y) { // top pixel black
num += 1
}
if code.Black(x, y+1) { // bottom pixel black
num += 2
}
qrRunes = append(qrRunes, tops_bottoms[num])
}
qrRunes = append(qrRunes, []rune(sgr.Reset)...)
qrRunes = append(qrRunes, '\n')
}
addWhiteRow(&qrRunes, code.Size+4)
return string(qrRunes), code.Size, nil
}
func addWhiteRow(qrRunes *[]rune, width int) {
if qrRunes == nil {
return
}
*qrRunes = append(*qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...)
for i := 1; i < width-3; i++ {
*qrRunes = append(*qrRunes, '▀')
}
*qrRunes = append(*qrRunes, []rune(sgr.Reset)...)
*qrRunes = append(*qrRunes, '\n')
}

View File

@@ -4,6 +4,9 @@ import (
"fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
@@ -15,21 +18,21 @@ var countries = []struct {
Code string
DialCode string
}{
{Name: "Denmark", Code: "DK", DialCode: "+45"},
{Name: "Finland", Code: "FI", DialCode: "+358"},
{Name: "Iceland", Code: "IS", DialCode: "+354"},
// {Name: "Denmark", Code: "DK", DialCode: "+45"},
// {Name: "Finland", Code: "FI", DialCode: "+358"},
// {Name: "Iceland", Code: "IS", DialCode: "+354"},
{Name: "Norway", Code: "NO", DialCode: "+47"},
{Name: "Sweden", Code: "SE", DialCode: "+46"},
// {Name: "Sweden", Code: "SE", DialCode: "+46"},
{Name: "USA", Code: "US", DialCode: "+1"},
}
// countryDialCodes maps country ISO codes to their dialing prefixes.
var countryDialCodes = map[string]string{
"DK": "+45",
"FI": "+358",
"IS": "+354",
// "DK": "+45",
// "FI": "+358",
// "IS": "+354",
"NO": "+47",
"SE": "+46",
// "SE": "+46",
"US": "+1",
}
@@ -109,8 +112,7 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
Key("country").
Options(countryOptions...).
Value(&state.Country).
Height(5).
Filtering(true),
Height(3),
huh.NewInput().
Title("Phone").
Key("phone").
@@ -118,7 +120,8 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
),
).
WithShowErrors(false).
WithShowHelp(false)
WithShowHelp(false).
WithTheme(m.theme.Form())
if m.size < medium {
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
@@ -134,7 +137,7 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
// so that huh's Value() bindings point to stable memory.
func (m Model) InitShippingForm() *ShippingFormState {
state := &ShippingFormState{
Country: "SE",
Country: "NO",
}
if m.User != nil && m.User.Name != "" {
@@ -238,19 +241,10 @@ func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd
// RenderShippingForm renders the shipping form view
func (m Model) RenderShippingForm(state *ShippingFormState) string {
if state.submitting {
loadingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(1)
return loadingStyle.Render("Processing shipping information...")
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing shipping information...")
}
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 0, 1, 0)
title := titleStyle.Render("Shipping Address")
title := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0).Render("Shipping Address")
form := state.form.View()
return lipgloss.JoinVertical(
@@ -260,11 +254,59 @@ func (m Model) RenderShippingForm(state *ShippingFormState) string {
)
}
func (m Model) updateShippingViewport() Model {
headerH := lipgloss.Height(m.BuildHeader())
breadH := lipgloss.Height(m.BuildBreadcrumbs())
footerH := lipgloss.Height(m.BuildFooter())
availH := m.heightContainer - headerH - footerH - breadH
if availH < 1 {
availH = 1
}
if !m.shipping.viewportReady {
m.shipping.viewport = viewport.New(m.widthContent, availH)
m.shipping.viewport.KeyMap = viewport.KeyMap{}
m.shipping.viewportReady = true
} else {
m.shipping.viewport.Width = m.widthContent
m.shipping.viewport.Height = availH
}
return m
}
func (m Model) ShippingPageView() string {
if !m.shipping.viewportReady {
m = m.updateShippingViewport()
}
if m.shipping.view == 0 && m.shipping.form == nil {
content := m.RenderAddressList()
m.shipping.viewport.SetContent(content)
itemHeight := 4
targetY := m.shipping.addressCursor * itemHeight
if targetY < m.shipping.viewport.YOffset {
m.shipping.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
}
if m.shipping.addressCursor == len(m.SavedAddresses) {
m.shipping.viewport.GotoBottom()
}
} else if m.shipping.form != nil {
m.shipping.viewport.SetContent(m.RenderShippingForm(m.shipping.form))
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.shipping.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.shipping.viewport.View(),
)
}
func (m Model) RenderAddressList() string {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := m.theme.TextDim()
labelStyle := m.theme.TextLabel()
title := titleStyle.Render("Selected Shipping Address")
@@ -272,7 +314,7 @@ func (m Model) RenderAddressList() string {
for i, addr := range m.SavedAddresses {
cursor := " "
style := inactiveStyle
if i == m.AddressCursor {
if i == m.shipping.addressCursor {
cursor = "> "
style = activeStyle
}
@@ -292,7 +334,7 @@ func (m Model) RenderAddressList() string {
addCursor := " "
addStyle := inactiveStyle
if m.AddressCursor == len(m.SavedAddresses) {
if m.shipping.addressCursor == len(m.SavedAddresses) {
addCursor = "> "
addStyle = activeStyle
}
@@ -304,3 +346,130 @@ func (m Model) RenderAddressList() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case ShippingFormCompleteMsg:
addr := models.Address{
Name: msg.Name, Street: msg.Street1, Street2: msg.Street2,
City: msg.City, State: msg.State, Country: msg.Country,
Zip: msg.Zip, Phone: msg.Phone,
}
return m, m.saveAddressCmd(addr)
case AddressesMsg:
if msg.Err != nil || len(msg.Addresses) == 0 {
m.SavedAddresses = nil
m.shipping.view = 1
m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.shipping.form.form.Init()
}
m.SavedAddresses = msg.Addresses
m.shipping.view = 0
m.shipping.addressCursor = 0
m.shipping.form = nil
return m, nil
case AddressSavedMsg:
if msg.Err != nil {
if m.shipping.form != nil {
m.shipping.form.submitting = false
m.shipping.form.form = m.buildShippingForm(m.shipping.form)
}
m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported."
return m, m.shipping.form.form.Init()
}
return m.PaymentSwitch()
case ShippingFormErrorMsg:
m.ErrorMsg = msg.Message
return m, nil
}
// Address list navigation
if m.shipping.view == 0 && m.shipping.form == nil {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
m.ErrorMsg = ""
switch keyMsg.String() {
case "esc":
return m.CartSwitch()
case "up", "k":
if m.shipping.addressCursor > 0 {
m.shipping.addressCursor--
}
case "down", "j":
if m.shipping.addressCursor < len(m.SavedAddresses) {
m.shipping.addressCursor++
}
case "enter":
if m.shipping.addressCursor < len(m.SavedAddresses) {
selected := m.SavedAddresses[m.shipping.addressCursor]
m.ShippingInfo = &selected
return m.PaymentSwitch()
}
m.shipping.view = 1
m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.shipping.form.form.Init()
case "d", "x":
if m.shipping.addressCursor < len(m.SavedAddresses) {
addr := m.SavedAddresses[m.shipping.addressCursor]
m.SavedAddresses = append(m.SavedAddresses[:m.shipping.addressCursor], m.SavedAddresses[m.shipping.addressCursor+1:]...)
if m.shipping.addressCursor >= len(m.SavedAddresses) && m.shipping.addressCursor > 0 {
m.shipping.addressCursor--
}
if len(m.SavedAddresses) == 0 {
m.shipping.view = 1
m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID))
}
return m, m.deleteAddressCmd(addr.ID)
}
}
return m, nil
}
// Form navigation
if m.shipping.form != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
m.ErrorMsg = ""
if len(m.SavedAddresses) > 0 {
m.shipping.view = 0
m.shipping.form = nil
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil
}
m.shipping.form = nil
m, cmd := m.CartSwitch()
return m, cmd
}
// Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = ""
}
return m, m.UpdateShippingForm(msg, m.shipping.form)
}
return m, nil
}

View File

@@ -2,113 +2,230 @@ package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
)
func (m Model) BuildShopView() string {
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2 // 2 for gap
// On small/medium, items are centered and span the full container width.
// On large, items are left-aligned in the narrow left column.
itemWidth := leftWidth - 2
itemAlign := lipgloss.Left
if m.size < large {
itemWidth = m.widthContainer
itemAlign = lipgloss.Center
}
// Build the left panel (coffee list)
leftPanel := ""
for i, coffee := range m.Coffees {
if m.Cursor == i {
style := lipgloss.NewStyle().
Background(lipgloss.Color(coffee.Color)).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Width(itemWidth).
Align(itemAlign)
leftPanel += style.Render(coffee.Name) + "\n"
} else {
style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Padding(0, 1).
Width(itemWidth).
Align(itemAlign)
leftPanel += style.Render(coffee.Name) + "\n"
}
}
var leftContainer lipgloss.Style
if m.size < large {
leftContainer = lipgloss.NewStyle().Width(m.widthContainer)
} else {
leftContainer = lipgloss.NewStyle().Width(leftWidth)
}
// Build the detail view (right panel)
detailView := ""
if m.Cursor >= 0 && m.Cursor < len(m.Coffees) {
selectedCoffee := m.Coffees[m.Cursor]
nameStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
infoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
priceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(selectedCoffee.Color)).
Bold(true)
// On small terminals, use full content width for description
descWidth := rightWidth - 2
func (m Model) calculateShopWidths() (int, int) {
menuWidth := 14
if len(m.Coffees) == 0 {
if m.size < large {
descWidth = m.widthContent - 2
return m.widthContent, m.widthContent
}
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Width(descWidth)
quantityStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
// Get current quantity (look up by CoffeeID)
quantity := 0
if item, exists := m.Cart[selectedCoffee.ID]; exists {
quantity = item.Quantity
return menuWidth, m.widthContent - menuWidth
}
for _, c := range m.Coffees {
if w := lipgloss.Width(c.Name); w > menuWidth {
menuWidth = w
}
}
if menuWidth > 0 {
menuWidth += 4
}
if m.size < large {
menuWidth = m.widthContent
}
detailWidth := m.widthContent - menuWidth
if m.size < large {
detailWidth = m.widthContent
}
return menuWidth, detailWidth
}
detailView += nameStyle.Render(selectedCoffee.Name) + "\n"
detailView += infoStyle.Render(fmt.Sprintf("%s | %doz | %s",
selectedCoffee.RoastType,
selectedCoffee.Ounces,
selectedCoffee.BeanType)) + "\n\n"
detailView += priceStyle.Render(fmt.Sprintf("$%.2f", selectedCoffee.Price)) + "\n\n"
detailView += descStyle.Render(selectedCoffee.Description) + "\n\n"
detailView += quantityStyle.Render(fmt.Sprintf("- %d +", quantity)) + "\n"
func (m Model) updateShopViewports() Model {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
availableHeight := m.heightContainer - headerHeight - footerHeight
if len(m.Coffees) == 0 {
return m
}
detailContainer := lipgloss.NewStyle().
Width(rightWidth)
menuWidth, detailWidth := m.calculateShopWidths()
// Responsive layout: side-by-side on large, stacked on small/medium
if !m.shop.viewportsReady {
m.shop.menuViewport = newViewport(menuWidth, availableHeight)
m.shop.menuViewport.KeyMap = viewport.KeyMap{} // menu nav is manual
m.shop.detailViewport = newViewport(detailWidth, availableHeight)
m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true
} else {
m.shop.menuViewport.Width = menuWidth
m.shop.menuViewport.Height = availableHeight
m.shop.detailViewport.Width = detailWidth
m.shop.detailViewport.Height = availableHeight
}
return m
}
func (m Model) getShopMenuContent() string {
menuWidth, _ := m.calculateShopWidths()
selectedColor := m.theme.Highlight()
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
if c := m.Coffees[m.shop.selected].Color; c != "" {
selectedColor = lipgloss.Color(c)
}
}
var normal, highlighted lipgloss.Style
if m.size < large {
return lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
detailContainer.Width(m.widthContainer).Render(detailView),
menuWidth = m.widthContent
normal = m.theme.TextBody().
Width(menuWidth - 1).Align(lipgloss.Center)
highlighted = m.theme.TextAccent().
Width(menuWidth - 1).Align(lipgloss.Center).
Background(selectedColor).Bold(true)
} else {
normal = m.theme.TextBody().
Width(menuWidth+2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth+2).Padding(0, 1).
Background(selectedColor).Bold(true)
}
var b strings.Builder
for i, c := range m.Coffees {
if i == m.shop.selected {
b.WriteString(highlighted.Render(c.Name) + "\n")
} else {
b.WriteString(normal.Render(c.Name) + "\n")
}
}
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
}
func (m Model) getShopDetailContent() string {
if len(m.Coffees) == 0 {
return ""
}
coffee := m.Coffees[m.shop.selected]
_, detailWidth := m.calculateShopWidths()
if m.size >= large {
detailWidth -= 2
}
quantity := 0
if item, exists := m.Cart[coffee.ID]; exists {
quantity = item.Quantity
}
detail := lipgloss.JoinVertical(lipgloss.Left,
m.theme.TextAccent().Bold(true).Render(coffee.Name),
m.theme.TextBody().Render(
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
),
"",
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
),
"",
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
"",
fmt.Sprintf("- %d +", quantity),
)
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
}
func (m Model) ShopView() string {
if !m.shop.viewportsReady {
m = m.updateShopViewports()
}
if len(m.Coffees) == 0 {
return lipgloss.Place(m.widthContent, m.heightContainer,
lipgloss.Center, lipgloss.Center, "No products available.")
}
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
if m.size < large {
return lipgloss.JoinVertical(lipgloss.Top,
m.shop.menuViewport.View(),
m.shop.detailViewport.View(),
)
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
return lipgloss.JoinHorizontal(lipgloss.Top,
m.shop.menuViewport.View(),
" ",
detailContainer.Render(detailView),
m.shop.detailViewport.View(),
)
}
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
if len(m.Coffees) == 0 {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
coffee := m.Coffees[m.shop.selected]
switch msg.String() {
case "up", "k":
m = m.shopMoveSelected(true)
case "down", "j":
m = m.shopMoveSelected(false)
case "+", "=":
coffeeID := coffee.ID
if item, exists := m.Cart[coffeeID]; exists {
item.Quantity++
} else {
m.Cart[coffeeID] = &models.CartItem{CoffeeID: coffeeID, Coffee: coffee, Quantity: 1}
}
return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity)
case "-", "_":
coffeeID := coffee.ID
if item, exists := m.Cart[coffeeID]; exists {
item.Quantity--
newQty := item.Quantity
if item.Quantity <= 0 {
delete(m.Cart, coffeeID)
newQty = 0
}
return m, m.syncCartItemCmd(coffeeID, newQty)
}
}
}
// Pass messages to the detail viewport so pgup/pgdn scroll works
m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg)
cmds = append(cmds, cmd)
if m.shop.viewportsReady {
m.shop.menuViewport.SetContent(m.getShopMenuContent())
m.shop.detailViewport.SetContent(m.getShopDetailContent())
}
return m, tea.Batch(cmds...)
}
func (m Model) shopMoveSelected(previous bool) Model {
next := m.shop.selected
if previous {
next--
} else {
next++
}
if next < 0 {
next = 0
}
if next >= len(m.Coffees) {
next = len(m.Coffees) - 1
}
m.shop.selected = next
if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2)
m.shop.detailViewport.GotoTop()
}
return m
}

59
pkg/tui/splash.go Normal file
View File

@@ -0,0 +1,59 @@
package tui
import (
"fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type DelayCompleteMsg struct{}
type splashCursorTickMsg struct{}
type SplashAuthMsg struct {
Token string
User models.PublicUser
Err error
}
type ViewInitMsg struct {
Data api.ViewInitData
Err error
}
func (m Model) splashViewInitCmd() tea.Msg {
data, err := m.APIClient.GetViewInit()
if err != nil {
return ViewInitMsg{Err: fmt.Errorf("failed to load data: %w", err)}
}
return ViewInitMsg{Data: data}
}
func (m Model) SplashView() string {
accent := m.theme.TextBody().Bold(true)
var cursor string
if m.splash.cursor {
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
} else {
cursor = " "
}
logo := accent.Render("terminal coffee") + cursor
content := lipgloss.JoinVertical(
lipgloss.Center,
logo,
)
return lipgloss.Place(
m.viewportWidth,
m.viewportHeight,
lipgloss.Center,
lipgloss.Center,
content,
)
}

39
pkg/tui/theme/huh.go Normal file
View File

@@ -0,0 +1,39 @@
package theme
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// HuhTheme creates a huh form theme using this project's color palette.
func HuhTheme(t Theme) *huh.Theme {
var th huh.Theme
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
f := &th.Focused
f.Base = t.renderer.NewStyle().
PaddingLeft(1).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderForeground(t.accent)
f.Title = t.renderer.NewStyle().Foreground(t.body)
f.Description = t.renderer.NewStyle().Foreground(t.body)
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
th.Help = help.New().Styles
th.Blurred = copyFieldStyles(*f)
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
return &th
}
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }

99
pkg/tui/theme/theme.go Normal file
View File

@@ -0,0 +1,99 @@
package theme
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// Theme holds all colors and styles for the terminal coffee shop TUI.
type Theme struct {
renderer *lipgloss.Renderer
background lipgloss.TerminalColor
border lipgloss.TerminalColor
body lipgloss.TerminalColor
dim lipgloss.TerminalColor
accent lipgloss.TerminalColor
highlight lipgloss.TerminalColor
brand lipgloss.TerminalColor
label lipgloss.TerminalColor
success lipgloss.TerminalColor
error lipgloss.TerminalColor
base lipgloss.Style
form *huh.Theme
}
// BasicTheme constructs the default theme for terminal coffee shop.
func BasicTheme(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#666666")
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
t.highlight = lipgloss.Color("#4682B4")
t.brand = lipgloss.Color("#4682B4")
t.label = lipgloss.Color("#999999")
t.success = lipgloss.Color("#00FF00")
t.error = lipgloss.Color("196")
t.base = renderer.NewStyle().Foreground(t.body)
t.form = HuhTheme(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.
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
// Style helpers — return a copy of base with the correct foreground.
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
func (t Theme) Base() lipgloss.Style { return t.base }
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
func (t Theme) TextDim() lipgloss.Style { return t.Base().Foreground(t.dim) }
func (t Theme) TextHighlight() lipgloss.Style { return t.Base().Foreground(t.highlight) }
func (t Theme) TextBrand() lipgloss.Style { return t.Base().Foreground(t.brand) }
func (t Theme) TextLabel() lipgloss.Style { return t.Base().Foreground(t.label) }
func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.success) }
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
// PanelError returns a style for the error banner: dark red bg, red fg.
func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
}
// Form returns the huh form theme for shipping and payment forms.
func (t Theme) Form() *huh.Theme { return t.form }

201
pkg/tui/update.go Normal file
View File

@@ -0,0 +1,201 @@
package tui
import (
"fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"time"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Resize debounce tick, must fire before page dispatch
if tick, ok := msg.(resizeTickMsg); ok {
if tick.seq == m.resizeSeq {
m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
case cartPage:
m = m.updateCartViewport()
case shippingPage:
m = m.updateShippingViewport()
case paymentPage:
m = m.updatePaymentViewport()
case confirmPage:
m = m.updateConfirmViewport()
case reviewPage:
// no viewport on review page
}
if m.shipping.form != nil {
m.shipping.form.form = m.buildShippingForm(m.shipping.form)
}
if m.payment.form != nil {
m.payment.form.form = m.buildPaymentForm(m.payment.form)
}
}
return m, nil
}
// Modal overlays intercept all input
if m.ShowingMenu {
return m.MenuUpdate(msg)
}
if m.ShowingHelp {
return m.HelpUpdate(msg)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.pendingWidth = msg.Width
m.pendingHeight = msg.Height
m.resizeSeq++
seq := m.resizeSeq
return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg {
return resizeTickMsg{seq: seq}
})
case SplashAuthMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("authentication failed: %v", msg.Err)
return m, nil
}
m.AccessToken = msg.Token
m.APIClient.Token = msg.Token
if msg.User.ID != 0 {
m.Username = msg.User.Name
}
return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd())
case ViewInitMsg:
if msg.Err != nil {
return m.ShopSwitch()
}
m.Loading = false
if len(msg.Data.Products) > 0 {
m.Coffees = msg.Data.Products
}
if msg.Data.User.ID != 0 {
u := models.User{
ID: msg.Data.User.ID,
Name: msg.Data.User.Email,
Email: msg.Data.User.Email,
}
m.User = &u
m.Username = u.Name
}
cart := &api.CartData{Items: msg.Data.Cart}
m.loadCartFromAPI(cart)
m.SavedAddresses = msg.Data.Addresses
m.SavedCards = msg.Data.Cards
m.SSHKeys = msg.Data.SSHKeys
m.SSHKeysLoaded = true
m.Orders = msg.Data.Orders
m.OrdersLoaded = true
m.splash.dataReady = true
if m.splash.delayDone {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
}
return m, nil
case CartSyncedMsg:
if msg.Err != nil {
return m, m.fetchCartCmd
}
if msg.UpdateID == m.lastCartUpdateID {
m.loadCartFromAPI(msg.Cart)
}
return m, nil
case tokenRefreshTickMsg:
return m, m.refreshTokenCmd()
case TokenRefreshedMsg:
if msg.Err != nil {
return m, tea.Tick(tokenRetryDuration, func(t time.Time) tea.Msg {
return tokenRefreshTickMsg{}
})
}
m.AccessToken = msg.Token
m.APIClient.Token = msg.Token
return m, m.scheduleTokenRefreshCmd()
case OrdersMsg:
if msg.Err == nil {
m.Orders = msg.Orders
m.OrdersLoaded = true
}
return m, nil
case AddressDeletedMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to delete address: %v", msg.Err)
}
return m, nil
case CardDeletedMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err)
}
return m, nil
case SSHKeysMsg:
if msg.Err == nil {
m.SSHKeys = msg.Keys
m.SSHKeysLoaded = true
}
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
case splashCursorTickMsg:
m.splash.cursor = !m.splash.cursor
return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg {
return splashCursorTickMsg{}
})
case tea.KeyMsg:
// If a form is active, skip global keys — let the page handler own input
if m.shipping.form != nil || m.payment.form != nil {
break
}
// Global navigation
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "?":
m.ShowingHelp = true
return m, nil
case "m":
m.ShowingMenu = true
m.menuLastPage = m.currentPage
return m, nil
case "s":
return m.ShopSwitch()
case "c":
return m.CartSwitch()
case "a":
return m.AccountSwitch()
}
// non-globals. fall through to page dispatch below
}
// Page-specifics
switch m.currentPage {
case shopPage:
return m.ShopUpdate(msg)
case cartPage:
return m.CartUpdate(msg)
case shippingPage:
return m.ShippingUpdate(msg)
case paymentPage:
return m.PaymentUpdate(msg)
case reviewPage:
return m.ReviewUpdate(msg)
case confirmPage:
return m.ConfirmUpdate(msg)
case accountPage:
return m.AccountUpdate(msg)
}
return m, nil
}

View File

@@ -8,8 +8,7 @@ import (
// ResizeView renders a message when the terminal is too small.
func (m Model) ResizeView() string {
msg := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
msg := m.theme.TextDim().
Align(lipgloss.Center).
Render("Terminal too small.\nPlease resize.")
@@ -32,6 +31,10 @@ func (m Model) View() string {
return m.ResizeView()
}
if m.currentPage == splashPage {
return m.SplashView()
}
// If the menu modal is showing, render it full-screen (bypass container)
if m.ShowingMenu {
return m.BuildMenuView()
@@ -68,62 +71,16 @@ func (m Model) View() string {
marginBottom = 0
}
// Build main content based on view
var content string
if m.Loading {
loadingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Padding(2, 4)
loadingStyle := m.theme.TextLoading().Padding(2, 4)
content = loadingStyle.Render("Loading products from API...")
} else if m.ErrorMsg != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Background(lipgloss.Color("52")).
Padding(0, 1).
MarginBottom(1)
errorBanner := errorStyle.Render("⚠ " + m.ErrorMsg)
if m.ViewingAccount {
content = errorBanner + "\n" + m.BuildAccountView(availableContentHeight)
} else if m.ViewingCart && m.CheckoutStep == 1 {
if m.ShippingView == 0 && m.ShippingForm == nil {
content = errorBanner + "\n" + m.RenderAddressList()
} else if m.ShippingForm != nil {
content = errorBanner + "\n" + m.RenderShippingForm(m.ShippingForm)
}
} else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut {
content = errorBanner + "\n submitting order..."
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentView == 0 && m.PaymentForm == nil {
content = errorBanner + "\n" + m.RenderCardList()
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil {
content = errorBanner + "\n" + m.RenderPaymentForm(m.PaymentForm)
} else if m.ViewingCart && m.CheckoutStep == 3 {
content = errorBanner + "\n" + m.RenderConfirmation()
} else if m.ViewingCart {
content = errorBanner + "\n" + m.BuildCartView()
} else {
content = errorBanner + "\n" + m.BuildShopView()
}
} else if m.ViewingAccount {
content = m.BuildAccountView(availableContentHeight)
} else if m.ViewingCart && m.CheckoutStep == 1 {
if m.ShippingView == 0 && m.ShippingForm == nil {
content = m.RenderAddressList()
} else if m.ShippingForm != nil {
content = m.RenderShippingForm(m.ShippingForm)
}
} else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut {
content = " submitting order..."
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentView == 0 && m.PaymentForm == nil {
content = m.RenderCardList()
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil {
content = m.RenderPaymentForm(m.PaymentForm)
} else if m.ViewingCart && m.CheckoutStep == 3 {
content = m.RenderConfirmation()
} else if m.ViewingCart {
content = m.BuildCartView()
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
errorBanner := errorStyle.Render(m.ErrorMsg)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} else {
content = m.BuildShopView()
content = m.buildPageContent(availableContentHeight)
}
// Split content into lines and handle scrolling
@@ -132,23 +89,23 @@ func (m Model) View() string {
// Skip global scroll when account view handles its own scrolling
// (order list/detail and FAQ focused use internal scrolling in BuildAccountView)
accountHandlesScroll := m.ViewingAccount && (m.OrderViewState >= 1 || m.FaqFocused)
accountHandlesScroll := m.currentPage == accountPage && (m.account.orderViewState >= 1 || m.account.faqFocused)
if !accountHandlesScroll {
// Ensure scroll offset is within bounds
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
if m.account.scrollOffset < 0 {
m.account.scrollOffset = 0
}
maxScroll := totalLines - availableContentHeight
if maxScroll < 0 {
maxScroll = 0
}
if m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
if m.account.scrollOffset > maxScroll {
m.account.scrollOffset = maxScroll
}
if totalLines > availableContentHeight {
start := m.ScrollOffset
start := m.account.scrollOffset
end := start + availableContentHeight
if end > totalLines {
end = totalLines
@@ -158,33 +115,25 @@ func (m Model) View() string {
}
content = strings.Join(contentLines, "\n")
// Add vertical spacing around content with fixed height
contentWithPadding := lipgloss.NewStyle().
MarginTop(marginTop).
MarginBottom(marginBottom).
Height(availableContentHeight).
Render(content)
// Build footer
footer := m.BuildFooter()
// Assemble all layers vertically within the container
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage
var child string
if breadcrumbs != "" {
child = lipgloss.JoinVertical(
lipgloss.Left,
header,
breadcrumbs,
contentWithPadding,
footer,
)
if isViewportPage {
if breadcrumbs != "" {
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, content, footer)
} else {
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
}
} else if breadcrumbs != "" {
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
} else {
child = lipgloss.JoinVertical(
lipgloss.Left,
header,
contentWithPadding,
footer,
)
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
}
// Constrain the assembled layout to the container dimensions
@@ -202,3 +151,24 @@ func (m Model) View() string {
constrained,
)
}
func (m Model) buildPageContent(height int) string {
switch m.currentPage {
case accountPage:
return m.BuildAccountView(height)
case shippingPage:
return m.ShippingPageView()
case paymentPage:
return m.PaymentPageView()
case reviewPage:
return m.ReviewView()
case confirmPage:
return m.ConfirmView()
case cartPage:
return m.CartView()
case shopPage:
return m.ShopView()
default:
return m.ShopView()
}
}

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