17 Commits

Author SHA1 Message Date
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
58 changed files with 4233 additions and 2916 deletions

8
.dockerignore Normal file
View File

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

3
Caddyfile Normal file
View File

@@ -0,0 +1,3 @@
api.yourdomain.com {
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 api ./api/main.go
FROM alpine:3.21
RUN apk add --no-cache libc6-compat
WORKDIR /root/
COPY --from=builder /app/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

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

443
README.md
View File

@@ -1,413 +1,62 @@
# 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
A terminal-based coffee shop, accessible over SSH. Built with Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, and Shippo.
```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 localhost -p 23456
```
## Environment Variables
## Running locally
```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
# Copy and fill in env vars
cp .env.example .env
# Start the API server
go run api/main.go
# Start the SSH server
go run main.go
```
## Resources
## Stack
- **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/
| Layer | Tech |
|-------|------|
| TUI | Bubbletea + Wish (SSH) |
| HTTP API | Chi router |
| Database | PostgreSQL + GORM |
| Auth | SSH key fingerprint + JWT |
| Payments | Stripe |
| Shipping | Shippo |
## Contributing Guidelines for Claude Code
## What's working
When working on this project:
- SSH server — key-based auth, auto user creation
- Shop page — browse products with live detail panel
- Cart — add/remove items, synced to API
- Checkout flow — shipping address → payment → confirmation
- Saved addresses and cards
- Order history in account view
- FAQ page
- Responsive layout (small/medium/large terminal sizes)
- Token refresh
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
## TODO
## License
**Features missing vs reference (terminal.shop):**
- [ ] Splash/loading screen on connect
- [ ] Animated logo with blinking cursor
- [ ] API token management page
- [ ] OAuth app management page
- [ ] About page
- [ ] Theme system — currently 40+ hardcoded hex colors scattered across files
- [ ] Viewport-based scrolling — currently manual scroll math with hardcoded heights
MIT (or your preferred license)
**Known bugs:**
- [ ] Address delete slice bug in `shipping_form.go:386` — `[m.AddressCursor+1:]...` missing
- [ ] Account page has a placeholder "something" menu item in `models/coffee.go`
**Code quality:**
- [ ] Nested state structs (reference uses `state.shop`, `state.cart`, etc. vs flat Model)
- [ ] Centralized styles file

92
TODO.md
View File

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

View File

@@ -0,0 +1,161 @@
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,8 +64,22 @@ 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", "database error", nil)
return
}
@@ -75,6 +93,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, 4)
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,86 @@ 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
}
db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false)
card.IsDefault = true
db.Save(&card)
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 +200,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 +214,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 +233,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 +242,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 +257,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 +283,48 @@ 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)
// 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
// Soft-delete the local record.
db.Delete(&card)
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
gdb.Save(user)
return nil
}

View File

@@ -5,15 +5,16 @@ import (
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
"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,
})
}
@@ -234,7 +234,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 +269,46 @@ 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
}
user.StripeCustomerID = cust.ID
db.Save(&user)
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
db.Save(&card)
}
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 +320,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,12 +346,86 @@ 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
}
audit.OrderPaid(userID, order.ID, total, pi.ID)
db.Preload("Items").First(&order, order.ID)
@@ -410,3 +433,4 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"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",
})
}

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

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

@@ -0,0 +1,55 @@
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 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,
"orders": orders,
})
}

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

@@ -0,0 +1,230 @@
package handlers
import (
"encoding/json"
"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.ConstructEvent(body, sig, h.webhookSecret); err != nil {
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.
}
tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
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()
db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed)
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)
}

BIN
api/main Executable file

