mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
Compare commits
163 Commits
viewport
...
3a3c7ee80b
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a3c7ee80b | |||
| 7c8a2b562f | |||
| 1c5765d25b | |||
| e6301e662f | |||
| 8b2889a524 | |||
| c2b2b6e834 | |||
| bba5a0dfa0 | |||
| a8e45bc82b | |||
| 95b9fa91b1 | |||
| 0fe013c399 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 | |||
| 48d65c48fa | |||
| c78021f520 | |||
| c429c1966e | |||
| 50e1ba32f3 | |||
| 85b0c53c0a | |||
| 366d80a9ee | |||
| 60f52f9851 | |||
| a4c446ba3c | |||
| 491f70e935 | |||
| 153e487155 | |||
| 1752ca534f | |||
| b2bd6ed623 | |||
| 96400ede14 | |||
| 30cf46370c | |||
| 31f0698d63 | |||
| 910b7f3141 | |||
| 19e493a3d5 | |||
| 2e6dda190f | |||
| 96e76f7e5b | |||
| 9d3ac9761d | |||
| e49fa01ca9 | |||
| d9e9895913 | |||
| f73e1d18da | |||
| 29f57000a7 | |||
| 1aaf82d7d6 | |||
| 9515603b32 | |||
| bfd183bfbf | |||
| e43040f702 | |||
| 2966474ff5 | |||
| 0e4d267adb | |||
| c1b3ccf302 | |||
| f6eed76456 | |||
| e5ec247a6b | |||
| e29dd6b30d | |||
| ee6e14a424 | |||
| 387758aa13 | |||
| ff9615b75a | |||
| a2afc47f32 | |||
| 37ad51949b | |||
| 99f34788ea | |||
| c22b64d6fa | |||
| a71f72ad71 | |||
| 700ad4926b | |||
| c5f4e492c0 | |||
| f88fe1d8d3 | |||
| c6ff75f2a5 | |||
| add6e573b3 | |||
| 9b45fb5138 | |||
| 1849e324fc | |||
| c90dadf9ad | |||
| 38cdda3d54 | |||
| c037a6e9f9 | |||
| ffb476ec2d | |||
| 177b080117 | |||
| 9bf57b4048 | |||
| c96f922c70 | |||
| 4e28b812bd | |||
| 7b92636b64 | |||
| 17df27ae20 | |||
| 52d58583e9 | |||
| 327bb9c867 | |||
| 9a5fff81bf | |||
| 1c10cf4390 | |||
| ae7eaa8e97 | |||
| 8ca209a4cc | |||
| 9e9a8325a8 | |||
| 179c8011ca | |||
| 9d4549a32d | |||
| 31b8e82e47 | |||
| 67c8a6bafb | |||
| 67f01f4a38 | |||
| f47c10ed8a | |||
| b35103657d | |||
| 42abf11d04 | |||
| 96c9be61ff | |||
| 80166ae8d0 | |||
| 6bcf651afd | |||
| cbb3e59878 | |||
| 00dd63668b | |||
| bd93989036 | |||
| 4083495704 | |||
| 7bebdeebed | |||
| e0ff8f5f76 | |||
| 011bf3a4ff | |||
| d51912917e | |||
| c8a5f4806d | |||
| e2dc2854b7 | |||
| f9885e2d5e | |||
| 34bb7a7c7f | |||
| 79c8238f1b | |||
| 5c38c7e726 | |||
| 4024cf4b40 | |||
| c78f5eb12b | |||
| c8af9cb551 | |||
| d7b172843e | |||
| d66b41eed3 | |||
| 6db7748d9f | |||
| abe11853f6 | |||
| 280585c2d0 | |||
| 8a86965981 | |||
| edc032d6d4 | |||
| 53c1b5e254 | |||
| 1bc03822b1 | |||
| 6006f9617d | |||
| 1c3ebf789c | |||
| bef7f85bda | |||
| fb5881f26f | |||
| 7a774ceee7 | |||
| a624037f65 | |||
| d111dafd62 | |||
| cb048bc3dc | |||
| 22ab7438e1 | |||
| 4403138f74 | |||
| 2d86e1d23f | |||
| b92132474b | |||
| 8157789e66 | |||
| 7a8f7e9c2a | |||
| 44a9de0209 | |||
| 7882667e35 | |||
| a22ba382c2 | |||
| 7cbefeae40 | |||
| e11e708095 | |||
| 6559c4dd8f | |||
| 157a752540 | |||
| de9120d1eb | |||
| 77a044e11d | |||
| 40dccc8139 | |||
| ac04676abb | |||
| 53e443e93d | |||
| 6dba7c6dc6 | |||
| c12b0dcbc2 | |||
| ef5c4ef554 | |||
| 7c00202929 | |||
| 639b75578e | |||
| 062242e346 | |||
| 6094e05b6f | |||
| a4d5a1559a | |||
| 296c052fa7 | |||
| 4eeb3eda9a | |||
| 4bd4dfabd2 | |||
| 3ff4aa9d50 | |||
| 93b257a612 | |||
| f13bfd6572 | |||
| d5cb4259f5 | |||
| 1a49360191 | |||
| 93cc69d028 | |||
| c7fa292730 | |||
| 0146fa3e76 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
terminal-shop-source/
|
||||
*.db
|
||||
data/
|
||||
docs/
|
||||
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Format check
|
||||
run: |
|
||||
unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...))
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files need gofmt:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v2.4.0
|
||||
|
||||
- name: Test (race detector)
|
||||
run: go test -race ./...
|
||||
27
.github/workflows/deploy.yml
vendored
Normal file
27
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
port: ${{ secrets.SERVER_PORT }}
|
||||
script: |
|
||||
cd ~/terminalShop
|
||||
git pull
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@@ -3,6 +3,15 @@
|
||||
.env.local
|
||||
.env.production
|
||||
.env.example
|
||||
.env.prod
|
||||
|
||||
docs/sca-psd2-compliance.md
|
||||
accept-a-payment/
|
||||
|
||||
|
||||
FULFILLMENT.md
|
||||
.tmp-gocache/
|
||||
.junie/
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
@@ -11,8 +20,13 @@
|
||||
*.so
|
||||
*.dylib
|
||||
terminalshop
|
||||
terminalShop
|
||||
server_bin
|
||||
terminalshop_*
|
||||
server.go.old
|
||||
main
|
||||
api/main
|
||||
api/api-server
|
||||
|
||||
# Go test/coverage files
|
||||
*.test
|
||||
@@ -32,6 +46,8 @@ vendor/
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
||||
.understand-anything/
|
||||
|
||||
# ssh config
|
||||
.ssh/
|
||||
|
||||
@@ -58,7 +74,9 @@ temp/
|
||||
|
||||
# OS-specific
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
CONTRIBUTING.md
|
||||
|
||||
|
||||
|
||||
# random stuff like ai and shit
|
||||
@@ -67,18 +85,32 @@ AGENTS.md
|
||||
MODELS.md
|
||||
REFACTORING.md
|
||||
TODO.md
|
||||
NEXT.md
|
||||
test_stripe.go
|
||||
REFACTOR_PLAN.md
|
||||
|
||||
|
||||
codebase_graph.html
|
||||
|
||||
# Don't commit the cloned reference repo (optional - you might want to keep it)
|
||||
# terminal-shop-source/
|
||||
terminal-shop-source/
|
||||
|
||||
# API documentation (auto-generated)
|
||||
api/docs/
|
||||
docs/
|
||||
|
||||
# Migration state (if using a migration tool that creates state files)
|
||||
migrations/*.state
|
||||
|
||||
gh-dash/
|
||||
|
||||
|
||||
|
||||
loadtest/run.pid
|
||||
loadtest/run.err
|
||||
|
||||
/00
|
||||
|
||||
ROADMAP.md
|
||||
|
||||
*.bak
|
||||
terminalshop.prod.*
|
||||
|
||||
16
.golangci.yml
Normal file
16
.golangci.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
exclusions:
|
||||
paths:
|
||||
- terminal-shop-source
|
||||
153
AGENTS.md
153
AGENTS.md
@@ -1,153 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents working in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Terminal Coffee Shop is a terminal-based e-commerce app for ordering coffee, built with Go and the Bubbletea TUI framework. It runs as an SSH server (Wish framework) with a REST API backend (Chi router), SQLite database (GORM), and JWT authentication.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
```bash
|
||||
# Build and run SSH server (TUI)
|
||||
go build -o terminalshop && ./terminalshop
|
||||
# Connect: ssh localhost -p 23457
|
||||
|
||||
# Quick run without building
|
||||
go run main.go
|
||||
|
||||
# Run API server (separate process, port 8080)
|
||||
go run main.go # from api/ directory
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run a single test by name
|
||||
go test ./api/handlers/ -run TestGetHealth
|
||||
|
||||
# Run tests in a specific package
|
||||
go test ./pkg/database/
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Run tests with coverage
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `main.go` -- SSH server entry point (Wish framework)
|
||||
- `api/` -- REST API server: `handlers/`, `middleware/`, `routes/`
|
||||
- `pkg/api/` -- API client used by TUI
|
||||
- `pkg/auth/` -- JWT + SSH key authentication
|
||||
- `pkg/config/` -- Environment configuration
|
||||
- `pkg/database/` -- SQLite connection, migrations, seeding
|
||||
- `pkg/models/` -- GORM data models (Coffee, User, CartItem)
|
||||
- `pkg/tui/` -- Bubbletea TUI components
|
||||
- `pkg/utils/` -- Standard API response helpers
|
||||
- `pkg/validate/` -- Generic validators; `pkg/validation/` -- request validation
|
||||
- `terminal-shop-source/` -- Reference implementation (read-only, do not modify)
|
||||
|
||||
## Code Style
|
||||
|
||||
### Imports
|
||||
|
||||
Three groups separated by blank lines: (1) stdlib, (2) external packages, (3) internal packages.
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Types/exports**: `PascalCase` (`ProductHandler`, `JWTManager`)
|
||||
- **Local variables**: `camelCase`
|
||||
- **Constructors**: `New<Type>` (`NewModel`, `NewJWTManager`, `NewClient`)
|
||||
- **Files**: `snake_case.go` (`ssh_auth.go`, `shipping_form.go`)
|
||||
- **JSON tags**: `snake_case` (`json:"roast_type"`, `json:"created_at"`)
|
||||
- **Error codes**: `PascalCase` constants (`ErrCodeValidation`)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Messages are lowercase, no trailing punctuation
|
||||
- Wrap errors with `%w`: `fmt.Errorf("failed to create user: %w", err)`
|
||||
- Use guard clauses with early returns in handlers
|
||||
- API errors use: `utils.RespondError(w, statusCode, "ERROR_CODE", "message", details)`
|
||||
- API success uses: `utils.RespondSuccess(w, statusCode, data)`
|
||||
|
||||
### Comments
|
||||
|
||||
- Doc comments start with the name of the thing: `// GenerateToken generates a new JWT token for a user`
|
||||
- Complete sentences with proper punctuation
|
||||
- Every exported type and function should have a doc comment
|
||||
|
||||
### Struct Tags
|
||||
|
||||
Always include `json` tags. Use GORM tags for database models.
|
||||
|
||||
```go
|
||||
type Coffee struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
RoastType string `gorm:"size:50;not null" json:"roast_type"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
}
|
||||
```
|
||||
|
||||
### Function Ordering in Files
|
||||
|
||||
1. Exported types and constants
|
||||
2. Constructors (`New...`)
|
||||
3. Public methods (alphabetically)
|
||||
4. Private methods
|
||||
5. Helpers
|
||||
|
||||
### Context
|
||||
|
||||
Always pass `context.Context` as the first parameter in functions that need it.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Use stdlib `testing` package with `t.Errorf`/`t.Fatalf` (no testify)
|
||||
- HTTP tests use `httptest.NewRequest` and `httptest.NewRecorder`
|
||||
- Test DB setup: create temp SQLite file, connect, migrate, seed, defer cleanup
|
||||
- Always `defer os.Remove(testDB)` and `defer database.ResetForTesting()`
|
||||
- Test functions named `Test<FunctionName>` or `Test<FunctionName><Scenario>`
|
||||
|
||||
## TUI Architecture (Bubbletea / Elm Architecture)
|
||||
|
||||
- All state lives in `Model` struct in `pkg/tui/model.go`
|
||||
- Views follow **left-panel + right-panel** layout with `lipgloss.JoinHorizontal()`
|
||||
- Right panel auto-updates based on cursor position (no Enter/ESC to view details)
|
||||
- Left panel width is 18 chars; right panel fills remaining space
|
||||
- Each view has a builder: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()`
|
||||
- Styling: active=#FFFFFF bold, inactive=#666666, accent=#4682B4
|
||||
- Cart uses `map[int]*CartItem` -- always sort via `GetCartItemsSlice()` before display
|
||||
- Reset `ScrollOffset = 0` when switching views
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The `terminal-shop-source/` directory is a read-only reference -- do not modify it
|
||||
- Environment config goes in `.env` (copy from `.env.example`); never commit `.env`
|
||||
- Read CLAUDE.md for detailed architecture decisions and common gotchas
|
||||
- Read MODELS.md for complete API specs, database schema, and design patterns
|
||||
202
CLAUDE.md
202
CLAUDE.md
@@ -1,202 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Terminal Coffee Shop is a terminal-based e-commerce application for ordering coffee, built with Go and the Bubbletea TUI framework. It's designed to eventually run as an SSH server similar to terminal.shop, with a backend API, PostgreSQL database, and Stripe payment integration.
|
||||
|
||||
**Current Status**: Phase 1 (Enhanced UI & Navigation) - The TUI is functional with shop, cart, and account views. Backend API has not been implemented yet.
|
||||
|
||||
## Essential Reading
|
||||
|
||||
Before making any changes, read these files in order:
|
||||
1. **README.md** - Complete roadmap and current phase status
|
||||
2. **MODELS.md** - Technical specifications, architecture, code standards, error handling patterns
|
||||
3. **CONTRIBUTING.md** - Workflow guidance for AI-assisted development
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current Structure (TUI-only)
|
||||
|
||||
```
|
||||
terminalShop/
|
||||
├── main.go # SSH server entry point (Wish framework)
|
||||
├── pkg/
|
||||
│ ├── tui/ # Bubbletea TUI components
|
||||
│ │ ├── model.go # Main model with state management
|
||||
│ │ ├── views.go # View rendering and layout
|
||||
│ │ ├── shop.go # Shop view (left: coffee list, right: details)
|
||||
│ │ ├── cart.go # Cart view with scrolling
|
||||
│ │ ├── account.go # Account view (auto-updating right panel)
|
||||
│ │ ├── header.go # Tab navigation header
|
||||
│ │ ├── footer.go # Keybind hints
|
||||
│ │ └── breadcrumbs.go # Checkout flow breadcrumbs (cart only)
|
||||
│ └── models/
|
||||
│ └── coffee.go # Coffee and CartItem models
|
||||
└── .ssh/ # SSH host keys
|
||||
```
|
||||
|
||||
### Planned Structure (Backend)
|
||||
|
||||
The `api/` directory structure from MODELS.md will be created in Phase 2 with:
|
||||
- Chi router for HTTP endpoints
|
||||
- GORM models for PostgreSQL
|
||||
- JWT authentication middleware
|
||||
- Stripe payment integration
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
### TUI State Management (Bubbletea/Elm Architecture)
|
||||
|
||||
The `Model` struct in `pkg/tui/model.go` contains all application state:
|
||||
- Navigation state: `ViewingCart`, `ViewingAccount`, `CheckoutStep`
|
||||
- Cursor positions: `Cursor` (shop), `CartCursor` (cart), `AccountCursor` (account)
|
||||
- Data: `Coffees` (product list), `Cart` (map[int]*CartItem)
|
||||
- Scroll: `ScrollOffset` for handling overflow
|
||||
|
||||
**Critical Pattern**: All views (shop, cart, account) follow a **left-panel + right-panel layout**:
|
||||
- Left panel: List of items with cursor navigation (j/k)
|
||||
- Right panel: Details that auto-update based on cursor position
|
||||
- No Enter/ESC navigation - hovering updates the right panel immediately
|
||||
|
||||
### View Rendering Flow
|
||||
|
||||
1. `views.go:View()` - Main entry point, handles vertical centering and scrolling
|
||||
2. Calls view builders: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()`
|
||||
3. Each view uses `lipgloss.JoinHorizontal()` for left+right layout
|
||||
4. Fixed content height calculated dynamically to prevent UI shifting
|
||||
|
||||
### Cart Management
|
||||
|
||||
**Important**: Cart uses `map[int]*models.CartItem` where the key is the coffee index in `m.Coffees`. This enables fast lookups but requires sorting in `GetCartItemsSlice()` because Go maps have randomized iteration order.
|
||||
|
||||
### Scrolling System
|
||||
|
||||
Implemented for cart view to handle long lists on small terminals:
|
||||
- `ScrollOffset` tracks current scroll position (line-based, not item-based)
|
||||
- Each cart item is ~5 lines tall (including borders)
|
||||
- Auto-scrolls when navigating with j/k to keep selected item visible
|
||||
- Manual scroll with pgup/pgdn
|
||||
|
||||
## Build and Run Commands
|
||||
|
||||
```bash
|
||||
# Build and run SSH server
|
||||
go build -o terminalshop && ./terminalshop
|
||||
# Server listens on localhost:23457
|
||||
# Connect with: ssh localhost -p 23457
|
||||
|
||||
# Quick run without building
|
||||
go run main.go
|
||||
|
||||
# Add dependencies
|
||||
go get <package>
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding a New View Component
|
||||
|
||||
1. Create new file in `pkg/tui/` (e.g., `shipping.go`)
|
||||
2. Add build function following the pattern: `func (m Model) BuildShippingView() string`
|
||||
3. Use left-panel + right-panel layout with `lipgloss.JoinHorizontal()`
|
||||
4. Update `views.go:View()` to conditionally render the new view
|
||||
5. Add navigation state to `Model` struct in `model.go`
|
||||
6. Add keybinds in `model.go:Update()`
|
||||
7. Update `footer.go` with new keybind hints
|
||||
|
||||
### Modifying Existing Views
|
||||
|
||||
The shop, cart, and account views are split across multiple files but all follow the same pattern:
|
||||
- Use `lipgloss.NewStyle()` for styling
|
||||
- Fixed widths for panels (leftWidth typically 18, rightWidth calculated)
|
||||
- Cursor-based selection with colored highlights
|
||||
- Responsive to `WindowWidth` and `WindowHeight`
|
||||
|
||||
### Working with the Model
|
||||
|
||||
When adding state to the Model struct:
|
||||
1. Add field to struct definition in `model.go`
|
||||
2. Initialize in `NewModel()` constructor
|
||||
3. Reset appropriately when changing views (see `case "s"`, `case "c"`, `case "a"`)
|
||||
4. Update in `Update()` function based on user input
|
||||
|
||||
## Important Implementation Details
|
||||
|
||||
### Styling Conventions
|
||||
|
||||
- Active/selected: White (#FFFFFF), bold
|
||||
- Inactive: Gray (#666666)
|
||||
- Accent color: Steel blue (#4682B4) - used for cart breadcrumbs and highlights
|
||||
- Coffee colors: Use individual coffee's `.Color` field (hex values like "#8B4513")
|
||||
|
||||
### Window Size Handling
|
||||
|
||||
`views.go` automatically adjusts content height based on terminal size:
|
||||
- Calculates available space after header, breadcrumbs, footer, margins
|
||||
- Minimum content height of 3 lines
|
||||
- Margins scale down on small windows (< 25 lines height)
|
||||
- Truncates content and enables scrolling when needed
|
||||
|
||||
### Breadcrumbs
|
||||
|
||||
Only visible in cart view, showing checkout flow: `cart / shipping / payment / confirmation`
|
||||
- Current step (tracked in `CheckoutStep`) is white/bold
|
||||
- Future steps are gray
|
||||
- Will be used when implementing checkout pages in later phases
|
||||
|
||||
### SSH Server
|
||||
|
||||
`main.go` uses Wish (Charm's SSH framework) to serve the TUI over SSH:
|
||||
- Host key in `.ssh/term_info_ed25519`
|
||||
- Port 23457
|
||||
- Each SSH session gets its own Bubbletea program instance
|
||||
- Username passed to `tui.NewModel(username)`
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Map iteration order**: Always sort cart items in `GetCartItemsSlice()` to prevent random reordering
|
||||
2. **Scroll offset**: When adding scrolling to new views, update viewport height calculation (currently hardcoded as `-9` for cart with breadcrumbs)
|
||||
3. **Content height**: Fixed height prevents UI shifting between pages but must account for all UI elements (header, breadcrumbs, footer, margins)
|
||||
4. **Panel widths**: Left panel is 18 chars, right panel is `WindowWidth - leftWidth - leftMargin - 4`
|
||||
5. **Reset scroll**: Always reset `ScrollOffset = 0` when switching views
|
||||
|
||||
## Code Style (from MODELS.md)
|
||||
|
||||
- Imports: stdlib, external deps, internal packages (separated by blank lines)
|
||||
- Error messages: lowercase, no punctuation, use `%w` for wrapping
|
||||
- Comments: Start with function/type name, complete sentences
|
||||
- Struct tags: Always include `json` tags with snake_case
|
||||
- Context: Always first parameter in functions
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
`terminal-shop-source/` contains the full terminal.shop codebase (TypeScript/Go hybrid):
|
||||
- `packages/go/pkg/tui/*.go` - TUI patterns and components
|
||||
- `packages/go/cmd/ssh/main.go` - SSH server setup
|
||||
- Study patterns, don't copy verbatim (different tech stack)
|
||||
|
||||
## Next Steps (from Roadmap)
|
||||
|
||||
Current phase is wrapping up. Next major phase:
|
||||
- **Phase 2**: Backend API Foundation with Chi router, PostgreSQL, GORM models
|
||||
- See MODELS.md for complete API specification and database schema
|
||||
- See README.md Phase 2 tasks for implementation order
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
- `DATABASE_URL` - PostgreSQL connection (for Phase 2)
|
||||
- `JWT_SECRET` - Auth token secret (for Phase 3)
|
||||
- `STRIPE_SECRET_KEY` / `STRIPE_PUBLIC_KEY` - Payment integration (for Phase 5)
|
||||
- `SSH_PORT` - Default 2222 (currently using 23457)
|
||||
|
||||
## Testing
|
||||
|
||||
Currently no tests. Testing standards defined in MODELS.md for future API implementation:
|
||||
- Unit tests for business logic
|
||||
- Integration tests for API endpoints
|
||||
- Use testify for assertions
|
||||
- Table-driven test pattern
|
||||
265
CONTRIBUTING.md
265
CONTRIBUTING.md
@@ -1,265 +0,0 @@
|
||||
# Contributing with Claude Code
|
||||
|
||||
This project is designed to be built incrementally with AI assistance (Claude Code). This guide explains how to effectively collaborate with Claude Code on this project.
|
||||
|
||||
## Quick Start for Claude Code Sessions
|
||||
|
||||
When starting a new Claude Code session, provide this context:
|
||||
|
||||
```
|
||||
I'm working on a terminal-based coffee shop app in Go. Please read README.md and MODELS.md to understand the project structure, current status, and technical standards before making any changes.
|
||||
```
|
||||
|
||||
## Project Documents - Read These First!
|
||||
|
||||
1. **README.md** - Project overview, roadmap, current status, and what to build next
|
||||
2. **MODELS.md** - Technical specs, architecture, code standards, error handling
|
||||
3. **terminal-shop-source/** - Reference implementation of terminal.shop (when stuck)
|
||||
|
||||
## How to Ask Claude Code for Help
|
||||
|
||||
### ✅ Good Prompts
|
||||
|
||||
**When starting a new feature:**
|
||||
```
|
||||
I want to implement Phase 1, Task 1.1 from the README - adding the account page view.
|
||||
Can you check the current model.go structure and add the account view following the
|
||||
patterns already established for shop and cart views?
|
||||
```
|
||||
|
||||
**When stuck on something:**
|
||||
```
|
||||
The cart isn't syncing properly between views. Can you check the cart state management
|
||||
in model.go and compare it to how terminal.shop handles this in their cart.go file?
|
||||
```
|
||||
|
||||
**When you want to follow best practices:**
|
||||
```
|
||||
I need to create the API endpoint for adding items to cart. Can you implement this
|
||||
following the error handling patterns and response format defined in MODELS.md?
|
||||
```
|
||||
|
||||
### ❌ Avoid Vague Prompts
|
||||
|
||||
```
|
||||
"Make the cart better" // Too vague - better what? Performance? UI? Features?
|
||||
"Add payment stuff" // Be specific - which phase? What functionality exactly?
|
||||
"Fix the bug" // What bug? Where? What's the expected behavior?
|
||||
```
|
||||
|
||||
## Workflow for Building Features
|
||||
|
||||
### Step 1: Identify the Task
|
||||
Look at README.md and find the current phase and next unchecked task:
|
||||
```
|
||||
## **PHASE 1: Enhanced UI & Navigation** (Current Phase)
|
||||
### Tasks
|
||||
#### 1.1 Add Account Page View
|
||||
- [ ] Add `viewingAccount bool` to model struct in `model.go`
|
||||
```
|
||||
|
||||
### Step 2: Reference the Specs
|
||||
Check MODELS.md for:
|
||||
- Code style rules
|
||||
- Error handling patterns
|
||||
- Similar examples
|
||||
- Architecture decisions
|
||||
|
||||
### Step 3: Ask Claude Code
|
||||
```
|
||||
I want to implement [specific task from README]. I've checked MODELS.md for
|
||||
the code standards. Can you implement this following the established patterns?
|
||||
```
|
||||
|
||||
### Step 4: Test and Verify
|
||||
```
|
||||
Run the app and test the new feature. If issues:
|
||||
"The account view isn't showing up when I press 'a'. Can you debug this?"
|
||||
```
|
||||
|
||||
### Step 5: Update Documentation
|
||||
```
|
||||
The account view is working! Can you check off the completed tasks in README.md?
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Starting a New Feature
|
||||
|
||||
1. Read the next task in README.md
|
||||
2. Check if there are related patterns in MODELS.md
|
||||
3. Look at terminal-shop-source for reference
|
||||
4. Ask Claude Code to implement following the patterns
|
||||
|
||||
**Example:**
|
||||
```
|
||||
I'm ready to start Phase 2 - Backend API Foundation. Let's begin with task 2.1
|
||||
"Project Setup". Can you create the directory structure and initialize the Chi
|
||||
router following the organization defined in MODELS.md?
|
||||
```
|
||||
|
||||
### Debugging Issues
|
||||
|
||||
Always provide context:
|
||||
- What you were trying to do
|
||||
- What you expected
|
||||
- What actually happened
|
||||
- Any error messages
|
||||
|
||||
**Example:**
|
||||
```
|
||||
When I add items to the cart and switch to the cart view, the items aren't showing.
|
||||
I expected to see the cart items listed. The console shows no errors. Can you check
|
||||
the cart state management in model.go?
|
||||
```
|
||||
|
||||
### Refactoring Code
|
||||
|
||||
Reference the style guide:
|
||||
|
||||
```
|
||||
The buildShopView function is getting long (300+ lines). Can you refactor it following
|
||||
the code organization principles in MODELS.md? Split it into smaller helper functions.
|
||||
```
|
||||
|
||||
### Adding API Endpoints
|
||||
|
||||
Be specific about the endpoint:
|
||||
|
||||
```
|
||||
I need to add the POST /api/v1/cart/items endpoint from the API specs in MODELS.md.
|
||||
Can you implement this with proper error handling and validation?
|
||||
```
|
||||
|
||||
## Using the Reference Implementation
|
||||
|
||||
The `terminal-shop-source/` directory contains terminal.shop's code. Use it when:
|
||||
|
||||
### When to Reference
|
||||
|
||||
1. **You need UI inspiration** - Look at their TUI components
|
||||
2. **You're stuck on Bubbletea patterns** - See how they handle state
|
||||
3. **You want to understand payment flow** - Check their payment.go
|
||||
4. **You need form examples** - Look at how they use `huh`
|
||||
|
||||
### How to Ask Claude Code to Reference It
|
||||
|
||||
```
|
||||
Can you look at how terminal.shop implements the payment form in
|
||||
terminal-shop-source/packages/go/pkg/tui/payment.go and create a similar
|
||||
pattern for our checkout flow?
|
||||
```
|
||||
|
||||
```
|
||||
I want to add breadcrumb navigation like terminal.shop has. Can you check their
|
||||
breadcrumbs.go implementation and adapt it to our project structure?
|
||||
```
|
||||
|
||||
## Best Practices for AI-Assisted Development
|
||||
|
||||
### 1. Be Incremental
|
||||
Build one small feature at a time. Don't try to implement entire phases at once.
|
||||
|
||||
✅ "Let's add the account view to the TUI"
|
||||
❌ "Implement the entire authentication system"
|
||||
|
||||
### 2. Verify After Each Change
|
||||
Run and test the code after each feature:
|
||||
```bash
|
||||
go run server.go model.go
|
||||
```
|
||||
|
||||
### 3. Keep Context Clear
|
||||
Start sessions with context, reference documents frequently:
|
||||
```
|
||||
Working on the cart API endpoints. I've checked MODELS.md for the API spec and
|
||||
error handling patterns. Ready to implement POST /api/v1/cart/items.
|
||||
```
|
||||
|
||||
### 4. Follow the Roadmap
|
||||
Stick to the phases in README.md. Don't skip ahead or work on random features.
|
||||
|
||||
### 5. Update Documentation
|
||||
When tasks are completed, update the checkboxes in README.md:
|
||||
```
|
||||
Can you mark task 1.1 "Add Account Page View" as complete in README.md?
|
||||
```
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### "Claude Code made changes that don't match the style guide"
|
||||
```
|
||||
I noticed the error messages aren't following the format in MODELS.md (they start
|
||||
with capitals). Can you fix them to match our error handling standards?
|
||||
```
|
||||
|
||||
### "The code doesn't follow the project structure"
|
||||
```
|
||||
The new handler was added to main.go but according to MODELS.md it should be in
|
||||
api/handlers/. Can you reorganize this to match our structure?
|
||||
```
|
||||
|
||||
### "I want to try a different approach"
|
||||
```
|
||||
Instead of using the pattern from MODELS.md, I want to try [your approach].
|
||||
Can you implement it this way and we'll compare?
|
||||
```
|
||||
|
||||
## Git Workflow
|
||||
|
||||
### Before Committing
|
||||
- [ ] Code follows MODELS.md standards
|
||||
- [ ] Tests pass (when applicable)
|
||||
- [ ] Documentation updated (README checkboxes)
|
||||
- [ ] No sensitive data (check .gitignore)
|
||||
|
||||
### Commit Messages
|
||||
Use conventional commits:
|
||||
```bash
|
||||
feat: add account page view to TUI
|
||||
fix: cart items not displaying correctly
|
||||
refactor: split buildShopView into helper functions
|
||||
docs: update README with completed tasks
|
||||
```
|
||||
|
||||
## Tips for Effective Sessions
|
||||
|
||||
1. **Start each session by providing context** about what you're working on
|
||||
2. **Reference the docs** (README.md, MODELS.md) frequently
|
||||
3. **Ask Claude Code to check the reference implementation** when uncertain
|
||||
4. **Build incrementally** - one feature at a time
|
||||
5. **Test frequently** - run the app after each change
|
||||
6. **Update the README** - check off completed tasks
|
||||
7. **Follow the roadmap** - stick to the phases in order
|
||||
|
||||
## Example Full Session Flow
|
||||
|
||||
```
|
||||
Session start:
|
||||
"I'm working on Terminal Coffee Shop. Last session we completed the cart view.
|
||||
Today I want to start Phase 1, Task 1.1 - adding the account page. Can you read
|
||||
README.md to see what needs to be done?"
|
||||
|
||||
Implementation:
|
||||
"Great! Now implement the account view following the same pattern as buildShopView
|
||||
and buildCartView in model.go. Add the viewingAccount bool and the keybind handling."
|
||||
|
||||
Testing:
|
||||
"I ran it but pressing 'a' doesn't switch to account view. Can you debug?"
|
||||
|
||||
Completion:
|
||||
"Perfect! It's working now. Can you update README.md to check off all the tasks
|
||||
we completed for 1.1?"
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Bubbletea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials)
|
||||
- [Lipgloss Examples](https://github.com/charmbracelet/lipgloss/tree/master/examples)
|
||||
- [Chi Router Guide](https://go-chi.io/#/pages/getting_started)
|
||||
- [GORM Documentation](https://gorm.io/docs/)
|
||||
- [Stripe Go SDK](https://github.com/stripe/stripe-go)
|
||||
|
||||
---
|
||||
|
||||
**Remember**: The goal is to build this incrementally and learn along the way. Don't rush through phases. Take time to understand each pattern before moving on!
|
||||
13
Dockerfile.api
Normal file
13
Dockerfile.api
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM golang:alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /tmp/api ./api/main.go
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /tmp/api ./api
|
||||
CMD ["./api"]
|
||||
11
Dockerfile.ssh
Normal file
11
Dockerfile.ssh
Normal 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
4
Procfile
Normal 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
|
||||
416
README.md
416
README.md
@@ -1,413 +1,73 @@
|
||||
# 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)
|
||||
```bash
|
||||
ssh sshops.uk
|
||||
```
|
||||
|
||||
## Development Roadmap
|
||||
## demo
|
||||
https://github.com/user-attachments/assets/daf895ac-2c01-4323-bf27-8d7829b9ddd5
|
||||
|
||||
This roadmap is designed to be executed incrementally. Each phase builds on the previous one.
|
||||
it's a coffee shop but in your terminal. you ssh in, browse the menu, chuck stuff in your cart, put in your address and card, and buy coffee without ever touching a browser. your ssh key is your account so there's no login or signup or any of that.
|
||||
|
||||
---
|
||||
made it because i thought it would be fun to see if you could build a real e-commerce flow that lives entirely in a terminal. turns out you can. heavily inspired by [terminal.shop](https://terminal.shop).
|
||||
|
||||
## **PHASE 1: Enhanced UI & Navigation** (Current Phase)
|
||||
## what it actually does
|
||||
|
||||
### Objective
|
||||
Build a polished, navigable TUI with shop, cart, and account views.
|
||||
ssh in and you get a TUI. browse coffee, add to cart, save addresses, save cards. checkout charges your card via stripe and creates a real order. addresses get validated against shippo (US) or bring (Norway) before they're allowed in. cards are tokenized server side so the secret key never touches your terminal session. adding cards through the tui is coming soon, i just need to register a business and i have no money. webhooks from stripe reconcile order state if anything goes weird mid charge, and a cron job catches orders that got charged but never recorded because the server crashed.
|
||||
|
||||
### Tasks
|
||||
it's a real shop. you can actually buy coffee.
|
||||
|
||||
#### 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
|
||||
## the stack
|
||||
|
||||
**Files to modify**: `model.go`
|
||||
**TUI + SSH.** Go with [Bubbletea](https://github.com/charmbracelet/bubbletea) for the terminal UI, [Wish](https://github.com/charmbracelet/wish) for the ssh server. every ssh session spawns its own bubbletea program. lipgloss for styling.
|
||||
|
||||
#### 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
|
||||
**API.** Go again, [Chi](https://github.com/go-chi/chi) for routing. JWT auth where the token is exchanged for an ssh fingerprint, so the TUI authenticates by proving it has the same key the user ssh'd in with. no passwords anywhere.
|
||||
|
||||
**Reference**: `terminal-shop-source/packages/go/pkg/tui/breadcrumbs.go`
|
||||
**Database.** SQLite via GORM. yeah just a file. it's fine. i back it up nightly with restic and the SQLite backup API so it's WAL safe even if a write is in flight. when traffic actually demands postgres i'll switch. the DSN prefix logic in `pkg/database/db.go` already handles both drivers.
|
||||
|
||||
#### 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)
|
||||
**Payments.** [Stripe](https://stripe.com). cards are tokenized server side, never raw card data on disk. webhooks handle async confirmations like 3DS, deferred charges, refunds. idempotency keys tied to order IDs so retries can't double charge.
|
||||
|
||||
**Reference**: `terminal-shop-source/packages/go/pkg/tui/cart.go` (lines 15-20)
|
||||
**Address validation.** [Shippo](https://goshippo.com) for US addresses, [Bring](https://www.bring.com) for Norway. invalid addresses get rejected before they hit the cart.
|
||||
|
||||
#### 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
|
||||
## where it runs
|
||||
|
||||
---
|
||||
production lives on a single Hetzner VPS in Helsinki, behind [Caddy](https://caddyserver.com) which handles auto TLS so there's no nginx config to babysit. everything in docker compose. the API, caddy, and the whole observability stack run as separate compose stacks on the same box. public DNS only resolves the API and the marketing site, everything else is tailnet only.
|
||||
|
||||
## **PHASE 2: Backend API Foundation**
|
||||
backups go to a second machine sitting in my house. that machine runs **Linux From Scratch** with a hand compiled kernel and a hand rolled ports repo i maintain myself with [scratchpkg](https://github.com/emmett1/scratchpkg). no docker, no apt, every binary built from a recipe i wrote. it also runs my self hosted [Gitea](https://gitea.com). it's got like 4 GB of RAM so it has to run lean. picked it because i wanted to learn how a linux box actually fits together when nobody's holding your hand.
|
||||
|
||||
### Objective
|
||||
Build a REST API backend in Go that the TUI can communicate with.
|
||||
backup pipeline runs nightly. cron on the Hetzner box snapshots the SQLite db via the backup API, ships it over Tailscale SFTP to the LFS box with [restic](https://restic.net), encrypted and deduped. dual dead man switches, [healthchecks.io](https://healthchecks.io) and a Uptime Kuma push monitor, both go off if the cron silently dies.
|
||||
|
||||
### 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`)
|
||||
## observability
|
||||
|
||||
### Tasks
|
||||
probably the over engineered part. honestly built it because i wanted to learn the stack.
|
||||
|
||||
#### 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
|
||||
**Prometheus** scrapes the API for the usual RED metrics plus custom counters like cart conversions, auth attempts, stripe events, log levels, orders created.
|
||||
|
||||
**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
|
||||
```
|
||||
**Grafana** sits in front of everything. tailnet only, no public DNS.
|
||||
|
||||
#### 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
|
||||
**Loki + Promtail** for logs. promtail auto discovers every container via docker labels so anything new on the box is logged for free.
|
||||
|
||||
**Schema to create**:
|
||||
```sql
|
||||
-- See MODELS.md for complete schema
|
||||
users, products, carts, cart_items, orders, order_items, addresses
|
||||
```
|
||||
**Tempo** for traces. the API exports OTLP via [otelchi](https://github.com/riandyrn/otelchi) on the inbound side and [otelhttp](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp) on outbound calls to stripe, shippo, bring. the GORM tracing plugin emits a span per SQL query.
|
||||
|
||||
#### 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
|
||||
**Uptime Kuma** for blackbox checks. API health, TLS, backup target reachable.
|
||||
|
||||
**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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
**Alerts** route through Grafana to a ntfy webhook to my phone.
|
||||
|
||||
#### 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
|
||||
end to end, a request to `/api/v1/cart` shows up as one trace with the chi handler as the root, gorm queries as children, and outbound HTTP to stripe as a sibling. all one tempo span tree.
|
||||
|
||||
---
|
||||
|
||||
## **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
|
||||
## local dev
|
||||
|
||||
```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
|
||||
git clone https://github.com/marcusDenslow/terminalShop
|
||||
cd terminalShop
|
||||
cp .env.example .env # fill in stripe test keys
|
||||
go run main.go # ssh server on :23457
|
||||
go run api/main.go # API on :8080
|
||||
ssh localhost -p 23457
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
uses SQLite by default so there's nothing to spin up.
|
||||
|
||||
```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
|
||||
```
|
||||
## inspiration
|
||||
|
||||
## Resources
|
||||
|
||||
- **Reference Implementation**: `terminal-shop-source/` directory
|
||||
- **Technical Specs**: See `MODELS.md`
|
||||
- **Bubbletea Tutorial**: https://github.com/charmbracelet/bubbletea/tree/master/tutorials
|
||||
- **Stripe Go Docs**: https://stripe.com/docs/api?lang=go
|
||||
- **Chi Router**: https://go-chi.io/
|
||||
|
||||
## Contributing Guidelines for Claude Code
|
||||
|
||||
When working on this project:
|
||||
|
||||
1. **Always check MODELS.md** before writing code to follow established patterns
|
||||
2. **Read the current phase** in this README to understand what to build next
|
||||
3. **Reference terminal.shop source code** in `terminal-shop-source/` when unsure
|
||||
4. **Update this README** when completing tasks (check off boxes)
|
||||
5. **Follow Go best practices** and the style guide in MODELS.md
|
||||
6. **Write tests** for new API endpoints
|
||||
7. **Keep commits focused** - one feature/fix per commit
|
||||
|
||||
## License
|
||||
|
||||
MIT (or your preferred license)
|
||||
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
||||
|
||||
141
REFACTORING.md
141
REFACTORING.md
@@ -1,141 +0,0 @@
|
||||
# Refactoring Complete!
|
||||
|
||||
The codebase has been refactored from a single `model.go` file (~650 lines) into a clean, modular structure.
|
||||
|
||||
## New Structure
|
||||
|
||||
```
|
||||
terminalShop/
|
||||
├── main.go # Entry point (~20 lines)
|
||||
├── pkg/
|
||||
│ ├── models/
|
||||
│ │ └── coffee.go # Data models (~25 lines)
|
||||
│ └── tui/
|
||||
│ ├── model.go # Core model & state (~160 lines)
|
||||
│ ├── views.go # Main View() function (~60 lines)
|
||||
│ ├── shop.go # Shop view (~90 lines)
|
||||
│ ├── cart.go # Cart view (~90 lines)
|
||||
│ ├── account.go # Account view (~75 lines)
|
||||
│ ├── header.go # Header rendering (~95 lines)
|
||||
│ └── footer.go # Footer rendering (~75 lines)
|
||||
├── model.go.old # Backup of old file
|
||||
└── server.go.old # Backup of old file
|
||||
```
|
||||
|
||||
## What Changed
|
||||
|
||||
### Before
|
||||
- **1 file**: `model.go` with 650+ lines
|
||||
- All code mixed together
|
||||
- Hard to navigate and maintain
|
||||
- Difficult to find specific features
|
||||
|
||||
### After
|
||||
- **8 focused files** averaging ~70 lines each
|
||||
- Clear separation of concerns
|
||||
- Easy to find and modify features
|
||||
- Follows Go best practices
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
| File | Purpose | Key Functions |
|
||||
|------|---------|---------------|
|
||||
| `main.go` | App entry point | `main()` |
|
||||
| `pkg/models/coffee.go` | Data structures | `Coffee`, `CartItem`, `AccountMenuItems` |
|
||||
| `pkg/tui/model.go` | State management | `Model`, `Init()`, `Update()`, `NewModel()` |
|
||||
| `pkg/tui/views.go` | Layout assembly | `View()` |
|
||||
| `pkg/tui/shop.go` | Shop view | `BuildShopView()` |
|
||||
| `pkg/tui/cart.go` | Cart view | `BuildCartView()` |
|
||||
| `pkg/tui/account.go` | Account views | `BuildAccountView()`, `buildAccountSubpage()` |
|
||||
| `pkg/tui/header.go` | Header/tabs | `BuildHeader()` |
|
||||
| `pkg/tui/footer.go` | Footer/keybinds | `BuildFooter()` |
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Easier to Navigate**: Find shop code in `shop.go`, cart code in `cart.go`, etc.
|
||||
2. **Easier to Test**: Each component can be tested independently
|
||||
3. **Easier to Extend**: Add new views by creating new files
|
||||
4. **Better Organization**: Related code stays together
|
||||
5. **Cleaner Git Diffs**: Changes to cart don't affect shop code
|
||||
6. **Follows Standards**: Matches Go project conventions
|
||||
|
||||
## Running the App
|
||||
|
||||
Same as before:
|
||||
|
||||
```bash
|
||||
# Run the app
|
||||
go run main.go
|
||||
|
||||
# Or build and run
|
||||
go build -o terminalshop main.go
|
||||
./terminalshop
|
||||
```
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### Adding a New View (e.g., "Settings")
|
||||
|
||||
1. Create `pkg/tui/settings.go`:
|
||||
```go
|
||||
package tui
|
||||
|
||||
func (m Model) BuildSettingsView() string {
|
||||
// Your settings view code
|
||||
}
|
||||
```
|
||||
|
||||
2. Update `model.go`:
|
||||
- Add `viewingSettings bool` to Model struct
|
||||
- Add keybind case in Update()
|
||||
|
||||
3. Update `views.go`:
|
||||
- Add condition for settings view
|
||||
|
||||
4. Update `header.go` and `footer.go`:
|
||||
- Add settings tab/keybinds
|
||||
|
||||
### Adding New Data Models
|
||||
|
||||
Add to `pkg/models/` directory:
|
||||
```go
|
||||
// pkg/models/order.go
|
||||
package models
|
||||
|
||||
type Order struct {
|
||||
ID string
|
||||
Items []CartItem
|
||||
Total float64
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- Old files backed up as `.old`
|
||||
- All functionality preserved
|
||||
- No behavior changes
|
||||
- Imports use `terminalShop/pkg/...`
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that the code is organized, you can easily:
|
||||
- Add new views (checkout, payment, orders)
|
||||
- Implement API client in `pkg/api/`
|
||||
- Add database models in `pkg/models/`
|
||||
- Create tests for each component
|
||||
- Continue with Phase 2 of the roadmap (Backend API)
|
||||
|
||||
## Rollback
|
||||
|
||||
If needed, restore the old structure:
|
||||
```bash
|
||||
mv model.go.old model.go
|
||||
mv server.go.old server.go
|
||||
rm -rf pkg/
|
||||
# Edit main.go to use InitialModel again
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Refactored on**: 2026-02-12
|
||||
**Lines of code**: Same functionality, better organized!
|
||||
42
TODO.md
42
TODO.md
@@ -1,42 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Done
|
||||
- [x] Cart persistence across sessions — sync cart to server, fetch on startup
|
||||
- [x] Token refresh — auto-refresh JWT before expiry
|
||||
- [x] Help screen — press ? to show keybindings
|
||||
- [x] Order detail view — press Enter on an order in account view to see full details
|
||||
|
||||
## Up Next
|
||||
- [x] FAQ page — replace hardcoded 2 Q&As with real content (embedded JSON, ~12 entries)
|
||||
- [x] About page — replace placeholder with real content/credits
|
||||
- [ ] Loading spinner — replace plain text with animated bubbles/spinner
|
||||
- [ ] Address update — PUT /api/v1/addresses/:id endpoint
|
||||
- [ ] Single order endpoint — GET /api/v1/orders/:id
|
||||
- [ ] User profile editing — PUT /api/v1/profile, set name/email from account view
|
||||
|
||||
## Feature Gaps (vs Reference)
|
||||
- [ ] Bulk data preload — single /view/init endpoint to fetch all user data at once
|
||||
- [ ] Splash screen — logo + auth + data preload on connect
|
||||
- [ ] Product variants — multiple variants per product (roast types) with separate prices
|
||||
- [ ] Featured/originals product sections — categorize products in shop view
|
||||
- [ ] Per-product theme colors — dynamic accent color based on selected product
|
||||
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle region
|
||||
- [ ] Stripe webhook — sync card updates from Stripe events
|
||||
- [ ] Shippo webhook — order tracking status updates
|
||||
- [ ] Email system — order confirmation, shipped notification emails
|
||||
|
||||
## Testing
|
||||
- [ ] API client tests — pkg/api/client.go has no tests
|
||||
- [ ] Card handler tests — api/handlers/cards.go
|
||||
- [ ] Order handler tests — api/handlers/orders.go
|
||||
- [ ] Address handler tests — api/handlers/addresses.go
|
||||
- [ ] TUI tests — model_test.go and menu_test.go are empty skeletons
|
||||
- [ ] Shippo/Bring integration tests
|
||||
|
||||
## Post-Release
|
||||
- [ ] Subscription model — database table, CRUD endpoints, scheduling
|
||||
- [ ] Subscription creation flow — TUI: select variant → shipping → payment → confirm
|
||||
- [ ] Subscription management — TUI: list, detail view, cancel with confirmation
|
||||
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
|
||||
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete in account view
|
||||
- [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts
|
||||
BIN
api/api-server
BIN
api/api-server
Binary file not shown.
@@ -2,7 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
func addressLog() *slog.Logger { return slog.With("component", "address") }
|
||||
|
||||
type AddressHandler struct {
|
||||
shippoKey string
|
||||
bringAPIUID string
|
||||
@@ -33,7 +35,7 @@ func NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey string) *AddressHa
|
||||
|
||||
// Retrieve all saved addresses for a user
|
||||
func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
@@ -46,7 +48,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
@@ -76,11 +78,11 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
switch req.Country {
|
||||
case "US":
|
||||
if h.shippoKey == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
|
||||
return
|
||||
}
|
||||
client := shippo.NewClient(h.shippoKey)
|
||||
validated, err := client.ValidateAddress(shippo.Address{
|
||||
validated, err := client.ValidateAddress(r.Context(), shippo.Address{
|
||||
Name: req.Name,
|
||||
Street1: req.Street,
|
||||
Street2: req.Street2,
|
||||
@@ -91,7 +93,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
Phone: req.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("shippo address validation failed: %v", err)
|
||||
addressLog().Warn("shippo address validation failed", "error", err, "country", "US")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
|
||||
return
|
||||
}
|
||||
@@ -112,7 +114,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
client := bring.NewClient(h.bringAPIUID, h.bringAPIKey)
|
||||
validated, err := client.ValidateAddress(bring.Address{
|
||||
validated, err := client.ValidateAddress(r.Context(), bring.Address{
|
||||
Name: req.Name,
|
||||
Street1: req.Street,
|
||||
Street2: req.Street2,
|
||||
@@ -122,7 +124,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
Phone: req.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("bring address validation failed: %v", err)
|
||||
addressLog().Warn("bring address validation failed", "error", err, "country", "NO")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
|
||||
return
|
||||
}
|
||||
@@ -141,6 +143,12 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// remove existing addres with the same street addres
|
||||
var existing models.Address
|
||||
if err := db.Where("user_id = ? AND street = ?", userID, address.Street).First(&existing).Error; err == nil {
|
||||
db.Delete(&existing)
|
||||
}
|
||||
|
||||
if err := db.Create(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create address", nil)
|
||||
return
|
||||
@@ -153,7 +161,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// DeleteAddress deltes a saved address
|
||||
func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -166,12 +174,14 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
var address models.Address
|
||||
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ADDRESS NOT FOUND", "address not found", nil)
|
||||
utils.RespondError(w, http.StatusNotFound, "ADDRESS_NOT_FOUND", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// ownership is guaranteed by the WHERE clause above
|
||||
db.Delete(&address)
|
||||
if err := db.Delete(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "address successfully deleted",
|
||||
@@ -181,7 +191,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
// Mark an address as the users default address
|
||||
|
||||
func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -197,10 +207,16 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false)
|
||||
if err := db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
address.IsDefault = true
|
||||
db.Save(&address)
|
||||
if err := db.Save(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"address": address,
|
||||
|
||||
160
api/handlers/addresses_test.go
Normal file
160
api/handlers/addresses_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupAddressTestDB(t *testing.T) (string, models.User) {
|
||||
t.Helper()
|
||||
testDB := "test_addresses.db"
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
if err := database.Seed(db); err != nil {
|
||||
t.Fatalf("failed to seed: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:addresstestfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA addresstestkey",
|
||||
Name: "Address Test User",
|
||||
Email: "address@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
return testDB, user
|
||||
}
|
||||
|
||||
func TestGetAddressesEmpty(t *testing.T) {
|
||||
testDB, user := setupAddressTestDB(t)
|
||||
defer func() { _ = 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 func() { _ = 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 func() { _ = 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))
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,20 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func authLog() *slog.Logger { return slog.With("component", "auth") }
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct {
|
||||
jwtManager *auth.JWTManager
|
||||
@@ -27,54 +33,86 @@ 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) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
var req TokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
middleware.RecordAuthAttempt("invalid_json")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// validate shared secret
|
||||
if req.ClientSecret != h.authFingerprintKey {
|
||||
middleware.RecordAuthAttempt("invalid_secret")
|
||||
authLog().Warn("invalid client secret", "remote", r.RemoteAddr)
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SECRET", "invalid client secret", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Return if no fingerprint is found
|
||||
if req.Fingerprint == "" {
|
||||
middleware.RecordAuthAttempt("missing_fingerprint")
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
middleware.RecordAuthAttempt("user_not_found")
|
||||
authLog().Warn("user not found and no public key for registration", "fingerprint", req.Fingerprint)
|
||||
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 {
|
||||
middleware.RecordAuthAttempt("error")
|
||||
authLog().Error("failed to create user", "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil)
|
||||
return
|
||||
}
|
||||
middleware.RecordAuthAttempt("registered")
|
||||
authLog().Info("user registered", "user_id", user.ID)
|
||||
} else if err != nil {
|
||||
middleware.RecordAuthAttempt("error")
|
||||
authLog().Error("failed to look up user", "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to look up user", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
middleware.RecordAuthAttempt("error")
|
||||
authLog().Error("failed to generate token", "user_id", user.ID, "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.RecordAuthAttempt("success")
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800, // 30 minutes in seconds
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,7 +127,7 @@ type RegisterRequest struct {
|
||||
// RegisterWithSSHKey registers a new user with their SSH public key
|
||||
// No username required - following terminal.shop pattern
|
||||
func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -142,7 +180,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// GetUserBySSHKey retrieves a user by their SSH key fingerprint
|
||||
func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
fingerprint := r.URL.Query().Get("fingerprint")
|
||||
if fingerprint == "" {
|
||||
|
||||
@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
|
||||
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
|
||||
t.Helper()
|
||||
testDB := "test_auth.db"
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
|
||||
|
||||
func TestGetTokenSuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
user := createAuthTestUser(t, "SHA256:testfingerprint123")
|
||||
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
|
||||
|
||||
func TestGetTokenBadSecret(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
|
||||
|
||||
func TestGetTokenMissingFingerprint(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
|
||||
|
||||
func TestGetTokenUnknownFingerprint(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
|
||||
|
||||
func TestGetTokenInvalidJSON(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
|
||||
@@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
createAuthTestUser(t, "SHA256:existingfp")
|
||||
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
||||
|
||||
func TestGetUserBySSHKeySuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
createAuthTestUser(t, "SHA256:lookupfp")
|
||||
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
|
||||
|
||||
func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
|
||||
@@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
||||
|
||||
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)
|
||||
|
||||
@@ -1,48 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const RedirectTTL = 10 * time.Minute
|
||||
|
||||
func storeRedirect(stripeURL, purpose string) (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
now := time.Now()
|
||||
rec := models.PayRedirect{
|
||||
Token: token,
|
||||
URL: stripeURL,
|
||||
Purpose: purpose,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(RedirectTTL),
|
||||
}
|
||||
if err := database.GetDB().Create(&rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
var rec models.PayRedirect
|
||||
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if time.Now().After(rec.ExpiresAt) {
|
||||
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, rec.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.
|
||||
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
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()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -63,16 +112,96 @@ 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) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid card id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "CARD_NOT_FOUND", "card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
card.IsDefault = true
|
||||
if err := db.Save(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
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, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||
return
|
||||
}
|
||||
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().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
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 +220,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 +234,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 +253,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 +262,14 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save the card locally
|
||||
// remove existing local card with the same last4 + brand + expiry
|
||||
var existingCard models.Card
|
||||
if err := db.Where("user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?",
|
||||
user.ID, pm.Card.Last4, string(pm.Card.Brand), pm.Card.ExpMonth, pm.Card.ExpYear).First(&existingCard).Error; err == nil {
|
||||
db.Delete(&existingCard)
|
||||
}
|
||||
|
||||
// Persist only the safe card metadata — no raw card data is stored.
|
||||
card := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: pm.ID,
|
||||
@@ -163,6 +284,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,
|
||||
})
|
||||
@@ -171,7 +294,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
// DeleteCard removes a saved card. If the card is currently set on the user's
|
||||
// cart, the cart's card selection is cleared.
|
||||
func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -187,20 +310,56 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the card from any cart that references it
|
||||
// Clear the card from any cart that references it.
|
||||
cardID := uint(id)
|
||||
db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil)
|
||||
if err := db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to clear card from cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Detach from Stripe if it's a PaymentMethod (pm_)
|
||||
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
|
||||
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
||||
stripe.Key = h.stripeKey
|
||||
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
|
||||
}
|
||||
|
||||
// Soft-delete the local record
|
||||
db.Delete(&card)
|
||||
// Soft-delete the local record.
|
||||
if err := db.Delete(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.CardDeleted(userID, card.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "card deleted",
|
||||
})
|
||||
}
|
||||
|
||||
// ensureStripeCustomer creates a Stripe Customer for the user if they don't
|
||||
// already have one and persists the customer ID. Safe to call concurrently —
|
||||
// a duplicate Stripe customer is harmless; the local record reflects the first
|
||||
// successful write.
|
||||
func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error {
|
||||
if user.StripeCustomerID != "" {
|
||||
return nil
|
||||
}
|
||||
params := &stripe.CustomerParams{
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
|
||||
}
|
||||
if user.Email != "" {
|
||||
params.Email = stripe.String(user.Email)
|
||||
}
|
||||
if user.Name != "" {
|
||||
params.Name = stripe.String(user.Name)
|
||||
}
|
||||
cust, err := customer.New(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
if err := gdb.Save(user).Error; err != nil {
|
||||
return fmt.Errorf("failed to save stripe customer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey}
|
||||
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
func getOrCreateCart(userID uint) (*models.Cart, error) {
|
||||
db := database.GetDB()
|
||||
func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
var cart models.Cart
|
||||
err := db.Where("user_id = ?", userID).First(&cart).Error
|
||||
if err != nil {
|
||||
@@ -39,8 +46,8 @@ func getOrCreateCart(userID uint) (*models.Cart, error) {
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(cart *models.Cart) map[string]interface{} {
|
||||
db := database.GetDB()
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
@@ -48,14 +55,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,
|
||||
})
|
||||
}
|
||||
@@ -73,14 +79,14 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
|
||||
func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(cart),
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,7 +96,7 @@ type SetItemRequest struct {
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetItemRequest
|
||||
@@ -110,14 +116,17 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Quantity <= 0 {
|
||||
db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{})
|
||||
if err := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var item models.CartItem
|
||||
result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item)
|
||||
@@ -127,14 +136,20 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
CoffeeID: req.CoffeeID,
|
||||
Quantity: req.Quantity,
|
||||
}
|
||||
db.Create(&item)
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create item", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
db.Model(&item).Update("quantity", req.Quantity)
|
||||
if err := db.Model(&item).Update("quantity", req.Quantity).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update quantity", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(cart),
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,7 +158,7 @@ type SetAddressRequest struct {
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetAddressRequest
|
||||
@@ -158,13 +173,16 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(cart).Update("address_id", req.AddressID)
|
||||
if err := db.Model(cart).Update("address_id", req.AddressID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "address set",
|
||||
@@ -176,7 +194,7 @@ type SetCardRequest struct {
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetCardRequest
|
||||
@@ -191,13 +209,16 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(cart).Update("card_id", req.CardID)
|
||||
if err := db.Model(cart).Update("card_id", req.CardID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "card set",
|
||||
@@ -205,16 +226,19 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
|
||||
if err := db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "cart cleared",
|
||||
@@ -222,11 +246,16 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
ctx, span := otel.Tracer("api").Start(r.Context(), "cart.convert")
|
||||
defer span.End()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
middleware.RecordCartConversion("cart_lookup_failed")
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
@@ -234,73 +263,91 @@ 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 {
|
||||
middleware.RecordCartConversion("validation_empty_cart")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if cart.AddressID == nil {
|
||||
middleware.RecordCartConversion("validation_missing_address")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if cart.CardID == nil {
|
||||
middleware.RecordCartConversion("validation_missing_card")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.First(&address, *cart.AddressID).Error; err != nil {
|
||||
middleware.RecordCartConversion("validation_invalid_address")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.First(&card, *cart.CardID).Error; err != nil {
|
||||
middleware.RecordCartConversion("validation_invalid_card")
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
middleware.RecordCartConversion("user_not_found")
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
middleware.RecordCartConversion("stripe_customer_failed")
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if user.Email != "" {
|
||||
params.Email = stripe.String(user.Email)
|
||||
}
|
||||
|
||||
if user.Name != "" {
|
||||
params.Name = stripe.String(user.Name)
|
||||
}
|
||||
|
||||
cust, err := customer.New(params)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
// All cards are now saved as pm_ PaymentMethods. This branch handles any
|
||||
// legacy tok_ records that predate the server-side tokenization refactor.
|
||||
paymentMethodID := card.StripePaymentID
|
||||
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
|
||||
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
|
||||
Type: stripe.String("card"),
|
||||
Card: &stripe.PaymentMethodCardParams{
|
||||
Token: stripe.String(paymentMethodID),
|
||||
},
|
||||
})
|
||||
if pmErr != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
|
||||
return
|
||||
}
|
||||
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
}); attachErr != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
|
||||
return
|
||||
}
|
||||
paymentMethodID = pm.ID
|
||||
card.StripePaymentID = pm.ID
|
||||
if err := db.Save(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
db.Save(&user)
|
||||
}
|
||||
|
||||
var subtotal int
|
||||
var orderItems []models.OrderItem
|
||||
for _, item := range items {
|
||||
priceInCents := int(item.Coffee.Price * 100)
|
||||
lineTotal := priceInCents * item.Quantity
|
||||
lineTotal := item.Coffee.Price * item.Quantity
|
||||
subtotal += lineTotal
|
||||
orderItems = append(orderItems, models.OrderItem{
|
||||
CoffeeID: item.CoffeeID,
|
||||
Name: item.Coffee.Name,
|
||||
Price: priceInCents,
|
||||
Price: item.Coffee.Price,
|
||||
Quantity: item.Quantity,
|
||||
})
|
||||
}
|
||||
@@ -308,76 +355,18 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
total := subtotal + cart.ShippingCost
|
||||
|
||||
if total < 50 {
|
||||
middleware.RecordCartConversion("validation_min_amount")
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||
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,
|
||||
@@ -393,20 +382,212 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
middleware.RecordCartConversion("db_create_order_failed")
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
|
||||
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),
|
||||
OffSession: stripe.Bool(true),
|
||||
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
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),
|
||||
}
|
||||
|
||||
stripeStart := time.Now()
|
||||
pi, err := paymentintent.New(piParams)
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if err != nil {
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
|
||||
if stripeErr, ok := err.(*stripe.Error); ok {
|
||||
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
switch stripeErr.Type {
|
||||
case stripe.ErrorTypeCard:
|
||||
middleware.RecordCartConversion("card_declined")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
case stripe.ErrorTypeInvalidRequest:
|
||||
middleware.RecordCartConversion("stripe_invalid_request")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
|
||||
default:
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
middleware.RecordCartConversion("stripe_error")
|
||||
audit.OrderFailed(userID, order.ID, total, err.Error())
|
||||
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
|
||||
|
||||
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
|
||||
h.respondRequiresAction(w, &order, paymentMethodID, pi)
|
||||
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.
|
||||
middleware.RecordCartConversion("payment_critical")
|
||||
audit.PaymentCritical(order.ID, pi.ID, txErr)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Preload("Items").First(&order, order.ID)
|
||||
audit.OrderPaid(userID, order.ID, total, pi.ID)
|
||||
middleware.RecordOrderCreated(string(order.Status))
|
||||
middleware.RecordCartConversion("success")
|
||||
middleware.ObserveOrderValueCents(total)
|
||||
|
||||
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil)
|
||||
return
|
||||
}
|
||||
go notify.SlackOrderPaid(&order)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
|
||||
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
|
||||
// The order stays pending. the webhook flips it to paid once the customer
|
||||
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
|
||||
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
|
||||
db := database.GetDB()
|
||||
|
||||
// After an off-session authentication_required decline, the PI sits in
|
||||
// status=requires_payment_method with payment_method detached. Re-confirm
|
||||
// on-session with the same PM so Stripe pushes the PI into requires_action
|
||||
// and populates next_action.redirect_to_url with the 3DS challenge URL.
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
OffSession: stripe.Bool(false),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
})
|
||||
if cerr == nil && confirmed != nil {
|
||||
pi = confirmed
|
||||
}
|
||||
}
|
||||
|
||||
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
|
||||
middleware.RecordCartConversion("sca_missing_next_action")
|
||||
db.Model(order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the paymentIntent id AND reset status to pending. The
|
||||
// payment_intent.payment_failed webhook from the initial off-session decline
|
||||
// often arrives before this response and flips the order to failed —
|
||||
// override it back so the 3DS-in-flight order shows the correct state.
|
||||
if err := db.Model(order).Updates(map[string]any{
|
||||
"stripe_payment_id": pi.ID,
|
||||
"status": models.OrderStatusPending,
|
||||
}).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.RecordCartConversion("requires_action")
|
||||
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"payment_intent_id": pi.ID,
|
||||
"status": "requires_action",
|
||||
"redirect_token": token,
|
||||
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
|
||||
func mapStripeCardErrCode(code stripe.ErrorCode) string {
|
||||
switch code {
|
||||
case stripe.ErrorCodeAuthenticationRequired:
|
||||
return "AUTHENTICATION_REQUIRED"
|
||||
case "authentication_not_supported":
|
||||
return "AUTHENTICATION_NOT_SUPPORTED"
|
||||
case "card_declined", "":
|
||||
return "CARD_DECLINED"
|
||||
case "expired_card":
|
||||
return "CARD_EXPIRED"
|
||||
case "incorrect_cvc":
|
||||
return "CARD_CVC_FAILED"
|
||||
case "insufficient_funds":
|
||||
return "CARD_INSUFFICIENT_FUNDS"
|
||||
case "processing_error":
|
||||
return "CARD_PROCESSING_ERROR"
|
||||
}
|
||||
return "CARD_DECLINED"
|
||||
}
|
||||
|
||||
//nolint:unused // wired into Convert cart later
|
||||
func appURLForHandler(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||
host = fh
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
func setupCartTestDB(t *testing.T) (string, models.User) {
|
||||
t.Helper()
|
||||
testDB := "test_cart.db"
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
@@ -62,10 +62,10 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request {
|
||||
|
||||
func TestGetCartEmpty(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -101,10 +101,10 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
|
||||
func TestSetItemAndGetCart(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -157,10 +157,10 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
|
||||
func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||
@@ -190,7 +190,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
|
||||
@@ -202,10 +202,10 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
|
||||
func TestSetItemRemove(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||
@@ -232,7 +232,7 @@ func TestSetItemRemove(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 0 {
|
||||
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
|
||||
@@ -241,10 +241,10 @@ func TestSetItemRemove(t *testing.T) {
|
||||
|
||||
func TestSetItemInvalidProduct(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -258,10 +258,10 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
||||
|
||||
func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
@@ -275,10 +275,10 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
|
||||
func TestClearCart(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
@@ -310,7 +310,7 @@ func TestClearCart(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 0 {
|
||||
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
|
||||
@@ -319,7 +319,7 @@ func TestClearCart(t *testing.T) {
|
||||
|
||||
func TestSetAddress(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -358,7 +358,7 @@ func TestSetAddress(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
|
||||
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
|
||||
@@ -367,7 +367,7 @@ func TestSetAddress(t *testing.T) {
|
||||
|
||||
func TestSetAddressWrongUser(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
@@ -402,7 +402,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
|
||||
func TestSetCard(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -440,7 +440,7 @@ func TestSetCard(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
|
||||
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
|
||||
@@ -449,7 +449,7 @@ func TestSetCard(t *testing.T) {
|
||||
|
||||
func TestSetCardWrongUser(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
@@ -482,10 +482,10 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
|
||||
func TestConvertCartMissingAddress(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add item but no address or card
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
@@ -505,10 +505,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
||||
|
||||
func TestConvertCartMissingCard(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
@@ -536,10 +536,10 @@ func TestConvertCartMissingCard(t *testing.T) {
|
||||
|
||||
func TestConvertCartEmpty(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Empty cart convert
|
||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
@@ -553,10 +553,10 @@ func TestConvertCartEmpty(t *testing.T) {
|
||||
|
||||
func TestMultipleItems(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
@@ -583,7 +583,7 @@ func TestMultipleItems(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 3 {
|
||||
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
|
||||
@@ -595,7 +595,7 @@ func TestMultipleItems(t *testing.T) {
|
||||
|
||||
func TestCartIsolation(t *testing.T) {
|
||||
testDB, user1 := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
handler := NewCartHandler("", "")
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
@@ -638,7 +638,7 @@ func TestCartIsolation(t *testing.T) {
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||
@@ -655,7 +655,7 @@ func TestCartIsolation(t *testing.T) {
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||
|
||||
@@ -1,33 +1,406 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/bring"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/shipping"
|
||||
"terminalShop/pkg/shippo"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/refund"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
stripeKey string
|
||||
bringAPIUID string
|
||||
bringAPIKey string
|
||||
bringCustomerNumber string
|
||||
}
|
||||
|
||||
type OrderHandler struct {}
|
||||
type refundRequestPayload struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// refundRequestCooldown is the minimum interval between customer-initiated
|
||||
// refund requests for the same order. Prevents accidental double-clicks and
|
||||
// repeated submissions from spamming the operator's Slack channel.
|
||||
const refundRequestCooldown = 15 * time.Minute
|
||||
|
||||
func NewOrderHandler() *OrderHandler {
|
||||
return &OrderHandler{}
|
||||
}
|
||||
|
||||
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
stripeKey: stripeSecretKey,
|
||||
bringAPIUID: bringAPIUID,
|
||||
bringAPIKey: bringAPIKey,
|
||||
bringCustomerNumber: bringCustomerNumber,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a customer refund request into the order's Slack
|
||||
// thread. It does not issue a Stripe refund.
|
||||
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
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 req refundRequestPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Message = strings.TrimSpace(req.Message)
|
||||
|
||||
if !models.IsValidRefundReason(req.Reason) {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
|
||||
return
|
||||
}
|
||||
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Preload("User").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
|
||||
}
|
||||
if !order.CanRequestRefund() {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
|
||||
"only paid, shipped, or delivered orders can request refunds", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.LastRefundRequestAt != nil {
|
||||
elapsed := time.Since(*order.LastRefundRequestAt)
|
||||
if elapsed < refundRequestCooldown {
|
||||
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
|
||||
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
|
||||
nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
|
||||
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
|
||||
if req.Message != "" {
|
||||
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
|
||||
text += "\n\n" + quoted
|
||||
}
|
||||
|
||||
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Record the timestamp after a successful Slack post so a Slack outage
|
||||
// doesn't consume the user's cooldown — they can retry immediately.
|
||||
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
|
||||
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
db.Where("user_id = ?", userID).
|
||||
Preload("Items").
|
||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||
Order("created_at desc").Find(&orders)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"orders": orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
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
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"id": order.ID,
|
||||
"status": string(order.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// 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().WithContext(r.Context())
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// updateTrackingRequest is the JSON body accepted by UpdateTracking.
|
||||
// shipped_at defaults to now if not otherwise specified
|
||||
type updateTrackingRequest struct {
|
||||
Carrier string `json:"carrier"`
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
TrackingURL string `json:"tracking_url"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
}
|
||||
|
||||
// UpdateTracking sets carrier metadata on the order. Transitions paid orders
|
||||
// to shipped automatically. Admin only, gated by RequireAdmin in routes.go,
|
||||
// not RequireAuth, so no user JWT is needed.
|
||||
func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(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 req updateTrackingRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
if req.Carrier == "" || req.TrackingNumber == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELDS", "carrier and or tracking_number fields are required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ?", id).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
shippedAt := req.ShippedAt
|
||||
if shippedAt == nil {
|
||||
now := time.Now()
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
"tracking_url": req.TrackingURL,
|
||||
"shipped_at": shippedAt,
|
||||
}
|
||||
if order.Status == models.OrderStatusPaid {
|
||||
updates["status"] = models.OrderStatusShipped
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to record shipment", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"order_id": order.ID,
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
"status": order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// PurchaseLabel buys a shipping label from shippo for the order, writes
|
||||
// the carrier, tracking, and label fields onto the order, and changes the status
|
||||
// from paid to shipped
|
||||
func (h *OrderHandler) PurchaseLabel(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(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.Preload("Items.Coffee").Preload("User").Where("id = ?", id).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.TrackingNumber != "" {
|
||||
utils.RespondError(w, http.StatusConflict, "ALREADY_LABELED", "order already has a tracking number", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if order.Status != models.OrderStatusPaid {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "only paid orders can be labeled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
const ozToKg = 0.0283495
|
||||
const defaultOunces = 12.0
|
||||
|
||||
totalKg := 0.0
|
||||
bringItems := make([]bring.LineItem, 0, len(order.Items))
|
||||
shippoItems := make([]shippo.LineItem, 0, len(order.Items))
|
||||
for _, item := range order.Items {
|
||||
oz := float64(item.Coffee.Ounces)
|
||||
if oz <= 0 {
|
||||
oz = defaultOunces
|
||||
}
|
||||
kg := oz * ozToKg
|
||||
totalKg += float64(item.Quantity) * kg
|
||||
bringItems = append(bringItems, bring.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
|
||||
shippoItems = append(shippoItems, shippo.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
|
||||
}
|
||||
|
||||
var result *shipping.LabelResult
|
||||
switch order.ShippingCountry {
|
||||
case "US":
|
||||
to := shippo.Address{
|
||||
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity, State: order.ShippingState, Country: order.ShippingCountry,
|
||||
Zip: order.ShippingZip, Phone: order.ShippingPhone,
|
||||
}
|
||||
client := shippo.NewClient(os.Getenv("SHIPPO_API_KEY"))
|
||||
result, err = client.CreateLabel(r.Context(), to, order.User.Email, shippoItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SHIPPO_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
case "NO":
|
||||
to := bring.BookingAddress{
|
||||
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity, Country: order.ShippingCountry, Zip: order.ShippingZip,
|
||||
Phone: order.ShippingPhone,
|
||||
}
|
||||
testMode := os.Getenv("ENVIRONMENT") != "production"
|
||||
client := bring.NewBookingClient(h.bringAPIUID, h.bringAPIKey, h.bringCustomerNumber, testMode)
|
||||
result, err = client.CreateLabel(r.Context(), to, order.User.Email, bringItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "BRING_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
default:
|
||||
utils.RespondError(w, http.StatusBadRequest, "UNSUPPORTED_DESTINATION", "no label vendor configured for this country", nil)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"carrier": result.Carrier,
|
||||
"tracking_number": result.TrackingNumber,
|
||||
"tracking_url": result.TrackingURL,
|
||||
"shipped_at": &now,
|
||||
"status": models.OrderStatusShipped,
|
||||
"shippo_transaction_id": result.TransactionID,
|
||||
"label_url": result.LabelURL,
|
||||
"label_cost_cents": result.CostCents,
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
audit.LabelOrphaned(order.ID, result.TransactionID, result.TrackingNumber, err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "label purchased but failed to record on order; ops will reconcile", nil)
|
||||
return
|
||||
}
|
||||
|
||||
audit.LabelPurchased(order.ID, result.Carrier, result.TrackingNumber, result.TransactionID, result.CostCents)
|
||||
go notify.SlackUnpinOrder(order.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"carrier": result.Carrier,
|
||||
"service": result.ServiceLevel,
|
||||
"tracking_number": result.TrackingNumber,
|
||||
"tracking_url": result.TrackingURL,
|
||||
"label_url": result.LabelURL,
|
||||
"cost_cents": result.CostCents,
|
||||
"status": models.OrderStatusShipped,
|
||||
})
|
||||
}
|
||||
|
||||
170
api/handlers/orders_test.go
Normal file
170
api/handlers/orders_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func setupOrderTestDB(t *testing.T) models.User {
|
||||
t.Helper()
|
||||
testDB := filepath.Join(t.TempDir(), "orders.db")
|
||||
database.ResetForTesting()
|
||||
t.Cleanup(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:ordertestfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
|
||||
Name: "Order Test User",
|
||||
Email: "orders@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
|
||||
req := authRequest(method, url, body, userID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", orderID)
|
||||
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||
}
|
||||
|
||||
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
|
||||
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Other","message":" "}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "MESSAGE_REQUIRED" {
|
||||
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusFailed,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "INVALID_STATE" {
|
||||
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
|
||||
user := setupOrderTestDB(t)
|
||||
|
||||
db := database.GetDB()
|
||||
recent := time.Now().Add(-1 * time.Minute)
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: 1,
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: 100,
|
||||
ShippingCost: 0,
|
||||
Total: 100,
|
||||
LastRefundRequestAt: &recent,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to create order: %v", err)
|
||||
}
|
||||
|
||||
handler := NewOrderHandler("", "", "", "")
|
||||
body := []byte(`{"reason":"Quality issue","message":""}`)
|
||||
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateRefundRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected status 429, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got == "" {
|
||||
t.Errorf("expected Retry-After header, got empty")
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "RATE_LIMITED" {
|
||||
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func NewProductHandler() *ProductHandler {
|
||||
|
||||
// GetProducts returns all products from the database
|
||||
func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
var products []models.Coffee
|
||||
|
||||
@@ -37,7 +37,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GetProduct returns a single product by ID
|
||||
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
|
||||
// Get ID from URL parameter
|
||||
idStr := chi.URLParam(r, "id")
|
||||
@@ -57,4 +57,3 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
|
||||
testDB := "test_products.db"
|
||||
|
||||
// Remove if exists
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
|
||||
// Reset singleton
|
||||
database.ResetForTesting()
|
||||
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
|
||||
|
||||
func TestGetProduct(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
|
||||
|
||||
func TestGetProductNotFound(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
|
||||
|
||||
func TestGetProductsEmptyDatabase(t *testing.T) {
|
||||
testDB := "test_empty.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
// Create database but don't seed
|
||||
|
||||
101
api/handlers/reconcile.go
Normal file
101
api/handlers/reconcile.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
reconcileLog().Info("checking pending orders", "count", 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()
|
||||
reconcileLog().Warn("found succeeded payment intent for unrecorded order — patching",
|
||||
"order_id", order.ID, "pi", pi.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 {
|
||||
reconcileLog().Error("failed to patch order", "order_id", order.ID, "error", txErr)
|
||||
}
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
reconcileLog().Error("stripe search error", "order_id", order.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
db := database.GetDB()
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
err := db.Where(
|
||||
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
|
||||
models.OrderStatusPaid, cutoff,
|
||||
).Order("created_at ASC").Find(&orders).Error
|
||||
if err != nil {
|
||||
reconcileLog().Error("unshipped query failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("unshipped orders found", "count", len(orders))
|
||||
notify.SlackUnshippedReminder(orders)
|
||||
}
|
||||
280
api/handlers/slack.go
Normal file
280
api/handlers/slack.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/notify"
|
||||
"terminalShop/pkg/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SlackHandler struct {
|
||||
signingSecret string
|
||||
adminKey string
|
||||
apiPort string
|
||||
shippoSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewSlackHandler(signingSecret, adminKey, apiPort, shippoWebhookSecret string) *SlackHandler {
|
||||
return &SlackHandler{
|
||||
signingSecret: signingSecret,
|
||||
adminKey: adminKey,
|
||||
apiPort: apiPort,
|
||||
shippoSecret: shippoWebhookSecret,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type slackAction struct {
|
||||
ActionID string `json:"action_id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type slackInteractivePayload struct {
|
||||
Type string `json:"type"`
|
||||
ResponseURL string `json:"response_url"`
|
||||
Actions []slackAction `json:"actions"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
func (h *SlackHandler) HandleInteractivity(w http.ResponseWriter, r *http.Request) {
|
||||
if h.signingSecret == "" {
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "SLACK_NOT_CONFIGURED", "slack signing secret not set", nil)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "READ_FAILED", "could not read body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.verifySignature(r, body) {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "slack signature mismatch", nil)
|
||||
return
|
||||
}
|
||||
|
||||
form, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "BAD_FORM", "could not parse form body", nil)
|
||||
return
|
||||
}
|
||||
rawPayload := form.Get("payload")
|
||||
if rawPayload == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_PAYLOAD", "no payload field", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var p slackInteractivePayload
|
||||
if err := json.Unmarshal([]byte(rawPayload), &p); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "BAD_JSON_PAYLOAD", "could not parse payload", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(p.Actions) == 0 {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
action := p.Actions[0]
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
go h.dispatch(action, p)
|
||||
}
|
||||
|
||||
func (h *SlackHandler) dispatch(action slackAction, p slackInteractivePayload) {
|
||||
switch action.ActionID {
|
||||
case "buy_label":
|
||||
h.handleBuyLabel(action.Value, p)
|
||||
case "mark_pre_transit":
|
||||
h.handleMarkStatus(action.Value, p, "PRE_TRANSIT")
|
||||
case "mark_in_transit":
|
||||
h.handleMarkStatus(action.Value, p, "TRANSIT")
|
||||
case "mark_delivered":
|
||||
h.handleMarkStatus(action.Value, p, "DELIVERED")
|
||||
default:
|
||||
slog.Warn("slack interactivity: unknown action", "action_id", action.ActionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePayload) {
|
||||
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
|
||||
if err != nil {
|
||||
slog.Warn("slack buy_label: invalid order id", "value", orderIDStr)
|
||||
return
|
||||
}
|
||||
orderID := uint(orderID64)
|
||||
|
||||
url := fmt.Sprintf("http://localhost:%s/api/v1/orders/%d/label", h.apiPort, orderID)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil)
|
||||
req.Header.Set("X-Admin-Key", h.adminKey)
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
Carrier string `json:"carrier"`
|
||||
LabelURL string `json:"label_url"`
|
||||
CostCents int `json:"cost_cents"`
|
||||
} `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
_ = json.Unmarshal(bodyBytes, &result)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
|
||||
return
|
||||
}
|
||||
|
||||
notify.SlackPostLabelPurchased(orderID, result.Data.LabelURL, result.Data.CostCents)
|
||||
|
||||
}
|
||||
|
||||
func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePayload, status string) {
|
||||
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
|
||||
if err != nil {
|
||||
slog.Warn("slack mark_status: invalid order id", "value", orderIDStr)
|
||||
return
|
||||
}
|
||||
orderID := uint(orderID64)
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("id", "tracking_number", "carrier").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("slack mark_status: order lookup failed", "order_id", orderID, "error", err)
|
||||
return
|
||||
}
|
||||
if order.TrackingNumber == "" {
|
||||
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
|
||||
return
|
||||
}
|
||||
|
||||
detail := defaultDetailFor(status)
|
||||
payload := map[string]any{
|
||||
"event": "track_updated",
|
||||
"data": map[string]any{
|
||||
"tracking_number": order.TrackingNumber,
|
||||
"carrier": strings.ToLower(order.Carrier),
|
||||
"tracking_status": map[string]any{
|
||||
"status": status,
|
||||
"status_date": time.Now().UTC().Format(time.RFC3339),
|
||||
"status_details": detail,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
webhookURL := fmt.Sprintf("http://localhost:%s/api/v1/webhooks/shippo?token=%s", h.apiPort, url.QueryEscape(h.shippoSecret))
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 300 {
|
||||
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
audit.TrackingMarkedManually(orderID, status, p.User.Username)
|
||||
|
||||
h.editClearActions(p.ResponseURL, status, p.User.Username)
|
||||
}
|
||||
|
||||
func defaultDetailFor(status string) string {
|
||||
switch status {
|
||||
case "PRE_TRANSIT":
|
||||
return "Marked pre-transit by operator"
|
||||
case "TRANSIT":
|
||||
return "Marked transit by operator"
|
||||
case "DELIVERED":
|
||||
return "Marked delivered by operator"
|
||||
}
|
||||
return "Marked by operator"
|
||||
}
|
||||
|
||||
func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
|
||||
if responseURL == "" {
|
||||
return
|
||||
}
|
||||
stamp := fmt.Sprintf(":white_check_mark: Marked %s by @%s at %s", strings.ToLower(status), actor, time.Now().UTC().Format("15:04 MST"))
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"replace_original": true,
|
||||
"text": stamp,
|
||||
"blocks": []map[string]any{
|
||||
{"type": "context", "elements": []map[string]any{
|
||||
{"type": "mrkdwn", "text": stamp},
|
||||
}},
|
||||
},
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, responseURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("slack mark_status: response_url edit failed", "error", err)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
|
||||
tsHeader := r.Header.Get("X-Slack-Request-Timestamp")
|
||||
sigHeader := r.Header.Get("X-Slack-Signature")
|
||||
if tsHeader == "" || sigHeader == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(tsHeader, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if abs(time.Now().Unix()-ts) > 60*5 {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(h.signingSecret))
|
||||
_, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body)
|
||||
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expected), []byte(sigHeader))
|
||||
}
|
||||
|
||||
func abs(x int64) int64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
var _ = os.Getenv
|
||||
33
api/handlers/tracking.go
Normal file
33
api/handlers/tracking.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TrackingRedirect resolves an order ID and 302s to the carrier's tracking URL.
|
||||
// Public (no auth) so the URL stays short enough to copy from any TUI width.
|
||||
func TrackingRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "bad order id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("tracking_url").Where("id = ?", uint(id)).First(&order).Error; err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if order.TrackingURL == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, order.TrackingURL, http.StatusFound)
|
||||
}
|
||||
60
api/handlers/view.go
Normal file
60
api/handlers/view.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ViewHandler struct{}
|
||||
|
||||
func NewViewHandler() *ViewHandler {
|
||||
return &ViewHandler{}
|
||||
}
|
||||
|
||||
func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
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").
|
||||
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
|
||||
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,
|
||||
})
|
||||
}
|
||||
448
api/handlers/webhook.go
Normal file
448
api/handlers/webhook.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/notify"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
|
||||
|
||||
// WebhookHandler handles inbound Stripe webhook events.
|
||||
type WebhookHandler struct {
|
||||
webhookSecret string
|
||||
stripeKey string
|
||||
shippoSecret string
|
||||
}
|
||||
|
||||
// NewWebhookHandler creates a new webhook handler.
|
||||
func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
|
||||
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
|
||||
}
|
||||
|
||||
// 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.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
|
||||
} else {
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||
IgnoreAPIVersionMismatch: true,
|
||||
}); err != nil {
|
||||
webhookLog().Warn("signature verification failed", "error", err)
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var event stripe.Event
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil)
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
|
||||
case "checkout.session.completed":
|
||||
h.handleCheckoutSessionCompleted(r.Context(), 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(r.Context(), 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(r.Context(), 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.
|
||||
webhookLog().Info("payment method event received", "event_type", string(event.Type))
|
||||
}
|
||||
|
||||
// Always return 200 so Stripe doesn't retry.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event stripe.Event) {
|
||||
var pi stripe.PaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
|
||||
webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.succeeded")
|
||||
return
|
||||
}
|
||||
|
||||
orderIDStr, ok := pi.Metadata["order_id"]
|
||||
if !ok {
|
||||
// PaymentIntent not tied to a local order — nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
var order models.Order
|
||||
if err := db.Preload("Items").Where("id = ?", orderIDStr).First(&order).Error; err != nil {
|
||||
webhookLog().Warn("order not found for payment intent", "order_id", orderIDStr, "pi", pi.ID)
|
||||
return
|
||||
}
|
||||
|
||||
if order.Status == models.OrderStatusPaid {
|
||||
// Already recorded — idempotent.
|
||||
return
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear the user's cart if it hasn't been cleared yet.
|
||||
var cart models.Cart
|
||||
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
|
||||
return nil // Cart already gone.
|
||||
}
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete cart item: %w", err)
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
webhookLog().Error("failed to record paid status",
|
||||
"order_id", orderIDStr, "pi", pi.ID, "error", txErr)
|
||||
return
|
||||
}
|
||||
|
||||
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
|
||||
go notify.SlackOrderPaid(&order)
|
||||
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event stripe.Event) {
|
||||
var pi stripe.PaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
|
||||
webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.payment_failed")
|
||||
return
|
||||
}
|
||||
|
||||
orderIDStr, ok := pi.Metadata["order_id"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// authentication_required is recoverable — the cart handler re-confirms the
|
||||
// PI on-session and the customer completes 3DS via redirect. Marking the
|
||||
// order failed here would race with the recovery path and surface a
|
||||
// momentarily-broken state in the TUI.
|
||||
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
|
||||
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
|
||||
"order_id", orderIDStr, "pi", pi.ID)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
|
||||
Update("status", models.OrderStatusFailed).Error; err != nil {
|
||||
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
|
||||
}
|
||||
|
||||
errMsg := "unknown"
|
||||
if pi.LastPaymentError != nil {
|
||||
errMsg = pi.LastPaymentError.Msg
|
||||
}
|
||||
webhookLog().Warn("payment failed", "order_id", orderIDStr, "pi", pi.ID, "reason", errMsg)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, event stripe.Event) {
|
||||
var sess stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||
webhookLog().Error("failed to unmarshal checkout session", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if sess.Mode != stripe.CheckoutSessionModeSetup {
|
||||
return
|
||||
}
|
||||
|
||||
if sess.SetupIntent == nil || sess.Customer == nil {
|
||||
webhookLog().Warn("checkout session missing setup_intent or customer")
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
|
||||
if err != nil {
|
||||
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if si.PaymentMethod == nil {
|
||||
webhookLog().Warn("setup intent has no payment method", "setup_intent", si.ID)
|
||||
return
|
||||
}
|
||||
|
||||
pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil)
|
||||
if err != nil {
|
||||
webhookLog().Error("failed to get payment method", "payment_method", si.PaymentMethod.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
var user models.User
|
||||
if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil {
|
||||
webhookLog().Warn("user not found for customer", "customer", sess.Customer.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotency: skip if this exact PaymentMethod is already in use
|
||||
var existing models.Card
|
||||
if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// dedup against local cards table, matching last4 + brand + expiry means
|
||||
// the user is re-adding the same physical card. detach the new pm from
|
||||
// stripe and skip the local insert. Local DB is the source of truth
|
||||
// the stripe customers paymentmethods list may contain orphans from before
|
||||
// this fix, which would cause false-positive dedup hits
|
||||
if pm.Card != nil {
|
||||
var dup models.Card
|
||||
err := db.Where(
|
||||
"user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?",
|
||||
user.ID, pm.Card.Last4, string(pm.Card.Brand),
|
||||
int(pm.Card.ExpMonth), int(pm.Card.ExpYear),
|
||||
).First(&dup).Error
|
||||
if err == nil {
|
||||
if _, derr := paymentmethod.Detach(pm.ID, nil); derr != nil {
|
||||
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
|
||||
}
|
||||
// Bump UpdatedAt so the TUI poll signal can detect that the webhook
|
||||
// fired even though no new row was inserted.
|
||||
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil {
|
||||
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
|
||||
}
|
||||
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
|
||||
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 {
|
||||
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4)
|
||||
webhookLog().Info("card saved via checkout.session.completed", "user_id", user.ID)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if h.shippoSecret == "" || token != h.shippoSecret {
|
||||
webhookLog().Warn("shippo webhook: token missing or invalid")
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_TOKEN", "token missing or invalid", nil)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Event string `json:"event"`
|
||||
Data struct {
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
Carrier string `json:"carrier"`
|
||||
TrackingStatus struct {
|
||||
Status string `json:"status"`
|
||||
StatusDetails string `json:"status_details"`
|
||||
StatusDate time.Time `json:"status_date"`
|
||||
} `json:"tracking_status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Event != "track_updated" {
|
||||
webhookLog().Info("shippo webhook: ignoring no-tracking event", "event", payload.Event)
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "non-tracking event",
|
||||
"event": payload.Event,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newStatus, known := mapShippoStatus(payload.Data.TrackingStatus.Status)
|
||||
if !known {
|
||||
webhookLog().Warn("shippo webhook: unrecognized status, skipping",
|
||||
"status", payload.Data.TrackingStatus.Status,
|
||||
"tracking_number", payload.Data.TrackingNumber)
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "unrecognized status",
|
||||
"raw_status": payload.Data.TrackingStatus.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
var order models.Order
|
||||
if err := db.Where("tracking_number = ?", payload.Data.TrackingNumber).First(&order).Error; err != nil {
|
||||
webhookLog().Warn("shippo webhook: order not found",
|
||||
"tracking_number", payload.Data.TrackingNumber)
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "order not found",
|
||||
"tracking_number": payload.Data.TrackingNumber,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if order.TrackingStatusUpdatedAt != nil && !payload.Data.TrackingStatus.StatusDate.After(*order.TrackingStatusUpdatedAt) {
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"skipped": "stale status_date",
|
||||
"order_id": order.ID,
|
||||
"current_tracking_status": string(order.TrackingStatus),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
statusDate := payload.Data.TrackingStatus.StatusDate
|
||||
previousTrackingStatus := order.TrackingStatus
|
||||
updates := map[string]any{
|
||||
"tracking_status": newStatus,
|
||||
"tracking_status_details": payload.Data.TrackingStatus.StatusDetails,
|
||||
"tracking_status_updated_at": &statusDate,
|
||||
}
|
||||
deliveredTransition := newStatus == models.TrackingStatusDelivered &&
|
||||
order.Status != models.OrderStatusDelivered
|
||||
if deliveredTransition {
|
||||
updates["status"] = models.OrderStatusDelivered
|
||||
}
|
||||
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
webhookLog().Error("shippo webhook: failed to persist tracking update",
|
||||
"order_id", order.ID, "error", err)
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DB_ERROR", "failed to persist", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if previousTrackingStatus != newStatus {
|
||||
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
|
||||
go func() {
|
||||
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
|
||||
}()
|
||||
}
|
||||
if deliveredTransition {
|
||||
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
|
||||
}
|
||||
writeShippoResponse(w, map[string]any{
|
||||
"received": true,
|
||||
"order_id": order.ID,
|
||||
"tracking_status": string(newStatus),
|
||||
"previous_tracking_status": string(previousTrackingStatus),
|
||||
"lifecycle_status": string(order.Status),
|
||||
"delivered_transition": deliveredTransition,
|
||||
"status_details": payload.Data.TrackingStatus.StatusDetails,
|
||||
})
|
||||
}
|
||||
|
||||
func formatTrackingUpdate(status models.TrackingStatus, details string) string {
|
||||
var icon, label string
|
||||
switch status {
|
||||
case models.TrackingStatusPreTransit:
|
||||
icon, label = ":label:", "Label created"
|
||||
case models.TrackingStatusTransit:
|
||||
icon, label = ":truck:", "In transit"
|
||||
case models.TrackingStatusDelivered:
|
||||
icon, label = ":white_check_mark:", "Delivered"
|
||||
case models.TrackingStatusReturned:
|
||||
icon, label = ":leftwards_arrow_with_hook:", "Returned to sender"
|
||||
case models.TrackingStatusFailure:
|
||||
icon, label = ":warning:", "Delivery exception"
|
||||
default:
|
||||
icon, label = ":package:", string(status)
|
||||
}
|
||||
if details != "" {
|
||||
return fmt.Sprintf("%s *%s* — %s", icon, label, details)
|
||||
}
|
||||
return fmt.Sprintf("%s *%s*", icon, label)
|
||||
}
|
||||
|
||||
func mapShippoStatus(s string) (models.TrackingStatus, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "UNKNOWN":
|
||||
return models.TrackingStatusUnknown, true
|
||||
case "PRE_TRANSIT":
|
||||
return models.TrackingStatusPreTransit, true
|
||||
case "TRANSIT":
|
||||
return models.TrackingStatusTransit, true
|
||||
case "DELIVERED":
|
||||
return models.TrackingStatusDelivered, true
|
||||
case "RETURNED":
|
||||
return models.TrackingStatusReturned, true
|
||||
case "FAILURE":
|
||||
return models.TrackingStatusFailure, true
|
||||
}
|
||||
return models.TrackingStatusUnknown, false
|
||||
}
|
||||
|
||||
func writeShippoResponse(w http.ResponseWriter, payload map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
66
api/main.go
66
api/main.go
@@ -10,15 +10,25 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/api/handlers"
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/api/routes"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/observability"
|
||||
"terminalShop/pkg/stripeclient"
|
||||
)
|
||||
|
||||
const version = "v0.1.0"
|
||||
|
||||
func main() {
|
||||
observability.InitLogger()
|
||||
middleware.SetBuildInfo(version)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -30,23 +40,38 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
// Run migrations
|
||||
if err := database.Migrate(db); err != nil {
|
||||
log.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
audit.SetDB(db)
|
||||
|
||||
// Seed database with initial data
|
||||
if err := database.Seed(db); err != nil {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
}
|
||||
|
||||
otlpEndpoint := os.Getenv("OTLP_ENDPOINT")
|
||||
if otlpEndpoint == "" {
|
||||
otlpEndpoint = "tempo:4318"
|
||||
}
|
||||
shutdown, err := observability.InitTracing(context.Background(), "terminalshop-api", otlpEndpoint)
|
||||
if err != nil {
|
||||
log.Printf("tracing init failed (non-fatal): %v", err)
|
||||
} else {
|
||||
defer func() { _ = shutdown(context.Background()) }()
|
||||
}
|
||||
|
||||
stripeclient.InitOTel()
|
||||
|
||||
// Init JWT manager (same secret + duration as SSH server)
|
||||
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.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
@@ -65,7 +90,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 +98,45 @@ 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
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
handlers.ReconcileUnshipped()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileUnshipped()
|
||||
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 {
|
||||
|
||||
25
api/middleware/admin.go
Normal file
25
api/middleware/admin.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// RequireAdmin gates a handler behind X-Admin-Key matching the ADMIN_API_KEY
|
||||
// environment variable. if ADMIN_API_KEY is not set the endpoint returns 503, admin actions
|
||||
// must be explicitly enabled, never be the default option
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := os.Getenv("ADMIN_API_KEY")
|
||||
if expected == "" {
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "ADMIN_DISABLED", "admin endpoints are disabled", nil)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-Admin-Key") != expected {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid admin api key", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -4,16 +4,19 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code
|
||||
// responseWriter wraps http.ResponseWriter to capture status code and response size.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
statusCode int
|
||||
bytesWritten int64
|
||||
}
|
||||
|
||||
func newResponseWriter(w http.ResponseWriter) *responseWriter {
|
||||
return &responseWriter{w, http.StatusOK}
|
||||
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
@@ -21,6 +24,12 @@ func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.bytesWritten += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Logger logs HTTP requests
|
||||
func Logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,14 +38,21 @@ func Logger(next http.Handler) http.Handler {
|
||||
wrapped := newResponseWriter(w)
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
sc := trace.SpanContextFromContext(r.Context())
|
||||
tid := ""
|
||||
if sc.HasTraceID() {
|
||||
tid = sc.TraceID().String()
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
log.Printf(
|
||||
"%s %s %d %s",
|
||||
"%s %s %d %s trace_id=%s",
|
||||
r.Method,
|
||||
r.RequestURI,
|
||||
r.URL.Path,
|
||||
wrapped.statusCode,
|
||||
duration,
|
||||
tid,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
194
api/middleware/metrics.go
Normal file
194
api/middleware/metrics.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
httpRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "Total HTTP requests processed, labeled by method, route, and status",
|
||||
},
|
||||
[]string{"method", "route", "status"},
|
||||
)
|
||||
|
||||
httpRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Help: "HTTP request latency in seconds, labeled by method and route",
|
||||
Buckets: []float64{.0001, .0005, .001, .0025, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
httpInflightRequests = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "http_inflight_requests",
|
||||
Help: "Number of HTTP requests currently being handled",
|
||||
},
|
||||
)
|
||||
|
||||
httpRequestSize = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_size_bytes",
|
||||
Help: "HTTP request body size in bytes",
|
||||
Buckets: prometheus.ExponentialBuckets(64, 4, 8),
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
httpResponseSize = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_response_size_bytes",
|
||||
Help: "HTTP response body size in bytes",
|
||||
Buckets: prometheus.ExponentialBuckets(64, 4, 8),
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
ordersCreatedTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "orders_created_total",
|
||||
Help: "Total orders successfully created, labeled by status",
|
||||
},
|
||||
[]string{"status"},
|
||||
)
|
||||
|
||||
cartConversionTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "cart_conversion_total",
|
||||
Help: "Cart checkout outcomes (success, validation_failed, card_declined, stripe_failed, db_failed, payment_critical)",
|
||||
},
|
||||
[]string{"outcome"},
|
||||
)
|
||||
|
||||
orderValueDollars = promauto.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "order_value_dollars",
|
||||
Help: "Distribution of completed order totals, in dollars",
|
||||
Buckets: []float64{1, 5, 10, 20, 30, 50, 75, 100, 150, 250, 500, 1000},
|
||||
},
|
||||
)
|
||||
|
||||
stripeRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "stripe_request_duration_seconds",
|
||||
Help: "Latency of outbound Stripe API calls",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"op", "outcome"},
|
||||
)
|
||||
|
||||
authAttemptsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "auth_attempts_total",
|
||||
Help: "Authentication attempts, by result (success, invalid_secret, missing_fingerprint, user_not_found, registered, error)",
|
||||
},
|
||||
[]string{"result"},
|
||||
)
|
||||
|
||||
rateLimitRejectionsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "rate_limit_rejections_total",
|
||||
Help: "Number of requests rejected by rate limiters",
|
||||
},
|
||||
[]string{"scope"},
|
||||
)
|
||||
|
||||
logMessagesTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "log_messages_total",
|
||||
Help: "Total log lines emitted, labeled by level",
|
||||
},
|
||||
[]string{"level"},
|
||||
)
|
||||
|
||||
buildInfo = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "terminalshop_build_info",
|
||||
Help: "Build metadata; value is always 1, version is on the label",
|
||||
},
|
||||
[]string{"version"},
|
||||
)
|
||||
)
|
||||
|
||||
// Metrics records request count, latency, sizes, and concurrency for every request.
|
||||
func Metrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
wrapped := newResponseWriter(w)
|
||||
|
||||
httpInflightRequests.Inc()
|
||||
defer httpInflightRequests.Dec()
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
route := chi.RouteContext(r.Context()).RoutePattern()
|
||||
if route == "" {
|
||||
route = "unknown"
|
||||
}
|
||||
|
||||
duration := time.Since(start).Seconds()
|
||||
status := strconv.Itoa(wrapped.statusCode)
|
||||
|
||||
httpRequestsTotal.WithLabelValues(r.Method, route, status).Inc()
|
||||
httpRequestDuration.WithLabelValues(r.Method, route).Observe(duration)
|
||||
if r.ContentLength > 0 {
|
||||
httpRequestSize.WithLabelValues(r.Method, route).Observe(float64(r.ContentLength))
|
||||
}
|
||||
httpResponseSize.WithLabelValues(r.Method, route).Observe(float64(wrapped.bytesWritten))
|
||||
})
|
||||
}
|
||||
|
||||
// MetricsHandler returns the Prometheus scrape endpoint.
|
||||
func MetricsHandler() http.Handler {
|
||||
return promhttp.Handler()
|
||||
}
|
||||
|
||||
// SetBuildInfo records build metadata as a gauge of value 1.
|
||||
func SetBuildInfo(version string) {
|
||||
buildInfo.WithLabelValues(version).Set(1)
|
||||
}
|
||||
|
||||
// RecordOrderCreated records a successfully created order.
|
||||
func RecordOrderCreated(status string) {
|
||||
ordersCreatedTotal.WithLabelValues(status).Inc()
|
||||
}
|
||||
|
||||
// RecordCartConversion records the outcome of a checkout attempt.
|
||||
func RecordCartConversion(outcome string) {
|
||||
cartConversionTotal.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
|
||||
// ObserveOrderValueCents records the value of a completed order (cents → dollars).
|
||||
func ObserveOrderValueCents(cents int) {
|
||||
orderValueDollars.Observe(float64(cents) / 100.0)
|
||||
}
|
||||
|
||||
// ObserveStripeRequest records latency of a Stripe API call.
|
||||
func ObserveStripeRequest(op, outcome string, durSec float64) {
|
||||
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
|
||||
}
|
||||
|
||||
// RecordAuthAttempt records an authentication attempt outcome.
|
||||
func RecordAuthAttempt(result string) {
|
||||
authAttemptsTotal.WithLabelValues(result).Inc()
|
||||
}
|
||||
|
||||
// RecordRateLimitRejection records a request blocked by a rate limiter.
|
||||
func RecordRateLimitRejection(scope string) {
|
||||
rateLimitRejectionsTotal.WithLabelValues(scope).Inc()
|
||||
}
|
||||
|
||||
// RecordLogLevel increments the per-level log counter.
|
||||
func RecordLogLevel(level string) {
|
||||
logMessagesTotal.WithLabelValues(level).Inc()
|
||||
}
|
||||
42
api/middleware/ratelimit.go
Normal file
42
api/middleware/ratelimit.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/httprate"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// RateLimitByIP returns a per-IP rate limiter for public endpoints.
|
||||
func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Handler {
|
||||
return httprate.Limit(
|
||||
limit, window,
|
||||
httprate.WithKeyFuncs(httprate.KeyByIP),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
RecordRateLimitRejection("ip")
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// RateLimitByUser returns a per-authenticated-user rate limiter for protected endpoints.
|
||||
func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Handler {
|
||||
return httprate.Limit(
|
||||
limit, window,
|
||||
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
|
||||
userID := UserIDFromContext(r.Context())
|
||||
if userID == 0 {
|
||||
// Fall back to IP if no user context (shouldn't happen on protected routes).
|
||||
return httprate.KeyByIP(r)
|
||||
}
|
||||
return "user:" + strconv.FormatUint(uint64(userID), 10), nil
|
||||
}),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
RecordRateLimitRejection("user")
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
@@ -13,7 +13,12 @@ func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("PANIC: %v\n%s", err, debug.Stack())
|
||||
slog.Error("panic recovered",
|
||||
"error", err,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
|
||||
utils.RespondError(
|
||||
w,
|
||||
|
||||
@@ -1,51 +1,109 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/handlers"
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/auth"
|
||||
|
||||
"github.com/riandyrn/otelchi"
|
||||
)
|
||||
|
||||
// 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,
|
||||
bringCustomerNumber string,
|
||||
shippoWebhookSecret string,
|
||||
appURL string,
|
||||
slackSigningSecret string,
|
||||
adminAPIKey string,
|
||||
apiPort string,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply global middleware
|
||||
// Global middleware
|
||||
r.Use(otelchi.Middleware("terminalshop-api", otelchi.WithChiRoutes(r)))
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Recovery)
|
||||
r.Use(middleware.Metrics)
|
||||
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()
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
viewHandler := handlers.NewViewHandler()
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
|
||||
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
|
||||
|
||||
// Short payment redirect and success page — no auth required.
|
||||
r.Get("/pay/{token}", handlers.PayRedirect)
|
||||
r.Get("/t/{id}", handlers.TrackingRedirect)
|
||||
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>`))
|
||||
})
|
||||
r.Get("/post-3ds", 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>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
|
||||
})
|
||||
r.Handle("/metrics", middleware.MetricsHandler())
|
||||
|
||||
// API v1 routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Health & testing endpoints
|
||||
// Health — accept HEAD for cheap probes from monitoring tools.
|
||||
r.Get("/health", healthHandler.GetHealth)
|
||||
r.Head("/health", healthHandler.GetHealth)
|
||||
r.Get("/ping", healthHandler.Ping)
|
||||
r.Head("/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)
|
||||
r.With(middleware.RateLimitByIP(60, time.Minute)).
|
||||
Post("/webhooks/shippo", webhookHandler.HandleShippo)
|
||||
|
||||
// Product endpoints (full CRUD)
|
||||
r.Get("/products", productHandler.GetProducts) // Public
|
||||
r.Get("/products/{id}", productHandler.GetProduct) // Public
|
||||
// Slack interactivity — unauthenticated at the HTTP layer; security
|
||||
// comes from HMAC signature verification inside the handler.
|
||||
r.With(middleware.RateLimitByIP(60, time.Minute)).
|
||||
Post("/slack/interactivity", slackHandler.HandleInteractivity)
|
||||
|
||||
// Protected routes - requires a valid JWT
|
||||
// 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)
|
||||
})
|
||||
|
||||
// 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 +111,28 @@ 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.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||
|
||||
// Addresses
|
||||
r.Route("/addresses", func(r chi.Router) {
|
||||
@@ -75,6 +142,13 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
|
||||
r.Put("/{id}/default", addressHandler.SetDefaultAddress)
|
||||
})
|
||||
})
|
||||
|
||||
// Admin endpoints, gated by ADMIN_API_KEY only, no JWT required
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAdmin)
|
||||
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
|
||||
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
73
cmd/reconcile-cards/main.go
Normal file
73
cmd/reconcile-cards/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "list orphans without detaching")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
if _, err := database.Connect(cfg.DatabaseURL); err != nil {
|
||||
log.Fatalf("db: %v", err)
|
||||
}
|
||||
db := database.GetDB()
|
||||
stripe.Key = cfg.StripeSecretKey
|
||||
|
||||
var users []models.User
|
||||
if err := db.Where("stripe_customer_id != ''").Find(&users).Error; err != nil {
|
||||
log.Fatalf("list users: %v", err)
|
||||
}
|
||||
|
||||
totalOrphans, totalDetached := 0, 0
|
||||
for _, user := range users {
|
||||
var activeCards []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&activeCards).Error; err != nil {
|
||||
log.Printf("user %d: list cards: %v", user.ID, err)
|
||||
continue
|
||||
}
|
||||
keep := make(map[string]bool, len(activeCards))
|
||||
for _, card := range activeCards {
|
||||
keep[card.StripePaymentID] = true
|
||||
}
|
||||
iter := paymentmethod.List(&stripe.PaymentMethodListParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
Type: stripe.String("card"),
|
||||
})
|
||||
for iter.Next() {
|
||||
pm := iter.PaymentMethod()
|
||||
if keep[pm.ID] {
|
||||
continue
|
||||
}
|
||||
totalOrphans++
|
||||
last4 := ""
|
||||
if pm.Card != nil {
|
||||
last4 = pm.Card.Last4
|
||||
}
|
||||
log.Printf("orphan: user=%d cus=%s pm=%s last4=%s", user.ID, user.StripeCustomerID, pm.ID, last4)
|
||||
if *dryRun {
|
||||
continue
|
||||
}
|
||||
if _, err := paymentmethod.Detach(pm.ID, nil); err != nil {
|
||||
log.Printf(" detach failed: %v", err)
|
||||
continue
|
||||
}
|
||||
totalDetached++
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
log.Printf("user %d: list pms: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("done. orphans=%d detached=%d dry_run=%v", totalOrphans, totalDetached, *dryRun)
|
||||
}
|
||||
25
cog.toml
Normal file
25
cog.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
from_latest_tag = false
|
||||
ignore_merge_commits = false
|
||||
ignore_fixup_commits = false
|
||||
disable_changelog = false
|
||||
disable_bump_commit = false
|
||||
generate_mono_repository_global_tag = true
|
||||
generate_mono_repository_package_tags = true
|
||||
branch_whitelist = []
|
||||
pre = "alpha.*"
|
||||
skip_ci = "[skip ci]"
|
||||
skip_untracked = false
|
||||
pre_bump_hooks = []
|
||||
post_bump_hooks = []
|
||||
pre_package_bump_hooks = []
|
||||
post_package_bump_hooks = []
|
||||
|
||||
[git_hooks]
|
||||
|
||||
[commit_types]
|
||||
|
||||
[changelog]
|
||||
path = "CHANGELOG.md"
|
||||
authors = []
|
||||
|
||||
[bump_profiles]
|
||||
19
dev.sh
Executable file
19
dev.sh
Executable 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
41
docker-compose.prod.yml
Normal 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:
|
||||
114
go.mod
114
go.mod
@@ -1,62 +1,112 @@
|
||||
module terminalShop
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
charm.land/wish/v2 v2.0.1
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||
github.com/charmbracelet/wish v1.4.7
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/riandyrn/otelchi v0.12.2
|
||||
github.com/stripe/stripe-go/v78 v78.12.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/log/v2 v2.0.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||
github.com/charmbracelet/log v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/conpty v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/input v0.3.4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/go-version v1.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/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/stripe/stripe-go/v78 v78.12.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.7 // indirect
|
||||
)
|
||||
|
||||
321
go.sum
321
go.sum
@@ -1,61 +1,69 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
|
||||
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
|
||||
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
|
||||
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
||||
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
|
||||
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
|
||||
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
|
||||
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
|
||||
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
||||
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -63,75 +71,244 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
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-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg=
|
||||
github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4=
|
||||
github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
|
||||
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
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.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
186
loadtest/overnight.js
Normal file
186
loadtest/overnight.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import http from 'k6/http';
|
||||
|
||||
const BASE = __ENV.BASE_URL || 'https://api.sshops.uk';
|
||||
const SECRET = __ENV.CLIENT_SECRET || '';
|
||||
|
||||
// Per-VU JWT cache. JWTs are valid for 30 minutes; we refresh ~5 min early.
|
||||
let cachedToken = null;
|
||||
let cachedExpiresAt = 0;
|
||||
let cachedFingerprint = null;
|
||||
|
||||
// SSHPublicKey has a uniqueIndex constraint, so each VU needs a distinct
|
||||
// (fake but unique) public key string. Suffixing with VU+timestamp keeps
|
||||
// the full text unique without affecting the rest of the body.
|
||||
let cachedPubkey = null;
|
||||
|
||||
function getToken() {
|
||||
const now = Date.now();
|
||||
if (cachedToken && now < cachedExpiresAt) return cachedToken;
|
||||
|
||||
if (!cachedFingerprint) {
|
||||
cachedFingerprint = `SHA256:k6-${__VU}-${now.toString(36)}`;
|
||||
cachedPubkey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoadTest${__VU}-${now.toString(36)} k6@vu${__VU}`;
|
||||
}
|
||||
|
||||
const res = http.post(
|
||||
`${BASE}/api/v1/auth/token`,
|
||||
JSON.stringify({
|
||||
client_secret: SECRET,
|
||||
fingerprint: cachedFingerprint,
|
||||
ssh_public_key: cachedPubkey,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_token_refresh' } },
|
||||
);
|
||||
|
||||
if (res.status !== 200) return null;
|
||||
const body = JSON.parse(res.body);
|
||||
cachedToken = body.access_token;
|
||||
cachedExpiresAt = now + (body.expires_in - 300) * 1000;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const tok = getToken();
|
||||
return tok ? { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' } : null;
|
||||
}
|
||||
|
||||
// Rate limits we are operating under (per-IP, single source IP):
|
||||
// global ........... 200 req/min
|
||||
// /api/v1/auth/* ... 20 req/min (auth/register, auth/user, auth/token)
|
||||
// /api/v1/webhooks/ 60 req/min
|
||||
// All rates below are tuned to stay UNDER these ceilings with headroom so
|
||||
// nothing gets rate-limited and the histogram captures real handler latency.
|
||||
// Total ~148 req/min combined. Wall time ~10h.
|
||||
export const options = {
|
||||
discardResponseBodies: true,
|
||||
summaryTrendStats: ['avg', 'p(95)', 'p(99)', 'max'],
|
||||
scenarios: {
|
||||
browse: {
|
||||
// /products, /products/{id}, /health, /ping. Only the global 200/min
|
||||
// limit applies to these. Hold steady at 100/min all night so we never
|
||||
// get close to the ceiling no matter what other scenarios do.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 100,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 5,
|
||||
maxVUs: 20,
|
||||
exec: 'browse',
|
||||
},
|
||||
err_404: {
|
||||
// /products/{99xxx} — chi route still matches, returns 404. Counts
|
||||
// against the global 200/min only.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 30,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 3,
|
||||
maxVUs: 10,
|
||||
exec: 'notFound',
|
||||
},
|
||||
auth_lookup: {
|
||||
// GET /auth/user — counts against the /auth/* 20/min group ceiling
|
||||
// SHARED with auth_warn. We split: 10/min lookup + 8/min warn = 18/min.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 10,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 2,
|
||||
maxVUs: 5,
|
||||
exec: 'authLookup',
|
||||
},
|
||||
auth_warn: {
|
||||
// POST /auth/token with bad creds — same 20/min group ceiling as above.
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: 8,
|
||||
timeUnit: '1m',
|
||||
duration: '10h',
|
||||
preAllocatedVUs: 2,
|
||||
maxVUs: 5,
|
||||
exec: 'authBad',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
// Smoke-test entry point for `--vus N --duration T` runs.
|
||||
browse();
|
||||
authBad();
|
||||
authLookup();
|
||||
notFound();
|
||||
if (SECRET) {
|
||||
authedBrowse();
|
||||
convertFail();
|
||||
addressBad();
|
||||
}
|
||||
}
|
||||
|
||||
export function browse() {
|
||||
const r = Math.random();
|
||||
if (r < 0.6) http.get(`${BASE}/api/v1/products`, { tags: { name: 'list_products' } });
|
||||
else if (r < 0.8) http.get(`${BASE}/api/v1/products/1`, { tags: { name: 'get_product' } });
|
||||
else if (r < 0.95) http.get(`${BASE}/api/v1/health`, { tags: { name: 'health' } });
|
||||
else http.get(`${BASE}/api/v1/ping`, { tags: { name: 'ping' } });
|
||||
}
|
||||
|
||||
export function authedBrowse() {
|
||||
const h = authHeaders();
|
||||
if (!h) return;
|
||||
const r = Math.random();
|
||||
if (r < 0.4) http.get(`${BASE}/api/v1/cart`, { headers: h, tags: { name: 'authed_get_cart' } });
|
||||
else if (r < 0.6) http.get(`${BASE}/api/v1/cards`, { headers: h, tags: { name: 'authed_list_cards' } });
|
||||
else if (r < 0.8) http.get(`${BASE}/api/v1/addresses`, { headers: h, tags: { name: 'authed_list_addresses' } });
|
||||
else http.get(`${BASE}/api/v1/orders`, { headers: h, tags: { name: 'authed_list_orders' } });
|
||||
}
|
||||
|
||||
export function convertFail() {
|
||||
const h = authHeaders();
|
||||
if (!h) return;
|
||||
// Reset cart, drop one item in, attempt convert. No card/address selected
|
||||
// → ConvertCart hits validation_missing_address → cart_conversion_total
|
||||
// outcome=validation_missing_address + audit.OrderFailed warn.
|
||||
http.del(`${BASE}/api/v1/cart`, null, { headers: h, tags: { name: 'cart_clear' } });
|
||||
http.put(
|
||||
`${BASE}/api/v1/cart/item`,
|
||||
JSON.stringify({ coffee_id: 1, quantity: 1 }),
|
||||
{ headers: h, tags: { name: 'cart_add_item' } },
|
||||
);
|
||||
http.post(`${BASE}/api/v1/cart/convert`, null, { headers: h, tags: { name: 'cart_convert_fail' } });
|
||||
}
|
||||
|
||||
export function addressBad() {
|
||||
const h = authHeaders();
|
||||
if (!h) return;
|
||||
// Junk US zip + street → shippo validation fails → addressLog.Warn.
|
||||
http.post(
|
||||
`${BASE}/api/v1/addresses`,
|
||||
JSON.stringify({
|
||||
name: 'Load Test',
|
||||
street: 'asdfasdfasdf',
|
||||
city: 'Nowhere',
|
||||
state: 'XX',
|
||||
zip: '00000',
|
||||
country: 'US',
|
||||
phone: '+15555550000',
|
||||
}),
|
||||
{ headers: h, tags: { name: 'address_create_bad' } },
|
||||
);
|
||||
}
|
||||
|
||||
export function authBad() {
|
||||
http.post(
|
||||
`${BASE}/api/v1/auth/token`,
|
||||
JSON.stringify({ client_secret: 'definitely_wrong', fingerprint: 'SHA256:fake' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_bad_creds' } },
|
||||
);
|
||||
}
|
||||
|
||||
export function authLookup() {
|
||||
const fp = `SHA256:${Math.random().toString(36).slice(2, 14)}`;
|
||||
http.get(`${BASE}/api/v1/auth/user?fingerprint=${fp}`, { tags: { name: 'auth_user_lookup' } });
|
||||
}
|
||||
|
||||
export function notFound() {
|
||||
const id = 99000 + Math.floor(Math.random() * 1000);
|
||||
http.get(`${BASE}/api/v1/products/${id}`, { tags: { name: 'product_not_found' } });
|
||||
}
|
||||
78
main.go
78
main.go
@@ -1,3 +1,4 @@
|
||||
// version: 0.1.1
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -7,59 +8,44 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/wish/v2"
|
||||
"charm.land/wish/v2/bubbletea"
|
||||
"charm.land/wish/v2/logging"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/charmbracelet/wish"
|
||||
"github.com/charmbracelet/wish/bubbletea"
|
||||
"github.com/charmbracelet/wish/logging"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui"
|
||||
)
|
||||
|
||||
const (
|
||||
host = "localhost"
|
||||
port = "23456"
|
||||
)
|
||||
func listenHost() string {
|
||||
if h := os.Getenv("SSH_HOST"); h != "" {
|
||||
return h
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
var (
|
||||
authService *auth.SSHAuthService
|
||||
apiURL string
|
||||
authFingerprintKey string
|
||||
stripePublicKey string
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
// 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)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -69,33 +55,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 := cfg.SSHPort
|
||||
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 +83,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 {
|
||||
|
||||
710
model.go.old
710
model.go.old
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
|
||||
// TokenRequest represents a request to exchange SSH fingerprint for JWT.
|
||||
type TokenRequest struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
SSHPublicKey string `json:"ssh_public_key"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
@@ -46,54 +47,59 @@ type TokenRequest struct {
|
||||
type TokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User models.PublicUser `json:"user"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshToken exchanges an SSH fingerprint and client secret for a new JWT.
|
||||
// It does not update c.Token — the caller is responsible for that.
|
||||
func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) {
|
||||
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||
|
||||
reqBody := TokenRequest{
|
||||
Fingerprint: fingerprint,
|
||||
SSHPublicKey: pubKeyStr,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal token request: %w", err)
|
||||
return "", models.PublicUser{}, fmt.Errorf("failed to marshal token request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||
return "", models.PublicUser{}, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token refresh request failed: %w", err)
|
||||
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = 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
|
||||
@@ -130,7 +136,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch products: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
@@ -160,7 +166,7 @@ func (c *Client) Health() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
@@ -210,7 +216,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var registerResp RegisterResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil {
|
||||
@@ -274,14 +280,31 @@ type CardResponse struct {
|
||||
}
|
||||
|
||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
|
||||
// redirect fields and OrderID instead.
|
||||
type ConvertCartResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Order models.Order `json:"order"`
|
||||
Order *models.Order `json:"order,omitempty"`
|
||||
OrderID uint `json:"order_id,omitempty"`
|
||||
PaymentIntentID string `json:"payment_intent_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RedirectToken string `json:"redirect_token,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
|
||||
// means the customer needs to complete 3DS at RedirectURL; poll
|
||||
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
|
||||
type CheckoutOutcome struct {
|
||||
Order *models.Order
|
||||
OrderID uint
|
||||
RequiresAction bool
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
// GetCart fetches the user's current cart.
|
||||
func (c *Client) GetCart() (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
@@ -295,7 +318,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cartResp CartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||
@@ -335,7 +358,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set cart item: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cartResp CartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||
@@ -374,7 +397,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set cart address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
@@ -413,7 +436,7 @@ func (c *Client) SetCartCard(cardID uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set cart card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
@@ -443,7 +466,7 @@ func (c *Client) ClearCart() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
@@ -461,7 +484,9 @@ func (c *Client) ClearCart() error {
|
||||
}
|
||||
|
||||
// ConvertCart converts the cart into an order, charging the saved card.
|
||||
func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
// Returns a CheckoutOutcome that either carries the completed Order or
|
||||
// indicates the customer must complete 3DS authentication via RedirectURL.
|
||||
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
@@ -473,7 +498,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var convertResp ConvertCartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
|
||||
@@ -487,7 +512,55 @@ func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
return nil, fmt.Errorf("failed to convert cart")
|
||||
}
|
||||
|
||||
return &convertResp.Data.Order, nil
|
||||
if convertResp.Data.Status == "requires_action" {
|
||||
return &CheckoutOutcome{
|
||||
OrderID: convertResp.Data.OrderID,
|
||||
RequiresAction: true,
|
||||
RedirectURL: convertResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if convertResp.Data.Order == nil {
|
||||
return nil, fmt.Errorf("malformed convert response: missing order")
|
||||
}
|
||||
return &CheckoutOutcome{
|
||||
Order: convertResp.Data.Order,
|
||||
OrderID: convertResp.Data.Order.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type OrderStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
|
||||
req, err := http.NewRequest("GET", 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 fetch order status: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var sResp OrderStatusResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode order status response: %w", err)
|
||||
}
|
||||
if !sResp.Success {
|
||||
if sResp.Error != nil {
|
||||
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("failed to fetch order status")
|
||||
}
|
||||
return sResp.Data.Status, nil
|
||||
}
|
||||
|
||||
// GetCards fetches all saved cards for the user.
|
||||
@@ -503,7 +576,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch cards: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cardsResp CardsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
|
||||
@@ -520,12 +593,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)
|
||||
}
|
||||
@@ -540,7 +619,7 @@ func (c *Client) SaveCard(token string) (*models.Card, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var cardResp CardResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
|
||||
@@ -557,6 +636,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 func() { _ = resp.Body.Close() }()
|
||||
|
||||
var collectResp CollectCardResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode collect response: %w", err)
|
||||
}
|
||||
|
||||
if !collectResp.Success {
|
||||
if collectResp.Error != nil {
|
||||
return "", fmt.Errorf("%s: %s", collectResp.Error.Code, collectResp.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("failed to collect card")
|
||||
}
|
||||
return collectResp.Data.URL, nil
|
||||
}
|
||||
|
||||
// DeleteCard removes a saved card by ID.
|
||||
func (c *Client) DeleteCard(id uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cards/%d", c.BaseURL, id)
|
||||
@@ -570,7 +685,7 @@ func (c *Client) DeleteCard(id uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
|
||||
@@ -587,6 +702,20 @@ type OrderResponse struct {
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RefundRequest is the customer-facing refund request payload.
|
||||
type RefundRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type refundRequestResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||
|
||||
@@ -599,7 +728,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch orders: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var ordersResp OrderResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
|
||||
@@ -615,6 +744,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
return ordersResp.Data.Orders, nil
|
||||
}
|
||||
|
||||
// CreateRefundRequest posts a refund request for an order.
|
||||
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
|
||||
|
||||
jsonData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal refund request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create refund request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send refund request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var refundResp refundRequestResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
|
||||
return fmt.Errorf("failed to decode refund response: %w", err)
|
||||
}
|
||||
|
||||
if !refundResp.Success {
|
||||
if refundResp.Error != nil {
|
||||
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to send refund request")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Addresses API response
|
||||
type AddressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -637,7 +802,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
|
||||
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var addrResp AddressResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
|
||||
@@ -683,7 +848,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -720,7 +885,7 @@ func (c *Client) DeleteAddress(id uint) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
|
||||
@@ -728,3 +893,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 func() { _ = 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
|
||||
}
|
||||
|
||||
314
pkg/audit/audit.go
Normal file
314
pkg/audit/audit.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Package audit writes structured 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"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type eventType string
|
||||
|
||||
const (
|
||||
eventCardAdded eventType = "card_added"
|
||||
eventCardDeleted eventType = "card_deleted"
|
||||
eventOrderCreated eventType = "order_created"
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = "order_requires_action"
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
eventLabelOrphaned eventType = "label_orphaned"
|
||||
eventTrackingUpdated eventType = "tracking_updated"
|
||||
eventOrderDelivered eventType = "order_delivered"
|
||||
eventTrackingMarkedManually eventType = "tracking_marked_manually"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
Event eventType
|
||||
UserID uint
|
||||
OrderID uint
|
||||
CardID uint
|
||||
Amount int
|
||||
StripeID string
|
||||
CardLast4 string
|
||||
CardBrand string
|
||||
Carrier string
|
||||
Tracking string
|
||||
Status string
|
||||
Error string
|
||||
Actor string
|
||||
}
|
||||
|
||||
func (e event) attrs() []any {
|
||||
a := []any{"event", string(e.Event)}
|
||||
if e.UserID != 0 {
|
||||
a = append(a, "user_id", e.UserID)
|
||||
}
|
||||
if e.OrderID != 0 {
|
||||
a = append(a, "order_id", e.OrderID)
|
||||
}
|
||||
if e.CardID != 0 {
|
||||
a = append(a, "card_id", e.CardID)
|
||||
}
|
||||
if e.Amount != 0 {
|
||||
a = append(a, "amount_cents", e.Amount)
|
||||
}
|
||||
if e.StripeID != "" {
|
||||
a = append(a, "stripe_id", e.StripeID)
|
||||
}
|
||||
if e.CardLast4 != "" {
|
||||
a = append(a, "card_last4", e.CardLast4)
|
||||
}
|
||||
if e.CardBrand != "" {
|
||||
a = append(a, "card_brand", e.CardBrand)
|
||||
}
|
||||
if e.Carrier != "" {
|
||||
a = append(a, "carrier", e.Carrier)
|
||||
}
|
||||
if e.Tracking != "" {
|
||||
a = append(a, "tracking", e.Tracking)
|
||||
}
|
||||
if e.Status != "" {
|
||||
a = append(a, "status", e.Status)
|
||||
}
|
||||
if e.Error != "" {
|
||||
a = append(a, "error", e.Error)
|
||||
}
|
||||
if e.Actor != "" {
|
||||
a = append(a, "actor", e.Actor)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// CardAdded records that a user saved a new payment method.
|
||||
func CardAdded(userID, cardID uint, brand, last4 string) {
|
||||
e := event{
|
||||
Event: eventCardAdded,
|
||||
UserID: userID,
|
||||
CardID: cardID,
|
||||
CardBrand: brand,
|
||||
CardLast4: last4,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(0, eventCardAdded, e)
|
||||
}
|
||||
|
||||
// CardDeleted records that a user removed a saved payment method.
|
||||
func CardDeleted(userID, cardID uint) {
|
||||
e := event{
|
||||
Event: eventCardDeleted,
|
||||
UserID: userID,
|
||||
CardID: cardID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(0, eventCardDeleted, e)
|
||||
}
|
||||
|
||||
// OrderCreated records that an order record was created (before payment).
|
||||
func OrderCreated(userID, orderID uint, amountCents int) {
|
||||
e := event{
|
||||
Event: eventOrderCreated,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderCreated, e)
|
||||
}
|
||||
|
||||
// OrderPaid records a successful charge.
|
||||
func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderPaid,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
StripeID: stripePaymentIntentID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderPaid, e)
|
||||
}
|
||||
|
||||
// OrderShipped records that an order has been marked as shipped with carrier metadata
|
||||
func OrderShipped(userID, orderID uint, carrier, trackingNumber string) {
|
||||
e := event{
|
||||
Event: eventOrderShipped,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderShipped, e)
|
||||
}
|
||||
|
||||
// OrderFailed records a failed charge attempt. Logged at WARN.
|
||||
func OrderFailed(userID, orderID uint, amountCents int, reason string) {
|
||||
e := event{
|
||||
Event: eventOrderFailed,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
Error: reason,
|
||||
}
|
||||
slog.Warn("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderFailed, e)
|
||||
}
|
||||
|
||||
// OrderRefunded records a refund.
|
||||
func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) {
|
||||
e := event{
|
||||
Event: eventOrderRefunded,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
Amount: amountCents,
|
||||
StripeID: stripeRefundID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderRefunded, e)
|
||||
}
|
||||
|
||||
// PaymentCritical records the dangerous state where Stripe charged the card
|
||||
// but the local database transaction failed. This MUST be investigated and
|
||||
// manually reconciled. Logged at ERROR so monitoring can alert on it.
|
||||
func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
|
||||
e := event{
|
||||
Event: eventPaymentCritical,
|
||||
OrderID: orderID,
|
||||
StripeID: stripePaymentIntentID,
|
||||
Error: err.Error(),
|
||||
}
|
||||
slog.Error("audit", e.attrs()...)
|
||||
persist(orderID, eventPaymentCritical, e)
|
||||
}
|
||||
|
||||
func LabelPurchased(orderID uint, carrier, trackingNumber, shippoTxID string, costCents int) {
|
||||
e := event{
|
||||
Event: eventLabelPurchased,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
StripeID: shippoTxID,
|
||||
Amount: costCents,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventLabelPurchased, e)
|
||||
}
|
||||
|
||||
func LabelOrphaned(orderID uint, shippoTxID, trackingNumber string, err error) {
|
||||
e := event{
|
||||
Event: eventLabelOrphaned,
|
||||
OrderID: orderID,
|
||||
StripeID: shippoTxID,
|
||||
Tracking: trackingNumber,
|
||||
Error: err.Error(),
|
||||
}
|
||||
slog.Error("audit", e.attrs()...)
|
||||
persist(orderID, eventLabelOrphaned, e)
|
||||
}
|
||||
|
||||
func TrackingUpdated(orderID uint, carrier, trackingNumber, status string) {
|
||||
e := event{
|
||||
Event: eventTrackingUpdated,
|
||||
OrderID: orderID,
|
||||
Carrier: carrier,
|
||||
Tracking: trackingNumber,
|
||||
Status: status,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventTrackingUpdated, e)
|
||||
}
|
||||
|
||||
func OrderDelivered(orderID uint, trackingNumber string) {
|
||||
e := event{
|
||||
Event: eventOrderDelivered,
|
||||
OrderID: orderID,
|
||||
Tracking: trackingNumber,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderDelivered, e)
|
||||
}
|
||||
|
||||
// TrackingMarkedManually records operator made tracking status mark
|
||||
// from the slack interactivity surface. Actor is the slack username that
|
||||
// clicked the button
|
||||
func TrackingMarkedManually(orderID uint, status, actor string) {
|
||||
e := event{
|
||||
Event: eventTrackingMarkedManually,
|
||||
OrderID: orderID,
|
||||
Status: status,
|
||||
Actor: actor,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventTrackingMarkedManually, e)
|
||||
}
|
||||
|
||||
// db is the optional persistence target. nil = slog-only.
|
||||
// set once at startup with SetDB, funcs stay slog-primary regardless.
|
||||
var db *gorm.DB
|
||||
|
||||
// SetDB wires the audit package to a database for persistent event rows.
|
||||
// call once after database.Migrate. Audit funcs still write slog when
|
||||
// db is nil, so tests don't need to wire this.
|
||||
func SetDB(d *gorm.DB) { db = d }
|
||||
|
||||
func persist(orderID uint, t eventType, e event) {
|
||||
if db == nil || orderID == 0 {
|
||||
return
|
||||
}
|
||||
actor := e.Actor
|
||||
if actor == "" {
|
||||
if e.UserID > 0 {
|
||||
actor = fmt.Sprintf("user:%d", e.UserID)
|
||||
} else {
|
||||
actor = "system"
|
||||
}
|
||||
}
|
||||
payload, err := json.Marshal(attrsToMap(e.attrs()))
|
||||
if err != nil {
|
||||
slog.Error("audit persist marshal", "error", err, "event", string(t))
|
||||
return
|
||||
}
|
||||
row := &models.OrderEvent{
|
||||
OrderID: orderID,
|
||||
Type: string(t),
|
||||
Payload: string(payload),
|
||||
Actor: actor,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
slog.Error("audit persist write", "error", err, "event", string(t), "order_id", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
func attrsToMap(a []any) map[string]any {
|
||||
m := make(map[string]any, len(a)/2)
|
||||
for i := 0; i+1 < len(a); i += 2 {
|
||||
k, ok := a[i].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[k] = a[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
//nolint:unused // called from ConvertCart in the next update
|
||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderRequiresAction,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
StripeID: paymentIntentID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(orderID, eventOrderRequiresAction, e)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func setupAuthTestDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
testDB := "test_ssh_auth.db"
|
||||
os.Remove(testDB)
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
|
||||
|
||||
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
||||
|
||||
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKey(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
|
||||
|
||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
|
||||
func TestIsSSHKeyRegistered(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
|
||||
}
|
||||
|
||||
// After authentication (which auto-creates)
|
||||
svc.AuthenticateSSHKey(key)
|
||||
_, _, _ = svc.AuthenticateSSHKey(key)
|
||||
|
||||
registered, err = svc.IsSSHKeyRegistered(key)
|
||||
if err != nil {
|
||||
|
||||
253
pkg/bring/booking.go
Normal file
253
pkg/bring/booking.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package bring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"terminalShop/pkg/shipping"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const bookingBaseURL = "https://api.bring.com/booking-api/api/booking"
|
||||
|
||||
type BookingClient struct {
|
||||
apiUID string
|
||||
apiKey string
|
||||
customerNumber string
|
||||
testMode bool
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewBookingClient(apiUID string, apiKey string, customerNumber string, testMode bool) *BookingClient {
|
||||
return &BookingClient{
|
||||
apiUID: apiUID,
|
||||
apiKey: apiKey,
|
||||
customerNumber: customerNumber,
|
||||
testMode: testMode,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type BookingAddress struct {
|
||||
Name string
|
||||
Street1 string
|
||||
Street2 string
|
||||
City string
|
||||
Country string
|
||||
Zip string
|
||||
Phone string
|
||||
Email string
|
||||
ContactName string
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
Title string
|
||||
Quantity int
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
func (c *BookingClient) CreateLabel(ctx context.Context, to BookingAddress, email string, items []LineItem) (*shipping.LabelResult, error) {
|
||||
from := fromAddressNO()
|
||||
if from.Street1 == "" {
|
||||
return nil, fmt.Errorf("SHIP_FROM_NO_STREET1 not configured")
|
||||
}
|
||||
if c.customerNumber == "" {
|
||||
return nil, fmt.Errorf("BRING_CUSTOMER_NUMBER not configured")
|
||||
}
|
||||
|
||||
totalKg := 0.0
|
||||
for _, item := range items {
|
||||
totalKg += float64(item.Quantity) * item.WeightKg
|
||||
}
|
||||
if totalKg <= 0 {
|
||||
return nil, fmt.Errorf("order has zero shipping weight")
|
||||
}
|
||||
|
||||
oslo, err := time.LoadLocation("Europe/Oslo")
|
||||
if err != nil {
|
||||
oslo = time.UTC
|
||||
}
|
||||
shippingAt := time.Now().In(oslo).Add(1 * time.Hour).Format("2006-01-02T15:04:05-07:00")
|
||||
|
||||
payload := map[string]any{
|
||||
"testIndicator": c.testMode,
|
||||
"schemaVersion": 1,
|
||||
"consignments": []map[string]any{{
|
||||
"shippingDateTime": shippingAt,
|
||||
"parties": map[string]any{
|
||||
"sender": partyPayload(from, ""),
|
||||
"recipient": partyPayload(to, email),
|
||||
},
|
||||
"product": map[string]any{
|
||||
"id": "SERVICEPAKKE",
|
||||
"customerNumber": c.customerNumber,
|
||||
},
|
||||
"packages": []map[string]any{{
|
||||
"weightInKg": roundN(totalKg, 3),
|
||||
"goodsDescription": "Roasted coffee beans",
|
||||
"dimensions": map[string]any{
|
||||
"heightInCm": 30,
|
||||
"widthInCm": 30,
|
||||
"lengthInCm": 30,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
var resp bookingResponse
|
||||
if err := c.doJSON(ctx, bookingBaseURL, payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Consignments) == 0 {
|
||||
return nil, fmt.Errorf("bring returned no consignments")
|
||||
}
|
||||
c0 := resp.Consignments[0]
|
||||
if c0.Confirmation == nil {
|
||||
return nil, fmt.Errorf("bring booking failed: %s", firstError(c0.Errors))
|
||||
}
|
||||
|
||||
confirmation := c0.Confirmation
|
||||
if len(confirmation.Packages) == 0 || confirmation.Packages[0].TrackingNumber == "" {
|
||||
return nil, fmt.Errorf("bring confirmation missing tracking number")
|
||||
}
|
||||
|
||||
labelURL := ""
|
||||
if confirmation.Links != nil {
|
||||
labelURL = confirmation.Links.Labels
|
||||
}
|
||||
|
||||
tracking := confirmation.Packages[0].TrackingNumber
|
||||
trackingURL := fmt.Sprintf("https://sporing.posten.no/sporing/%s", tracking)
|
||||
|
||||
return &shipping.LabelResult{
|
||||
TransactionID: confirmation.ConsignmentNumber,
|
||||
Carrier: "BRING",
|
||||
ServiceLevel: "SERVICEPAKKE",
|
||||
TrackingNumber: tracking,
|
||||
TrackingURL: trackingURL,
|
||||
LabelURL: labelURL,
|
||||
CostCents: 0,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
type bookingResponse struct {
|
||||
Consignments []struct {
|
||||
Confirmation *struct {
|
||||
ConsignmentNumber string `json:"consignmentNumber"`
|
||||
Links *struct {
|
||||
Labels string `json:"labels"`
|
||||
} `json:"links"`
|
||||
Packages []struct {
|
||||
CorrelationID string `json:"correlationId"`
|
||||
TrackingNumber string `json:"trackingNumber"`
|
||||
} `json:"packages"`
|
||||
} `json:"confirmation"`
|
||||
Errors []struct {
|
||||
Messages []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
} `json:"errors"`
|
||||
} `json:"consignments"`
|
||||
}
|
||||
|
||||
func firstError(errs []struct {
|
||||
Messages []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
}) string {
|
||||
if len(errs) == 0 || len(errs[0].Messages) == 0 {
|
||||
return "no detail"
|
||||
}
|
||||
return errs[0].Messages[0].Message
|
||||
}
|
||||
|
||||
func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Mybring-API-Uid", c.apiUID)
|
||||
req.Header.Set("X-Mybring-API-Key", c.apiKey)
|
||||
req.Header.Set("X-Mybring-API-Customer-Number", c.customerNumber)
|
||||
clientURL := os.Getenv("BRING_CLIENT_URL")
|
||||
if clientURL == "" {
|
||||
clientURL = "https://sshops.uk"
|
||||
}
|
||||
req.Header.Set("X-Bring-Client-URL", clientURL)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bring booking %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roundN(v float64, n int) float64 {
|
||||
pow := 1.0
|
||||
for i := 0; i < n; i++ {
|
||||
pow *= 10
|
||||
}
|
||||
if v >= 0 {
|
||||
return float64(int64(v*pow+0.5)) / pow
|
||||
}
|
||||
return float64(int64(v*pow-0.5)) / pow
|
||||
}
|
||||
|
||||
func partyPayload(a BookingAddress, email string) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": a.Name,
|
||||
"addressLine": a.Street1,
|
||||
"postalCode": a.Zip,
|
||||
"city": a.City,
|
||||
"countryCode": a.Country,
|
||||
}
|
||||
if a.Street2 != "" {
|
||||
m["addressLine2"] = a.Street2
|
||||
}
|
||||
if email != "" || a.Phone != "" {
|
||||
m["contact"] = map[string]any{"name": a.Name, "email": email, "phoneNumber": a.Phone}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func fromAddressNO() BookingAddress {
|
||||
return BookingAddress{
|
||||
Name: os.Getenv("SHIP_FROM_NO_NAME"),
|
||||
Street1: os.Getenv("SHIP_FROM_NO_STREET1"),
|
||||
Street2: os.Getenv("SHIP_FROM_NO_STREET2"),
|
||||
City: os.Getenv("SHIP_FROM_NO_CITY"),
|
||||
Country: os.Getenv("SHIP_FROM_NO_COUNTRY"),
|
||||
Zip: os.Getenv("SHIP_FROM_NO_ZIP"),
|
||||
Phone: os.Getenv("SHIP_FROM_NO_PHONE"),
|
||||
Email: os.Getenv("SHIP_FROM_NO_EMAIL"),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package bring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.bring.com/address"
|
||||
@@ -26,7 +29,8 @@ func NewClient(apiUID, apiKey string) *Client {
|
||||
apiUID: apiUID,
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -42,7 +46,6 @@ type Address struct {
|
||||
Phone string
|
||||
}
|
||||
|
||||
|
||||
type validationMatch struct {
|
||||
StreetName string `json:"street_name"`
|
||||
HouseNumber int `json:"house_number"`
|
||||
@@ -80,7 +83,7 @@ func parseStreet(street string) (name string, number string) {
|
||||
return strings.TrimSpace(street), ""
|
||||
}
|
||||
|
||||
func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, error) {
|
||||
streetName, streetNumber := parseStreet(addr.Street1)
|
||||
|
||||
params := url.Values{}
|
||||
@@ -94,7 +97,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/no/validation?%s", baseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -107,7 +110,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bring request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
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
|
||||
BringCustomerNumber string
|
||||
ShippoWebhookSecret string
|
||||
SlackSigningSecret string
|
||||
AdminAPIKey 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 +46,84 @@ 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"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||
AdminAPIKey: os.Getenv("ADMIN_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
|
||||
|
||||
@@ -3,11 +3,16 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"gorm.io/plugin/opentelemetry/tracing"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -15,23 +20,50 @@ 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
|
||||
}
|
||||
|
||||
if pluginErr := db.Use(tracing.NewPlugin(
|
||||
tracing.WithoutMetrics(),
|
||||
tracing.WithoutQueryVariables(),
|
||||
)); pluginErr != nil {
|
||||
err = fmt.Errorf("failed to register otel plugin: %w", pluginErr)
|
||||
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")
|
||||
})
|
||||
|
||||
@@ -69,7 +101,7 @@ func Close() error {
|
||||
// WARNING: Only use this in tests!
|
||||
func ResetForTesting() {
|
||||
if db != nil {
|
||||
Close()
|
||||
_ = Close()
|
||||
db = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestConnect(t *testing.T) {
|
||||
// Use a temporary test database
|
||||
testDB := "test_terminalshop.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
testDB := "test_migrate.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
|
||||
|
||||
func TestSeed(t *testing.T) {
|
||||
testDB := "test_seed.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
@@ -108,14 +108,14 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDB(t *testing.T) {
|
||||
testDB := "test_getdb.db"
|
||||
defer os.Remove(testDB)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
// Initialize connection
|
||||
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Migrate runs database migrations
|
||||
@@ -15,14 +16,16 @@ func Migrate(db *gorm.DB) error {
|
||||
// AutoMigrate will create tables, missing columns, and indexes
|
||||
// It will NOT delete unused columns or change column types
|
||||
if err := db.AutoMigrate(
|
||||
&models.Cart{}, // Server-side shipping cart
|
||||
&models.CartItem{}, // Cart line items
|
||||
&models.Coffee{},
|
||||
&models.Cart{}, // Server-side shopping cart
|
||||
&models.CartItem{}, // Cart line items
|
||||
&models.Coffee{}, // Coffees
|
||||
&models.User{}, // SSH key-based authentication
|
||||
&models.Card{}, // Saved payment methods
|
||||
&models.Order{}, // Completed purchases
|
||||
&models.OrderItem{}, // Line items within orders
|
||||
&models.Address{}, // Saved addresses
|
||||
&models.OrderEvent{},
|
||||
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
|
||||
); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ type Cart struct {
|
||||
CardID *uint `json:"card_id,omitempty"`
|
||||
ShippingCost int `gorm:"default:0" json:"shipping_cost"`
|
||||
ShippingService string `gorm:"size:255" json:"shipping_service,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
|
||||
@@ -13,7 +13,7 @@ type Coffee struct {
|
||||
RoastType string `gorm:"size:100" json:"roast_type"`
|
||||
Ounces int `json:"ounces"`
|
||||
BeanType string `gorm:"size:100" json:"bean_type"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
Price int `gorm:"not null" json:"price"`
|
||||
Color string `gorm:"size:7" json:"color"` // Hex color for background
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -26,11 +26,12 @@ func (Coffee) TableName() string {
|
||||
return "coffees"
|
||||
}
|
||||
|
||||
|
||||
// AccountMenuItems something
|
||||
// AccountMenuItems
|
||||
var AccountMenuItems = []string{
|
||||
"active orders",
|
||||
"order history",
|
||||
"addresses",
|
||||
"cards",
|
||||
"faq",
|
||||
"about",
|
||||
"something",
|
||||
}
|
||||
|
||||
@@ -16,34 +16,69 @@ const (
|
||||
OrderStatusDelivered OrderStatus = "delivered"
|
||||
OrderStatusCancelled OrderStatus = "cancelled"
|
||||
OrderStatusRefunded OrderStatus = "refunded"
|
||||
OrderStatusFailed OrderStatus = "failed"
|
||||
)
|
||||
|
||||
type TrackingStatus string
|
||||
|
||||
const (
|
||||
TrackingStatusUnknown TrackingStatus = ""
|
||||
TrackingStatusPreTransit TrackingStatus = "pre_transit"
|
||||
TrackingStatusTransit TrackingStatus = "transit"
|
||||
TrackingStatusDelivered TrackingStatus = "delivered"
|
||||
TrackingStatusReturned TrackingStatus = "returned"
|
||||
TrackingStatusFailure TrackingStatus = "failure"
|
||||
)
|
||||
|
||||
// Order represents a completed purchase.
|
||||
type Order struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
CardID uint `gorm:"not null" json:"card_id"`
|
||||
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
|
||||
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
|
||||
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
|
||||
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
|
||||
Total int `gorm:"not null" json:"total"` // Amount in cents
|
||||
ShippingName string `gorm:"size:255" json:"shipping_name"`
|
||||
ShippingStreet string `gorm:"size:255" json:"shipping_street"`
|
||||
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"`
|
||||
ShippingCity string `gorm:"size:255" json:"shipping_city"`
|
||||
ShippingState string `gorm:"size:50" json:"shipping_state"`
|
||||
ShippingZip string `gorm:"size:20" json:"shipping_zip"`
|
||||
ShippingCountry string `gorm:"size:2" json:"shipping_country"`
|
||||
ShippingPhone string `gorm:"size:20" json:"shipping_phone"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
CardID uint `gorm:"not null" json:"card_id"`
|
||||
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
|
||||
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
|
||||
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
|
||||
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
|
||||
Total int `gorm:"not null" json:"total"` // Amount in cents
|
||||
ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to
|
||||
ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to
|
||||
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason
|
||||
ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to
|
||||
ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to
|
||||
ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to
|
||||
ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to
|
||||
ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to
|
||||
|
||||
// Shipment / tracking - single embedded blob (since its just one warehouse (my house))
|
||||
Carrier string `gorm:"size:100" json:"carrier"`
|
||||
TrackingNumber string `gorm:"size:255;index" json:"tracking_number"`
|
||||
TrackingURL string `gorm:"size:512" json:"tracking_url"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
TrackingStatus TrackingStatus `gorm:"size:50" json:"tracking_status"`
|
||||
TrackingStatusDetails string `gorm:"size:512" json:"tracking_status_details"`
|
||||
TrackingStatusUpdatedAt *time.Time `json:"tracking_status_updated_at"`
|
||||
|
||||
// Shippo label shit, populated by the PurchaseLabel handler
|
||||
ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"-"`
|
||||
LabelURL string `gorm:"size:512" json:"-"`
|
||||
LabelCostCents int `json:"-"`
|
||||
|
||||
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
|
||||
SlackThreadTS *string `gorm:"size:64" json:"-"`
|
||||
|
||||
// LastRefundRequestAt timestamps the most recent customer-initiated refund
|
||||
// request for this order. Used to rate-limit repeat submissions.
|
||||
LastRefundRequestAt *time.Time `json:"-"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Card Card `gorm:"foreignKey:CardID" json:"-"`
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
|
||||
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the Order model.
|
||||
@@ -76,3 +111,41 @@ func (OrderItem) TableName() string {
|
||||
func (o *Order) TotalInDollars() float64 {
|
||||
return float64(o.Total) / 100
|
||||
}
|
||||
|
||||
// IsActive returns whether the order is still active, which is paid but unfulfilled,
|
||||
// or shipped but not yet delivered.
|
||||
func (o *Order) IsActive() bool {
|
||||
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
|
||||
}
|
||||
|
||||
// RefundRequestReasons is the canonical list of customer-facing refund reasons
|
||||
// accepted by both the API and the TUI.
|
||||
var RefundRequestReasons = []string{
|
||||
"Wrong item received",
|
||||
"Item damaged in transit",
|
||||
"Item never arrived",
|
||||
"Quality issue",
|
||||
"Other",
|
||||
}
|
||||
|
||||
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
|
||||
const RefundRequestReasonOther = "Other"
|
||||
|
||||
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
|
||||
func IsValidRefundReason(r string) bool {
|
||||
for _, reason := range RefundRequestReasons {
|
||||
if reason == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanRequestRefund reports whether a customer is allowed to request a refund
|
||||
// for this order. Paid, shipped, and delivered orders qualify; everything else
|
||||
// is either still unpaid or already in a terminal state.
|
||||
func (o *Order) CanRequestRefund() bool {
|
||||
return o.Status == OrderStatusPaid ||
|
||||
o.Status == OrderStatusShipped ||
|
||||
o.Status == OrderStatusDelivered
|
||||
}
|
||||
|
||||
96
pkg/models/order_display.go
Normal file
96
pkg/models/order_display.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package models
|
||||
|
||||
// DisplayKind is a ui agnostic color/severity token.
|
||||
// The TUI (or any other renderer) maps this to a concrete style
|
||||
// the model stays free of UI deps.
|
||||
type DisplayKind string
|
||||
|
||||
const (
|
||||
DisplayKindNeutral DisplayKind = "neutral" // pending, default
|
||||
DisplayKindAccent DisplayKind = "accent" // paid, awaiting fulfillment
|
||||
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
|
||||
DisplayKindSuccess DisplayKind = "success" // delivered
|
||||
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
|
||||
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
|
||||
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
|
||||
)
|
||||
|
||||
// refundPending reports whether the order has an open customer refund request
|
||||
// that has not yet been resolved by a terminal status change.
|
||||
func (order *Order) refundPending() bool {
|
||||
if order.LastRefundRequestAt == nil {
|
||||
return false
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (order *Order) DisplayState() string {
|
||||
if order.refundPending() {
|
||||
return "Refund Requested"
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return "Awaiting payment"
|
||||
case OrderStatusPaid:
|
||||
if order.TrackingNumber == "" {
|
||||
return "Paid * awaiting label"
|
||||
}
|
||||
// !IMPORTANT
|
||||
// Reachable only if UpdateTracking auto-promote is removed later
|
||||
if order.TrackingStatus == TrackingStatusPreTransit {
|
||||
return "Paid * ready to ship"
|
||||
}
|
||||
return "Paid"
|
||||
case OrderStatusShipped:
|
||||
switch order.TrackingStatus {
|
||||
case TrackingStatusTransit:
|
||||
return "In Transit"
|
||||
case TrackingStatusFailure:
|
||||
return "Delivery Issue"
|
||||
case TrackingStatusReturned:
|
||||
return "Returned to Sender"
|
||||
case TrackingStatusDelivered:
|
||||
// Webhook hasn't promoted Status yet, trust tracking
|
||||
return "Delivered"
|
||||
}
|
||||
return "Label Created"
|
||||
case OrderStatusDelivered:
|
||||
return "Delivered"
|
||||
case OrderStatusCancelled:
|
||||
return "Cancelled"
|
||||
case OrderStatusRefunded:
|
||||
return "Refunded"
|
||||
case OrderStatusFailed:
|
||||
return "Payment failed"
|
||||
}
|
||||
return string(order.Status)
|
||||
}
|
||||
|
||||
func (order *Order) DisplayKind() DisplayKind {
|
||||
if order.refundPending() {
|
||||
return DisplayKindRefund
|
||||
}
|
||||
switch order.Status {
|
||||
case OrderStatusPending:
|
||||
return DisplayKindNeutral
|
||||
case OrderStatusPaid:
|
||||
return DisplayKindAccent
|
||||
case OrderStatusShipped:
|
||||
switch order.TrackingStatus {
|
||||
case TrackingStatusFailure, TrackingStatusReturned:
|
||||
return DisplayKindWarning
|
||||
case TrackingStatusDelivered:
|
||||
return DisplayKindSuccess
|
||||
}
|
||||
return DisplayKindBrand
|
||||
case OrderStatusDelivered:
|
||||
return DisplayKindSuccess
|
||||
case OrderStatusCancelled, OrderStatusRefunded, OrderStatusFailed:
|
||||
return DisplayKindError
|
||||
}
|
||||
return DisplayKindNeutral
|
||||
}
|
||||
45
pkg/models/order_display_test.go
Normal file
45
pkg/models/order_display_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOrderDisplayState(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
order Order
|
||||
want string
|
||||
kind DisplayKind
|
||||
}{
|
||||
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
|
||||
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
|
||||
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
|
||||
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},
|
||||
{"shipped no tracking event", Order{Status: OrderStatusShipped}, "Label Created", DisplayKindBrand},
|
||||
{"shipped pre-transit", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusPreTransit}, "Label Created", DisplayKindBrand},
|
||||
{"shipped in transit", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusTransit}, "In Transit", DisplayKindBrand},
|
||||
{"shipped failure", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusFailure}, "Delivery Issue", DisplayKindWarning},
|
||||
{"shipped returned", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusReturned}, "Returned to Sender", DisplayKindWarning},
|
||||
{"shipped tracking-delivered (webhook lag)", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusDelivered}, "Delivered", DisplayKindSuccess},
|
||||
{"delivered", Order{Status: OrderStatusDelivered}, "Delivered", DisplayKindSuccess},
|
||||
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
|
||||
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
|
||||
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
|
||||
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
|
||||
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
|
||||
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.order.DisplayState(); got != tc.want {
|
||||
t.Errorf("DisplayState() = %q, want %q", got, tc.want)
|
||||
}
|
||||
if got := tc.order.DisplayKind(); got != tc.kind {
|
||||
t.Errorf("DisplayKind() = %q, want %q", got, tc.kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
63
pkg/models/order_event.go
Normal file
63
pkg/models/order_event.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OrderEvent struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint `gorm:"not null;index" json:"order_id"`
|
||||
Type string `gorm:"size:100;not null;index" json:"type"`
|
||||
Payload string `gorm:"type:text" json:"payload"`
|
||||
Actor string `gorm:"size:255" json:"actor"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (OrderEvent) TableName() string {
|
||||
return "order_events"
|
||||
}
|
||||
|
||||
func (e *OrderEvent) PayloadMap() map[string]any {
|
||||
if e.Payload == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *OrderEvent) DisplayLabel() (string, bool) {
|
||||
switch e.Type {
|
||||
case "order_paid":
|
||||
return "Paid", true
|
||||
case "label_purchased":
|
||||
return "Label created", true
|
||||
case "order_delivered":
|
||||
return "Delivered", true
|
||||
case "order_refunded":
|
||||
return "Refunded", true
|
||||
case "order_failed":
|
||||
return "Payment failed", true
|
||||
case "tracking_updated":
|
||||
status, _ := e.PayloadMap()["status"].(string)
|
||||
return trackingLabel(status)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func trackingLabel(status string) (string, bool) {
|
||||
switch status {
|
||||
case "transit":
|
||||
return "In transit", true
|
||||
case "failure":
|
||||
return "Delivery issue", true
|
||||
case "returned":
|
||||
return "Returned to sender", true
|
||||
case "pre_transit":
|
||||
return "Label scanned by carrier", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
23
pkg/models/pay_redirect.go
Normal file
23
pkg/models/pay_redirect.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PayRedirect maps a short token to a stripe hosted url with a TTL.
|
||||
// survives api restarts so a customer mid-3Ds authentication still
|
||||
// lands on the bank-hosted challenge page after a deploy or crash
|
||||
type PayRedirect struct {
|
||||
Token string `gorm:"primaryKey;size:64" json:"token"`
|
||||
URL string `gorm:"type:text;not null" json:"url"`
|
||||
Purpose string `gorm:"size:32;not null" json:"purpose"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
RedirectPurposeCheckout3DS = "checkout_3ds"
|
||||
RedirectPurposeAddCard = "add_card"
|
||||
)
|
||||
|
||||
func (PayRedirect) TableName() string {
|
||||
return "pay_redirects"
|
||||
}
|
||||
28
pkg/models/ssh_key.go
Normal file
28
pkg/models/ssh_key.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SSHKey represents one of a users registered SSH public keys.
|
||||
// A user may have multiple keys, any of them grants access.
|
||||
type SSHKey struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Fingerprint string `gorm:"uniqueIndex;size:100;not null" json:"fingerprint"`
|
||||
PublicKey string `gorm:"type:text" json:"-"`
|
||||
Comment string `gorm:"size:255" json:"comment"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the SSHKey model.
|
||||
func (SSHKey) TableName() string {
|
||||
return "ssh_keys"
|
||||
}
|
||||
327
pkg/notify/slack.go
Normal file
327
pkg/notify/slack.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// Package notify provides a simple interface to send notifications to Slack.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// slack client
|
||||
var slackClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// dollars converts cents to dollars
|
||||
func dollars(cents int) string {
|
||||
return fmt.Sprintf("$%.2f", float64(cents)/100)
|
||||
}
|
||||
|
||||
// postSlackMessage POSTs to chat.postMessage and returns (ts, ok). Logs on failure.
|
||||
func postSlackMessage(token string, payload map[string]any) (string, bool) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := slackClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("slack post failed", "error", err)
|
||||
return "", false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var parsed struct {
|
||||
Ok bool `json:"ok"`
|
||||
TS string `json:"ts"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&parsed)
|
||||
if !parsed.Ok {
|
||||
slog.Warn("slack post non-ok", "error", parsed.Error)
|
||||
return "", false
|
||||
}
|
||||
return parsed.TS, true
|
||||
}
|
||||
|
||||
func pinMessage(token, channel, ts string) bool {
|
||||
return slackPinCall(token, "https://slack.com/api/pins.add", channel, ts, "already_pinned")
|
||||
}
|
||||
|
||||
func unpinMessage(token, channel, ts string) bool {
|
||||
return slackPinCall(token, "https://slack.com/api/pins.remove", channel, ts, "no_pin")
|
||||
}
|
||||
|
||||
func slackPinCall(token, endpoint, channel, ts, benignErr string) bool {
|
||||
body, _ := json.Marshal(map[string]any{"channel": channel, "timestamp": ts})
|
||||
req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := slackClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var parsed struct {
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&parsed)
|
||||
if !parsed.Ok && parsed.Error != benignErr {
|
||||
slog.Warn("slack pin call non-ok", "endpoint", endpoint, "error", parsed.Error)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildReceiptBlocks(order *models.Order) []map[string]any {
|
||||
blocks := []map[string]any{
|
||||
{"type": "divider"},
|
||||
}
|
||||
for _, item := range order.Items {
|
||||
line := fmt.Sprintf("*%s* × %d\n%s", item.Name, item.Quantity, dollars(item.Price*item.Quantity))
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "section",
|
||||
"text": map[string]any{"type": "mrkdwn", "text": line},
|
||||
})
|
||||
}
|
||||
blocks = append(blocks,
|
||||
map[string]any{"type": "divider"},
|
||||
map[string]any{
|
||||
"type": "section",
|
||||
"fields": []map[string]any{
|
||||
{"type": "mrkdwn", "text": "*Subtotal:*\n" + dollars(order.Subtotal)},
|
||||
{"type": "mrkdwn", "text": "*Shipping:*\n" + dollars(order.ShippingCost)},
|
||||
{"type": "mrkdwn", "text": "*Total:*\n" + dollars(order.Total)},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "section",
|
||||
"text": map[string]any{
|
||||
"type": "mrkdwn",
|
||||
"text": fmt.Sprintf(":package: *Ship to:*\n%s\n%s\n%s, %s %s",
|
||||
order.ShippingName,
|
||||
order.ShippingStreet,
|
||||
order.ShippingCity,
|
||||
order.ShippingZip,
|
||||
order.ShippingCountry,
|
||||
),
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "actions",
|
||||
"elements": []map[string]any{
|
||||
{
|
||||
"type": "button",
|
||||
"text": map[string]any{"type": "plain_text", "text": "Buy Label", "emoji": true},
|
||||
"value": fmt.Sprintf("%d", order.ID),
|
||||
"action_id": "buy_label",
|
||||
"style": "primary",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
return blocks
|
||||
}
|
||||
|
||||
// SlackOrderPaid posts a compact parent message ("Order #N paid - $X.XX") to
|
||||
// the orders channel, persists the resulting ts onto the order, and posts the
|
||||
// full receipt as the first thread reply. All subsequent order events
|
||||
// (label-buy, tracking updates) should be posted as further thread replies
|
||||
// via SlackPostToOrderThread. No-op if SLACK_BOT_TOKEN or SLACK_CHANNEL unset.
|
||||
func SlackOrderPaid(order *models.Order) {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
parentText := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total))
|
||||
ts, ok := postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"text": parentText,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.GetDB().Model(&models.Order{}).Where("id = ?", order.ID).Update("slack_thread_ts", ts).Error; err != nil {
|
||||
slog.Warn("failed to save slack thread ts", "order_id", order.ID, "error", err)
|
||||
}
|
||||
|
||||
pinMessage(token, channel, ts)
|
||||
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": ts,
|
||||
"text": parentText,
|
||||
"blocks": buildReceiptBlocks(order),
|
||||
})
|
||||
}
|
||||
|
||||
func SlackUnpinOrder(orderID uint) {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("failed to look up order for unpin", "order_id", orderID, "error", err)
|
||||
return
|
||||
}
|
||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||
return
|
||||
}
|
||||
|
||||
unpinMessage(token, channel, *order.SlackThreadTS)
|
||||
}
|
||||
|
||||
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
|
||||
// It looks up the thread ts from the database and returns an error when the
|
||||
// reply cannot be posted.
|
||||
func SlackPostToOrderThread(orderID uint, text string) error {
|
||||
return slackPostToOrderThread(orderID, text, false)
|
||||
}
|
||||
|
||||
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
|
||||
// to the channel (reply_broadcast=true) so channel members get a normal
|
||||
// notification. Use for messages that need immediate operator attention.
|
||||
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
|
||||
return slackPostToOrderThread(orderID, text, true)
|
||||
}
|
||||
|
||||
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return fmt.Errorf("slack is not configured")
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
|
||||
return fmt.Errorf("failed to look up order thread: %w", err)
|
||||
}
|
||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||
return fmt.Errorf("order has no slack thread")
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": *order.SlackThreadTS,
|
||||
"text": text,
|
||||
}
|
||||
if broadcast {
|
||||
payload["reply_broadcast"] = true
|
||||
}
|
||||
|
||||
if _, ok := postSlackMessage(token, payload); !ok {
|
||||
slog.Warn("failed to post slack thread reply", "order_id", orderID)
|
||||
return fmt.Errorf("failed to post slack thread reply")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var order models.Order
|
||||
if err := database.GetDB().
|
||||
Select("id", "carrier", "tracking_number", "tracking_url", "slack_thread_ts").
|
||||
Where("id = ?", orderID).First(&order).Error; err != nil {
|
||||
slog.Warn("failed to look up order for label-purchased slack post", "order_id", orderID, "error", err)
|
||||
return
|
||||
}
|
||||
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
|
||||
return
|
||||
}
|
||||
|
||||
headLine := fmt.Sprintf(":white_check_mark: Labeled - %s `%s` (%s). <%s|Download label PDF>", order.Carrier, order.TrackingNumber, dollars(costCents), labelURL)
|
||||
|
||||
blocks := []map[string]any{
|
||||
{
|
||||
"type": "section",
|
||||
"text": map[string]any{"type": "mrkdwn", "text": headLine},
|
||||
},
|
||||
}
|
||||
|
||||
if strings.EqualFold(order.Carrier, "BRING") {
|
||||
blocks = append(blocks, markStatusActionBlock(order.ID))
|
||||
}
|
||||
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"thread_ts": *order.SlackThreadTS,
|
||||
"text": headLine,
|
||||
"blocks": blocks,
|
||||
})
|
||||
}
|
||||
|
||||
func markStatusActionBlock(orderID uint) map[string]any {
|
||||
id := fmt.Sprintf("%d", orderID)
|
||||
return map[string]any{
|
||||
"type": "actions",
|
||||
"elements": []map[string]any{
|
||||
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Pre-Transit", "emoji": true}, "value": id, "action_id": "mark_pre_transit"},
|
||||
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark In Transit", "emoji": true}, "value": id, "action_id": "mark_in_transit", "style": "primary"},
|
||||
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Delivered", "emoji": true}, "value": id, "action_id": "mark_delivered"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func SlackUnshippedReminder(orders []models.Order) {
|
||||
token := os.Getenv("SLACK_BOT_TOKEN")
|
||||
channel := os.Getenv("SLACK_CHANNEL")
|
||||
if token == "" || channel == "" || len(orders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
|
||||
|
||||
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
|
||||
for _, order := range orders {
|
||||
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
|
||||
}
|
||||
|
||||
_, _ = postSlackMessage(token, map[string]any{
|
||||
"channel": channel,
|
||||
"text": strings.Join(lines, "\n"),
|
||||
})
|
||||
}
|
||||
|
||||
func unshippedOrderLine(order models.Order, channel, workspace string) string {
|
||||
age := time.Since(order.CreatedAt).Round(time.Hour)
|
||||
location := order.ShippingCountry
|
||||
if order.ShippingCity != "" {
|
||||
location = order.ShippingCity + ", " + order.ShippingCountry
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("Order #%d", order.ID)
|
||||
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
|
||||
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
|
||||
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
|
||||
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
|
||||
}
|
||||
|
||||
tag := ""
|
||||
if order.ShippingCountry == "NO" {
|
||||
tag = " _(NO * Bring blocked)_"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
|
||||
}
|
||||
62
pkg/observability/logger.go
Normal file
62
pkg/observability/logger.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
)
|
||||
|
||||
// countingHandler wraps a slog.Handler and increments the log_messages_total
|
||||
// counter for every record before delegating to the inner handler. This is
|
||||
// what gives the dashboard a per-level log rate without forcing call sites
|
||||
// to thread metrics manually.
|
||||
type countingHandler struct {
|
||||
inner slog.Handler
|
||||
}
|
||||
|
||||
func (h *countingHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
return h.inner.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (h *countingHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
middleware.RecordLogLevel(strings.ToLower(r.Level.String()))
|
||||
return h.inner.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (h *countingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &countingHandler{inner: h.inner.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h *countingHandler) WithGroup(name string) slog.Handler {
|
||||
return &countingHandler{inner: h.inner.WithGroup(name)}
|
||||
}
|
||||
|
||||
// InitLogger installs a slog-based default logger with a level-counting wrapper
|
||||
// and bridges the stdlib `log` package through slog so existing log.Printf
|
||||
// calls are also counted (as INFO).
|
||||
func InitLogger() {
|
||||
level := slog.LevelInfo
|
||||
if env := strings.ToLower(os.Getenv("LOG_LEVEL")); env != "" {
|
||||
switch env {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
}
|
||||
|
||||
base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
|
||||
handler := &countingHandler{inner: base}
|
||||
slog.SetDefault(slog.New(handler))
|
||||
|
||||
// Route the stdlib log package through slog at INFO so legacy log.Printf
|
||||
// calls show up in structured output and bump the counter.
|
||||
slog.SetLogLoggerLevel(slog.LevelInfo)
|
||||
log.SetFlags(0)
|
||||
}
|
||||
32
pkg/observability/tracing.go
Normal file
32
pkg/observability/tracing.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
func InitTracing(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
|
||||
exp, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(serviceName)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(5*time.Second)), sdktrace.WithResource(res))
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.TraceContext{})
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
12
pkg/shipping/types.go
Normal file
12
pkg/shipping/types.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package shipping
|
||||
|
||||
type LabelResult struct {
|
||||
TransactionID string
|
||||
RateID string
|
||||
Carrier string
|
||||
ServiceLevel string
|
||||
TrackingNumber string
|
||||
TrackingURL string
|
||||
LabelURL string
|
||||
CostCents int
|
||||
}
|
||||
@@ -2,15 +2,27 @@ package shippo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"terminalShop/pkg/shipping"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.goshippo.com"
|
||||
|
||||
const (
|
||||
labelPollInterval = 1 * time.Second
|
||||
labelPollTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Client handles communication with the Shippo API.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
@@ -22,11 +34,325 @@ func NewClient(apiKey string) *Client {
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Parcel struct {
|
||||
LengthCm float64
|
||||
WidthCm float64
|
||||
HeightCm float64
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
Title string
|
||||
Quantity int
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
type shipmentRate struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Provider string `json:"provider"`
|
||||
ServiceLevel struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"servicelevel"`
|
||||
}
|
||||
|
||||
type shipmentResponse struct {
|
||||
Status string `json:"status"`
|
||||
Rates []shipmentRate `json:"rates"`
|
||||
}
|
||||
|
||||
type shippoOrderResponse struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
}
|
||||
|
||||
type transactionResponse struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
Status string `json:"status"`
|
||||
ObjectState string `json:"object_state"`
|
||||
TrackingNumber string `json:"tracking_number"`
|
||||
TrackingURL string `json:"tracking_url_provider"`
|
||||
LabelURL string `json:"label_url"`
|
||||
Rate string `json:"rate"`
|
||||
Messages []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateLabel(ctx context.Context, to Address, email string, items []LineItem) (*shipping.LabelResult, error) {
|
||||
from := fromAddress()
|
||||
if from.Street1 == "" {
|
||||
return nil, fmt.Errorf("SHIP_FROM_STREET1 not configured")
|
||||
}
|
||||
|
||||
totalKg := 0.0
|
||||
for _, it := range items {
|
||||
totalKg += float64(it.Quantity) * it.WeightKg
|
||||
}
|
||||
if totalKg <= 0 {
|
||||
return nil, fmt.Errorf("order has zero shipping weight")
|
||||
}
|
||||
|
||||
parcel := Parcel{LengthCm: 30, WidthCm: 30, HeightCm: 30, WeightKg: totalKg}
|
||||
|
||||
rate, err := c.createShipmentForRates(ctx, from, to, parcel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get rates: %w", err)
|
||||
}
|
||||
|
||||
shippoOrderID, err := c.createShippoOrder(ctx, to, email, items, totalKg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create shippo order: %w", err)
|
||||
}
|
||||
|
||||
tx, err := c.buyLabel(ctx, rate.ObjectID, shippoOrderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("buy label: %w", err)
|
||||
}
|
||||
|
||||
final, err := c.pollTransaction(ctx, tx.ObjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll transaction: %w", err)
|
||||
}
|
||||
|
||||
costCents := parseAmountCents(rate.Amount)
|
||||
|
||||
return &shipping.LabelResult{
|
||||
TransactionID: final.ObjectID,
|
||||
RateID: rate.ObjectID,
|
||||
Carrier: rate.Provider,
|
||||
ServiceLevel: rate.ServiceLevel.Name,
|
||||
TrackingNumber: final.TrackingNumber,
|
||||
TrackingURL: final.TrackingURL,
|
||||
LabelURL: final.LabelURL,
|
||||
CostCents: costCents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) createShipmentForRates(ctx context.Context, from, to Address, parcel Parcel) (*shipmentRate, error) {
|
||||
payload := map[string]any{
|
||||
"address_from": addrPayload(from),
|
||||
"address_to": addrPayload(to),
|
||||
"parcels": []map[string]any{{
|
||||
"length": parcel.LengthCm,
|
||||
"width": parcel.WidthCm,
|
||||
"height": parcel.HeightCm,
|
||||
"distance_unit": "cm",
|
||||
"weight": parcel.WeightKg,
|
||||
"mass_unit": "kg",
|
||||
}},
|
||||
"async": false,
|
||||
"extra": map[string]any{"bypass_address_validation": true},
|
||||
}
|
||||
|
||||
var resp shipmentResponse
|
||||
if err := c.doJSON(ctx, "POST", "/shipments", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Status != "SUCCESS" || len(resp.Rates) == 0 {
|
||||
return nil, fmt.Errorf("shippo returned no rates (status=%s)", resp.Status)
|
||||
}
|
||||
|
||||
filtered := resp.Rates[:0:0]
|
||||
for _, r := range resp.Rates {
|
||||
if r.Provider == "USPS" {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, fmt.Errorf("no USPS rates available for this route")
|
||||
}
|
||||
resp.Rates = filtered
|
||||
|
||||
sort.Slice(resp.Rates, func(i, j int) bool {
|
||||
ai, _ := strconv.ParseFloat(resp.Rates[i].Amount, 64)
|
||||
aj, _ := strconv.ParseFloat(resp.Rates[j].Amount, 64)
|
||||
return ai < aj
|
||||
})
|
||||
r := resp.Rates[0]
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c *Client) createShippoOrder(ctx context.Context, to Address, email string, items []LineItem, totalKg float64) (string, error) {
|
||||
lines := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
lines = append(lines, map[string]any{
|
||||
"quantity": item.Quantity,
|
||||
"title": item.Title,
|
||||
"weight": roundN(item.WeightKg, 4),
|
||||
"weight_unit": "kg",
|
||||
})
|
||||
}
|
||||
toPayload := addrPayload(to)
|
||||
if email != "" {
|
||||
toPayload["email"] = email
|
||||
}
|
||||
payload := map[string]any{
|
||||
"to_address": toPayload,
|
||||
"line_items": lines,
|
||||
"placed_at": time.Now().Format(time.RFC3339),
|
||||
"weight": roundN(totalKg, 2),
|
||||
"weight_unit": "kg",
|
||||
}
|
||||
var resp shippoOrderResponse
|
||||
if err := c.doJSON(ctx, "POST", "/v1/orders", payload, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.ObjectID == "" {
|
||||
return "", fmt.Errorf("shippo order returned no object_id")
|
||||
}
|
||||
return resp.ObjectID, nil
|
||||
}
|
||||
|
||||
func (c *Client) buyLabel(ctx context.Context, rateID, shippoOrderID string) (*transactionResponse, error) {
|
||||
payload := map[string]any{
|
||||
"rate": rateID,
|
||||
"order": shippoOrderID,
|
||||
"async": true,
|
||||
}
|
||||
var resp transactionResponse
|
||||
if err := c.doJSON(ctx, "POST", "/v1/transactions", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.ObjectState == "INVALID" {
|
||||
return nil, fmt.Errorf("transaction invalid: %s", firstMessage(resp.Messages))
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollTransaction(ctx context.Context, txID string) (*transactionResponse, error) {
|
||||
deadLine := time.Now().Add(labelPollTimeout)
|
||||
for {
|
||||
var resp transactionResponse
|
||||
if err := c.doJSON(ctx, "GET", "/transactions/"+txID, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch resp.Status {
|
||||
case "SUCCESS":
|
||||
return &resp, nil
|
||||
case "ERROR":
|
||||
return nil, fmt.Errorf("shippo transaction error: %s", firstMessage(resp.Messages))
|
||||
}
|
||||
if time.Now().After(deadLine) {
|
||||
return nil, fmt.Errorf("timed out waiting for label (last status=%s)", resp.Status)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(labelPollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "ShippoToken "+c.apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("shippo %s %s -> %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("parse reponse: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addrPayload(a Address) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": a.Name,
|
||||
"street1": a.Street1,
|
||||
"city": a.City,
|
||||
"country": a.Country,
|
||||
"zip": a.Zip,
|
||||
}
|
||||
if a.Street2 != "" {
|
||||
m["street2"] = a.Street2
|
||||
}
|
||||
if a.State != "" {
|
||||
m["state"] = a.State
|
||||
}
|
||||
if a.Phone != "" {
|
||||
m["phone"] = a.Phone
|
||||
}
|
||||
if a.Email != "" {
|
||||
m["email"] = a.Email
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// roundN rounds to n decimal places. Shippo's /v1/orders rejects >2 decimals
|
||||
// on parcel weight and >4 on line-item weight.
|
||||
func roundN(v float64, n int) float64 {
|
||||
pow := 1.0
|
||||
for i := 0; i < n; i++ {
|
||||
pow *= 10
|
||||
}
|
||||
if v >= 0 {
|
||||
return float64(int64(v*pow+0.5)) / pow
|
||||
}
|
||||
return float64(int64(v*pow-0.5)) / pow
|
||||
}
|
||||
|
||||
func parseAmountCents(s string) int {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(f * 100)
|
||||
}
|
||||
|
||||
func firstMessage(msgs []struct {
|
||||
Text string `json:"text"`
|
||||
}) string {
|
||||
if len(msgs) == 0 {
|
||||
return "no detail"
|
||||
}
|
||||
return msgs[0].Text
|
||||
}
|
||||
|
||||
func fromAddress() Address {
|
||||
return Address{
|
||||
Name: os.Getenv("SHIP_FROM_NAME"),
|
||||
Street1: os.Getenv("SHIP_FROM_STREET1"),
|
||||
Street2: os.Getenv("SHIP_FROM_STREET2"),
|
||||
City: os.Getenv("SHIP_FROM_CITY"),
|
||||
State: os.Getenv("SHIP_FROM_STATE"),
|
||||
Country: os.Getenv("SHIP_FROM_COUNTRY"),
|
||||
Zip: os.Getenv("SHIP_FROM_ZIP"),
|
||||
Phone: os.Getenv("SHIP_FROM_PHONE"),
|
||||
Email: os.Getenv("SHIP_FROM_EMAIL"),
|
||||
}
|
||||
}
|
||||
|
||||
// Address represents a shipping address for validation.
|
||||
type Address struct {
|
||||
Name string `json:"name"`
|
||||
@@ -37,6 +363,7 @@ type Address struct {
|
||||
Country string `json:"country"`
|
||||
Zip string `json:"zip"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// addressRequest is the payload sent to Shippo's address validation endpoint.
|
||||
@@ -75,7 +402,7 @@ type addressResponse struct {
|
||||
|
||||
// ValidateAddress sends an address to Shippo for validation.
|
||||
// Returns the normalized/corrected address if valid, or an error if invalid.
|
||||
func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, error) {
|
||||
reqBody := addressRequest{
|
||||
Name: addr.Name,
|
||||
Street1: addr.Street1,
|
||||
@@ -93,7 +420,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
return nil, fmt.Errorf("failed to marshal address: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", baseURL+"/v1/addresses", bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/addresses", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -105,7 +432,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shippo request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
||||
22
pkg/stripeclient/init.go
Normal file
22
pkg/stripeclient/init.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package stripeclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// InitOTel wires the Stripe SDK to use an HTTP client whose transport emits
|
||||
// OpenTelemetry client spans. Call once at startup, after tracing is initialized.
|
||||
func InitOTel() {
|
||||
httpClient := &http.Client{
|
||||
Timeout: 80 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
}
|
||||
backends := stripe.NewBackends(httpClient)
|
||||
stripe.SetBackend(stripe.APIBackend, backends.API)
|
||||
stripe.SetBackend(stripe.UploadsBackend, backends.Uploads)
|
||||
stripe.SetBackend(stripe.ConnectBackend, backends.Connect)
|
||||
}
|
||||
@@ -1,361 +1,51 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func (m Model) BuildAccountView(availableHeight int) string {
|
||||
// updateAccountViewport initializes or resizes the detail viewport for the account page.
|
||||
// Follows the reference pattern: each page owns its own viewport(s).
|
||||
func (m Model) updateAccountViewport() Model {
|
||||
// Match the reference: only subtract header + footer, no extra margins
|
||||
// (account is a viewport page — views.go renders it without margin padding)
|
||||
headerHeight := lipgloss.Height(m.BuildHeader())
|
||||
footerHeight := lipgloss.Height(m.BuildFooter())
|
||||
verticalMarginHeight := headerHeight + footerHeight
|
||||
|
||||
availableHeight := m.heightContainer - verticalMarginHeight
|
||||
|
||||
leftWidth := 18
|
||||
rightWidth := m.widthContent - leftWidth - 2 // 2 for gap
|
||||
|
||||
// Build the left panel (menu items)
|
||||
leftPanel := ""
|
||||
for i, item := range models.AccountMenuItems {
|
||||
if m.AccountCursor == i {
|
||||
style := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#4682B4")).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Padding(0, 1).
|
||||
Width(leftWidth - 2).
|
||||
Align(lipgloss.Left)
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
// On small terminals, use full content width
|
||||
detailContentWidth := rightWidth - 2
|
||||
if m.size < large {
|
||||
detailContentWidth = m.widthContent - 2
|
||||
}
|
||||
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA")).
|
||||
Width(detailContentWidth)
|
||||
|
||||
selectedItem := models.AccountMenuItems[m.AccountCursor]
|
||||
switch selectedItem {
|
||||
case "order history":
|
||||
if !m.OrdersLoaded {
|
||||
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
|
||||
} else if len(m.Orders) == 0 {
|
||||
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.")
|
||||
} else if m.OrderViewState == 2 {
|
||||
// State 2: full detail for the selected order
|
||||
detailView = m.buildOrderDetailView(m.Orders[m.OrderCursor], detailContentWidth)
|
||||
} else {
|
||||
// State 0 (preview) or State 1 (browsing with cursor)
|
||||
boxWidth := detailContentWidth - 4
|
||||
boxPadding := 0
|
||||
if m.heightContainer >= 25 {
|
||||
boxPadding = 1
|
||||
}
|
||||
|
||||
lines := titleStyle.Render("Order History") + "\n\n"
|
||||
for i, order := range m.Orders {
|
||||
isSelected := m.OrderViewState == 1 && i == m.OrderCursor
|
||||
lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, detailContentWidth)
|
||||
if i < len(m.Orders)-1 {
|
||||
lines += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
if m.OrderViewState == 0 {
|
||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
lines += "\n" + hintStyle.Render("enter: browse orders")
|
||||
}
|
||||
detailView = lines
|
||||
}
|
||||
case "faq":
|
||||
questionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
|
||||
faqContent := ""
|
||||
for i, faq := range m.FAQs {
|
||||
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
|
||||
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
|
||||
if i < len(m.FAQs)-1 {
|
||||
faqContent += "\n\n"
|
||||
}
|
||||
}
|
||||
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
if !m.FaqFocused {
|
||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||
}
|
||||
case "about":
|
||||
accentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
userStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("C6DDF0")).
|
||||
Bold(true)
|
||||
|
||||
aboutContent := contentStyle.Render(wordWrap(
|
||||
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
|
||||
detailContentWidth,
|
||||
)) + "\n\n"
|
||||
aboutContent += userStyle.Render("@thdxr") + "\n"
|
||||
aboutContent += userStyle.Render("@adamdotdev") + "\n"
|
||||
aboutContent += userStyle.Render("@theprimeagen") + "\n"
|
||||
aboutContent += userStyle.Render("@teej_dv") + "\n"
|
||||
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
|
||||
aboutContent += contentStyle.Render("And me:") + "\n\n"
|
||||
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
|
||||
aboutContent += accentStyle.Render("Terminal Products, Inc.")
|
||||
|
||||
detailView = titleStyle.Render("About") + "\n\n" + aboutContent
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Apply internal scrolling to just the detail view
|
||||
// Scrolling is needed for: order detail/list and FAQ when focused
|
||||
if m.OrderViewState >= 1 || m.FaqFocused {
|
||||
detailLines := strings.Split(detailView, "\n")
|
||||
totalLines := len(detailLines)
|
||||
|
||||
// In stacked mode, reduce available height by left panel
|
||||
scrollHeight := availableHeight
|
||||
if m.size < large {
|
||||
leftPanelHeight := len(models.AccountMenuItems) + 1
|
||||
scrollHeight = availableHeight - leftPanelHeight
|
||||
}
|
||||
if scrollHeight < 3 {
|
||||
scrollHeight = 3
|
||||
}
|
||||
|
||||
// Clamp scroll offset locally (don't mutate m since value receiver)
|
||||
offset := m.ScrollOffset
|
||||
maxScroll := totalLines - scrollHeight
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if offset > maxScroll {
|
||||
offset = maxScroll
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// Slice to visible window
|
||||
if totalLines > scrollHeight {
|
||||
end := offset + scrollHeight
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
detailLines = detailLines[offset:end]
|
||||
}
|
||||
detailView = strings.Join(detailLines, "\n")
|
||||
}
|
||||
|
||||
detailContainer := lipgloss.NewStyle().
|
||||
Width(rightWidth)
|
||||
|
||||
// Responsive layout: side-by-side on large, stacked on small/medium
|
||||
rightWidth := m.widthContent - leftWidth - 2
|
||||
detailWidth := rightWidth
|
||||
if m.size < large {
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
leftContainer.Render(leftPanel),
|
||||
detailContainer.Width(m.widthContent).Render(detailView),
|
||||
)
|
||||
detailWidth = m.widthContent
|
||||
// In stacked mode, reduce available height by the left panel
|
||||
leftPanelHeight := len(models.AccountMenuItems) + 1
|
||||
availableHeight -= leftPanelHeight
|
||||
}
|
||||
if availableHeight < 3 {
|
||||
availableHeight = 3
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
leftContainer.Render(leftPanel),
|
||||
" ",
|
||||
detailContainer.Render(detailView),
|
||||
)
|
||||
}
|
||||
|
||||
// buildOrderCard renders a single order as a bordered box for the order list.
|
||||
// Shows order number, date, status, total, and the first 2 items as a preview.
|
||||
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
|
||||
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
|
||||
nameStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
|
||||
statusStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
|
||||
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
|
||||
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
|
||||
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
|
||||
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
|
||||
|
||||
rightSide := date + " " + status + " " + total
|
||||
rightWidth := lipgloss.Width(rightSide)
|
||||
leftWidth := lipgloss.Width(orderLabel)
|
||||
spacing := boxWidth - leftWidth - rightWidth - 4 // 4 for padding
|
||||
if spacing < 1 {
|
||||
spacing = 1
|
||||
}
|
||||
|
||||
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + rightSide
|
||||
|
||||
// Item preview lines (show first 2 items)
|
||||
var lines []string
|
||||
lines = append(lines, line1)
|
||||
|
||||
previewCount := 2
|
||||
if len(order.Items) < previewCount {
|
||||
previewCount = len(order.Items)
|
||||
}
|
||||
for i := 0; i < previewCount; i++ {
|
||||
item := order.Items[i]
|
||||
itemLine := dimStyle.Render(fmt.Sprintf("%dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0))
|
||||
lines = append(lines, itemLine)
|
||||
}
|
||||
if len(order.Items) > 2 {
|
||||
extra := len(order.Items) - 2
|
||||
moreText := "item"
|
||||
if extra > 1 {
|
||||
moreText = "items"
|
||||
}
|
||||
lines = append(lines, dimStyle.Render(fmt.Sprintf("+ %d more %s", extra, moreText)))
|
||||
}
|
||||
|
||||
boxContent := strings.Join(lines, "\n")
|
||||
|
||||
// Create bordered box — highlight if selected
|
||||
var itemBox lipgloss.Style
|
||||
if isSelected {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), true).
|
||||
BorderForeground(lipgloss.Color("#FFFFFF")).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth)
|
||||
if !m.account.viewportReady {
|
||||
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
||||
m.account.detailViewport.KeyMap = modifiedKeyMap
|
||||
m.account.viewportReady = true
|
||||
} else {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("#666666")).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth)
|
||||
m.account.detailViewport.SetWidth(detailWidth)
|
||||
m.account.detailViewport.SetHeight(availableHeight)
|
||||
}
|
||||
|
||||
centered := lipgloss.NewStyle().
|
||||
Width(containerWidth).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
return centered.Render(itemBox.Render(boxContent))
|
||||
return m
|
||||
}
|
||||
|
||||
// buildOrderDetailView renders the full detail view for a single order.
|
||||
func (m Model) buildOrderDetailView(order models.Order, width int) string {
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
MarginBottom(1)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA"))
|
||||
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Status and date
|
||||
b.WriteString(labelStyle.Render("Status: "))
|
||||
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render("Date: "))
|
||||
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Items
|
||||
b.WriteString(labelStyle.Render("Items"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
|
||||
b.WriteString("\n")
|
||||
for _, item := range order.Items {
|
||||
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
|
||||
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
|
||||
b.WriteString(valueStyle.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Totals
|
||||
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
|
||||
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
|
||||
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Shipping address
|
||||
b.WriteString(labelStyle.Render("Ship To"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingName))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
|
||||
b.WriteString("\n")
|
||||
if order.ShippingStreet2 != "" {
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
cityLine := order.ShippingCity
|
||||
if order.ShippingState != "" {
|
||||
cityLine += ", " + order.ShippingState
|
||||
}
|
||||
cityLine += " " + order.ShippingZip
|
||||
b.WriteString(valueStyle.Render(" " + cityLine))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Footer hint
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// computeFaqScrollMax calculates the maximum scroll offset for the FAQ content.
|
||||
// This renders the FAQ the same way BuildAccountView does, counts the lines,
|
||||
// and returns the max scroll value. Returns 0 if no scrolling is needed.
|
||||
func (m Model) computeFaqScrollMax() int {
|
||||
// getAccountDetailContent generates the content string for the current detail panel.
|
||||
func (m Model) getAccountDetailContent() string {
|
||||
leftWidth := 18
|
||||
rightWidth := m.widthContent - leftWidth - 2
|
||||
detailContentWidth := rightWidth - 2
|
||||
@@ -363,41 +53,389 @@ 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)
|
||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||
return ""
|
||||
}
|
||||
|
||||
faqContent := ""
|
||||
for i, faq := range m.FAQs {
|
||||
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
|
||||
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
|
||||
if i < len(m.FAQs)-1 {
|
||||
faqContent += "\n\n"
|
||||
selectedItem := models.AccountMenuItems[m.account.cursor]
|
||||
switch selectedItem {
|
||||
case "active orders":
|
||||
return m.ActiveOrdersView(detailContentWidth)
|
||||
case "order history":
|
||||
return m.OrdersView(detailContentWidth)
|
||||
case "addresses":
|
||||
return m.AddressesView(detailContentWidth)
|
||||
case "cards":
|
||||
return m.CardsView(detailContentWidth)
|
||||
case "faq":
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||
faqContent := ""
|
||||
for i, faq := range m.FAQs {
|
||||
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
|
||||
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
|
||||
if i < len(m.FAQs)-1 {
|
||||
faqContent += "\n\n"
|
||||
}
|
||||
}
|
||||
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
if !m.account.faqFocused {
|
||||
hintStyle := m.theme.TextDim()
|
||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||
}
|
||||
return detailView
|
||||
case "about":
|
||||
return m.AboutView(detailContentWidth)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scrollToAccountDetailItem adjusts the viewport scroll to keep the selected item visible.
|
||||
// Follows the reference pattern: cursor moves immediately, viewport catches up.
|
||||
//
|
||||
// Each sub-page has a title header (e.g. "Order History" + margin + newlines) that
|
||||
// pushes the first item down. headerOffset accounts for those lines. itemHeight
|
||||
// must match the actual rendered card height including borders, padding, and the
|
||||
// inter-card gap ("\n" between cards in the view function).
|
||||
func (m Model) scrollToAccountDetailItem() Model {
|
||||
var itemHeight, itemCount, selectedIndex, headerOffset int
|
||||
|
||||
switch {
|
||||
case m.account.orderViewState == 1:
|
||||
// No title in list mode, cards joined with JoinVertical (no gaps)
|
||||
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
|
||||
headerOffset = 0
|
||||
itemHeight = 4
|
||||
itemCount = len(m.currentOrderList())
|
||||
selectedIndex = m.account.orderCursor
|
||||
case m.account.addressListFocused:
|
||||
headerOffset = 3
|
||||
itemHeight = 5
|
||||
itemCount = len(m.SavedAddresses)
|
||||
selectedIndex = m.account.addressCursor
|
||||
case m.account.cardListFocused:
|
||||
headerOffset = 3
|
||||
itemHeight = 4
|
||||
itemCount = len(m.SavedCards)
|
||||
selectedIndex = m.account.cardCursor
|
||||
default:
|
||||
return m
|
||||
}
|
||||
|
||||
if itemCount == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
targetY := headerOffset + (selectedIndex * itemHeight)
|
||||
vpH := m.account.detailViewport.Height()
|
||||
offset := m.account.detailViewport.YOffset()
|
||||
|
||||
// 1-item buffer: scroll when the NEXT item would be off-screen,
|
||||
// not when the current item hits the very edge
|
||||
if targetY-itemHeight < offset {
|
||||
m.account.detailViewport.SetYOffset(targetY - itemHeight)
|
||||
}
|
||||
if targetY+itemHeight+itemHeight > offset+vpH {
|
||||
m.account.detailViewport.SetYOffset(targetY - vpH + itemHeight + itemHeight)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) BuildAccountView(availableHeight int) string {
|
||||
leftWidth := 18
|
||||
|
||||
// Build the left panel (menu items)
|
||||
leftPanel := ""
|
||||
for i, item := range models.AccountMenuItems {
|
||||
if m.account.cursor == i {
|
||||
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
||||
leftPanel += style.Render(item) + "\n"
|
||||
} else {
|
||||
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
||||
leftPanel += style.Render(item) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
MarginBottom(2)
|
||||
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
leftContainer := m.theme.Base().
|
||||
Width(leftWidth)
|
||||
|
||||
totalLines := len(strings.Split(detailView, "\n"))
|
||||
// Generate detail content and set it on the viewport
|
||||
detailContent := m.getAccountDetailContent()
|
||||
m.account.detailViewport.SetContent(detailContent)
|
||||
|
||||
scrollHeight := m.heightContainer - 7
|
||||
// Viewport handles scrolling/clipping — no manual line-slicing needed
|
||||
detailRendered := m.account.detailViewport.View()
|
||||
|
||||
// Responsive layout: side-by-side on large, stacked on small/medium
|
||||
if m.size < large {
|
||||
scrollHeight -= len(models.AccountMenuItems) + 1
|
||||
}
|
||||
if scrollHeight < 3 {
|
||||
scrollHeight = 3
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
leftContainer.Render(leftPanel),
|
||||
detailRendered,
|
||||
)
|
||||
}
|
||||
|
||||
maxScroll := totalLines - scrollHeight
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
return maxScroll
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
leftContainer.Render(leftPanel),
|
||||
" ",
|
||||
detailRendered,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) AboutView(width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
// USE this if wanting to add a corp name later
|
||||
// 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:",
|
||||
width,
|
||||
)) + "\n\n"
|
||||
aboutContent += userStyle.Render("@thdxr") + "\n"
|
||||
aboutContent += userStyle.Render("@adamdotdev") + "\n"
|
||||
aboutContent += userStyle.Render("@theprimeagen") + "\n"
|
||||
aboutContent += userStyle.Render("@teej_dv") + "\n"
|
||||
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
|
||||
aboutContent += contentStyle.Render("And me:") + "\n\n"
|
||||
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
|
||||
aboutContent += contentStyle.Render(wordWrap(
|
||||
"This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
|
||||
width,
|
||||
)) + "\n"
|
||||
|
||||
return titleStyle.Render("About") + "\n\n" + aboutContent
|
||||
}
|
||||
|
||||
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
// Pass non-key messages to viewport (e.g. window resize)
|
||||
var cmd tea.Cmd
|
||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
selectedItem := ""
|
||||
if m.account.cursor < len(models.AccountMenuItems) {
|
||||
selectedItem = models.AccountMenuItems[m.account.cursor]
|
||||
}
|
||||
|
||||
switch keyMsg.String() {
|
||||
case "up", "k":
|
||||
// In order detail or FAQ, scroll the viewport directly
|
||||
if m.account.orderViewState == 2 || m.account.faqFocused {
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m.account.detailViewport.ScrollUp(3)
|
||||
return m, nil
|
||||
}
|
||||
if m.account.addressListFocused && m.account.addressDeleting == nil {
|
||||
if m.account.addressCursor > 0 {
|
||||
m.account.addressCursor--
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m = m.scrollToAccountDetailItem()
|
||||
}
|
||||
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
|
||||
if m.account.cardCursor > 0 {
|
||||
m.account.cardCursor--
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m = m.scrollToAccountDetailItem()
|
||||
}
|
||||
} else if m.account.orderViewState == 1 && m.account.orderCursor > 0 {
|
||||
m.account.orderCursor--
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m = m.scrollToAccountDetailItem()
|
||||
} else if m.account.orderViewState == 0 && m.account.cursor > 0 {
|
||||
m.account.cursor--
|
||||
m.account.detailViewport.GotoTop()
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if m.account.orderViewState == 2 || m.account.faqFocused {
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m.account.detailViewport.ScrollDown(3)
|
||||
return m, nil
|
||||
}
|
||||
if m.account.addressListFocused && m.account.addressDeleting == nil {
|
||||
if m.account.addressCursor < len(m.SavedAddresses)-1 {
|
||||
m.account.addressCursor++
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m = m.scrollToAccountDetailItem()
|
||||
}
|
||||
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
|
||||
if m.account.cardCursor < len(m.SavedCards)-1 {
|
||||
m.account.cardCursor++
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m = m.scrollToAccountDetailItem()
|
||||
}
|
||||
} else if m.account.orderViewState == 1 && m.account.orderCursor < len(m.currentOrderList())-1 {
|
||||
m.account.orderCursor++
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
m = m.scrollToAccountDetailItem()
|
||||
} else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && m.account.cursor < len(models.AccountMenuItems)-1 {
|
||||
m.account.cursor++
|
||||
m.account.detailViewport.GotoTop()
|
||||
}
|
||||
|
||||
case "pgup", "ctrl+u", "pgdown", "ctrl+d":
|
||||
// Ensure viewport has content before delegating scroll
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
var cmd tea.Cmd
|
||||
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case "r":
|
||||
if m.account.orderViewState == 2 {
|
||||
if order, ok := m.selectedRefundableOrder(); ok {
|
||||
return m.OpenRefundComposer(order.ID)
|
||||
}
|
||||
}
|
||||
|
||||
case "x", "d":
|
||||
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
|
||||
m.account.addressDeleting = &m.account.addressCursor
|
||||
} else if m.account.cardListFocused && m.account.cardDeleting == nil && m.account.cardCursor < len(m.SavedCards) {
|
||||
m.account.cardDeleting = &m.account.cardCursor
|
||||
}
|
||||
|
||||
case "y":
|
||||
if m.account.addressDeleting != nil {
|
||||
cursor := *m.account.addressDeleting
|
||||
addr := m.SavedAddresses[cursor]
|
||||
m.account.addressDeleting = nil
|
||||
m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...)
|
||||
if m.account.addressCursor >= len(m.SavedAddresses) && m.account.addressCursor > 0 {
|
||||
m.account.addressCursor--
|
||||
}
|
||||
return m, m.deleteAddressCmd(addr.ID)
|
||||
} else if m.account.cardDeleting != nil {
|
||||
cursor := *m.account.cardDeleting
|
||||
card := m.SavedCards[cursor]
|
||||
m.account.cardDeleting = nil
|
||||
m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...)
|
||||
if m.account.cardCursor >= len(m.SavedCards) && m.account.cardCursor > 0 {
|
||||
m.account.cardCursor--
|
||||
}
|
||||
return m, m.deleteCardCmd(card.ID)
|
||||
}
|
||||
|
||||
case "n":
|
||||
m.account.addressDeleting = nil
|
||||
m.account.cardDeleting = nil
|
||||
|
||||
case "p", "enter":
|
||||
switch selectedItem {
|
||||
case "order history", "active orders":
|
||||
if len(m.currentOrderList()) > 0 {
|
||||
switch m.account.orderViewState {
|
||||
case 0:
|
||||
m.account.orderViewState = 1
|
||||
m.account.orderCursor = 0
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "orders"},
|
||||
{key: "enter", value: "details"},
|
||||
{key: "esc", value: "back"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
case 1:
|
||||
m.account.orderYOffset = m.account.detailViewport.YOffset()
|
||||
m.account.orderViewState = 2
|
||||
m.account.detailViewport.GotoTop()
|
||||
footer := []footerCommand{
|
||||
{key: "esc", value: "back"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
if _, ok := m.selectedRefundableOrder(); ok {
|
||||
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
|
||||
}
|
||||
m.footer = footer
|
||||
}
|
||||
}
|
||||
case "addresses":
|
||||
if !m.account.addressListFocused {
|
||||
m.account.addressListFocused = true
|
||||
m.account.addressCursor = 0
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "addresses"},
|
||||
{key: "x", value: "delete"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
}
|
||||
case "cards":
|
||||
if !m.account.cardListFocused {
|
||||
m.account.cardListFocused = true
|
||||
m.account.cardCursor = 0
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "cards"},
|
||||
{key: "x", value: "delete"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
}
|
||||
case "faq":
|
||||
if !m.account.faqFocused {
|
||||
m.account.faqFocused = true
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "scroll"},
|
||||
{key: "esc", value: "back"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "c", value: "cart"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "esc":
|
||||
accountDefaultFooter := []footerCommand{
|
||||
{key: "j/k", value: "navigate"},
|
||||
{key: "enter", value: "select"},
|
||||
{key: "s", value: "shop"},
|
||||
{key: "c", value: "cart"},
|
||||
{key: "?", value: "help"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
if m.account.addressDeleting != nil {
|
||||
m.account.addressDeleting = nil
|
||||
} else if m.account.addressListFocused {
|
||||
m.account.addressListFocused = false
|
||||
m.account.addressCursor = 0
|
||||
m.footer = accountDefaultFooter
|
||||
} else if m.account.cardDeleting != nil {
|
||||
m.account.cardDeleting = nil
|
||||
} else if m.account.cardListFocused {
|
||||
m.account.cardListFocused = false
|
||||
m.account.cardCursor = 0
|
||||
m.footer = accountDefaultFooter
|
||||
} else if m.account.faqFocused {
|
||||
m.account.faqFocused = false
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = accountDefaultFooter
|
||||
} else if m.account.orderViewState > 0 {
|
||||
m.account.orderViewState--
|
||||
if m.account.orderViewState == 1 {
|
||||
// Set content first so SetYOffset can clamp against correct line count
|
||||
m.account.detailViewport.SetContent(m.getAccountDetailContent())
|
||||
if m.account.orderYOffset > 0 {
|
||||
m.account.detailViewport.SetYOffset(m.account.orderYOffset)
|
||||
m.account.orderYOffset = 0
|
||||
}
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "orders"},
|
||||
{key: "enter", value: "details"},
|
||||
{key: "esc", value: "back"},
|
||||
{key: "q", value: "quit"},
|
||||
}
|
||||
} else {
|
||||
m.account.detailViewport.GotoTop()
|
||||
m.footer = accountDefaultFooter
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
30
pkg/tui/account_addresses.go
Normal file
30
pkg/tui/account_addresses.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package tui
|
||||
|
||||
func (m Model) AddressesView(width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
lines := titleStyle.Render("Address") + "\n\n"
|
||||
if len(m.SavedAddresses) == 0 {
|
||||
lines += contentStyle.Render("No saved addresses")
|
||||
} else {
|
||||
for i, addr := range m.SavedAddresses {
|
||||
isSelected := m.account.addressListFocused && i == m.account.addressCursor
|
||||
label := addr.Name + " " + addr.Street + ", " + addr.City
|
||||
if len(label) > width-4 {
|
||||
label = label[:width-4]
|
||||
}
|
||||
if m.account.addressDeleting != nil && *m.account.addressDeleting == i {
|
||||
lines += m.theme.TextError().Bold(true).Render(" deletes? (y/n)") + "\n"
|
||||
} else {
|
||||
lines += m.formatListItemCustom(label, isSelected, width, true) + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.account.addressListFocused {
|
||||
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
|
||||
} else {
|
||||
lines += "\n" + m.theme.TextDim().Render("enter: manage")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
32
pkg/tui/account_cards.go
Normal file
32
pkg/tui/account_cards.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (m Model) CardsView(width int) string {
|
||||
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
lines := titleSTyle.Render("Cards") + "\n\n"
|
||||
if m.SavedCardsIsEmpty() {
|
||||
lines += contentStyle.Render("No saved cards")
|
||||
} else {
|
||||
for i, card := range m.SavedCards {
|
||||
isSelected := m.account.cardListFocused && i == m.account.cardCursor
|
||||
label := fmt.Sprintf("**** **** **** %s %s exp %02d/%02d", card.Last4, card.Brand, card.ExpMonth, card.ExpYear%100)
|
||||
if m.account.cardDeleting != nil && *m.account.cardDeleting == i {
|
||||
lines += m.theme.TextError().Bold(true).Render(" delete? (y/s)") + "\n"
|
||||
} else {
|
||||
lines += m.formatListItemCustom(label, isSelected, width, true) + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.account.cardListFocused {
|
||||
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
|
||||
} else {
|
||||
lines += "\n" + m.theme.TextDim().Render("enter: manage")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
286
pkg/tui/account_orders.go
Normal file
286
pkg/tui/account_orders.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) OrdersView(width int) string {
|
||||
return m.orderListView("Order History", m.HistoryOrders(), width)
|
||||
}
|
||||
|
||||
func (m Model) ActiveOrdersView(width int) string {
|
||||
return m.orderListView("Active Orders", m.ActiveOrders(), width)
|
||||
}
|
||||
|
||||
func (m Model) ActiveOrders() []models.Order {
|
||||
out := make([]models.Order, 0, len(m.Orders))
|
||||
for _, o := range m.Orders {
|
||||
if o.IsActive() {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) HistoryOrders() []models.Order {
|
||||
out := make([]models.Order, 0, len(m.Orders))
|
||||
for _, o := range m.Orders {
|
||||
if !o.IsActive() {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// selectedRefundableOrder returns the order under the cursor in the active
|
||||
// order-detail view, paired with ok=true, when the user is allowed to file a
|
||||
// refund request for it. Returns (zero, false) otherwise.
|
||||
func (m Model) selectedRefundableOrder() (models.Order, bool) {
|
||||
orders := m.currentOrderList()
|
||||
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
|
||||
return models.Order{}, false
|
||||
}
|
||||
order := orders[m.account.orderCursor]
|
||||
if !order.CanRequestRefund() {
|
||||
return models.Order{}, false
|
||||
}
|
||||
return order, true
|
||||
}
|
||||
|
||||
func (m Model) currentOrderList() []models.Order {
|
||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||
return nil
|
||||
}
|
||||
switch models.AccountMenuItems[m.account.cursor] {
|
||||
case "active orders":
|
||||
return m.ActiveOrders()
|
||||
case "order history":
|
||||
return m.HistoryOrders()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) orderListView(title string, orders []models.Order, width int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||
contentStyle := m.theme.TextBody().Width(width)
|
||||
|
||||
if !m.OrdersLoaded {
|
||||
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
|
||||
}
|
||||
if m.account.orderViewState == 2 {
|
||||
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
|
||||
}
|
||||
|
||||
boxWidth := width - 2
|
||||
|
||||
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
|
||||
var cards []string
|
||||
for i, order := range orders {
|
||||
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
|
||||
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
|
||||
}
|
||||
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
|
||||
|
||||
// In preview mode (viewState 0), show title + hint
|
||||
if m.account.orderViewState == 0 {
|
||||
hintStyle := m.theme.TextDim()
|
||||
return titleStyle.Render(title) + "\n" +
|
||||
cardList + "\n" +
|
||||
hintStyle.Render("enter: browse orders")
|
||||
}
|
||||
|
||||
// In list browsing mode (viewState 1), just show cards — no title
|
||||
// (the left panel menu already shows "order history" as selected)
|
||||
return cardList
|
||||
}
|
||||
|
||||
// buildOrderCard renders a single order as a bordered box with fixed height.
|
||||
// Shows order number + total on line 1, date + status on line 2.
|
||||
// Item details are shown in the detail view (viewState 2).
|
||||
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool) string {
|
||||
nameStyle := m.theme.TextDim()
|
||||
if isSelected {
|
||||
nameStyle = m.theme.TextAccent().Bold(true)
|
||||
}
|
||||
|
||||
// When selected, secondary text uses accent color so the whole card "lights up"
|
||||
dimStyle := m.theme.TextDim()
|
||||
if isSelected {
|
||||
dimStyle = m.theme.TextAccent()
|
||||
}
|
||||
|
||||
// Line 1: "Order #N" left, "$X.XX" right
|
||||
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
|
||||
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
|
||||
|
||||
innerWidth := boxWidth - 4 // account for border + padding
|
||||
leftWidth := lipgloss.Width(orderLabel)
|
||||
rightWidth := lipgloss.Width(total)
|
||||
spacing := innerWidth - leftWidth - rightWidth
|
||||
if spacing < 1 {
|
||||
spacing = 1
|
||||
}
|
||||
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + total
|
||||
|
||||
// Line 2: date + derived status
|
||||
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
|
||||
status := m.displayStyle(order.DisplayKind()).Bold(true).Render(order.DisplayState())
|
||||
line2 := date + " " + status
|
||||
|
||||
boxContent := line1 + "\n" + line2
|
||||
|
||||
return m.CreateBoxCustom(boxContent, isSelected, boxWidth)
|
||||
}
|
||||
|
||||
// buildOrderDetailView renders the full detail view for a single order
|
||||
func (m Model) buildOrderDetailView(order models.Order, _ int) string {
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||
|
||||
labelStyle := m.theme.TextHighlight().Bold(true)
|
||||
|
||||
valueStyle := m.theme.TextBody()
|
||||
|
||||
dimStyle := m.theme.TextDim()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Status and date
|
||||
b.WriteString(labelStyle.Render("Status: "))
|
||||
b.WriteString(m.displayStyle(order.DisplayKind()).Render(order.DisplayState()))
|
||||
b.WriteString("\n")
|
||||
if order.TrackingStatusDetails != "" {
|
||||
b.WriteString(dimStyle.Render(" " + order.TrackingStatusDetails))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(labelStyle.Render("Date: "))
|
||||
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Items
|
||||
b.WriteString(labelStyle.Render("Items"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
|
||||
b.WriteString("\n")
|
||||
for _, item := range order.Items {
|
||||
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
|
||||
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
|
||||
b.WriteString(valueStyle.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Totals
|
||||
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
|
||||
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
|
||||
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Shipping (only when at least one shipment exists)
|
||||
if order.Carrier != "" {
|
||||
b.WriteString(labelStyle.Render("Shipping"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", order.Carrier)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", order.TrackingNumber)))
|
||||
b.WriteString("\n")
|
||||
if order.TrackingStatus != "" {
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Status", string(order.TrackingStatus))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if order.ShippedAt != nil {
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s",
|
||||
"Shipped", order.ShippedAt.Format("Jan 02 2006"))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if order.TrackingURL != "" {
|
||||
b.WriteString(dimStyle.Render(fmt.Sprintf(" https://api.sshops.uk/t/%d", order.ID)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
visible := make([]models.OrderEvent, 0, len(order.Events))
|
||||
for _, event := range order.Events {
|
||||
if _, ok := event.DisplayLabel(); ok {
|
||||
visible = append(visible, event)
|
||||
}
|
||||
}
|
||||
if len(visible) > 0 {
|
||||
b.WriteString(labelStyle.Render("Timeline"))
|
||||
b.WriteString("\n")
|
||||
for _, event := range visible {
|
||||
label, _ := event.DisplayLabel()
|
||||
when := event.CreatedAt.Format("Jan 02 3:04 PM")
|
||||
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Shipping address
|
||||
b.WriteString(labelStyle.Render("Ship To"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingName))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
|
||||
b.WriteString("\n")
|
||||
if order.ShippingStreet2 != "" {
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
cityLine := order.ShippingCity
|
||||
if order.ShippingState != "" {
|
||||
cityLine += ", " + order.ShippingState
|
||||
}
|
||||
cityLine += " " + order.ShippingZip
|
||||
b.WriteString(valueStyle.Render(" " + cityLine))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Footer hint
|
||||
if order.CanRequestRefund() {
|
||||
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
|
||||
} else {
|
||||
b.WriteString(dimStyle.Render("esc: back to orders"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// displayStyle maps a model-layer DisplayKind token to a themed lipgloss style.
|
||||
// Keeps pkg/models free of UI deps.
|
||||
func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
|
||||
switch k {
|
||||
case models.DisplayKindSuccess:
|
||||
return m.theme.TextSuccess()
|
||||
case models.DisplayKindBrand:
|
||||
return m.theme.TextBrand()
|
||||
case models.DisplayKindAccent:
|
||||
return m.theme.TextAccent()
|
||||
case models.DisplayKindError:
|
||||
return m.theme.TextError()
|
||||
case models.DisplayKindWarning:
|
||||
return m.theme.TextLoading()
|
||||
case models.DisplayKindRefund:
|
||||
return m.theme.TextBrand()
|
||||
}
|
||||
return m.theme.TextHighlight()
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
168
pkg/tui/cart.go
168
pkg/tui/cart.go
@@ -3,67 +3,77 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildCartView() string {
|
||||
if len(m.Cart) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
func (m Model) updateCartViewport() Model {
|
||||
headerH := lipgloss.Height(m.BuildHeader())
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
if !m.cart.viewportReady {
|
||||
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.cart.viewportReady = true
|
||||
} else {
|
||||
m.cart.viewport.SetWidth(m.widthContent)
|
||||
m.cart.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) generateCartContent() string {
|
||||
if m.IsCartEmpty() {
|
||||
emptyStyle := m.theme.TextDim().
|
||||
Align(lipgloss.Center).
|
||||
Width(m.widthContent).
|
||||
Padding(2, 0)
|
||||
return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
||||
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
||||
return render
|
||||
}
|
||||
|
||||
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 +87,93 @@ 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")).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true)
|
||||
isSelected := idx == m.cart.cursor
|
||||
if isSelected {
|
||||
boxContent = m.theme.TextAccent().Bold(true).Render(boxContent)
|
||||
} else {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("#666666")).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth).
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
boxContent = m.theme.TextAccent().Render(boxContent)
|
||||
}
|
||||
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
|
||||
cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
|
||||
|
||||
// Center the box within the container
|
||||
centered := lipgloss.NewStyle().
|
||||
Width(m.widthContent).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
cartItems += centered.Render(itemBox.Render(boxContent))
|
||||
|
||||
// Add spacing between items
|
||||
if idx < len(itemSlice)-1 {
|
||||
cartItems += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return cartItems
|
||||
}
|
||||
|
||||
func (m Model) CartView() string {
|
||||
if !m.cart.viewportReady {
|
||||
m = m.updateCartViewport()
|
||||
}
|
||||
content := m.generateCartContent()
|
||||
m.cart.viewport.SetContent(content)
|
||||
if len(m.Cart) > 0 {
|
||||
boxPadding := 0
|
||||
if m.heightContainer >= 25 {
|
||||
boxPadding = 1
|
||||
}
|
||||
itemHeight := 4 + 2*boxPadding + 1
|
||||
targetY := m.cart.cursor * itemHeight
|
||||
if targetY < m.cart.viewport.YOffset() {
|
||||
m.cart.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
|
||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||
}
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.cart.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.cart.viewport.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
switch keyMsg.String() {
|
||||
case "up", "k":
|
||||
if m.cart.cursor > 0 {
|
||||
m.cart.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cart.cursor < len(m.Cart)-1 {
|
||||
m.cart.cursor++
|
||||
}
|
||||
case "+", "=":
|
||||
cartItems := m.GetCartItemsSlice()
|
||||
if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
|
||||
cartItems[m.cart.cursor].Quantity++
|
||||
m, cmd := m.syncCartItemCmd(cartItems[m.cart.cursor].CoffeeID, cartItems[m.cart.cursor].Quantity)
|
||||
return m, cmd
|
||||
}
|
||||
case "-", "_":
|
||||
cartItems := m.GetCartItemsSlice()
|
||||
if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
|
||||
coffeeID := cartItems[m.cart.cursor].CoffeeID
|
||||
cartItems[m.cart.cursor].Quantity--
|
||||
newQty := cartItems[m.cart.cursor].Quantity
|
||||
if cartItems[m.cart.cursor].Quantity <= 0 {
|
||||
delete(m.Cart, coffeeID)
|
||||
if len(m.Cart) == 0 {
|
||||
m.cart.cursor = 0
|
||||
} else if m.cart.cursor >= len(m.Cart) {
|
||||
m.cart.cursor = len(m.Cart) - 1
|
||||
}
|
||||
newQty = 0
|
||||
}
|
||||
m, cmd := m.syncCartItemCmd(coffeeID, newQty)
|
||||
return m, cmd
|
||||
}
|
||||
case "p", "enter":
|
||||
if !m.IsCartEmpty() {
|
||||
return m.ShippingSwitch()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -2,58 +2,54 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// RenderConfirmation renders the order confirmation view
|
||||
func (m Model) RenderConfirmation() string {
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(0, 0, 1, 0)
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
|
||||
sectionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#CCCCCC")).
|
||||
Padding(0, 0, 1, 2)
|
||||
sectionStyle := m.theme.TextBody().Padding(0, 0, 1, 2)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
labelStyle := m.theme.TextDim()
|
||||
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
valueStyle := m.theme.TextAccent()
|
||||
|
||||
successStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00FF00")).
|
||||
Bold(true).
|
||||
Padding(1, 0)
|
||||
successStyle := m.theme.TextSuccess().Bold(true).Padding(1, 0)
|
||||
|
||||
title := titleStyle.Render("Order Confirmation")
|
||||
|
||||
// Shipping summary
|
||||
shippingSection := ""
|
||||
if m.ShippingInfo != nil {
|
||||
if m.confirm.shipping != nil {
|
||||
shippingSection = sectionStyle.Render(
|
||||
labelStyle.Render("Ship to: ") +
|
||||
valueStyle.Render(m.ShippingInfo.Name) + "\n" +
|
||||
valueStyle.Render(m.confirm.shipping.Name) + "\n" +
|
||||
labelStyle.Render(" ") +
|
||||
valueStyle.Render(m.ShippingInfo.Street) + "\n" +
|
||||
valueStyle.Render(m.confirm.shipping.Street) + "\n" +
|
||||
labelStyle.Render(" ") +
|
||||
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)),
|
||||
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.confirm.shipping.City, m.confirm.shipping.State, m.confirm.shipping.Zip, m.confirm.shipping.Country)),
|
||||
)
|
||||
}
|
||||
|
||||
// Cart summary
|
||||
// Items summary
|
||||
cartSection := ""
|
||||
total := 0.0
|
||||
items := m.GetCartItemsSlice()
|
||||
for _, item := range items {
|
||||
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, item.Coffee.Price*float64(item.Quantity))
|
||||
for _, item := range m.confirm.items {
|
||||
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
|
||||
cartSection += sectionStyle.Render(line) + "\n"
|
||||
total += item.Coffee.Price * float64(item.Quantity)
|
||||
}
|
||||
cartSection += sectionStyle.Render(
|
||||
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", total)),
|
||||
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
|
||||
) + "\n"
|
||||
cartSection += sectionStyle.Render(
|
||||
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.confirm.total)/100)),
|
||||
)
|
||||
|
||||
success := successStyle.Render("Order placed successfully!")
|
||||
@@ -69,3 +65,288 @@ func (m Model) RenderConfirmation() string {
|
||||
success,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) updateConfirmViewport() Model {
|
||||
headerH := lipgloss.Height(m.BuildHeader())
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
if !m.confirm.viewportReady {
|
||||
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
|
||||
m.confirm.viewportReady = true
|
||||
} else {
|
||||
m.confirm.viewport.SetWidth(m.widthContent)
|
||||
m.confirm.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) ConfirmView() string {
|
||||
if !m.confirm.viewportReady {
|
||||
m = m.updateConfirmViewport()
|
||||
}
|
||||
m.confirm.viewport.SetContent(m.RenderConfirmation())
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.confirm.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.confirm.viewport.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
if m.confirm.viewportReady {
|
||||
m.confirm.viewport, cmd = m.confirm.viewport.Update(msg)
|
||||
}
|
||||
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if keyMsg.String() != "esc" && keyMsg.String() != "s" {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
m.CheckingOut = false
|
||||
m.confirm.total = 0
|
||||
m.confirm.items = nil
|
||||
m.confirm.shipping = nil
|
||||
m, _ = m.ShopSwitch()
|
||||
return m, func() tea.Msg {
|
||||
if m.APIClient != nil {
|
||||
_ = m.APIClient.ClearCart()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatUSD(cents int) string {
|
||||
return fmt.Sprintf("$%d.%02d", cents/100, cents%100)
|
||||
}
|
||||
|
||||
func (m Model) generateReviewContent() string {
|
||||
view := strings.Builder{}
|
||||
|
||||
if m.review.cardJustAdded {
|
||||
if m.review.cardWasDuplicate {
|
||||
view.WriteString(m.theme.TextDim().Render("identical card already saved... using existing card") + "\n\n")
|
||||
} else {
|
||||
view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if m.ShippingInfo != nil {
|
||||
view.WriteString(m.ShippingInfo.Name + "\n")
|
||||
view.WriteString(m.ShippingInfo.Street + "\n")
|
||||
if m.ShippingInfo.Street2 != "" {
|
||||
view.WriteString(m.ShippingInfo.Street2 + "\n")
|
||||
}
|
||||
fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)
|
||||
}
|
||||
view.WriteString("\n")
|
||||
|
||||
if m.SelectedCard != nil {
|
||||
fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4)
|
||||
}
|
||||
view.WriteString("\n")
|
||||
|
||||
subtotal := m.CalculateSubtotal()
|
||||
|
||||
fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal))
|
||||
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
|
||||
view.WriteString("\n")
|
||||
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")
|
||||
|
||||
return m.theme.Base().Padding(0, 1).Render(view.String())
|
||||
}
|
||||
|
||||
func (m Model) ReviewView() string {
|
||||
if m.CheckingOut {
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
||||
}
|
||||
if m.review.authFailed {
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
if m.review.requiresAction {
|
||||
return m.renderRequiresActionView()
|
||||
}
|
||||
if m.review.success {
|
||||
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
content := m.generateReviewContent()
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
|
||||
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
|
||||
func (m Model) renderRequiresActionView() string {
|
||||
base := m.theme.TextLabel()
|
||||
accent := m.theme.TextHighlight()
|
||||
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
||||
|
||||
if m.review.redirectURL == "" {
|
||||
return base.Render(" waiting for authentication link...")
|
||||
}
|
||||
|
||||
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
||||
if err != nil || qrSize > m.widthContent {
|
||||
body := header + "\n\n" +
|
||||
base.Render("open this url on your phone to authenticate:") + "\n\n" +
|
||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||
base.Render("waiting for completion...")
|
||||
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
|
||||
}
|
||||
|
||||
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
|
||||
return qr +
|
||||
header + "\n" +
|
||||
base.Render("scan or visit to complete authentication") + "\n" +
|
||||
accent.Render(m.review.redirectURL) + "\n\n" +
|
||||
base.Render("waiting for completion...")
|
||||
}
|
||||
|
||||
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case CheckoutResultMsg:
|
||||
m.CheckingOut = false
|
||||
if msg.Err != nil {
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
m.review.cardJustAdded = false
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
if msg.RequiresAction {
|
||||
m.review.requiresAction = true
|
||||
m.review.redirectURL = msg.RedirectURL
|
||||
m.review.orderID = msg.OrderID
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.cart.cursor = 0
|
||||
m.ShippingInfo = nil
|
||||
m.OrdersLoaded = false
|
||||
m.review.success = true
|
||||
return m, nil
|
||||
|
||||
case OrderStatusMsg:
|
||||
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
|
||||
return m, nil
|
||||
}
|
||||
if msg.Err != nil {
|
||||
// Transient — keep polling.
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
switch msg.Status {
|
||||
case "paid", "shipped", "delivered":
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.cart.cursor = 0
|
||||
m.ShippingInfo = nil
|
||||
m.OrdersLoaded = false
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.success = true
|
||||
return m, nil
|
||||
case "failed", "cancelled":
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.authFailed = true
|
||||
return m, nil
|
||||
default:
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.CheckingOut {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.authFailed {
|
||||
if msg.String() == "esc" || msg.String() == "enter" {
|
||||
m.review.authFailed = false
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.requiresAction {
|
||||
if msg.String() == "esc" {
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.success {
|
||||
m.review.success = false
|
||||
m.review.cardJustAdded = false
|
||||
m.shipping.form = nil
|
||||
m.payment.form = nil
|
||||
m, _ = m.ShopSwitch()
|
||||
return m, func() tea.Msg {
|
||||
if m.APIClient != nil {
|
||||
_ = m.APIClient.ClearCart()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m = m.SwitchPage(paymentPage)
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
m.CheckingOut = true
|
||||
return m, m.checkoutWithSavedCard()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func friendlyCheckoutError(raw error) string {
|
||||
s := raw.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
||||
return "your bank wants to verify this charge, authentication step coming soon."
|
||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||
return "this card doesn't support online authentication. Try another card"
|
||||
case strings.Contains(s, "CARD_EXPIRED"):
|
||||
return "card expired. Add a new card and try again"
|
||||
case strings.Contains(s, "CARD_CVC_FAILED"):
|
||||
return "card cvc incorrect. Check the back of the card and try again"
|
||||
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
|
||||
return "insufficient funds on this card"
|
||||
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
|
||||
return "card processing error. Try again in a moment"
|
||||
case strings.Contains(s, "CARD_DECLINED"):
|
||||
return "card declined"
|
||||
}
|
||||
return "checkout failed, try again."
|
||||
}
|
||||
|
||||
@@ -1,50 +1,10 @@
|
||||
[
|
||||
{
|
||||
"question": "help, i have a question about my order!",
|
||||
"answer": "send us an email at support@terminal.shop."
|
||||
"question": "i have a question!",
|
||||
"answer": "I'm happy to answer any question! From questions about your order, to questions about working on the code, please contact me through my github @marcusDenslow, or at my email. marcus.allen.denslow@gmail.com"
|
||||
},
|
||||
{
|
||||
"question": "when was my coffee roasted? the roast date is blank on my bag.",
|
||||
"answer": "we roast your coffee within 24 hours of shipping, and you'll receive an email once your order ships. we're working on a solution to stamping the roast date on the bags, but so far all attempts have failed (the ink wipes off)."
|
||||
},
|
||||
{
|
||||
"question": "where do you ship?",
|
||||
"answer": "we ship all of our blends in the US. Unfortunately, we don't ship to other countries at this time. We are actively looking for other small-batch roasters in other countries and regions though!"
|
||||
},
|
||||
{
|
||||
"question": "is your coffee ethically sourced?",
|
||||
"answer": "absolutely."
|
||||
},
|
||||
{
|
||||
"question": "is ordering via ssh secure?",
|
||||
"answer": "you bet it is. arguably more secure than your browser. ssh incorporates encryption and authentication via a process called public key cryptography. if that doesn't sound secure we don't know what does."
|
||||
},
|
||||
{
|
||||
"question": "what is cron?",
|
||||
"answer": "cron is our monthly membership, available only in the US (for now). if you subscribe, you'll receive a new coffee blend every month, along with other surprises both physical and digital."
|
||||
},
|
||||
{
|
||||
"question": "i tried to subscribe to cron, but it says i need a public key?",
|
||||
"answer": "we don't allow subscriptions without a public key, as you won't be able to ssh back in with the same identity (we treat users without a public key as anonymous). create a key with `ssh-keygen -t rsa` and make sure your ssh client isn't omitting your key."
|
||||
},
|
||||
{
|
||||
"question": "how do you store my data?",
|
||||
"answer": "we store minimal information about you when you order through our ssh shop: name, shipping address and email, along with a fingerprint generated from your public key. all payment information is stored exclusively by Stripe."
|
||||
},
|
||||
{
|
||||
"question": "do you offer a subscription?",
|
||||
"answer": "in addition to cron, our monthly membership, you can also subscribe to any of the blends offered in our shop."
|
||||
},
|
||||
{
|
||||
"question": "will Terminal coffee make me a better developer?",
|
||||
"answer": "legally we cannot guarantee that it will, but..."
|
||||
},
|
||||
{
|
||||
"question": "does coffee from the command line taste better than regular coffee?",
|
||||
"answer": "it's just science!"
|
||||
},
|
||||
{
|
||||
"question": "is it true your coffee contains the tears of @thdxr?",
|
||||
"answer": "yes, this is true."
|
||||
"question": "why is it so empty here?",
|
||||
"answer": "more is to come!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,207 +1,25 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) BuildFooter() string {
|
||||
// Style for keybinds (bold)
|
||||
keybindStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
Bold(true)
|
||||
keybindStyle := m.theme.TextDim().Bold(true)
|
||||
descStyle := m.theme.TextDim()
|
||||
|
||||
// Style for descriptions (normal)
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
|
||||
// On small terminals, only show the menu hint
|
||||
if m.size == small {
|
||||
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
|
||||
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
|
||||
}
|
||||
|
||||
var footerText string
|
||||
if m.FaqFocused {
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("scroll"),
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
keybindStyle.Render("s"),
|
||||
descStyle.Render("shop"),
|
||||
keybindStyle.Render("c"),
|
||||
descStyle.Render("cart"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
} else if m.ViewingAccount {
|
||||
switch m.OrderViewState {
|
||||
case 2:
|
||||
// Viewing single order detail
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s",
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
keybindStyle.Render("s"),
|
||||
descStyle.Render("shop"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
case 1:
|
||||
// Browsing order list
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("orders"),
|
||||
keybindStyle.Render("enter"),
|
||||
descStyle.Render("details"),
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
default:
|
||||
// Account tabs
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("navigate"),
|
||||
keybindStyle.Render("enter"),
|
||||
descStyle.Render("select"),
|
||||
keybindStyle.Render("s"),
|
||||
descStyle.Render("shop"),
|
||||
keybindStyle.Render("c"),
|
||||
descStyle.Render("cart"),
|
||||
keybindStyle.Render("?"),
|
||||
descStyle.Render("help"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
}
|
||||
} else if m.ViewingCart && m.CheckoutStep == 3 {
|
||||
// Confirmation screen
|
||||
footerText = fmt.Sprintf("%s %s %s %s",
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back to shop"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
} else if m.ViewingCart && m.CheckoutStep == 1 {
|
||||
if m.ShippingView == 0 && m.ShippingForm == nil {
|
||||
// Address list
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("addresses"),
|
||||
keybindStyle.Render("enter"),
|
||||
descStyle.Render("select"),
|
||||
keybindStyle.Render("d/x"),
|
||||
descStyle.Render("delete"),
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
)
|
||||
} else {
|
||||
// Shipping form
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s",
|
||||
keybindStyle.Render("tab"),
|
||||
descStyle.Render("next"),
|
||||
keybindStyle.Render("enter"),
|
||||
descStyle.Render("submit"),
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
)
|
||||
}
|
||||
} else if m.ViewingCart && m.CheckoutStep == 2 {
|
||||
if m.PaymentView == 0 && m.PaymentForm == nil {
|
||||
// Card list
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("cards"),
|
||||
keybindStyle.Render("enter"),
|
||||
descStyle.Render("select"),
|
||||
keybindStyle.Render("d/x"),
|
||||
descStyle.Render("delete"),
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
)
|
||||
} else if m.CheckingOut {
|
||||
// Submitting order
|
||||
footerText = descStyle.Render("submitting order...")
|
||||
} else {
|
||||
// Payment form
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s",
|
||||
keybindStyle.Render("tab"),
|
||||
descStyle.Render("next"),
|
||||
keybindStyle.Render("enter"),
|
||||
descStyle.Render("submit"),
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
)
|
||||
}
|
||||
} else if m.ViewingCart && m.CheckoutStep == 0 {
|
||||
// In cart view, show proceed option
|
||||
if len(m.Cart) > 0 {
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("items"),
|
||||
keybindStyle.Render("+/-"),
|
||||
descStyle.Render("qty"),
|
||||
keybindStyle.Render("p/enter"),
|
||||
descStyle.Render("checkout"),
|
||||
keybindStyle.Render("s"),
|
||||
descStyle.Render("shop"),
|
||||
keybindStyle.Render("a"),
|
||||
descStyle.Render("account"),
|
||||
keybindStyle.Render("pgup/pgdn"),
|
||||
descStyle.Render("scroll"),
|
||||
keybindStyle.Render("?"),
|
||||
descStyle.Render("help"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
} else {
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("items"),
|
||||
keybindStyle.Render("+/-"),
|
||||
descStyle.Render("qty"),
|
||||
keybindStyle.Render("s"),
|
||||
descStyle.Render("shop"),
|
||||
keybindStyle.Render("a"),
|
||||
descStyle.Render("account"),
|
||||
keybindStyle.Render("?"),
|
||||
descStyle.Render("help"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
}
|
||||
} else if m.ViewingCart {
|
||||
// Other checkout steps
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s",
|
||||
keybindStyle.Render("esc"),
|
||||
descStyle.Render("back"),
|
||||
keybindStyle.Render("s"),
|
||||
descStyle.Render("shop"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
} else {
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
|
||||
keybindStyle.Render("j/k"),
|
||||
descStyle.Render("products"),
|
||||
keybindStyle.Render("+/-"),
|
||||
descStyle.Render("qty"),
|
||||
keybindStyle.Render("c"),
|
||||
descStyle.Render("cart"),
|
||||
keybindStyle.Render("a"),
|
||||
descStyle.Render("account"),
|
||||
keybindStyle.Render("?"),
|
||||
descStyle.Render("help"),
|
||||
keybindStyle.Render("q"),
|
||||
descStyle.Render("quit"),
|
||||
)
|
||||
parts := []string{}
|
||||
for _, cmd := range m.footer {
|
||||
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
|
||||
}
|
||||
footerText := strings.Join(parts, " ")
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Align(lipgloss.Center).
|
||||
Width(m.widthContainer).
|
||||
Render(footerText)
|
||||
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
|
||||
}
|
||||
|
||||
@@ -3,90 +3,56 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
)
|
||||
|
||||
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
|
||||
bold := m.theme.TextAccent().Bold(true).Render
|
||||
accent := m.theme.TextAccent().Render
|
||||
base := m.theme.Base().Render
|
||||
cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ")
|
||||
|
||||
mark := bold("t") + cursor
|
||||
logo := bold("terminal")
|
||||
menu := bold("m") + base(" ☰")
|
||||
|
||||
tab := func(key, name string, active bool) string {
|
||||
if active {
|
||||
return accent(key + " " + name)
|
||||
}
|
||||
return accent(key) + base(" "+name)
|
||||
}
|
||||
shop := tab("s", "shop", m.currentPage == shopPage)
|
||||
account := tab("a", "account", m.currentPage == accountPage)
|
||||
|
||||
// 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)
|
||||
|
||||
// Build cart tab (used in all sizes)
|
||||
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
|
||||
|
||||
var cartKeybind, cartName, cartInfo string
|
||||
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
|
||||
cartKeybind = boldGrayStyle.Render("c")
|
||||
cartName = grayStyle.Render("cart")
|
||||
cartInfo = fmt.Sprintf(" %s %s",
|
||||
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)),
|
||||
itemCountText)
|
||||
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
|
||||
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
|
||||
cartLabel := base(" cart")
|
||||
if m.inCartFlow() {
|
||||
cartLabel = accent(" cart")
|
||||
}
|
||||
cartTab := cartKeybind + " " + cartName + cartInfo
|
||||
|
||||
var tabsContent string
|
||||
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
|
||||
|
||||
var tabs []string
|
||||
switch m.size {
|
||||
case small:
|
||||
// Small: just a mark + Cart
|
||||
mark := boldWhiteStyle.Render("t")
|
||||
tabsContent = mark + " " + cartTab
|
||||
tabs = []string{mark, cart}
|
||||
case medium:
|
||||
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
|
||||
logo := boldWhiteStyle.Render("terminal coffee")
|
||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
||||
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
|
||||
tabs = []string{menu, logo, cart}
|
||||
default:
|
||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
||||
|
||||
var shopKeybind, shopName string
|
||||
if !m.ViewingCart && !m.ViewingAccount {
|
||||
shopKeybind = boldWhiteStyle.Render("s")
|
||||
shopName = boldWhiteStyle.Render("shop")
|
||||
} else {
|
||||
shopKeybind = boldGrayStyle.Render("s")
|
||||
shopName = grayStyle.Render("shop")
|
||||
}
|
||||
|
||||
var accountKeybind, accountName string
|
||||
if m.ViewingAccount {
|
||||
accountKeybind = boldWhiteStyle.Render("a")
|
||||
accountName = boldWhiteStyle.Render("account")
|
||||
} else {
|
||||
accountKeybind = boldGrayStyle.Render("a")
|
||||
accountName = grayStyle.Render("account")
|
||||
}
|
||||
|
||||
tabsContent = fmt.Sprintf("%s %s %s %s %s %s %s", shopKeybind, shopName, separator, accountKeybind, accountName, separator, cartTab)
|
||||
tabs = []string{logo, shop, account, cart}
|
||||
}
|
||||
|
||||
// Add box with padding (no vertical padding, just horizontal)
|
||||
tabBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
Padding(0, 2).
|
||||
MarginBottom(1)
|
||||
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
|
||||
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
|
||||
}).Render()
|
||||
|
||||
// Center the whole thing within the container
|
||||
centered := lipgloss.NewStyle().
|
||||
Width(m.widthContainer).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
return centered.Render(tabBox.Render(tabsContent))
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(tbl),
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
tbl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
|
||||
func (m Model) BuildMenuView() string {
|
||||
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
bold := m.theme.TextAccent().Bold(true)
|
||||
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
|
||||
base := m.theme.TextBody()
|
||||
|
||||
rowStyle := lipgloss.NewStyle().Padding(0,1)
|
||||
rowStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||
rows := []string{
|
||||
rowStyle.Render(bold.Render("s") + " " + base.Render("shop")),
|
||||
rowStyle.Render(bold.Render("a") + " " + base.Render("account")),
|
||||
@@ -20,28 +20,22 @@ func (m Model) BuildMenuView() string {
|
||||
rowStyle.Render(bold.Render("q") + " " + base.Render("quit")),
|
||||
}
|
||||
|
||||
|
||||
menuContent := ""
|
||||
for _, row := range rows {
|
||||
menuContent += row + "\n"
|
||||
}
|
||||
|
||||
|
||||
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
logoStyle := m.theme.TextAccent().Bold(true)
|
||||
logo := logoStyle.Render("terminal coffee")
|
||||
|
||||
modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666")).Padding(1,2).Render(menuContent)
|
||||
modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border()).Padding(1, 2).Render(menuContent)
|
||||
|
||||
hint := base.Render("press esc to close")
|
||||
|
||||
itemCount := 0
|
||||
total := 0.0
|
||||
for _, item := range m.Cart {
|
||||
total += float64(item.Quantity) * item.Coffee.Price
|
||||
itemCount += item.Quantity
|
||||
}
|
||||
total := m.CalculateSubtotal()
|
||||
itemCount := m.CartItemCount()
|
||||
|
||||
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", total, itemCount))
|
||||
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount))
|
||||
|
||||
assembled := lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
@@ -51,7 +45,7 @@ func (m Model) BuildMenuView() string {
|
||||
"",
|
||||
hint,
|
||||
cartHint,
|
||||
)
|
||||
)
|
||||
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
@@ -59,6 +53,34 @@ func (m Model) BuildMenuView() string {
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
assembled,
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func (m Model) MenuUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
switch keyMsg.String() {
|
||||
case "s":
|
||||
m.ShowingMenu = false
|
||||
m, cmd := m.ShopSwitch()
|
||||
return m, cmd
|
||||
case "a":
|
||||
m.ShowingMenu = false
|
||||
return m.AccountSwitch()
|
||||
case "c":
|
||||
m.ShowingMenu = false
|
||||
m, cmd := m.CartSwitch()
|
||||
return m, cmd
|
||||
case "esc":
|
||||
m.ShowingMenu = false
|
||||
case "?":
|
||||
m.ShowingMenu = false
|
||||
m.ShowingHelp = true
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
1631
pkg/tui/model.go
1631
pkg/tui/model.go
File diff suppressed because it is too large
Load Diff
@@ -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 = 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/qrfefe"
|
||||
"terminalShop/pkg/validate"
|
||||
)
|
||||
|
||||
@@ -36,11 +40,40 @@ type PaymentFormCompleteMsg struct {
|
||||
BillingZip string
|
||||
}
|
||||
|
||||
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
|
||||
// The user is taken to the review page to confirm before checkout
|
||||
type CardSavedForReviewMsg struct {
|
||||
Card models.Card
|
||||
Err error
|
||||
}
|
||||
|
||||
// PaymentFormErrorMsg is sent when payment form validation fails
|
||||
type PaymentFormErrorMsg struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// PollPaymentInitMsg is sent when the collect endpoint returns a URL
|
||||
type PollPaymentInitMsg struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// PollPaymentStatusMsg is sent each polling tick while waiting for the browser
|
||||
// card entry to complete.
|
||||
type PollPaymentStatusMsg struct {
|
||||
Baseline time.Time
|
||||
BaselineCount int
|
||||
Deadline time.Time
|
||||
}
|
||||
|
||||
// PollPaymentCompleteMsg is sent when the card count increases, meaning the
|
||||
// webhook saved the new card
|
||||
type PollPaymentCompleteMsg struct {
|
||||
Cards []models.Card
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type PollPaymentTimeoutMsg struct{}
|
||||
|
||||
// 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 +112,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)
|
||||
@@ -174,7 +208,8 @@ func validatePaymentForm(state *PaymentFormState) string {
|
||||
// UpdatePaymentForm handles updates to the payment form.
|
||||
// Mutates state in place, returns only the tea.Cmd.
|
||||
func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
|
||||
if _, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
switch _, ok := msg.(tea.WindowSizeMsg); {
|
||||
case ok:
|
||||
if m.size < medium {
|
||||
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
} else {
|
||||
@@ -216,17 +251,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,43 +271,338 @@ func (m Model) RenderPaymentForm(state *PaymentFormState) string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) updatePaymentViewport() Model {
|
||||
headerH := lipgloss.Height(m.BuildHeader())
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
if !m.payment.viewportReady {
|
||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.payment.viewportReady = true
|
||||
} else {
|
||||
m.payment.viewport.SetWidth(m.widthContent)
|
||||
m.payment.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) PaymentPageView() string {
|
||||
if !m.payment.viewportReady {
|
||||
m = m.updatePaymentViewport()
|
||||
}
|
||||
var content string
|
||||
if m.CheckingOut {
|
||||
content = " submitting order..."
|
||||
} else if m.payment.view == 2 {
|
||||
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
m.payment.viewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.renderPaymentHttpsView(),
|
||||
)
|
||||
} else if m.payment.view == 0 && m.payment.form == nil {
|
||||
content = m.RenderCardList()
|
||||
m.payment.viewport.SetContent(content)
|
||||
itemHeight := 3
|
||||
targetY := m.payment.cardCursor * itemHeight
|
||||
if targetY < m.payment.viewport.YOffset() {
|
||||
m.payment.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
|
||||
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.viewport.GotoBottom()
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.payment.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.payment.viewport.View(),
|
||||
)
|
||||
} else if m.payment.form != nil {
|
||||
content = m.RenderPaymentForm(m.payment.form)
|
||||
}
|
||||
m.payment.viewport.SetContent(content)
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.payment.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.payment.viewport.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) RenderCardList() string {
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
|
||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
|
||||
title := titleStyle.Render("Select Payment Method")
|
||||
|
||||
var lines []string
|
||||
var boxes []string
|
||||
for i, card := range m.SavedCards {
|
||||
cursor := " "
|
||||
style := inactiveStyle
|
||||
if i == m.CardCursor {
|
||||
cursor = "> "
|
||||
style = activeStyle
|
||||
}
|
||||
focused := i == m.payment.cardCursor
|
||||
|
||||
brand := style.Render(strings.ToUpper(card.Brand[:1]) + card.Brand[1:])
|
||||
last4 := labelStyle.Render(fmt.Sprintf("**** **** **** %s", card.Last4))
|
||||
exp := labelStyle.Render(fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100))
|
||||
brand := strings.ToUpper(card.Brand[:1]) + card.Brand[1:]
|
||||
last4 := fmt.Sprintf("**** **** **** %s", card.Last4)
|
||||
exp := fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100)
|
||||
content := brand + " " + last4 + "\n" + exp
|
||||
|
||||
lines = append(lines, cursor+brand+" "+last4)
|
||||
lines = append(lines, " "+exp)
|
||||
lines = append(lines, "")
|
||||
box := m.CreateBox(m.formatListItem(content, focused), focused)
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
|
||||
addCursor := " "
|
||||
addStyle := inactiveStyle
|
||||
if m.CardCursor == len(m.SavedCards) {
|
||||
addCursor = "> "
|
||||
addStyle = activeStyle
|
||||
}
|
||||
sshFocused := m.payment.cardCursor == len(m.SavedCards)
|
||||
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
|
||||
boxes = append(boxes, sshBox)
|
||||
|
||||
lines = append(lines, addCursor+addStyle.Render("+ Add new card"))
|
||||
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
|
||||
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
|
||||
boxes = append(boxes, httpsBox)
|
||||
|
||||
parts := []string{title}
|
||||
parts = append(parts, strings.Join(lines, "\n"))
|
||||
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m Model) renderPaymentHttpsView() string {
|
||||
baseStyle := m.theme.TextLabel()
|
||||
accentStyle := m.theme.TextHighlight()
|
||||
|
||||
if m.payment.collectTimeOut {
|
||||
minutes := int(cardPollTimeout / time.Minute)
|
||||
return m.theme.TextError().Bold(true).Render("payment link timed out") + "\n\n" +
|
||||
baseStyle.Render(fmt.Sprintf("no card saved after %d minutes.", minutes)) + "\n" +
|
||||
baseStyle.Render("press ") + accentStyle.Render("esc") + baseStyle.Render(" to go back and try again")
|
||||
}
|
||||
|
||||
if m.payment.collectURL == nil {
|
||||
return baseStyle.Render(" generating payment link...")
|
||||
}
|
||||
|
||||
qr, qrSize, err := qrfefe.Generate(*m.payment.collectURL)
|
||||
if err != nil || qrSize > m.widthContent {
|
||||
// QR too wide for the terminal — just show the URL.
|
||||
return baseStyle.Render("open in browser to add payment information:") + "\n\n" +
|
||||
accentStyle.Render(*m.payment.collectURL) + "\n"
|
||||
}
|
||||
|
||||
// Concatenate manually — lipgloss layout functions (JoinVertical with Center)
|
||||
// measure string widths and add padding that corrupts qrfefe's raw ANSI codes.
|
||||
return qr +
|
||||
baseStyle.Render("scan or visit to add payment information") + "\n" +
|
||||
accentStyle.Render(*m.payment.collectURL) + "\n"
|
||||
}
|
||||
|
||||
// PaymentUpdate handles all messages while on the payment page.
|
||||
// Moved from model.go Update() lines 429-578.
|
||||
func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case CardsMsg:
|
||||
if msg.Err != nil {
|
||||
m.SavedCards = nil
|
||||
} else {
|
||||
m.SavedCards = msg.Cards
|
||||
}
|
||||
m.payment.view = 0
|
||||
m.payment.cardCursor = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
|
||||
case PaymentFormCompleteMsg:
|
||||
if m.payment.form != nil && !m.payment.form.submitting {
|
||||
m.payment.form.submitting = true
|
||||
return m, m.saveCardOnlyCmd(msg)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case PaymentFormErrorMsg:
|
||||
m.error = &VisibleError{message: msg.Message}
|
||||
return m, nil
|
||||
|
||||
case CardSavedForReviewMsg:
|
||||
if m.payment.form != nil {
|
||||
m.payment.form.submitting = false
|
||||
}
|
||||
if msg.Err != nil {
|
||||
m.error = &VisibleError{message: fmt.Sprintf("failed to save card: %v", msg.Err)}
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.payment.form.form.Init()
|
||||
}
|
||||
m.SavedCards = append(m.SavedCards, msg.Card)
|
||||
selected := msg.Card
|
||||
m.SelectedCard = &selected
|
||||
m.payment.form = nil
|
||||
m.review.cardJustAdded = true
|
||||
return m.ReviewSwitch()
|
||||
|
||||
case PollPaymentInitMsg:
|
||||
url := msg.URL
|
||||
m.payment.collectURL = &url
|
||||
return m, m.pollCardsCmd(m.payment.collectBaseline, m.payment.collectCardCount, m.payment.collectDeadline)
|
||||
|
||||
case PollPaymentStatusMsg:
|
||||
if m.payment.view != 2 {
|
||||
return m, nil // user navigated away, stop polling
|
||||
}
|
||||
return m, m.pollCardsCmd(msg.Baseline, msg.BaselineCount, msg.Deadline)
|
||||
case PollPaymentTimeoutMsg:
|
||||
if m.payment.view != 2 {
|
||||
return m, nil
|
||||
}
|
||||
m.payment.collectTimeOut = true
|
||||
return m, nil
|
||||
|
||||
case PollPaymentCompleteMsg:
|
||||
if m.payment.view != 2 {
|
||||
return m, nil
|
||||
}
|
||||
m.SavedCards = msg.Cards
|
||||
m.payment.view = 0
|
||||
m.payment.collectURL = nil
|
||||
var selected models.Card
|
||||
if msg.Duplicate {
|
||||
for _, card := range m.SavedCards {
|
||||
if selected.ID == 0 || card.UpdatedAt.After(selected.UpdatedAt) {
|
||||
selected = card
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selected = m.SavedCards[len(m.SavedCards)-1]
|
||||
}
|
||||
m.SelectedCard = &selected
|
||||
m.review.cardJustAdded = true
|
||||
m.review.cardWasDuplicate = msg.Duplicate
|
||||
return m.ReviewSwitch()
|
||||
}
|
||||
|
||||
// Card list navigation
|
||||
if m.payment.view == 0 && m.payment.form == nil {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok || m.CheckingOut {
|
||||
return m, nil
|
||||
}
|
||||
m.error = nil
|
||||
switch keyMsg.String() {
|
||||
case "esc":
|
||||
return m.ShippingSwitch()
|
||||
case "up", "k":
|
||||
if m.payment.cardCursor > 0 {
|
||||
m.payment.cardCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.payment.cardCursor < len(m.SavedCards)+1 {
|
||||
m.payment.cardCursor++
|
||||
}
|
||||
|
||||
case "enter":
|
||||
if m.payment.cardCursor < len(m.SavedCards) {
|
||||
selected := m.SavedCards[m.payment.cardCursor]
|
||||
m.SelectedCard = &selected
|
||||
return m.ReviewSwitch()
|
||||
}
|
||||
if m.payment.cardCursor == len(m.SavedCards) {
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.payment.form.form.Init()
|
||||
}
|
||||
// Browser payment
|
||||
m.payment.view = 2
|
||||
m.payment.collectURL = nil
|
||||
m.payment.collectTimeOut = false
|
||||
m.payment.collectCardCount = len(m.SavedCards)
|
||||
var maxT time.Time
|
||||
for _, card := range m.SavedCards {
|
||||
if card.UpdatedAt.After(maxT) {
|
||||
maxT = card.UpdatedAt
|
||||
}
|
||||
}
|
||||
m.payment.collectBaseline = maxT
|
||||
m.payment.collectDeadline = time.Now().Add(cardPollTimeout)
|
||||
m.footer = []footerCommand{
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.collectCardCmd()
|
||||
|
||||
case "d", "x":
|
||||
if m.payment.cardCursor < len(m.SavedCards) {
|
||||
card := m.SavedCards[m.payment.cardCursor]
|
||||
m.SavedCards = append(m.SavedCards[:m.payment.cardCursor], m.SavedCards[m.payment.cardCursor+1:]...)
|
||||
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
|
||||
m.payment.cardCursor--
|
||||
}
|
||||
if len(m.SavedCards) == 0 {
|
||||
m.payment.view = 1
|
||||
m.payment.form = m.InitPaymentForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
|
||||
}
|
||||
return m, m.deleteCardCmd(card.ID)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// HTTPS/browser view navigation
|
||||
if m.payment.view == 2 {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
|
||||
m.payment.view = 0
|
||||
m.payment.collectURL = nil
|
||||
m.payment.collectTimeOut = false
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "cards"},
|
||||
{key: "enter", value: "select"},
|
||||
{key: "d/x", value: "delete"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Payment form navigation
|
||||
if m.payment.form != nil {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "esc" {
|
||||
m.error = nil
|
||||
if len(m.SavedCards) > 0 {
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "cards"},
|
||||
{key: "enter", value: "select"},
|
||||
{key: "d/x", value: "delete"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.payment.form = nil
|
||||
m = m.SwitchPage(shippingPage)
|
||||
return m, m.fetchAddressesCmd()
|
||||
}
|
||||
// Clear error only when user starts typing, not on internal huh messages
|
||||
m.error = nil
|
||||
}
|
||||
return m, m.UpdatePaymentForm(msg, m.payment.form)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
59
pkg/tui/qrfefe/qr.go
Normal file
59
pkg/tui/qrfefe/qr.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package qrfefe
|
||||
|
||||
import (
|
||||
sgr "github.com/foize/go.sgr"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
var topsBottoms = []rune{' ', '▀', '▄', '█'}
|
||||
|
||||
func Generate(text string) (string, int, error) {
|
||||
return generate(qr.L, text)
|
||||
}
|
||||
|
||||
func generate(level qr.Level, text string) (string, int, error) {
|
||||
// How to mark this as not re-used code for JetBrains, it's from the reference
|
||||
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, topsBottoms[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')
|
||||
}
|
||||
395
pkg/tui/refund.go
Normal file
395
pkg/tui/refund.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// Refund composer layout constants.
|
||||
const (
|
||||
refundMinTextareaRows = 5
|
||||
refundMaxTextareaRows = 10
|
||||
// chrome above the textarea inside the modal (title, label, padding, footer).
|
||||
refundModalChrome = 12
|
||||
refundModalMaxWidth = 58
|
||||
refundModalMinWidth = 24
|
||||
// horizontal chrome the modal subtracts from m.widthContent.
|
||||
refundModalHGutter = 6
|
||||
// inner padding around the textarea inside the modal body.
|
||||
refundTextareaHPad = 4
|
||||
)
|
||||
|
||||
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
|
||||
// SSH client terminal; renders as tofu otherwise.
|
||||
const (
|
||||
refundIconUndo = "\uf0e2" // nf-fa-undo
|
||||
refundIconTag = "\uf02b" // nf-oct-tag
|
||||
refundIconComment = "\uf41f" // nf-oct-comment
|
||||
refundIconKey = "\uf11c" // nf-fa-keyboard_o
|
||||
)
|
||||
|
||||
// refundFocus identifies which field in the refund composer has keyboard focus.
|
||||
type refundFocus int
|
||||
|
||||
const (
|
||||
refundFocusReason refundFocus = iota
|
||||
refundFocusMessage
|
||||
refundFocusCount
|
||||
)
|
||||
|
||||
type refundState struct {
|
||||
open bool
|
||||
orderID uint
|
||||
gen uint64 // bumped each open so late async results from a prior session are ignored
|
||||
reason string
|
||||
form *huh.Form
|
||||
textarea textarea.Model
|
||||
focusIdx refundFocus
|
||||
submitting bool
|
||||
err string
|
||||
reasonErr string
|
||||
messageErr string
|
||||
}
|
||||
|
||||
type refundRequestMsg struct {
|
||||
OrderID uint
|
||||
Gen uint64
|
||||
Err error
|
||||
}
|
||||
|
||||
// OpenRefundComposer opens the refund request composer for an order.
|
||||
func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) {
|
||||
state := refundState{
|
||||
open: true,
|
||||
orderID: orderID,
|
||||
gen: m.refund.gen + 1,
|
||||
}
|
||||
state.textarea = m.newRefundTextarea()
|
||||
state.form = m.buildRefundForm(&state)
|
||||
m.refund = state
|
||||
return m, m.refund.form.Init()
|
||||
}
|
||||
|
||||
// RefundUpdate handles all input and async results while the refund composer is open.
|
||||
func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
state := &m.refund
|
||||
if !state.open {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case refundRequestMsg:
|
||||
if msg.Gen != state.gen || msg.OrderID != state.orderID {
|
||||
return m, nil
|
||||
}
|
||||
state.submitting = false
|
||||
if msg.Err != nil {
|
||||
state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err)
|
||||
return m, nil
|
||||
}
|
||||
gen := state.gen
|
||||
m.refund = refundState{gen: gen}
|
||||
m.notice = &VisibleNotice{message: "Refund request sent"}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
state.form = m.buildRefundForm(state)
|
||||
m.resizeRefundTextarea(state)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if state.submitting {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
gen := state.gen
|
||||
m.refund = refundState{gen: gen}
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
return m, m.submitRefundRequest(state)
|
||||
case "tab", "shift+tab":
|
||||
return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount)
|
||||
}
|
||||
}
|
||||
|
||||
if state.focusIdx == refundFocusReason {
|
||||
state.err = ""
|
||||
state.reasonErr = ""
|
||||
form, cmd := state.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
state.form = f
|
||||
}
|
||||
if state.form.State == huh.StateCompleted {
|
||||
state.form = m.buildRefundForm(state)
|
||||
return m, m.setRefundFocus(state, refundFocusMessage)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
state.err = ""
|
||||
state.messageErr = ""
|
||||
}
|
||||
ta, cmd := state.textarea.Update(msg)
|
||||
state.textarea = ta
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) buildRefundForm(state *refundState) *huh.Form {
|
||||
options := make([]huh.Option[string], 0, len(models.RefundRequestReasons))
|
||||
for _, reason := range models.RefundRequestReasons {
|
||||
options = append(options, huh.NewOption(reason, reason))
|
||||
}
|
||||
|
||||
return huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Reason").
|
||||
Key("reason").
|
||||
Options(options...).
|
||||
Value(&state.reason).
|
||||
Height(len(models.RefundRequestReasons) + 1),
|
||||
),
|
||||
).
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false).
|
||||
WithTheme(m.theme.Form()).
|
||||
WithLayout(huh.LayoutStack).
|
||||
WithWidth(m.refundContentWidth())
|
||||
}
|
||||
|
||||
func (m Model) newRefundTextarea() textarea.Model {
|
||||
ta := textarea.New()
|
||||
ta.Prompt = ""
|
||||
ta.Placeholder = "Describe the issue"
|
||||
ta.ShowLineNumbers = true
|
||||
ta.CharLimit = 65536
|
||||
ta.DynamicHeight = false
|
||||
ta.SetHeight(m.refundTextareaHeight())
|
||||
ta.SetWidth(m.refundTextareaWidth())
|
||||
|
||||
base := lipgloss.NewStyle()
|
||||
// Cursor-line uses a darker grey than theme.Border() so the active line
|
||||
// reads as a subtle wash, not a bright bar competing with the highlight.
|
||||
cursorLineBG := lipgloss.Color("236")
|
||||
ta.SetStyles(textarea.Styles{
|
||||
Focused: textarea.StyleState{
|
||||
Base: base,
|
||||
Text: base.Foreground(m.theme.Accent()),
|
||||
LineNumber: base.Foreground(m.theme.Dim()),
|
||||
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
|
||||
CursorLineNumber: base.Foreground(m.theme.Body()),
|
||||
Placeholder: base.Foreground(m.theme.Dim()),
|
||||
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
||||
},
|
||||
Blurred: textarea.StyleState{
|
||||
Base: base,
|
||||
Text: base.Foreground(m.theme.Body()),
|
||||
LineNumber: base.Foreground(m.theme.Dim()),
|
||||
CursorLine: base.Foreground(m.theme.Background()),
|
||||
CursorLineNumber: base.Foreground(m.theme.Dim()),
|
||||
Placeholder: base.Foreground(m.theme.Dim()),
|
||||
EndOfBuffer: base.Foreground(m.theme.Dim()),
|
||||
},
|
||||
Cursor: textarea.CursorStyle{
|
||||
Color: m.theme.Highlight(),
|
||||
Shape: tea.CursorBlock,
|
||||
Blink: true,
|
||||
},
|
||||
})
|
||||
return ta
|
||||
}
|
||||
|
||||
func (m Model) resizeRefundTextarea(state *refundState) {
|
||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
||||
state.textarea.SetWidth(m.refundTextareaWidth())
|
||||
}
|
||||
|
||||
func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd {
|
||||
state.focusIdx = focusIdx
|
||||
if focusIdx == refundFocusReason {
|
||||
state.textarea.Blur()
|
||||
state.form = m.buildRefundForm(state)
|
||||
return state.form.Init()
|
||||
}
|
||||
return state.textarea.Focus()
|
||||
}
|
||||
|
||||
func (m Model) submitRefundRequest(state *refundState) tea.Cmd {
|
||||
state.err = ""
|
||||
state.reasonErr = ""
|
||||
state.messageErr = ""
|
||||
|
||||
reason := strings.TrimSpace(state.reason)
|
||||
message := strings.TrimSpace(state.textarea.Value())
|
||||
|
||||
if reason == "" {
|
||||
state.reasonErr = "select a refund reason"
|
||||
return nil
|
||||
}
|
||||
|
||||
if reason == models.RefundRequestReasonOther && message == "" {
|
||||
state.messageErr = "message is required when reason is other"
|
||||
return nil
|
||||
}
|
||||
|
||||
state.submitting = true
|
||||
return m.createRefundRequestCmd(state.orderID, state.gen, reason, message)
|
||||
}
|
||||
|
||||
func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")}
|
||||
}
|
||||
err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
})
|
||||
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// RenderRefundOverlay renders the refund composer as a centered modal.
|
||||
// The modal has no border or background fill — only padding for breathing
|
||||
// room. lipgloss.Place handles centering against the container.
|
||||
func (m Model) RenderRefundOverlay() string {
|
||||
state := &m.refund
|
||||
contentWidth := m.refundContentWidth()
|
||||
|
||||
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
|
||||
title := m.theme.TextAccent().
|
||||
Bold(true).
|
||||
Width(contentWidth).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(m.theme.Highlight()).
|
||||
Render(titleText)
|
||||
|
||||
body := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
m.renderRefundReason(state),
|
||||
"",
|
||||
m.renderRefundTextarea(state),
|
||||
"",
|
||||
m.renderRefundFooter(state),
|
||||
)
|
||||
|
||||
modal := lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
Render(body)
|
||||
|
||||
return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal)
|
||||
}
|
||||
|
||||
func (m Model) renderRefundReason(state *refundState) string {
|
||||
if state.focusIdx == 0 && state.form != nil {
|
||||
out := state.form.View()
|
||||
if state.reasonErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.reasonErr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
value := state.reason
|
||||
if value == "" {
|
||||
value = "Select a reason"
|
||||
}
|
||||
|
||||
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
|
||||
selected := m.theme.TextAccent().
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
PaddingLeft(1).
|
||||
Width(m.refundContentWidth()).
|
||||
Render(value)
|
||||
|
||||
out := lipgloss.JoinVertical(lipgloss.Left, label, selected)
|
||||
if state.reasonErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.reasonErr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) renderRefundTextarea(state *refundState) string {
|
||||
label := "Message (optional)"
|
||||
placeholder := "Describe the issue"
|
||||
if state.reason == models.RefundRequestReasonOther {
|
||||
label = "Message (required)"
|
||||
placeholder = "Describe the issue..."
|
||||
}
|
||||
state.textarea.Placeholder = placeholder
|
||||
|
||||
border := m.theme.Border()
|
||||
if state.focusIdx == refundFocusMessage {
|
||||
border = m.theme.Highlight()
|
||||
}
|
||||
|
||||
wrapper := lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(border)
|
||||
|
||||
contentWidth := m.refundContentWidth()
|
||||
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
|
||||
state.textarea.SetHeight(m.refundTextareaHeight())
|
||||
|
||||
textareaBox := wrapper.Render(state.textarea.View())
|
||||
|
||||
counter := m.theme.TextDim().
|
||||
Width(contentWidth).
|
||||
Align(lipgloss.Right).
|
||||
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
|
||||
|
||||
out := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
m.theme.TextLabel().Render(refundIconComment+" "+label),
|
||||
textareaBox,
|
||||
counter,
|
||||
)
|
||||
if state.messageErr != "" {
|
||||
out += "\n" + m.theme.TextError().Render(state.messageErr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) renderRefundFooter(state *refundState) string {
|
||||
if state.submitting {
|
||||
return m.theme.TextAccent().Render("sending refund request...")
|
||||
}
|
||||
if state.err != "" {
|
||||
return m.theme.TextError().Render(state.err)
|
||||
}
|
||||
key := m.theme.TextHighlight().Bold(true)
|
||||
sep := m.theme.TextDim().Render(" · ")
|
||||
dim := m.theme.TextDim()
|
||||
return dim.Render(refundIconKey+" ") +
|
||||
key.Render("tab") + dim.Render(" next") + sep +
|
||||
key.Render("shift+tab") + dim.Render(" prev") + sep +
|
||||
key.Render("ctrl+s") + dim.Render(" submit") + sep +
|
||||
key.Render("esc") + dim.Render(" cancel")
|
||||
}
|
||||
|
||||
func (m Model) refundContentWidth() int {
|
||||
width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth)
|
||||
if width < refundModalMinWidth {
|
||||
width = max(1, m.widthContent-refundTextareaHPad)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func (m Model) refundTextareaWidth() int {
|
||||
return max(1, m.refundContentWidth()-refundTextareaHPad)
|
||||
}
|
||||
|
||||
func (m Model) refundTextareaHeight() int {
|
||||
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// countries defines the available countries with their dial codes.
|
||||
@@ -15,21 +18,21 @@ var countries = []struct {
|
||||
Code string
|
||||
DialCode string
|
||||
}{
|
||||
{Name: "Denmark", Code: "DK", DialCode: "+45"},
|
||||
{Name: "Finland", Code: "FI", DialCode: "+358"},
|
||||
{Name: "Iceland", Code: "IS", DialCode: "+354"},
|
||||
// {Name: "Denmark", Code: "DK", DialCode: "+45"},
|
||||
// {Name: "Finland", Code: "FI", DialCode: "+358"},
|
||||
// {Name: "Iceland", Code: "IS", DialCode: "+354"},
|
||||
{Name: "Norway", Code: "NO", DialCode: "+47"},
|
||||
{Name: "Sweden", Code: "SE", DialCode: "+46"},
|
||||
// {Name: "Sweden", Code: "SE", DialCode: "+46"},
|
||||
{Name: "USA", Code: "US", DialCode: "+1"},
|
||||
}
|
||||
|
||||
// countryDialCodes maps country ISO codes to their dialing prefixes.
|
||||
var countryDialCodes = map[string]string{
|
||||
"DK": "+45",
|
||||
"FI": "+358",
|
||||
"IS": "+354",
|
||||
// "DK": "+45",
|
||||
// "FI": "+358",
|
||||
// "IS": "+354",
|
||||
"NO": "+47",
|
||||
"SE": "+46",
|
||||
// "SE": "+46",
|
||||
"US": "+1",
|
||||
}
|
||||
|
||||
@@ -78,6 +81,11 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
||||
|
||||
f := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Country").
|
||||
Key("country").
|
||||
Options(countryOptions...).
|
||||
Value(&state.Country),
|
||||
huh.NewInput().
|
||||
Title("Full Name").
|
||||
Key("name").
|
||||
@@ -104,13 +112,6 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
||||
Title("Postal Code").
|
||||
Key("zip").
|
||||
Value(&state.Zip),
|
||||
huh.NewSelect[string]().
|
||||
Title("Country").
|
||||
Key("country").
|
||||
Options(countryOptions...).
|
||||
Value(&state.Country).
|
||||
Height(5).
|
||||
Filtering(true),
|
||||
huh.NewInput().
|
||||
Title("Phone").
|
||||
Key("phone").
|
||||
@@ -118,7 +119,8 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
||||
),
|
||||
).
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false)
|
||||
WithShowHelp(false).
|
||||
WithTheme(m.theme.Form())
|
||||
|
||||
if m.size < medium {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
@@ -134,7 +136,7 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
||||
// so that huh's Value() bindings point to stable memory.
|
||||
func (m Model) InitShippingForm() *ShippingFormState {
|
||||
state := &ShippingFormState{
|
||||
Country: "SE",
|
||||
Country: "US",
|
||||
}
|
||||
|
||||
if m.User != nil && m.User.Name != "" {
|
||||
@@ -238,19 +240,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,47 +253,210 @@ func (m Model) RenderShippingForm(state *ShippingFormState) string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) updateShippingViewport() Model {
|
||||
headerH := lipgloss.Height(m.BuildHeader())
|
||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||
footerH := lipgloss.Height(m.BuildFooter())
|
||||
availH := m.heightContainer - headerH - footerH - breadH
|
||||
if availH < 1 {
|
||||
availH = 1
|
||||
}
|
||||
if !m.shipping.viewportReady {
|
||||
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||
m.shipping.viewport.KeyMap = viewport.KeyMap{}
|
||||
m.shipping.viewportReady = true
|
||||
} else {
|
||||
m.shipping.viewport.SetWidth(m.widthContent)
|
||||
m.shipping.viewport.SetHeight(availH)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) ShippingPageView() string {
|
||||
if !m.shipping.viewportReady {
|
||||
m = m.updateShippingViewport()
|
||||
}
|
||||
if m.shipping.view == 0 && m.shipping.form == nil {
|
||||
content := m.RenderAddressList()
|
||||
m.shipping.viewport.SetContent(content)
|
||||
itemHeight := 4
|
||||
targetY := m.shipping.addressCursor * itemHeight
|
||||
if targetY < m.shipping.viewport.YOffset() {
|
||||
m.shipping.viewport.SetYOffset(targetY)
|
||||
}
|
||||
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
|
||||
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
|
||||
}
|
||||
if m.shipping.addressCursor == len(m.SavedAddresses) {
|
||||
m.shipping.viewport.GotoBottom()
|
||||
}
|
||||
} else if m.shipping.form != nil {
|
||||
m.shipping.viewport.SetContent(m.RenderShippingForm(m.shipping.form))
|
||||
}
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(m.shipping.viewport.View()),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
m.shipping.viewport.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) RenderAddressList() string {
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
|
||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
|
||||
title := titleStyle.Render("Selected Shipping Address")
|
||||
|
||||
var lines []string
|
||||
var boxes []string
|
||||
for i, addr := range m.SavedAddresses {
|
||||
cursor := " "
|
||||
style := inactiveStyle
|
||||
if i == m.AddressCursor {
|
||||
cursor = "> "
|
||||
style = activeStyle
|
||||
}
|
||||
focused := i == m.shipping.addressCursor
|
||||
|
||||
name := style.Render(addr.Name)
|
||||
street := labelStyle.Render(addr.Street)
|
||||
street := addr.Street
|
||||
if addr.Street2 != "" {
|
||||
street += ", " + labelStyle.Render(addr.Street2)
|
||||
street += ", " + addr.Street2
|
||||
}
|
||||
city := labelStyle.Render(fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip))
|
||||
city := fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip)
|
||||
content := addr.Name + "\n" + street + "\n" + city
|
||||
|
||||
lines = append(lines, cursor+name)
|
||||
lines = append(lines, " "+street)
|
||||
lines = append(lines, " "+city)
|
||||
lines = append(lines, "")
|
||||
box := m.CreateBox(m.formatListItem(content, focused), focused)
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
|
||||
addCursor := " "
|
||||
addStyle := inactiveStyle
|
||||
if m.AddressCursor == len(m.SavedAddresses) {
|
||||
addCursor = "> "
|
||||
addStyle = activeStyle
|
||||
}
|
||||
|
||||
lines = append(lines, addCursor+addStyle.Render("+ Add new address"))
|
||||
addFocused := m.shipping.addressCursor == len(m.SavedAddresses)
|
||||
addBox := m.CreateBox(m.formatListItem("+ Add new address", addFocused), addFocused)
|
||||
boxes = append(boxes, addBox)
|
||||
|
||||
parts := []string{title}
|
||||
parts = append(parts, strings.Join(lines, "\n"))
|
||||
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...))
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShippingFormCompleteMsg:
|
||||
addr := models.Address{
|
||||
Name: msg.Name, Street: msg.Street1, Street2: msg.Street2,
|
||||
City: msg.City, State: msg.State, Country: msg.Country,
|
||||
Zip: msg.Zip, Phone: msg.Phone,
|
||||
}
|
||||
return m, m.saveAddressCmd(addr)
|
||||
|
||||
case AddressesMsg:
|
||||
if msg.Err != nil || len(msg.Addresses) == 0 {
|
||||
m.SavedAddresses = nil
|
||||
m.shipping.view = 1
|
||||
m.shipping.form = m.InitShippingForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.shipping.form.form.Init()
|
||||
}
|
||||
m.SavedAddresses = msg.Addresses
|
||||
m.shipping.view = 0
|
||||
m.shipping.addressCursor = 0
|
||||
m.shipping.form = nil
|
||||
return m, nil
|
||||
|
||||
case AddressSavedMsg:
|
||||
if msg.Err != nil {
|
||||
if m.shipping.form != nil {
|
||||
m.shipping.form.submitting = false
|
||||
m.shipping.form.form = m.buildShippingForm(m.shipping.form)
|
||||
}
|
||||
m.error = &VisibleError{message: "Address could not be validated. Check the street, region, and postal code. US and Norway only."}
|
||||
return m, m.shipping.form.form.Init()
|
||||
}
|
||||
saved := msg.Address
|
||||
m.ShippingInfo = &saved
|
||||
m.SavedAddresses = append(m.SavedAddresses, saved)
|
||||
return m.PaymentSwitch()
|
||||
|
||||
case ShippingFormErrorMsg:
|
||||
m.error = &VisibleError{message: msg.Message}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Address list navigation
|
||||
if m.shipping.view == 0 && m.shipping.form == nil {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.error = nil
|
||||
switch keyMsg.String() {
|
||||
case "esc":
|
||||
return m.CartSwitch()
|
||||
case "up", "k":
|
||||
if m.shipping.addressCursor > 0 {
|
||||
m.shipping.addressCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.shipping.addressCursor < len(m.SavedAddresses) {
|
||||
m.shipping.addressCursor++
|
||||
}
|
||||
case "enter":
|
||||
if m.shipping.addressCursor < len(m.SavedAddresses) {
|
||||
selected := m.SavedAddresses[m.shipping.addressCursor]
|
||||
m.ShippingInfo = &selected
|
||||
return m.PaymentSwitch()
|
||||
}
|
||||
m.shipping.view = 1
|
||||
m.shipping.form = m.InitShippingForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, m.shipping.form.form.Init()
|
||||
case "d", "x":
|
||||
if m.shipping.addressCursor < len(m.SavedAddresses) {
|
||||
addr := m.SavedAddresses[m.shipping.addressCursor]
|
||||
m.SavedAddresses = append(m.SavedAddresses[:m.shipping.addressCursor], m.SavedAddresses[m.shipping.addressCursor+1:]...)
|
||||
if m.shipping.addressCursor >= len(m.SavedAddresses) && m.shipping.addressCursor > 0 {
|
||||
m.shipping.addressCursor--
|
||||
}
|
||||
if len(m.SavedAddresses) == 0 {
|
||||
m.shipping.view = 1
|
||||
m.shipping.form = m.InitShippingForm()
|
||||
m.footer = []footerCommand{
|
||||
{key: "tab", value: "next"},
|
||||
{key: "enter", value: "submit"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID))
|
||||
}
|
||||
return m, m.deleteAddressCmd(addr.ID)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Form navigation
|
||||
if m.shipping.form != nil {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "esc" {
|
||||
m.error = nil
|
||||
if len(m.SavedAddresses) > 0 {
|
||||
m.shipping.view = 0
|
||||
m.shipping.form = nil
|
||||
m.footer = []footerCommand{
|
||||
{key: "j/k", value: "addresses"},
|
||||
{key: "enter", value: "select"},
|
||||
{key: "d/x", value: "delete"},
|
||||
{key: "esc", value: "back"},
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.shipping.form = nil
|
||||
m, cmd := m.CartSwitch()
|
||||
return m, cmd
|
||||
}
|
||||
// Clear error only when user starts typing, not on internal huh messages
|
||||
m.error = nil
|
||||
}
|
||||
return m, m.UpdateShippingForm(msg, m.shipping.form)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
307
pkg/tui/shop.go
307
pkg/tui/shop.go
@@ -2,113 +2,232 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"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.SetWidth(menuWidth)
|
||||
m.shop.menuViewport.SetHeight(availableHeight)
|
||||
m.shop.detailViewport.SetWidth(detailWidth)
|
||||
m.shop.detailViewport.SetHeight(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}
|
||||
}
|
||||
m, cmd := m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity)
|
||||
return m, cmd
|
||||
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
|
||||
}
|
||||
m, cmd := m.syncCartItemCmd(coffeeID, newQty)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass messages to the detail viewport so pgup/pgdn scroll works
|
||||
m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
if m.shop.viewportsReady {
|
||||
m.shop.menuViewport.SetContent(m.getShopMenuContent())
|
||||
m.shop.detailViewport.SetContent(m.getShopDetailContent())
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) shopMoveSelected(previous bool) Model {
|
||||
next := m.shop.selected
|
||||
if previous {
|
||||
next--
|
||||
} else {
|
||||
next++
|
||||
}
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
if next >= len(m.Coffees) {
|
||||
next = len(m.Coffees) - 1
|
||||
}
|
||||
m.shop.selected = next
|
||||
|
||||
if m.shop.viewportsReady {
|
||||
targetY := (m.shop.selected + 2) * 1
|
||||
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
|
||||
m.shop.detailViewport.GotoTop()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
59
pkg/tui/splash.go
Normal file
59
pkg/tui/splash.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type DelayCompleteMsg struct{}
|
||||
type splashCursorTickMsg struct{}
|
||||
|
||||
type SplashAuthMsg struct {
|
||||
Token string
|
||||
User models.PublicUser
|
||||
Err error
|
||||
}
|
||||
|
||||
type ViewInitMsg struct {
|
||||
Data api.ViewInitData
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m Model) splashViewInitCmd() tea.Msg {
|
||||
data, err := m.APIClient.GetViewInit()
|
||||
if err != nil {
|
||||
return ViewInitMsg{Err: fmt.Errorf("failed to load data: %w", err)}
|
||||
}
|
||||
return ViewInitMsg{Data: data}
|
||||
}
|
||||
|
||||
func (m Model) SplashView() string {
|
||||
accent := m.theme.TextBody().Bold(true)
|
||||
|
||||
var cursor string
|
||||
if m.splash.cursor {
|
||||
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
|
||||
} else {
|
||||
cursor = " "
|
||||
}
|
||||
|
||||
logo := accent.Render("terminal coffee") + cursor
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
logo,
|
||||
)
|
||||
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
m.viewportHeight,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
58
pkg/tui/styles.go
Normal file
58
pkg/tui/styles.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
|
||||
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
|
||||
|
||||
var style lipgloss.Style
|
||||
if selected {
|
||||
style = base.BorderForeground(m.theme.Highlight())
|
||||
} else {
|
||||
style = base.BorderForeground(m.theme.Border())
|
||||
}
|
||||
return style.Padding(paddingV, paddingH).Render(padded)
|
||||
}
|
||||
|
||||
func (m Model) CreateBox(content string, selected bool) string {
|
||||
return m.createBoxInner(content, selected, lipgloss.Left, 1, 0, m.widthContent-2)
|
||||
}
|
||||
|
||||
func (m Model) CreateBoxCustom(content string, selected bool, totalWidth int) string {
|
||||
return m.createBoxInner(content, selected, lipgloss.Left, 1, 0, totalWidth)
|
||||
}
|
||||
|
||||
func (m Model) formatListItem(text string, focused bool) string {
|
||||
return m.formatListItemCustom(text, focused, m.widthContent, true)
|
||||
}
|
||||
|
||||
func (m Model) formatListItemCustom(text string, focused bool, totalWidth int, showRadio bool) string {
|
||||
accent := m.theme.TextAccent().Render
|
||||
|
||||
content := " " + text
|
||||
hint := ""
|
||||
if focused {
|
||||
content = accent(" ☉ " + text)
|
||||
hint = accent("enter")
|
||||
}
|
||||
|
||||
if !showRadio {
|
||||
content = text
|
||||
}
|
||||
|
||||
padding := 6
|
||||
if !showRadio {
|
||||
padding = 2
|
||||
}
|
||||
|
||||
var lines = strings.Split(content, "\n")
|
||||
var firstLine = lines[0]
|
||||
hintSpace := totalWidth - lipgloss.Width(hint) - lipgloss.Width(firstLine) - padding
|
||||
lines[0] = firstLine + m.theme.Base().Width(hintSpace).Render() + hint
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
50
pkg/tui/theme/huh.go
Normal file
50
pkg/tui/theme/huh.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// HuhTheme creates a huh form theme using this project's color palette.
|
||||
func HuhTheme(t Theme) huh.Theme {
|
||||
var s huh.Styles
|
||||
|
||||
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n")
|
||||
|
||||
f := &s.Focused
|
||||
f.Base = lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.accent)
|
||||
f.Title = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.Description = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
|
||||
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
|
||||
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
|
||||
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
|
||||
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
|
||||
|
||||
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.Option = lipgloss.NewStyle().Foreground(t.body)
|
||||
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
|
||||
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
|
||||
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
|
||||
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
|
||||
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
|
||||
|
||||
s.Help = help.New().Styles
|
||||
|
||||
s.Blurred = copyFieldStyles(*f)
|
||||
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
|
||||
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
|
||||
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim)
|
||||
|
||||
return huh.ThemeFunc(func(bool) *huh.Styles { return &s })
|
||||
}
|
||||
|
||||
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }
|
||||
96
pkg/tui/theme/theme.go
Normal file
96
pkg/tui/theme/theme.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/compat"
|
||||
)
|
||||
|
||||
// Theme holds all colors and styles for the TUI
|
||||
type Theme struct {
|
||||
background color.Color
|
||||
border color.Color
|
||||
body color.Color
|
||||
dim color.Color
|
||||
accent color.Color
|
||||
highlight color.Color
|
||||
brand color.Color
|
||||
label color.Color
|
||||
success color.Color
|
||||
error color.Color
|
||||
|
||||
base lipgloss.Style
|
||||
form huh.Theme
|
||||
}
|
||||
|
||||
func BasicTheme() Theme {
|
||||
t := Theme{}
|
||||
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#666666")
|
||||
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#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 = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func Catppuccin() Theme {
|
||||
t := Theme{}
|
||||
|
||||
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
|
||||
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
|
||||
t.body = lipgloss.Color("#AAAAAA")
|
||||
t.dim = lipgloss.Color("#6C7086")
|
||||
t.accent = lipgloss.Color("#CBA6F7")
|
||||
t.highlight = lipgloss.Color("#CBA6F7")
|
||||
t.brand = lipgloss.Color("#CBA6F7")
|
||||
t.label = lipgloss.Color("#A6ADC8")
|
||||
t.success = lipgloss.Color("#A6E3A1")
|
||||
t.error = lipgloss.Color("#F38BA8")
|
||||
|
||||
t.base = lipgloss.NewStyle().Foreground(t.body)
|
||||
t.form = HuhTheme(t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// Color accessors.
|
||||
func (t Theme) Background() color.Color { return t.background }
|
||||
func (t Theme) Border() color.Color { return t.border }
|
||||
func (t Theme) Body() color.Color { return t.body }
|
||||
func (t Theme) Dim() color.Color { return t.dim }
|
||||
func (t Theme) Accent() color.Color { return t.accent }
|
||||
func (t Theme) Highlight() color.Color { return t.highlight }
|
||||
func (t Theme) Brand() color.Color { return t.brand }
|
||||
func (t Theme) Label() color.Color { return t.label }
|
||||
func (t Theme) Success() color.Color { return t.success }
|
||||
func (t Theme) Error() color.Color { return t.error }
|
||||
|
||||
// Style helpers.
|
||||
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")) }
|
||||
|
||||
func (t Theme) PanelError() lipgloss.Style {
|
||||
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
||||
}
|
||||
|
||||
func (t Theme) Form() huh.Theme { return t.form }
|
||||
235
pkg/tui/update.go
Normal file
235
pkg/tui/update.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
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 = m.updateLayout(m.pendingWidth, m.pendingHeight)
|
||||
switch m.currentPage {
|
||||
case shopPage:
|
||||
m = m.updateShopViewports()
|
||||
case cartPage:
|
||||
m = m.updateCartViewport()
|
||||
case shippingPage:
|
||||
m = m.updateShippingViewport()
|
||||
case paymentPage:
|
||||
m = m.updatePaymentViewport()
|
||||
case confirmPage:
|
||||
m = m.updateConfirmViewport()
|
||||
case accountPage:
|
||||
m = m.updateAccountViewport()
|
||||
case reviewPage:
|
||||
// no viewport on review page
|
||||
}
|
||||
if m.shipping.form != nil {
|
||||
m.shipping.form.form = m.buildShippingForm(m.shipping.form)
|
||||
}
|
||||
if m.payment.form != nil {
|
||||
m.payment.form.form = m.buildPaymentForm(m.payment.form)
|
||||
}
|
||||
if m.refund.open {
|
||||
m.refund.form = m.buildRefundForm(&m.refund)
|
||||
m.resizeRefundTextarea(&m.refund)
|
||||
}
|
||||
}
|
||||
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 VisibleError:
|
||||
m.error = &msg
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
case VisibleNotice:
|
||||
m.notice = &msg
|
||||
m.error = nil
|
||||
return m, nil
|
||||
case error:
|
||||
m.error = &VisibleError{message: msg.Error()}
|
||||
m.notice = nil
|
||||
if m.currentPage == shopPage || m.currentPage == cartPage {
|
||||
return m, m.fetchCartCmd
|
||||
}
|
||||
return m, nil
|
||||
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.error = &VisibleError{message: fmt.Sprintf("authentication failed: %v", msg.Err)}
|
||||
// Still attempt to load view data (products may not require auth)
|
||||
// so the splash screen doesn't get permanently stuck.
|
||||
return m, m.splashViewInitCmd
|
||||
}
|
||||
m.AccessToken = msg.Token
|
||||
m.APIClient.Token = msg.Token
|
||||
if msg.User.ID != 0 {
|
||||
m.Username = msg.User.Name
|
||||
}
|
||||
return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd())
|
||||
case ViewInitMsg:
|
||||
if msg.Err != nil {
|
||||
return m.ShopSwitch()
|
||||
}
|
||||
m.Loading = false
|
||||
if len(msg.Data.Products) > 0 {
|
||||
m.Coffees = msg.Data.Products
|
||||
}
|
||||
if msg.Data.User.ID != 0 {
|
||||
u := models.User{
|
||||
ID: msg.Data.User.ID,
|
||||
Name: msg.Data.User.Email,
|
||||
Email: msg.Data.User.Email,
|
||||
}
|
||||
m.User = &u
|
||||
m.Username = u.Name
|
||||
}
|
||||
cart := &api.CartData{Items: msg.Data.Cart}
|
||||
m = m.loadCartFromAPI(cart)
|
||||
m.SavedAddresses = msg.Data.Addresses
|
||||
m.SavedCards = msg.Data.Cards
|
||||
m.Orders = msg.Data.Orders
|
||||
m.OrdersLoaded = true
|
||||
m.splash.dataReady = true
|
||||
m.ordersPollStarted = true
|
||||
pollCmd := m.pollOrderCmd()
|
||||
if m.splash.delayDone {
|
||||
nextM, switchCmd := m.ShopSwitch()
|
||||
return nextM, tea.Batch(switchCmd, pollCmd)
|
||||
}
|
||||
return m, pollCmd
|
||||
case CartSyncedMsg:
|
||||
if msg.Err != nil {
|
||||
return m, m.fetchCartCmd
|
||||
}
|
||||
if msg.UpdateID == m.lastCartUpdateID {
|
||||
m = 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
|
||||
if !m.ordersPollStarted {
|
||||
m.ordersPollStarted = true
|
||||
return m, m.pollOrderCmd()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case OrdersPollTickMsg:
|
||||
return m, tea.Batch(m.fetchOrdersCmd(), m.pollOrderCmd())
|
||||
case AddressDeletedMsg:
|
||||
if msg.Err != nil {
|
||||
m.error = &VisibleError{message: fmt.Sprintf("failed to delete address: %v", msg.Err)}
|
||||
}
|
||||
return m, nil
|
||||
case CardDeletedMsg:
|
||||
if msg.Err != nil {
|
||||
m.error = &VisibleError{message: fmt.Sprintf("failed to delete card: %v", msg.Err)}
|
||||
}
|
||||
return m, nil
|
||||
case DelayCompleteMsg:
|
||||
m.splash.delayDone = true
|
||||
if m.currentPage == splashPage && m.splash.dataReady {
|
||||
return m.ShopSwitch()
|
||||
}
|
||||
return m, nil
|
||||
case splashCursorTickMsg:
|
||||
m.splash.cursor = !m.splash.cursor
|
||||
return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return splashCursorTickMsg{}
|
||||
})
|
||||
case tea.KeyMsg:
|
||||
// If a form is active, skip global keys — let the page handler own input
|
||||
if m.shipping.form != nil || m.payment.form != nil {
|
||||
break
|
||||
}
|
||||
// Refund overlay owns input when open; dispatched below before page handlers.
|
||||
if m.refund.open {
|
||||
break
|
||||
}
|
||||
// Dismiss banners with ESC.
|
||||
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
|
||||
m.error = nil
|
||||
m.notice = nil
|
||||
return m, nil
|
||||
}
|
||||
// Global navigation
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "?":
|
||||
m.ShowingHelp = true
|
||||
return m, nil
|
||||
case "m":
|
||||
m.ShowingMenu = true
|
||||
m.menuLastPage = m.currentPage
|
||||
return m, nil
|
||||
case "s":
|
||||
return m.ShopSwitch()
|
||||
case "c":
|
||||
return m.CartSwitch()
|
||||
case "a":
|
||||
return m.AccountSwitch()
|
||||
}
|
||||
// non-globals. fall through to page dispatch below
|
||||
}
|
||||
|
||||
// Refund overlay intercepts all input after global handling.
|
||||
if m.refund.open {
|
||||
return m.RefundUpdate(msg)
|
||||
}
|
||||
|
||||
// Page-specifics
|
||||
switch m.currentPage {
|
||||
case shopPage:
|
||||
return m.ShopUpdate(msg)
|
||||
case cartPage:
|
||||
return m.CartUpdate(msg)
|
||||
case shippingPage:
|
||||
return m.ShippingUpdate(msg)
|
||||
case paymentPage:
|
||||
return m.PaymentUpdate(msg)
|
||||
case reviewPage:
|
||||
return m.ReviewUpdate(msg)
|
||||
case confirmPage:
|
||||
return m.ConfirmUpdate(msg)
|
||||
case accountPage:
|
||||
return m.AccountUpdate(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
179
pkg/tui/views.go
179
pkg/tui/views.go
@@ -1,15 +1,13 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// 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.")
|
||||
|
||||
@@ -22,7 +20,13 @@ func (m Model) ResizeView() string {
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
func (m Model) View() tea.View {
|
||||
v := tea.NewView(m.renderString())
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m Model) renderString() string {
|
||||
if m.viewportWidth == 0 {
|
||||
m.updateLayout(120, 30)
|
||||
}
|
||||
@@ -32,6 +36,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,123 +76,45 @@ 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.error != nil {
|
||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||
// soft-wrap long messages instead of clipping.
|
||||
if m.widthContent > 4 {
|
||||
errorStyle = errorStyle.Width(m.widthContent - 4)
|
||||
}
|
||||
} 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()
|
||||
errorBanner := errorStyle.Render(m.error.message)
|
||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
} else if m.notice != nil {
|
||||
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
||||
noticeBanner := noticeStyle.Render(m.notice.message)
|
||||
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
} else {
|
||||
content = m.BuildShopView()
|
||||
content = m.buildPageContent(availableContentHeight)
|
||||
}
|
||||
|
||||
// Split content into lines and handle scrolling
|
||||
contentLines := strings.Split(content, "\n")
|
||||
totalLines := len(contentLines)
|
||||
|
||||
// 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)
|
||||
if !accountHandlesScroll {
|
||||
// Ensure scroll offset is within bounds
|
||||
if m.ScrollOffset < 0 {
|
||||
m.ScrollOffset = 0
|
||||
}
|
||||
|
||||
maxScroll := totalLines - availableContentHeight
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.ScrollOffset > maxScroll {
|
||||
m.ScrollOffset = maxScroll
|
||||
}
|
||||
|
||||
if totalLines > availableContentHeight {
|
||||
start := m.ScrollOffset
|
||||
end := start + availableContentHeight
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
contentLines = contentLines[start:end]
|
||||
}
|
||||
}
|
||||
content = strings.Join(contentLines, "\n")
|
||||
|
||||
// Add vertical spacing around content with fixed height
|
||||
contentWithPadding := lipgloss.NewStyle().
|
||||
MarginTop(marginTop).
|
||||
MarginBottom(marginBottom).
|
||||
Height(availableContentHeight).
|
||||
Render(content)
|
||||
|
||||
// Build footer
|
||||
footer := m.BuildFooter()
|
||||
|
||||
// Assemble all layers vertically within the container
|
||||
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
|
||||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
|
||||
m.currentPage == confirmPage || m.currentPage == accountPage
|
||||
|
||||
var child string
|
||||
if breadcrumbs != "" {
|
||||
child = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
header,
|
||||
breadcrumbs,
|
||||
contentWithPadding,
|
||||
footer,
|
||||
)
|
||||
if isViewportPage {
|
||||
if breadcrumbs != "" {
|
||||
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, content, footer)
|
||||
} else {
|
||||
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
|
||||
}
|
||||
} else if breadcrumbs != "" {
|
||||
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
|
||||
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
|
||||
} else {
|
||||
child = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
header,
|
||||
contentWithPadding,
|
||||
footer,
|
||||
)
|
||||
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
|
||||
child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
|
||||
}
|
||||
|
||||
// Constrain the assembled layout to the container dimensions
|
||||
@@ -193,6 +123,10 @@ func (m Model) View() string {
|
||||
MaxHeight(m.heightContainer).
|
||||
Render(child)
|
||||
|
||||
if m.refund.open {
|
||||
constrained = m.RenderRefundOverlay()
|
||||
}
|
||||
|
||||
// Center the entire container in the terminal viewport
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
@@ -202,3 +136,24 @@ func (m Model) View() string {
|
||||
constrained,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) buildPageContent(height int) string {
|
||||
switch m.currentPage {
|
||||
case accountPage:
|
||||
return m.BuildAccountView(height)
|
||||
case shippingPage:
|
||||
return m.ShippingPageView()
|
||||
case paymentPage:
|
||||
return m.PaymentPageView()
|
||||
case reviewPage:
|
||||
return m.ReviewView()
|
||||
case confirmPage:
|
||||
return m.ConfirmView()
|
||||
case cartPage:
|
||||
return m.CartView()
|
||||
case shopPage:
|
||||
return m.ShopView()
|
||||
default:
|
||||
return m.ShopView()
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user