Binary file not shown.

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,38 @@
package middleware
import (
"net/http"
"time"
"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 http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), 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,73 @@ 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)
// 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 +86,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) {

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:

83
docs/checkout.md Normal file
View File

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

342
docs/deployment-plan.md Normal file
View File

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

93
docs/gap-analysis.md Normal file
View File

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

View File

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

View File

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

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=

72
main.go
View File

@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -17,48 +18,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 +69,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 +97,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,16 +47,16 @@ 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{
@@ -65,35 +66,40 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
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 +526,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 +569,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 devode 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)
@@ -728,3 +776,41 @@ 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"`
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

@@ -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

@@ -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

@@ -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,9 @@ func (Coffee) TableName() string {
return "coffees"
}
// AccountMenuItems something
// AccountMenuItems
var AccountMenuItems = []string{
"order history",
"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.

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
@@ -18,15 +19,15 @@ func (m Model) BuildAccountView(availableHeight int) string {
for i, item := range models.AccountMenuItems {
if m.AccountCursor == i {
style := lipgloss.NewStyle().
Background(lipgloss.Color("#4682B4")).
Foreground(lipgloss.Color("#FFFFFF")).
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")).
Foreground(m.theme.Body()).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
@@ -40,10 +41,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
// 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)
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
// On small terminals, use full content width
detailContentWidth := rightWidth - 2
@@ -51,9 +49,7 @@ 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]
switch selectedItem {
@@ -83,15 +79,13 @@ func (m Model) BuildAccountView(availableHeight int) string {
}
if m.OrderViewState == 0 {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders")
}
detailView = lines
}
case "faq":
questionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
questionStyle := m.theme.TextHighlight().Bold(true)
faqContent := ""
for i, faq := range m.FAQs {
@@ -103,16 +97,12 @@ func (m Model) BuildAccountView(availableHeight int) string {
}
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.FaqFocused {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
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)
accentStyle := m.theme.TextHighlight().Bold(true)
userStyle := m.theme.TextAccent().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:",
@@ -125,6 +115,8 @@ func (m Model) BuildAccountView(availableHeight int) string {
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",
detailContentWidth)) + "\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
detailView = titleStyle.Render("About") + "\n\n" + aboutContent
@@ -196,16 +188,11 @@ func (m Model) BuildAccountView(availableHeight int) string {
// 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"))
nameStyle := m.theme.TextAccent().Bold(true)
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
dimStyle := m.theme.TextDim()
statusStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
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))
@@ -251,13 +238,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
if isSelected {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(lipgloss.Color("#FFFFFF")).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth)
}
@@ -271,20 +258,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
// 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)
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
labelStyle := m.theme.TextHighlight().Bold(true)
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA"))
valueStyle := m.theme.TextBody()
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
dimStyle := m.theme.TextDim()
var b strings.Builder
@@ -363,12 +343,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 +355,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 +374,98 @@ 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
}
switch keyMsg.String() {
case "up", "k":
if m.FaqFocused {
m.ScrollOffset -= 3
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
}
} else if m.OrderViewState == 1 && m.OrderCursor > 0 {
m.OrderCursor--
cardHeight := 5
if m.heightContainer >= 25 {
cardHeight = 7
}
targetTop := 3 + m.OrderCursor*cardHeight
if targetTop < m.ScrollOffset {
m.ScrollOffset = targetTop
}
} else if m.OrderViewState == 0 && m.AccountCursor > 0 {
m.AccountCursor--
m.ScrollOffset = 0
}
case "down", "j":
if m.FaqFocused {
m.ScrollOffset += 3
maxScroll := m.computeFaqScrollMax()
if m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
}
} else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 {
m.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.OrderCursor+1)*cardHeight
if targetBottom > m.ScrollOffset+viewportHeight {
m.ScrollOffset = targetBottom - viewportHeight
}
} else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 {
m.AccountCursor++
m.ScrollOffset = 0
}
case "pgup", "ctrl+u":
if m.FaqFocused || m.OrderViewState >= 2 {
m.ScrollOffset -= 3
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
}
}
case "pgdown", "ctrl+d":
if m.FaqFocused || m.OrderViewState >= 2 {
m.ScrollOffset += 3
maxScroll := m.computeFaqScrollMax()
if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
}
}
case "p", "enter":
if m.AccountCursor == 0 && len(m.Orders) > 0 {
if m.OrderViewState == 0 {
m.OrderViewState = 1
m.OrderCursor = 0
m.ScrollOffset = 0
} else if m.OrderViewState == 1 {
m.OrderViewState = 2
m.ScrollOffset = 0
}
} else if m.AccountCursor == 1 && !m.FaqFocused {
m.FaqFocused = true
m.ScrollOffset = 0
}
case "esc":
if m.FaqFocused {
m.FaqFocused = false
m.ScrollOffset = 0
} else if m.OrderViewState > 0 {
m.OrderViewState--
m.ScrollOffset = 0
}
}
return m, nil
}

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 {
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.cartVPReady {
m.cartVP = viewport.New(m.widthContent, availH)
m.cartVP.KeyMap = viewport.KeyMap{}
m.cartVPReady = true
} else {
m.cartVP.Width = m.widthContent
m.cartVP.Height = availH
}
return m
}
func (m Model) generateCartContent() string {
if len(m.Cart) == 0 {
emptyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
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,104 @@ 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 {
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.cartVPReady {
m = m.updateCartViewport()
}
content := m.generateCartContent()
m.cartVP.SetContent(content)
if len(m.Cart) > 0 {
itemHeight := 5
targetY := m.CartCursor * itemHeight
if targetY < m.cartVP.YOffset {
m.cartVP.SetYOffset(targetY)
}
if targetY+itemHeight > m.cartVP.YOffset+m.cartVP.Height {
m.cartVP.SetYOffset(targetY - m.cartVP.Height + itemHeight)
}
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.cartVP.View()),
lipgloss.Center, lipgloss.Center,
m.cartVP.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.CartCursor > 0 {
m.CartCursor--
}
case "down", "j":
if m.CartCursor < len(m.Cart)-1 {
m.CartCursor++
}
case "+", "=":
cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) {
cartItems[m.CartCursor].Quantity++
return m, m.syncCartItemCmd(cartItems[m.CartCursor].CoffeeID, cartItems[m.CartCursor].Quantity)
}
case "-", "_":
cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) {
coffeeID := cartItems[m.CartCursor].CoffeeID
cartItems[m.CartCursor].Quantity--
newQty := cartItems[m.CartCursor].Quantity
if cartItems[m.CartCursor].Quantity <= 0 {
delete(m.Cart, coffeeID)
if len(m.Cart) == 0 {
m.CartCursor = 0
} else if m.CartCursor >= len(m.Cart) {
m.CartCursor = len(m.Cart) - 1
}
newQty = 0
}
return m, m.syncCartItemCmd(coffeeID, newQty)
}
case "p", "enter":
if len(m.Cart) > 0 {
m = m.SwitchPage(shippingPage)
m = m.updateShippingViewport()
return m, m.fetchAddressesCmd()
}
}
return m, nil
}

View File

@@ -3,57 +3,49 @@ package tui
import (
"fmt"
"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.ConfirmShipping != nil {
shippingSection = sectionStyle.Render(
labelStyle.Render("Ship to: ") +
valueStyle.Render(m.ShippingInfo.Name) + "\n" +
valueStyle.Render(m.ConfirmShipping.Name) + "\n" +
labelStyle.Render(" ") +
valueStyle.Render(m.ShippingInfo.Street) + "\n" +
valueStyle.Render(m.ConfirmShipping.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.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.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.ConfirmItems {
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.ConfirmTotal)/100)),
)
success := successStyle.Render("Order placed successfully!")
@@ -69,3 +61,64 @@ 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.confirmVPReady {
m.confirmVP = viewport.New(m.widthContent, availH)
m.confirmVP.KeyMap = viewport.DefaultKeyMap()
m.confirmVPReady = true
} else {
m.confirmVP.Width = m.widthContent
m.confirmVP.Height = availH
}
return m
}
func (m Model) ConfirmView() string {
if !m.confirmVPReady {
m = m.updateConfirmViewport()
}
m.confirmVP.SetContent(m.RenderConfirmation())
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.confirmVP.View()),
lipgloss.Center, lipgloss.Center,
m.confirmVP.View(),
)
}
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
if m.confirmVPReady {
m.confirmVP, cmd = m.confirmVP.Update(msg)
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, cmd
}
if keyMsg.String() != "esc" && keyMsg.String() != "s" {
return m, cmd
}
m = m.SwitchPage(shopPage)
m.CheckingOut = false
m.ConfirmTotal = 0
m.ConfirmItems = nil
m.ConfirmShipping = nil
m = m.resetPageState()
return m, func() tea.Msg {
if m.APIClient != nil {
_ = m.APIClient.ClearCart()
}
return nil
}
}

View File

@@ -8,13 +8,10 @@ import (
func (m Model) BuildFooter() string {
// Style for keybinds (bold)
keybindStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Bold(true)
keybindStyle := m.theme.TextDim().Bold(true)
// Style for descriptions (normal)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
descStyle := m.theme.TextDim()
// On small terminals, only show the menu hint
if m.size == small {
@@ -36,7 +33,7 @@ func (m Model) BuildFooter() string {
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.ViewingAccount {
} else if m.currentPage == accountPage {
switch m.OrderViewState {
case 2:
// Viewing single order detail
@@ -77,7 +74,7 @@ func (m Model) BuildFooter() string {
descStyle.Render("quit"),
)
}
} else if m.ViewingCart && m.CheckoutStep == 3 {
} else if m.currentPage == confirmPage {
// Confirmation screen
footerText = fmt.Sprintf("%s %s %s %s",
keybindStyle.Render("esc"),
@@ -85,7 +82,7 @@ func (m Model) BuildFooter() string {
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.ViewingCart && m.CheckoutStep == 1 {
} else if m.currentPage == shippingPage {
if m.ShippingView == 0 && m.ShippingForm == nil {
// Address list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
@@ -109,7 +106,7 @@ func (m Model) BuildFooter() string {
descStyle.Render("back"),
)
}
} else if m.ViewingCart && m.CheckoutStep == 2 {
} else if m.currentPage == paymentPage {
if m.PaymentView == 0 && m.PaymentForm == nil {
// Card list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
@@ -136,7 +133,7 @@ func (m Model) BuildFooter() string {
descStyle.Render("back"),
)
}
} else if m.ViewingCart && m.CheckoutStep == 0 {
} else if m.currentPage == cartPage {
// 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",
@@ -173,16 +170,6 @@ func (m Model) BuildFooter() string {
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"),

View File

@@ -8,35 +8,35 @@ import (
func (m Model) BuildHeader() string {
// Calculate cart total and item count
total := 0.0
total := 0
itemCount := 0
for _, item := range m.Cart {
total += float64(item.Quantity) * item.Coffee.Price
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
// 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 +51,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 +66,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

@@ -3,13 +3,14 @@ package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
)
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)
rows := []string{
@@ -27,21 +28,21 @@ func (m Model) BuildMenuView() string {
}
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
total := 0
for _, item := range m.Cart {
total += float64(item.Quantity) * item.Coffee.Price
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
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,
@@ -62,3 +63,33 @@ func (m Model) BuildMenuView() string {
)
}
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
return m.SwitchPage(shopPage).resetPageState(), nil
case "a":
m.ShowingMenu = false
m = m.SwitchPage(accountPage).resetPageState()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
case "c":
m.ShowingMenu = false
return m.SwitchPage(cartPage).resetPageState(), nil
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"
)
@@ -41,6 +44,23 @@ 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 +99,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 +237,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 +257,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.paymentVPReady {
m.paymentVP = viewport.New(m.widthContent, availH)
m.paymentVP.KeyMap = viewport.KeyMap{}
m.paymentVPReady = true
} else {
m.paymentVP.Width = m.widthContent
m.paymentVP.Height = availH
}
return m
}
func (m Model) PaymentPageView() string {
if !m.paymentVPReady {
m = m.updatePaymentViewport()
}
var content string
if m.CheckingOut {
content = " submitting order..."
} else if m.PaymentView == 2 {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place(
m.widthContainer,
m.paymentVP.Height,
lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(),
)
} else if m.PaymentView == 0 && m.PaymentForm == nil {
content = m.RenderCardList()
m.paymentVP.SetContent(content)
itemHeight := 3
targetY := m.CardCursor * itemHeight
if targetY < m.paymentVP.YOffset {
m.paymentVP.SetYOffset(targetY)
}
if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height {
m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1)
}
if m.CardCursor == len(m.SavedCards) {
m.paymentVP.GotoBottom()
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.paymentVP.View()),
lipgloss.Center, lipgloss.Center,
m.paymentVP.View(),
)
} else if m.PaymentForm != nil {
content = m.RenderPaymentForm(m.PaymentForm)
}
m.paymentVP.SetContent(content)
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.paymentVP.View()),
lipgloss.Center, lipgloss.Center,
m.paymentVP.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")
@@ -269,17 +349,214 @@ func (m Model) RenderCardList() string {
lines = append(lines, "")
}
addCursor := " "
addStyle := inactiveStyle
sshCursor := " "
sshStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards) {
addCursor = "> "
addStyle = activeStyle
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.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.CollectURL == nil {
return baseStyle.Render(" generating payment link...")
}
qr, qrSize, err := qrfefe.Generate(0, *m.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.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.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.PaymentView = 0
m.CardCursor = 0
m.PaymentForm = nil
return m, nil
case PaymentFormCompleteMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = true
m.CheckingOut = true
return m, m.saveCardAndConvert(msg)
}
return m, nil
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
if len(m.SavedCards) > 0 {
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
// Re-open the form so the user can retry with corrected details.
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
// Snapshot order details before clearing cart/shipping
m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
// Clear cart and shipping immediately so they don't appear stale elsewhere
m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil
case PaymentFormErrorMsg:
m.ErrorMsg = msg.Message
return m, nil
case PollPaymentInitMsg:
url := msg.URL
m.CollectURL = &url
return m, m.pollCardsCmd(m.CollectCardCount)
case PollPaymentStatusMsg:
if m.PaymentView != 2 {
return m, nil // user navigated away, stop polling
}
return m, m.pollCardsCmd(msg.CardCount)
case PollPaymentCompleteMsg:
if m.PaymentView != 2 {
return m, nil
}
m.SavedCards = msg.Cards
m.PaymentView = 0
m.CollectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
// Card list navigation
if m.PaymentView == 0 && m.PaymentForm == nil {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok || m.CheckingOut {
return m, nil
}
m.ErrorMsg = ""
switch keyMsg.String() {
case "esc":
m = m.SwitchPage(shippingPage)
return m, m.fetchAddressesCmd()
case "up", "k":
if m.CardCursor > 0 {
m.CardCursor--
}
case "down", "j":
if m.CardCursor < len(m.SavedCards)+1 {
m.CardCursor++
}
case "enter":
if m.CardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.CardCursor]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
if m.CardCursor == len(m.SavedCards) {
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
// Browser payment
m.PaymentView = 2
m.CollectURL = nil
m.CollectCardCount = len(m.SavedCards)
return m, m.collectCardCmd()
case "d", "x":
if m.CardCursor < len(m.SavedCards) {
card := m.SavedCards[m.CardCursor]
m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...)
if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 {
m.CardCursor--
}
if len(m.SavedCards) == 0 {
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID)
}
}
return m, nil
}
// HTTPS/browser view avigation
if m.PaymentView == 2 {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
m.PaymentView = 0
m.CollectURL = nil
return m, nil
}
return m, nil
}
// Payment form navigation
if m.PaymentForm != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
m.ErrorMsg = ""
if len(m.SavedCards) > 0 {
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
m.PaymentForm = 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.PaymentForm)
}
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"
@@ -118,7 +121,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)
@@ -238,19 +242,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 +255,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.shippingVPReady {
m.shippingVP = viewport.New(m.widthContent, availH)
m.shippingVP.KeyMap = viewport.KeyMap{}
m.shippingVPReady = true
} else {
m.shippingVP.Width = m.widthContent
m.shippingVP.Height = availH
}
return m
}
func (m Model) ShippingPageView() string {
if !m.shippingVPReady {
m = m.updateShippingViewport()
}
if m.ShippingView == 0 && m.ShippingForm == nil {
content := m.RenderAddressList()
m.shippingVP.SetContent(content)
itemHeight := 4
targetY := m.AddressCursor * itemHeight
if targetY < m.shippingVP.YOffset {
m.shippingVP.SetYOffset(targetY)
}
if targetY+itemHeight > m.shippingVP.YOffset+m.shippingVP.Height {
m.shippingVP.SetYOffset(targetY - m.shippingVP.Height + itemHeight + 1)
}
if m.AddressCursor == len(m.SavedAddresses) {
m.shippingVP.GotoBottom()
}
} else if m.ShippingForm != nil {
m.shippingVP.SetContent(m.RenderShippingForm(m.ShippingForm))
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.shippingVP.View()),
lipgloss.Center, lipgloss.Center,
m.shippingVP.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")
@@ -304,3 +347,116 @@ 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.ShippingView = 1
m.ShippingForm = m.InitShippingForm()
return m, m.ShippingForm.form.Init()
}
m.SavedAddresses = msg.Addresses
m.ShippingView = 0
m.AddressCursor = 0
m.ShippingForm = nil
return m, nil
case AddressSavedMsg:
if msg.Err != nil {
if m.ShippingForm != nil {
m.ShippingForm.submitting = false
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
}
m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported."
return m, m.ShippingForm.form.Init()
}
m.ShippingInfo = &msg.Address
m.ShippingForm = nil
m = m.SwitchPage(paymentPage)
m.SelectedCard = nil
return m, m.fetchCardsCmd()
case ShippingFormErrorMsg:
m.ErrorMsg = msg.Message
return m, nil
}
// Address list navigation
if m.ShippingView == 0 && m.ShippingForm == nil {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
m.ErrorMsg = ""
switch keyMsg.String() {
case "esc":
m = m.SwitchPage(cartPage)
return m, nil
case "up", "k":
if m.AddressCursor > 0 {
m.AddressCursor--
}
case "down", "j":
if m.AddressCursor < len(m.SavedAddresses) {
m.AddressCursor++
}
case "enter":
if m.AddressCursor < len(m.SavedAddresses) {
selected := m.SavedAddresses[m.AddressCursor]
m.ShippingInfo = &selected
m = m.SwitchPage(paymentPage)
m.SelectedCard = nil
return m, m.fetchCardsCmd()
}
m.ShippingView = 1
m.ShippingForm = m.InitShippingForm()
return m, m.ShippingForm.form.Init()
case "d", "x":
if m.AddressCursor < len(m.SavedAddresses) {
addr := m.SavedAddresses[m.AddressCursor]
m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor+1:]...)
if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 {
m.AddressCursor--
}
if len(m.SavedAddresses) == 0 {
m.ShippingView = 1
m.ShippingForm = m.InitShippingForm()
return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID))
}
return m, m.deleteAddressCmd(addr.ID)
}
}
return m, nil
}
// Form navigation
if m.ShippingForm != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
m.ErrorMsg = ""
if len(m.SavedAddresses) > 0 {
m.ShippingView = 0
m.ShippingForm = nil
return m, nil
}
m.ShippingForm = nil
m = m.SwitchPage(cartPage)
return m, nil
}
// Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = ""
}
return m, m.UpdateShippingForm(msg, m.ShippingForm)
}
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
}

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

@@ -0,0 +1,71 @@
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) splashAuthCmd() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken(
m.Fingerprint,
m.SSHPublicKeyStr,
m.AuthFingerprintKey,
)
if err != nil {
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
}
return SplashAuthMsg{Token: token, User: user}
}
func (m Model) splashViewInitCmd() tea.Msg {
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.splashCursor {
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
} else {
cursor = " "
}
logo := accent.Render("terminal coffe") + 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 }

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

@@ -0,0 +1,79 @@
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
}
// 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 }

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

@@ -0,0 +1,196 @@
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()
}
if m.ShippingForm != nil {
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
}
if m.PaymentForm != nil {
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm)
}
}
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 {
m.ErrorMsg = fmt.Sprintf("failed to load: %v", msg.Err)
m.Loading = false
return m, nil
}
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.Orders = msg.Data.Orders
m.OrdersLoaded = true
m.splashDataReady = true
if m.splashDelayDone {
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 DelayCompleteMsg:
m.splashDelayDone = true
if m.currentPage == splashPage && m.splashDataReady {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
}
return m, nil
case splashCursorTickMsg:
m.splashCursor = !m.splashCursor
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.ShippingForm != nil || m.PaymentForm != 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":
m = m.SwitchPage(shopPage).resetPageState()
m = m.updateShopViewports()
return m, nil
case "c":
m = m.SwitchPage(cartPage).resetPageState()
m = m.updateCartViewport()
return m, nil
case "a":
m = m.SwitchPage(accountPage).resetPageState()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
}
// 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 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,7 +89,7 @@ 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.OrderViewState >= 1 || m.FaqFocused)
if !accountHandlesScroll {
// Ensure scroll offset is within bounds
if m.ScrollOffset < 0 {
@@ -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 != "" {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, ccontentWithPadding, footer)
} else {
child = lipgloss.JoinVertical(
lipgloss.Left,
header,
contentWithPadding,
footer,
)
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, ccontentWithPadding, footer)
}
// Constrain the assembled layout to the container dimensions
@@ -202,3 +151,22 @@ 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 confirmPage:
return m.ConfirmView()
case cartPage:
return m.CartView()
case shopPage:
return m.ShopView()
default:
return m.ShopView()
}
}