theme system
This commit is contained in:
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
|
||||||
@@ -47,7 +47,6 @@ go run main.go
|
|||||||
**Features missing vs reference (terminal.shop):**
|
**Features missing vs reference (terminal.shop):**
|
||||||
- [ ] Splash/loading screen on connect
|
- [ ] Splash/loading screen on connect
|
||||||
- [ ] Animated logo with blinking cursor
|
- [ ] Animated logo with blinking cursor
|
||||||
- [ ] Subscriptions (recurring orders) — subscribe, manage, cancel
|
|
||||||
- [ ] API token management page
|
- [ ] API token management page
|
||||||
- [ ] OAuth app management page
|
- [ ] OAuth app management page
|
||||||
- [ ] About page
|
- [ ] About page
|
||||||
|
|||||||
93
docs/gap-analysis.md
Normal file
93
docs/gap-analysis.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Gap Analysis: Terminal Coffee Shop vs Reference (terminal.shop)
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Already Working
|
||||||
|
|
||||||
|
- SSH server with key-based auth + auto user creation
|
||||||
|
- Shop page with live detail panel
|
||||||
|
- Cart (add/remove, synced to API)
|
||||||
|
- Full checkout flow: shipping → payment → confirmation
|
||||||
|
- Saved addresses and saved cards
|
||||||
|
- Order history in account view
|
||||||
|
- FAQ page
|
||||||
|
- About page
|
||||||
|
- Splash screen with blinking cursor animation
|
||||||
|
- Responsive layout (small/medium/large)
|
||||||
|
- Token refresh
|
||||||
|
- Full backend API (Chi router, PostgreSQL, GORM, Stripe webhooks)
|
||||||
|
- Shippo shipping integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Features (vs reference)
|
||||||
|
|
||||||
|
### 1. API Token Management
|
||||||
|
|
||||||
|
- **Reference:** Create, view, and revoke personal API tokens
|
||||||
|
- **Current:** Not implemented
|
||||||
|
- **Reference file:** `tokens.go`
|
||||||
|
- **Task:** Add tokens section under account view with list + create/revoke actions
|
||||||
|
|
||||||
|
### 2. OAuth App Management
|
||||||
|
|
||||||
|
- **Reference:** Create, edit, delete OAuth apps with redirect URIs; uses `huh` form
|
||||||
|
- **Current:** Not implemented
|
||||||
|
- **Reference file:** `apps.go`
|
||||||
|
- **Task:** Add apps section under account view with form-based CRUD
|
||||||
|
|
||||||
|
### 3. Keyboard Interactive SSH Auth (Anonymous Sessions)
|
||||||
|
|
||||||
|
- **Reference:** Supports keyboard interactive auth in addition to public key, generates UUID fingerprints for anonymous users
|
||||||
|
- **Current:** Public key auth only (`WithPublicKeyAuth` in `main.go`)
|
||||||
|
- **Task:** Add `WithKeyboardInteractiveAuth` to Wish options; generate UUID fingerprint for anonymous sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality Issues
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
|
||||||
|
- **Reference:** Centralized `theme.Theme` struct with methods (`Base()`, `TextAccent()`, `Border()`, `Brand()`, `Accent()`)
|
||||||
|
- **Current:** 40+ hardcoded hex colors scattered across view files (e.g. `#4682B4`, `#FFFFFF`, `#666666`)
|
||||||
|
- **Task:** Create `pkg/tui/theme/theme.go`, extract all colors and styles, update all view files to reference theme
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- **Reference:** Nested state substruct: `state.shop`, `state.cart`, `state.account`, etc. — each feature owns its state
|
||||||
|
- **Current:** Flat `Model` struct with 80+ fields, all mixed together
|
||||||
|
- **Task:** Refactor into nested substructs per feature area (breaking change — do after features are stable)
|
||||||
|
|
||||||
|
### Viewport-Based Scrolling
|
||||||
|
|
||||||
|
- **Reference:** Uses `charmbracelet/bubbles/viewport` throughout for scrollable content
|
||||||
|
- **Current:** Manual scroll offset math with hardcoded heights (e.g. `-9` for cart viewport height)
|
||||||
|
- **Task:** Replace manual scroll math with `viewport.Model` in cart, account, and FAQ views
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Comparison
|
||||||
|
|
||||||
|
| Area | Current | Reference |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| State management | Flat `Model` struct | Nested `state` substructs per feature |
|
||||||
|
| Theme/styling | 40+ hardcoded colors | Centralized `theme.Theme` |
|
||||||
|
| Scrolling | Manual offset math | `viewport.Model` |
|
||||||
|
| Backend | Full custom API (Chi + GORM) | External SDK (`terminal-sdk-go`) |
|
||||||
|
| Auth | SSH pubkey + JWT | SSH pubkey + keyboard interactive |
|
||||||
|
| Product model | Custom `Coffee` struct | SDK `terminal.Product` with variants |
|
||||||
|
| Forms | `huh` (partial) | `huh` (comprehensive, with huh theme) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Order of Work
|
||||||
|
|
||||||
|
1. **Fix known bugs** — address delete bug and placeholder item (quick wins, low risk)
|
||||||
|
2. **Theme system** — extract colors; unblocks all visual polish
|
||||||
|
3. **Viewport-based scrolling** — replace manual math, fixes responsiveness
|
||||||
|
4. **API token management** — useful feature, account section
|
||||||
|
5. **OAuth app management** — lowest priority unless API access is a goal
|
||||||
|
6. **Keyboard interactive SSH auth** — enables anonymous browsing
|
||||||
|
7. **Nested state structs** — last, after features are stable (pure refactor)
|
||||||
80
docs/stripe-js-card-form.md
Normal file
80
docs/stripe-js-card-form.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Custom Card Form with Stripe.js
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The reference (terminal.shop) uses their own branded card input page with Stripe.js
|
||||||
|
instead of Stripe Hosted Checkout. This means:
|
||||||
|
|
||||||
|
- Full control over the UI/branding
|
||||||
|
- No webhooks needed for card saving (token POSTed directly to API)
|
||||||
|
- No Stripe Checkout Session required
|
||||||
|
- Cleaner flow, fewer moving parts
|
||||||
|
|
||||||
|
## How it would work
|
||||||
|
|
||||||
|
1. TUI calls `POST /api/v1/cards/collect` → gets `https://yourdomain.com/pay/TOKEN`
|
||||||
|
2. User opens URL → sees your own card form (HTML + Stripe.js)
|
||||||
|
3. Stripe.js tokenizes card in the browser (client-side, no raw card data APIs needed)
|
||||||
|
4. Page POSTs token + TOKEN to your API: `POST /api/v1/cards/collect/TOKEN`
|
||||||
|
5. API saves the card to the database
|
||||||
|
6. TUI polling detects new card and auto-proceeds
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. HTML page served at `GET /pay/{token}`
|
||||||
|
|
||||||
|
Instead of redirecting to Stripe, serve an HTML page with Stripe Elements.
|
||||||
|
The TOKEN from the URL is used to associate the card with the right user.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form id="card-form">
|
||||||
|
<div id="card-element"></div>
|
||||||
|
<button type="submit">Save Card</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
|
||||||
|
const elements = stripe.elements();
|
||||||
|
const card = elements.create('card');
|
||||||
|
card.mount('#card-element');
|
||||||
|
|
||||||
|
document.getElementById('card-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const { token } = await stripe.createToken(card);
|
||||||
|
await fetch('/api/v1/cards/collect/TOKEN_FROM_URL', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: token.id })
|
||||||
|
});
|
||||||
|
window.location.href = '/card-added';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. New API endpoint: `POST /api/v1/cards/collect/{token}`
|
||||||
|
|
||||||
|
- Looks up the user from the token (stored in DB or memory map)
|
||||||
|
- Calls Stripe to convert token → PaymentMethod
|
||||||
|
- Attaches to customer
|
||||||
|
- Saves card to DB
|
||||||
|
- No webhook needed
|
||||||
|
|
||||||
|
### 3. Remove Stripe Checkout Session logic from CollectCard
|
||||||
|
|
||||||
|
`CollectCard` just generates a token, stores user association, returns the URL.
|
||||||
|
No `stripeSession.New()` call needed.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Requires a publicly accessible HTTPS domain (same as current approach)
|
||||||
|
- The in-memory token→userID map works fine (same TTL concerns as current approach)
|
||||||
|
- Could remove the `checkout.session.completed` webhook handler entirely
|
||||||
|
- Would need to remove the Stripe Checkout Session dependency from `cards.go`
|
||||||
225
docs/theme-implementation.md
Normal file
225
docs/theme-implementation.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Theme System Implementation Reference
|
||||||
|
|
||||||
|
> Use this document when implementing or modifying the theme system.
|
||||||
|
> Every decision and pattern is explained so context loss doesn't stall progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We're Doing
|
||||||
|
|
||||||
|
Replacing 40+ scattered `lipgloss.Color("#XXXXXX")` calls across 13 TUI files with a
|
||||||
|
centralized `Theme` struct in `pkg/tui/theme/`. Copied closely from the reference
|
||||||
|
implementation in `terminal-shop-source/packages/go/pkg/tui/theme/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files to Create
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/tui/theme/theme.go — Theme struct, BasicTheme(), all color/style methods
|
||||||
|
pkg/tui/theme/huh.go — HuhTheme(), copyFieldStyles(), copyTextStyles()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify (in order)
|
||||||
|
|
||||||
|
| Step | File | Type of change |
|
||||||
|
|------|------|----------------|
|
||||||
|
| 1 | `pkg/tui/theme/theme.go` | Create |
|
||||||
|
| 2 | `pkg/tui/theme/huh.go` | Create |
|
||||||
|
| 3 | `pkg/tui/model.go` | Add `renderer` + `theme` fields; update constructors |
|
||||||
|
| 4 | `main.go` | Create renderer from SSH session; pass to NewModelWithAuth |
|
||||||
|
| 5 | `pkg/tui/views.go` | 3 color swaps |
|
||||||
|
| 6 | `pkg/tui/splash.go` | 2 color swaps |
|
||||||
|
| 7 | `pkg/tui/header.go` | 4 color swaps |
|
||||||
|
| 8 | `pkg/tui/footer.go` | 2 color swaps |
|
||||||
|
| 9 | `pkg/tui/breadcrumbs.go` | 3 color swaps |
|
||||||
|
| 10 | `pkg/tui/shop.go` | 6 color swaps |
|
||||||
|
| 11 | `pkg/tui/cart.go` | 9 color swaps |
|
||||||
|
| 12 | `pkg/tui/account.go` | 14 color swaps |
|
||||||
|
| 13 | `pkg/tui/menu.go` | 4 color swaps |
|
||||||
|
| 14 | `pkg/tui/help.go` | 5 color swaps |
|
||||||
|
| 15 | `pkg/tui/confirmation.go` | 5 color swaps |
|
||||||
|
| 16 | `pkg/tui/shipping_form.go` | 4 color swaps + `.WithTheme()` |
|
||||||
|
| 17 | `pkg/tui/payment_form.go` | 5 color swaps + `.WithTheme()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Mapping (hardcoded → theme)
|
||||||
|
|
||||||
|
| Hardcoded | Semantic role | Text style method | Color accessor |
|
||||||
|
|-----------|--------------|-------------------|----------------|
|
||||||
|
| `#FFFFFF` | active / selected text | `m.theme.TextAccent()` | `m.theme.Accent()` |
|
||||||
|
| `#AAAAAA` | body / regular text | `m.theme.TextBody()` | `m.theme.Body()` |
|
||||||
|
| `#666666` | hints / inactive / dim text | `m.theme.TextDim()` | `m.theme.Dim()` |
|
||||||
|
| `#666666` | border foreground | — | `m.theme.Border()` |
|
||||||
|
| `#4682B4` | highlight / brand / steel blue | `m.theme.TextHighlight()` | `m.theme.Highlight()` |
|
||||||
|
| `#999999` | form label text | `m.theme.TextLabel()` | `m.theme.Label()` |
|
||||||
|
| `#00FF00` | success / order placed | `m.theme.TextSuccess()` | `m.theme.Success()` |
|
||||||
|
| `"196"` | error text | `m.theme.TextError()` | `m.theme.Error()` |
|
||||||
|
| `"205"` | loading / spinner | `m.theme.TextLoading()` | — |
|
||||||
|
| `"196"` + `"52"` | error banner bg+fg | `m.theme.PanelError()` | — |
|
||||||
|
| `"C6DDF0"` | user name (light blue) | `m.theme.TextHighlight()` | — |
|
||||||
|
| `#CCCCCC` | muted subtext | `m.theme.TextBody()` | — |
|
||||||
|
|
||||||
|
**Note:** `#666666` is used for BOTH dim text AND border foreground in this project.
|
||||||
|
Both `Dim()` and `Border()` return the same value. Use `Border()` in `BorderForeground()`,
|
||||||
|
use `Dim()` / `TextDim()` for text styling.
|
||||||
|
|
||||||
|
**Note:** Dynamic product colors (`coffee.Color` = `"#8B4513"` etc.) are NOT replaced.
|
||||||
|
Keep those as `lipgloss.Color(coffee.Color)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme Colors (what BasicTheme sets)
|
||||||
|
|
||||||
|
```go
|
||||||
|
background = AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
||||||
|
border = AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
||||||
|
body = Color("#AAAAAA")
|
||||||
|
dim = Color("#666666")
|
||||||
|
accent = AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
|
||||||
|
highlight = Color("#4682B4")
|
||||||
|
brand = Color("#4682B4") // same as highlight for this project
|
||||||
|
label = Color("#999999")
|
||||||
|
success = Color("#00FF00")
|
||||||
|
error = Color("196")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Renderer Works
|
||||||
|
|
||||||
|
The reference implementation creates a `*lipgloss.Renderer` **per SSH session**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// main.go — SSH handler
|
||||||
|
renderer := bubbletea.MakeRenderer(s) // s = ssh.Session
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets lipgloss detect the correct color profile for each client's terminal
|
||||||
|
(e.g. TrueColor for Ghostty vs 256-color for basic terminals). All style creation
|
||||||
|
inside the theme uses `renderer.NewStyle()` instead of the global `lipgloss.NewStyle()`.
|
||||||
|
|
||||||
|
In this project:
|
||||||
|
- SSH sessions → renderer from `bubbletea.MakeRenderer(s)`
|
||||||
|
- Local / test mode → `lipgloss.DefaultRenderer()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Pattern in Views
|
||||||
|
|
||||||
|
All view functions are methods on `Model`, so `m.theme` is always available.
|
||||||
|
|
||||||
|
**Text-only style (before → after):**
|
||||||
|
```go
|
||||||
|
// before
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(18)
|
||||||
|
|
||||||
|
// after
|
||||||
|
m.theme.TextAccent().Bold(true).Width(18)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Border foreground (before → after):**
|
||||||
|
```go
|
||||||
|
// before
|
||||||
|
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666"))
|
||||||
|
|
||||||
|
// after
|
||||||
|
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selected item background (before → after):**
|
||||||
|
```go
|
||||||
|
// before
|
||||||
|
lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Foreground(lipgloss.Color("#FFFFFF"))
|
||||||
|
|
||||||
|
// after
|
||||||
|
lipgloss.NewStyle().Background(m.theme.Highlight()).Foreground(m.theme.Accent())
|
||||||
|
```
|
||||||
|
|
||||||
|
**`lipgloss.NewStyle()` stays for layout-only styles** (Width, Margin, Padding, Align
|
||||||
|
with no color). Only color-setting calls move to the theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Huh Form Theme
|
||||||
|
|
||||||
|
`HuhTheme()` creates a `*huh.Theme` applied to shipping and payment forms.
|
||||||
|
|
||||||
|
Applied by chaining `.WithTheme(m.theme.Form())` on the `huh.NewForm(...)` call:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// shipping_form.go and payment_form.go — in buildShippingForm / buildPaymentForm
|
||||||
|
f := huh.NewForm(...).
|
||||||
|
WithShowErrors(false).
|
||||||
|
WithShowHelp(false).
|
||||||
|
WithTheme(m.theme.Form()) // ← add this line
|
||||||
|
```
|
||||||
|
|
||||||
|
Form theme styles:
|
||||||
|
- Focused border: thick left border in accent color (`#FFFFFF`)
|
||||||
|
- Field title + body text: body color (`#AAAAAA`)
|
||||||
|
- Cursor: highlight color (`#4682B4`)
|
||||||
|
- Placeholder text: dim color (`#666666`)
|
||||||
|
- Error: color `"196"`
|
||||||
|
- Blurred fields: hidden border (same styles, border invisible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constructor Changes (model.go)
|
||||||
|
|
||||||
|
**Before:** `NewModel(username string)` initializes everything.
|
||||||
|
`NewModelWithAuth(fingerprint, pubKey, apiURL, secret, stripeKey)` calls NewModel.
|
||||||
|
|
||||||
|
**After:** A private `newModelWithRenderer(username, renderer)` does the heavy init.
|
||||||
|
Both public constructors call it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewModel(username string) Model {
|
||||||
|
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint, pubKey, apiURL, secret, stripeKey string) Model {
|
||||||
|
m := newModelWithRenderer("", renderer)
|
||||||
|
m.Fingerprint = fingerprint
|
||||||
|
// ... etc
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`NewModelWithAuth` signature change: **renderer moves to first parameter**.
|
||||||
|
Update the call in `main.go` accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme Methods Quick Reference
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Color accessors (lipgloss.TerminalColor — use in .Foreground() / .Background())
|
||||||
|
m.theme.Accent() // #FFFFFF / #11181C adaptive
|
||||||
|
m.theme.Body() // #AAAAAA
|
||||||
|
m.theme.Dim() // #666666
|
||||||
|
m.theme.Border() // #666666 / #D7DBDF adaptive
|
||||||
|
m.theme.Highlight() // #4682B4
|
||||||
|
m.theme.Brand() // #4682B4
|
||||||
|
m.theme.Label() // #999999
|
||||||
|
m.theme.Success() // #00FF00
|
||||||
|
m.theme.Error() // "196"
|
||||||
|
m.theme.Background() // #000000 / #FBFCFD adaptive
|
||||||
|
|
||||||
|
// Style helpers (lipgloss.Style — chain .Bold(), .Width(), .Padding() etc.)
|
||||||
|
m.theme.Base() // base style, body foreground
|
||||||
|
m.theme.TextAccent() // white text
|
||||||
|
m.theme.TextBody() // gray text (#AAAAAA)
|
||||||
|
m.theme.TextDim() // dim text (#666666)
|
||||||
|
m.theme.TextHighlight() // steel blue text (#4682B4)
|
||||||
|
m.theme.TextBrand() // same as TextHighlight
|
||||||
|
m.theme.TextLabel() // label text (#999999)
|
||||||
|
m.theme.TextSuccess() // green text (#00FF00)
|
||||||
|
m.theme.TextError() // error text ("196")
|
||||||
|
m.theme.TextLoading() // loading pink text ("205")
|
||||||
|
m.theme.PanelError() // error banner: dark red bg + red fg
|
||||||
|
m.theme.Form() // *huh.Theme for form styling
|
||||||
|
```
|
||||||
4
main.go
4
main.go
@@ -33,6 +33,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
|
renderer := bubbletea.MakeRenderer(s)
|
||||||
|
|
||||||
// Extract SSH public key from session
|
// Extract SSH public key from session
|
||||||
pubKey := s.PublicKey()
|
pubKey := s.PublicKey()
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create TUI model with user context (no registration screen needed!)
|
// Create TUI model with user context (no registration screen needed!)
|
||||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (Coffee) TableName() string {
|
|||||||
return "coffees"
|
return "coffees"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountMenuItems something
|
// AccountMenuItems
|
||||||
var AccountMenuItems = []string{
|
var AccountMenuItems = []string{
|
||||||
"order history",
|
"order history",
|
||||||
"faq",
|
"faq",
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
|||||||
for i, item := range models.AccountMenuItems {
|
for i, item := range models.AccountMenuItems {
|
||||||
if m.AccountCursor == i {
|
if m.AccountCursor == i {
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Background(lipgloss.Color("#4682B4")).
|
Background(m.theme.Highlight()).
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
Foreground(m.theme.Accent()).
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Width(leftWidth - 2).
|
Width(leftWidth - 2).
|
||||||
Align(lipgloss.Left)
|
Align(lipgloss.Left)
|
||||||
leftPanel += style.Render(item) + "\n"
|
leftPanel += style.Render(item) + "\n"
|
||||||
} else {
|
} else {
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#AAAAAA")).
|
Foreground(m.theme.Body()).
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Width(leftWidth - 2).
|
Width(leftWidth - 2).
|
||||||
Align(lipgloss.Left)
|
Align(lipgloss.Left)
|
||||||
@@ -41,10 +41,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
|||||||
// Build the detail view (right panel) based on cursor position
|
// Build the detail view (right panel) based on cursor position
|
||||||
detailView := ""
|
detailView := ""
|
||||||
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) {
|
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) {
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
MarginBottom(2)
|
|
||||||
|
|
||||||
// On small terminals, use full content width
|
// On small terminals, use full content width
|
||||||
detailContentWidth := rightWidth - 2
|
detailContentWidth := rightWidth - 2
|
||||||
@@ -52,9 +49,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
|||||||
detailContentWidth = m.widthContent - 2
|
detailContentWidth = m.widthContent - 2
|
||||||
}
|
}
|
||||||
|
|
||||||
contentStyle := lipgloss.NewStyle().
|
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||||
Foreground(lipgloss.Color("#AAAAAA")).
|
|
||||||
Width(detailContentWidth)
|
|
||||||
|
|
||||||
selectedItem := models.AccountMenuItems[m.AccountCursor]
|
selectedItem := models.AccountMenuItems[m.AccountCursor]
|
||||||
switch selectedItem {
|
switch selectedItem {
|
||||||
@@ -84,15 +79,13 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.OrderViewState == 0 {
|
if m.OrderViewState == 0 {
|
||||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
hintStyle := m.theme.TextDim()
|
||||||
lines += "\n" + hintStyle.Render("enter: browse orders")
|
lines += "\n" + hintStyle.Render("enter: browse orders")
|
||||||
}
|
}
|
||||||
detailView = lines
|
detailView = lines
|
||||||
}
|
}
|
||||||
case "faq":
|
case "faq":
|
||||||
questionStyle := lipgloss.NewStyle().
|
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||||
Foreground(lipgloss.Color("#4682B4")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
faqContent := ""
|
faqContent := ""
|
||||||
for i, faq := range m.FAQs {
|
for i, faq := range m.FAQs {
|
||||||
@@ -104,16 +97,12 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
|||||||
}
|
}
|
||||||
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
|
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||||
if !m.FaqFocused {
|
if !m.FaqFocused {
|
||||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
hintStyle := m.theme.TextDim()
|
||||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||||
}
|
}
|
||||||
case "about":
|
case "about":
|
||||||
accentStyle := lipgloss.NewStyle().
|
accentStyle := m.theme.TextHighlight().Bold(true)
|
||||||
Foreground(lipgloss.Color("#4682B4")).
|
userStyle := m.theme.TextAccent().Bold(true)
|
||||||
Bold(true)
|
|
||||||
userStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("C6DDF0")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
aboutContent := contentStyle.Render(wordWrap(
|
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:",
|
"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:",
|
||||||
@@ -199,16 +188,11 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
|||||||
// Shows order number, date, status, total, and the first 2 items as a preview.
|
// 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 {
|
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
|
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
|
||||||
nameStyle := lipgloss.NewStyle().
|
nameStyle := m.theme.TextAccent().Bold(true)
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF"))
|
|
||||||
|
|
||||||
dimStyle := lipgloss.NewStyle().
|
dimStyle := m.theme.TextDim()
|
||||||
Foreground(lipgloss.Color("#666666"))
|
|
||||||
|
|
||||||
statusStyle := lipgloss.NewStyle().
|
statusStyle := m.theme.TextHighlight().Bold(true)
|
||||||
Foreground(lipgloss.Color("#4682B4")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
|
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
|
||||||
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
|
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
|
||||||
@@ -254,13 +238,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
|
|||||||
if isSelected {
|
if isSelected {
|
||||||
itemBox = lipgloss.NewStyle().
|
itemBox = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), true).
|
Border(lipgloss.NormalBorder(), true).
|
||||||
BorderForeground(lipgloss.Color("#FFFFFF")).
|
BorderForeground(m.theme.Accent()).
|
||||||
Padding(boxPadding, 2).
|
Padding(boxPadding, 2).
|
||||||
Width(boxWidth)
|
Width(boxWidth)
|
||||||
} else {
|
} else {
|
||||||
itemBox = lipgloss.NewStyle().
|
itemBox = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("#666666")).
|
BorderForeground(m.theme.Border()).
|
||||||
Padding(boxPadding, 2).
|
Padding(boxPadding, 2).
|
||||||
Width(boxWidth)
|
Width(boxWidth)
|
||||||
}
|
}
|
||||||
@@ -274,20 +258,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
|
|||||||
|
|
||||||
// buildOrderDetailView renders the full detail view for a single order.
|
// buildOrderDetailView renders the full detail view for a single order.
|
||||||
func (m Model) buildOrderDetailView(order models.Order, width int) string {
|
func (m Model) buildOrderDetailView(order models.Order, width int) string {
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().
|
labelStyle := m.theme.TextHighlight().Bold(true)
|
||||||
Foreground(lipgloss.Color("#4682B4")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
valueStyle := lipgloss.NewStyle().
|
valueStyle := m.theme.TextBody()
|
||||||
Foreground(lipgloss.Color("#AAAAAA"))
|
|
||||||
|
|
||||||
dimStyle := lipgloss.NewStyle().
|
dimStyle := m.theme.TextDim()
|
||||||
Foreground(lipgloss.Color("#666666"))
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
@@ -366,12 +343,8 @@ func (m Model) computeFaqScrollMax() int {
|
|||||||
detailContentWidth = m.widthContent - 2
|
detailContentWidth = m.widthContent - 2
|
||||||
}
|
}
|
||||||
|
|
||||||
questionStyle := lipgloss.NewStyle().
|
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||||
Foreground(lipgloss.Color("#4682B4")).
|
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||||
Bold(true)
|
|
||||||
contentStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#AAAAAA")).
|
|
||||||
Width(detailContentWidth)
|
|
||||||
|
|
||||||
faqContent := ""
|
faqContent := ""
|
||||||
for i, faq := range m.FAQs {
|
for i, faq := range m.FAQs {
|
||||||
@@ -382,10 +355,7 @@ func (m Model) computeFaqScrollMax() int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
MarginBottom(2)
|
|
||||||
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||||
|
|
||||||
totalLines := len(strings.Split(detailView, "\n"))
|
totalLines := len(strings.Split(detailView, "\n"))
|
||||||
|
|||||||
@@ -10,15 +10,9 @@ func (m Model) BuildBreadcrumbs() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
activeStyle := lipgloss.NewStyle().
|
activeStyle := m.theme.TextAccent().Bold(true)
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
inactiveStyle := m.theme.TextDim()
|
||||||
Bold(true)
|
sepStyle := m.theme.TextDim()
|
||||||
|
|
||||||
inactiveStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#666666"))
|
|
||||||
|
|
||||||
sepStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#666666"))
|
|
||||||
|
|
||||||
sep := sepStyle.Render(" / ")
|
sep := sepStyle.Render(" / ")
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ func (m Model) updateCartViewport() Model {
|
|||||||
|
|
||||||
func (m Model) generateCartContent() string {
|
func (m Model) generateCartContent() string {
|
||||||
if len(m.Cart) == 0 {
|
if len(m.Cart) == 0 {
|
||||||
emptyStyle := lipgloss.NewStyle().
|
emptyStyle := m.theme.TextDim().
|
||||||
Foreground(lipgloss.Color("#666666")).
|
|
||||||
Align(lipgloss.Center).
|
Align(lipgloss.Center).
|
||||||
Width(m.widthContent).
|
Width(m.widthContent).
|
||||||
Padding(2, 0)
|
Padding(2, 0)
|
||||||
@@ -49,23 +48,17 @@ func (m Model) generateCartContent() string {
|
|||||||
for idx, item := range itemSlice {
|
for idx, item := range itemSlice {
|
||||||
itemTotal := item.Quantity * item.Coffee.Price
|
itemTotal := item.Quantity * item.Coffee.Price
|
||||||
|
|
||||||
nameStyle := lipgloss.NewStyle().
|
nameStyle := m.theme.TextAccent().Bold(true)
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#FFFFFF"))
|
|
||||||
|
|
||||||
individualPriceStyle := lipgloss.NewStyle().
|
individualPriceStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(item.Coffee.Color)).
|
Foreground(lipgloss.Color(item.Coffee.Color)).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
infoStyle := lipgloss.NewStyle().
|
infoStyle := m.theme.TextBody()
|
||||||
Foreground(lipgloss.Color("#AAAAAA"))
|
|
||||||
|
|
||||||
quantityStyle := lipgloss.NewStyle().
|
quantityStyle := m.theme.TextAccent()
|
||||||
Foreground(lipgloss.Color("#FFFFFF"))
|
|
||||||
|
|
||||||
totalPriceStyle := lipgloss.NewStyle().
|
totalPriceStyle := m.theme.TextAccent().Bold(true)
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
nameText := nameStyle.Render(item.Coffee.Name)
|
nameText := nameStyle.Render(item.Coffee.Name)
|
||||||
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100))
|
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100))
|
||||||
@@ -97,18 +90,18 @@ func (m Model) generateCartContent() string {
|
|||||||
if idx == m.CartCursor {
|
if idx == m.CartCursor {
|
||||||
itemBox = lipgloss.NewStyle().
|
itemBox = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), true).
|
Border(lipgloss.NormalBorder(), true).
|
||||||
BorderForeground(lipgloss.Color("#FFFFFF")).
|
BorderForeground(m.theme.Accent()).
|
||||||
Padding(boxPadding, 2).
|
Padding(boxPadding, 2).
|
||||||
Width(boxWidth).
|
Width(boxWidth).
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
Foreground(m.theme.Accent()).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
} else {
|
} else {
|
||||||
itemBox = lipgloss.NewStyle().
|
itemBox = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("#666666")).
|
BorderForeground(m.theme.Border()).
|
||||||
Padding(boxPadding, 2).
|
Padding(boxPadding, 2).
|
||||||
Width(boxWidth).
|
Width(boxWidth).
|
||||||
Foreground(lipgloss.Color("#FFFFFF"))
|
Foreground(m.theme.Accent())
|
||||||
}
|
}
|
||||||
|
|
||||||
centered := lipgloss.NewStyle().
|
centered := lipgloss.NewStyle().
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -12,52 +10,42 @@ import (
|
|||||||
|
|
||||||
// RenderConfirmation renders the order confirmation view
|
// RenderConfirmation renders the order confirmation view
|
||||||
func (m Model) RenderConfirmation() string {
|
func (m Model) RenderConfirmation() string {
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Bold(true).
|
|
||||||
Padding(0, 0, 1, 0)
|
|
||||||
|
|
||||||
sectionStyle := lipgloss.NewStyle().
|
sectionStyle := m.theme.TextBody().Padding(0, 0, 1, 2)
|
||||||
Foreground(lipgloss.Color("#CCCCCC")).
|
|
||||||
Padding(0, 0, 1, 2)
|
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().
|
labelStyle := m.theme.TextDim()
|
||||||
Foreground(lipgloss.Color("#666666"))
|
|
||||||
|
|
||||||
valueStyle := lipgloss.NewStyle().
|
valueStyle := m.theme.TextAccent()
|
||||||
Foreground(lipgloss.Color("#FFFFFF"))
|
|
||||||
|
|
||||||
successStyle := lipgloss.NewStyle().
|
successStyle := m.theme.TextSuccess().Bold(true).Padding(1, 0)
|
||||||
Foreground(lipgloss.Color("#00FF00")).
|
|
||||||
Bold(true).
|
|
||||||
Padding(1, 0)
|
|
||||||
|
|
||||||
title := titleStyle.Render("Order Confirmation")
|
title := titleStyle.Render("Order Confirmation")
|
||||||
|
|
||||||
// Shipping summary
|
// Shipping summary
|
||||||
shippingSection := ""
|
shippingSection := ""
|
||||||
if m.ShippingInfo != nil {
|
if m.ConfirmShipping != nil {
|
||||||
shippingSection = sectionStyle.Render(
|
shippingSection = sectionStyle.Render(
|
||||||
labelStyle.Render("Ship to: ") +
|
labelStyle.Render("Ship to: ") +
|
||||||
valueStyle.Render(m.ShippingInfo.Name) + "\n" +
|
valueStyle.Render(m.ConfirmShipping.Name) + "\n" +
|
||||||
labelStyle.Render(" ") +
|
labelStyle.Render(" ") +
|
||||||
valueStyle.Render(m.ShippingInfo.Street) + "\n" +
|
valueStyle.Render(m.ConfirmShipping.Street) + "\n" +
|
||||||
labelStyle.Render(" ") +
|
labelStyle.Render(" ") +
|
||||||
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)),
|
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.Country)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cart summary
|
// Items summary
|
||||||
cartSection := ""
|
cartSection := ""
|
||||||
total := 0
|
for _, item := range m.ConfirmItems {
|
||||||
items := m.GetCartItemsSlice()
|
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
|
||||||
for _, item := range 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"
|
cartSection += sectionStyle.Render(line) + "\n"
|
||||||
total += item.Coffee.Price * item.Quantity
|
|
||||||
}
|
}
|
||||||
cartSection += sectionStyle.Render(
|
cartSection += sectionStyle.Render(
|
||||||
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(total)/100)),
|
labelStyle.Render("Shipping: ") + valueStyle.Render("$0.00"),
|
||||||
|
) + "\n"
|
||||||
|
cartSection += sectionStyle.Render(
|
||||||
|
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)),
|
||||||
)
|
)
|
||||||
|
|
||||||
success := successStyle.Render("Order placed successfully!")
|
success := successStyle.Render("Order placed successfully!")
|
||||||
@@ -122,10 +110,10 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m = m.SwitchPage(shopPage)
|
m = m.SwitchPage(shopPage)
|
||||||
m.ShippingInfo = nil
|
|
||||||
m.CheckingOut = false
|
m.CheckingOut = false
|
||||||
m.Cart = make(map[uint]*models.CartItem)
|
m.ConfirmTotal = 0
|
||||||
m.CartCursor = 0
|
m.ConfirmItems = nil
|
||||||
|
m.ConfirmShipping = nil
|
||||||
m = m.resetPageState()
|
m = m.resetPageState()
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
if m.APIClient != nil {
|
if m.APIClient != nil {
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ import (
|
|||||||
|
|
||||||
func (m Model) BuildFooter() string {
|
func (m Model) BuildFooter() string {
|
||||||
// Style for keybinds (bold)
|
// Style for keybinds (bold)
|
||||||
keybindStyle := lipgloss.NewStyle().
|
keybindStyle := m.theme.TextDim().Bold(true)
|
||||||
Foreground(lipgloss.Color("#666666")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
// Style for descriptions (normal)
|
// Style for descriptions (normal)
|
||||||
descStyle := lipgloss.NewStyle().
|
descStyle := m.theme.TextDim()
|
||||||
Foreground(lipgloss.Color("#666666"))
|
|
||||||
|
|
||||||
// On small terminals, only show the menu hint
|
// On small terminals, only show the menu hint
|
||||||
if m.size == small {
|
if m.size == small {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ func (m Model) BuildHeader() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define colors
|
// Define colors
|
||||||
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
|
grayStyle := m.theme.TextBody()
|
||||||
boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
boldWhiteStyle := m.theme.TextAccent().Bold(true)
|
||||||
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
|
boldGrayStyle := m.theme.TextBody().Bold(true)
|
||||||
|
|
||||||
// Build cart tab (used in all sizes)
|
// Build cart tab (used in all sizes)
|
||||||
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
|
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
|
||||||
@@ -51,10 +51,10 @@ func (m Model) BuildHeader() string {
|
|||||||
case medium:
|
case medium:
|
||||||
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
|
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
|
||||||
logo := boldWhiteStyle.Render("terminal coffee")
|
logo := boldWhiteStyle.Render("terminal coffee")
|
||||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
separator := m.theme.TextAccent().Render("|")
|
||||||
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
|
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
|
||||||
default:
|
default:
|
||||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
separator := m.theme.TextAccent().Render("|")
|
||||||
|
|
||||||
var shopKeybind, shopName string
|
var shopKeybind, shopName string
|
||||||
if m.currentPage == shopPage {
|
if m.currentPage == shopPage {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) BuildHelpView() string {
|
func (m Model) BuildHelpView() 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()
|
||||||
header := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4")).Bold(true)
|
header := m.theme.TextHighlight().Bold(true)
|
||||||
rowStyle := lipgloss.NewStyle().Padding(0, 1)
|
rowStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
sections := []struct {
|
sections := []struct {
|
||||||
@@ -76,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")
|
logo := logoStyle.Render("keybindings")
|
||||||
|
|
||||||
modal := lipgloss.NewStyle().
|
modal := lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("#666666")).
|
BorderForeground(m.theme.Border()).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
Render(content)
|
Render(content)
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
|
|
||||||
|
|
||||||
func (m Model) BuildMenuView() string {
|
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{
|
rows := []string{
|
||||||
@@ -28,10 +28,10 @@ func (m Model) BuildMenuView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
logoStyle := m.theme.TextAccent().Bold(true)
|
||||||
logo := logoStyle.Render("terminal coffee")
|
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")
|
hint := base.Render("press esc to close")
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/stripe/stripe-go/v78"
|
"github.com/stripe/stripe-go/v78"
|
||||||
stripetoken "github.com/stripe/stripe-go/v78/token"
|
stripetoken "github.com/stripe/stripe-go/v78/token"
|
||||||
|
|
||||||
"terminalShop/pkg/api"
|
"terminalShop/pkg/api"
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
|
"terminalShop/pkg/tui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
// termSize represents the terminal size category for responsive layout.
|
// termSize represents the terminal size category for responsive layout.
|
||||||
@@ -101,6 +103,10 @@ type Model struct {
|
|||||||
ErrorMsg string // error message if API fetch fails
|
ErrorMsg string // error message if API fetch fails
|
||||||
APIClient *api.Client
|
APIClient *api.Client
|
||||||
|
|
||||||
|
// Rendering
|
||||||
|
renderer *lipgloss.Renderer
|
||||||
|
theme theme.Theme
|
||||||
|
|
||||||
// Checkout state
|
// Checkout state
|
||||||
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
|
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
|
||||||
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
|
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
|
||||||
@@ -130,6 +136,11 @@ type Model struct {
|
|||||||
splashDelayDone bool // true when minimum display time has elapsed
|
splashDelayDone bool // true when minimum display time has elapsed
|
||||||
splashCursor bool // toggles for blinking cursor animation
|
splashCursor bool // toggles for blinking cursor animation
|
||||||
|
|
||||||
|
// Confirmation screen — snapshot populated on checkout, cleared when leaving confirm page
|
||||||
|
ConfirmTotal int // server-confirmed order total in cents
|
||||||
|
ConfirmItems []models.CartItem // cart items at time of purchase
|
||||||
|
ConfirmShipping *models.Address // shipping address at time of purchase
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type shopState struct {
|
type shopState struct {
|
||||||
@@ -610,11 +621,18 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModel creates a new model with default coffee options
|
// NewModel creates a new model using the default renderer.
|
||||||
func NewModel(username string) Model {
|
func NewModel(username string) Model {
|
||||||
|
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newModelWithRenderer creates a new model with the given renderer.
|
||||||
|
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||||
m := Model{
|
m := Model{
|
||||||
Username: username,
|
Username: username,
|
||||||
currentPage: splashPage,
|
currentPage: splashPage,
|
||||||
|
renderer: renderer,
|
||||||
|
theme: theme.BasicTheme(renderer),
|
||||||
Coffees: []models.Coffee{
|
Coffees: []models.Coffee{
|
||||||
{
|
{
|
||||||
Name: "Espresso",
|
Name: "Espresso",
|
||||||
@@ -684,9 +702,9 @@ func NewModel(username string) Model {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModelWithAuth creates a new model with user authentication context
|
// NewModelWithAuth creates a new model with user authentication context.
|
||||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||||
m := NewModel("")
|
m := newModelWithRenderer("", renderer)
|
||||||
m.Fingerprint = fingerprint
|
m.Fingerprint = fingerprint
|
||||||
m.SSHPublicKeyStr = pubKeyStr
|
m.SSHPublicKeyStr = pubKeyStr
|
||||||
m.AuthFingerprintKey = clientSecret
|
m.AuthFingerprintKey = clientSecret
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
|
|||||||
),
|
),
|
||||||
).
|
).
|
||||||
WithShowErrors(false).
|
WithShowErrors(false).
|
||||||
WithShowHelp(false)
|
WithShowHelp(false).
|
||||||
|
WithTheme(m.theme.Form())
|
||||||
|
|
||||||
if m.size < medium {
|
if m.size < medium {
|
||||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||||
@@ -236,17 +237,10 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
|
|||||||
// RenderPaymentForm renders the payment form view
|
// RenderPaymentForm renders the payment form view
|
||||||
func (m Model) RenderPaymentForm(state *PaymentFormState) string {
|
func (m Model) RenderPaymentForm(state *PaymentFormState) string {
|
||||||
if state.submitting {
|
if state.submitting {
|
||||||
loadingStyle := lipgloss.NewStyle().
|
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing payment...")
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Bold(true).
|
|
||||||
Padding(1)
|
|
||||||
return loadingStyle.Render("Processing payment...")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Bold(true).
|
|
||||||
Padding(0, 0, 1, 0)
|
|
||||||
|
|
||||||
cardLast4 := ""
|
cardLast4 := ""
|
||||||
if len(state.CardNumber) >= 4 {
|
if len(state.CardNumber) >= 4 {
|
||||||
@@ -330,10 +324,10 @@ func (m Model) PaymentPageView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) RenderCardList() string {
|
func (m Model) RenderCardList() 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)
|
||||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
activeStyle := m.theme.TextAccent().Bold(true)
|
||||||
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
inactiveStyle := m.theme.TextDim()
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
labelStyle := m.theme.TextLabel()
|
||||||
|
|
||||||
title := titleStyle.Render("Select Payment Method")
|
title := titleStyle.Render("Select Payment Method")
|
||||||
|
|
||||||
@@ -378,8 +372,8 @@ func (m Model) RenderCardList() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderPaymentHttpsView() string {
|
func (m Model) renderPaymentHttpsView() string {
|
||||||
baseStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
baseStyle := m.theme.TextLabel()
|
||||||
accentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4"))
|
accentStyle := m.theme.TextHighlight()
|
||||||
|
|
||||||
if m.CollectURL == nil {
|
if m.CollectURL == nil {
|
||||||
return baseStyle.Render(" generating payment link...")
|
return baseStyle.Render(" generating payment link...")
|
||||||
@@ -436,11 +430,19 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
m.PaymentForm = m.InitPaymentForm()
|
m.PaymentForm = m.InitPaymentForm()
|
||||||
return m, m.PaymentForm.form.Init()
|
return m, m.PaymentForm.form.Init()
|
||||||
}
|
}
|
||||||
m = m.SwitchPage(confirmPage)
|
// Snapshot order details before clearing cart/shipping
|
||||||
m.OrdersLoaded = false
|
m.ConfirmTotal = msg.Total
|
||||||
|
for _, item := range m.GetCartItemsSlice() {
|
||||||
|
m.ConfirmItems = append(m.ConfirmItems, *item)
|
||||||
|
}
|
||||||
|
m.ConfirmShipping = m.ShippingInfo
|
||||||
|
|
||||||
|
// Clear cart and shipping immediately so they don't appear stale elsewhere
|
||||||
m.Cart = make(map[uint]*models.CartItem)
|
m.Cart = make(map[uint]*models.CartItem)
|
||||||
m.CartCursor = 0
|
m.CartCursor = 0
|
||||||
m.ShippingInfo = nil
|
m.ShippingInfo = nil
|
||||||
|
m.OrdersLoaded = false
|
||||||
|
m = m.SwitchPage(confirmPage)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case PaymentFormErrorMsg:
|
case PaymentFormErrorMsg:
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
|||||||
),
|
),
|
||||||
).
|
).
|
||||||
WithShowErrors(false).
|
WithShowErrors(false).
|
||||||
WithShowHelp(false)
|
WithShowHelp(false).
|
||||||
|
WithTheme(m.theme.Form())
|
||||||
|
|
||||||
if m.size < medium {
|
if m.size < medium {
|
||||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||||
@@ -241,19 +242,10 @@ func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd
|
|||||||
// RenderShippingForm renders the shipping form view
|
// RenderShippingForm renders the shipping form view
|
||||||
func (m Model) RenderShippingForm(state *ShippingFormState) string {
|
func (m Model) RenderShippingForm(state *ShippingFormState) string {
|
||||||
if state.submitting {
|
if state.submitting {
|
||||||
loadingStyle := lipgloss.NewStyle().
|
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing shipping information...")
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Bold(true).
|
|
||||||
Padding(1)
|
|
||||||
return loadingStyle.Render("Processing shipping information...")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
title := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0).Render("Shipping Address")
|
||||||
Foreground(lipgloss.Color("#FFFFFF")).
|
|
||||||
Bold(true).
|
|
||||||
Padding(0, 0, 1, 0)
|
|
||||||
|
|
||||||
title := titleStyle.Render("Shipping Address")
|
|
||||||
form := state.form.View()
|
form := state.form.View()
|
||||||
|
|
||||||
return lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
@@ -312,10 +304,10 @@ func (m Model) ShippingPageView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) RenderAddressList() string {
|
func (m Model) RenderAddressList() 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)
|
||||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
activeStyle := m.theme.TextAccent().Bold(true)
|
||||||
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
inactiveStyle := m.theme.TextDim()
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
labelStyle := m.theme.TextLabel()
|
||||||
|
|
||||||
title := titleStyle.Render("Selected Shipping Address")
|
title := titleStyle.Render("Selected Shipping Address")
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (m Model) updateShopViewports() Model {
|
|||||||
func (m Model) getShopMenuContent() string {
|
func (m Model) getShopMenuContent() string {
|
||||||
menuWidth, _ := m.calculateShopWidths()
|
menuWidth, _ := m.calculateShopWidths()
|
||||||
|
|
||||||
selectedColor := lipgloss.Color("#4682B4")
|
selectedColor := m.theme.Highlight()
|
||||||
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
|
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
|
||||||
if c := m.Coffees[m.shop.selected].Color; c != "" {
|
if c := m.Coffees[m.shop.selected].Color; c != "" {
|
||||||
selectedColor = lipgloss.Color(c)
|
selectedColor = lipgloss.Color(c)
|
||||||
@@ -76,19 +76,17 @@ func (m Model) getShopMenuContent() string {
|
|||||||
var normal, highlighted lipgloss.Style
|
var normal, highlighted lipgloss.Style
|
||||||
if m.size < large {
|
if m.size < large {
|
||||||
menuWidth = m.widthContent
|
menuWidth = m.widthContent
|
||||||
normal = lipgloss.NewStyle().
|
normal = m.theme.TextBody().
|
||||||
|
Width(menuWidth - 1).Align(lipgloss.Center)
|
||||||
|
highlighted = m.theme.TextAccent().
|
||||||
Width(menuWidth - 1).Align(lipgloss.Center).
|
Width(menuWidth - 1).Align(lipgloss.Center).
|
||||||
Foreground(lipgloss.Color("#AAAAAA"))
|
Background(selectedColor).Bold(true)
|
||||||
highlighted = lipgloss.NewStyle().
|
|
||||||
Width(menuWidth - 1).Align(lipgloss.Center).
|
|
||||||
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
|
||||||
} else {
|
} else {
|
||||||
normal = lipgloss.NewStyle().
|
normal = m.theme.TextBody().
|
||||||
|
Width(menuWidth + 2).Padding(0, 1)
|
||||||
|
highlighted = m.theme.TextAccent().
|
||||||
Width(menuWidth + 2).Padding(0, 1).
|
Width(menuWidth + 2).Padding(0, 1).
|
||||||
Foreground(lipgloss.Color("#AAAAAA"))
|
Background(selectedColor).Bold(true)
|
||||||
highlighted = lipgloss.NewStyle().
|
|
||||||
Width(menuWidth + 2).Padding(0, 1).
|
|
||||||
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -118,8 +116,8 @@ func (m Model) getShopDetailContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
detail := lipgloss.JoinVertical(lipgloss.Left,
|
detail := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Render(coffee.Name),
|
m.theme.TextAccent().Bold(true).Render(coffee.Name),
|
||||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render(
|
m.theme.TextBody().Render(
|
||||||
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
|
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
|
||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
@@ -127,7 +125,7 @@ func (m Model) getShopDetailContent() string {
|
|||||||
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
|
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
|
||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Width(detailWidth).Render(coffee.Description),
|
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
|
||||||
"",
|
"",
|
||||||
fmt.Sprintf("- %d +", quantity),
|
fmt.Sprintf("- %d +", quantity),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ func (m Model) splashViewInitCmd() tea.Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) SplashView() string {
|
func (m Model) SplashView() string {
|
||||||
accent := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
|
accent := m.theme.TextBody().Bold(true)
|
||||||
|
|
||||||
var cursor string
|
var cursor string
|
||||||
if m.splashCursor {
|
if m.splashCursor {
|
||||||
cursor = lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Render(" ")
|
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
|
||||||
} else {
|
} else {
|
||||||
cursor = " "
|
cursor = " "
|
||||||
}
|
}
|
||||||
|
|||||||
39
pkg/tui/theme/huh.go
Normal file
39
pkg/tui/theme/huh.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HuhTheme creates a huh form theme using this project's color palette.
|
||||||
|
func HuhTheme(t Theme) *huh.Theme {
|
||||||
|
var th huh.Theme
|
||||||
|
|
||||||
|
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
|
||||||
|
|
||||||
|
f := &th.Focused
|
||||||
|
f.Base = t.renderer.NewStyle().
|
||||||
|
PaddingLeft(1).
|
||||||
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
|
BorderLeft(true).
|
||||||
|
BorderForeground(t.accent)
|
||||||
|
f.Title = t.renderer.NewStyle().Foreground(t.body)
|
||||||
|
f.Description = t.renderer.NewStyle().Foreground(t.body)
|
||||||
|
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
|
||||||
|
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
|
||||||
|
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
|
||||||
|
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
|
||||||
|
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
|
||||||
|
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
|
||||||
|
th.Help = help.New().Styles
|
||||||
|
|
||||||
|
th.Blurred = copyFieldStyles(*f)
|
||||||
|
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||||
|
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
|
||||||
|
|
||||||
|
return &th
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
|
||||||
|
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }
|
||||||
79
pkg/tui/theme/theme.go
Normal file
79
pkg/tui/theme/theme.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Theme holds all colors and styles for the terminal coffee shop TUI.
|
||||||
|
type Theme struct {
|
||||||
|
renderer *lipgloss.Renderer
|
||||||
|
|
||||||
|
background lipgloss.TerminalColor
|
||||||
|
border lipgloss.TerminalColor
|
||||||
|
body lipgloss.TerminalColor
|
||||||
|
dim lipgloss.TerminalColor
|
||||||
|
accent lipgloss.TerminalColor
|
||||||
|
highlight lipgloss.TerminalColor
|
||||||
|
brand lipgloss.TerminalColor
|
||||||
|
label lipgloss.TerminalColor
|
||||||
|
success lipgloss.TerminalColor
|
||||||
|
error lipgloss.TerminalColor
|
||||||
|
|
||||||
|
base lipgloss.Style
|
||||||
|
form *huh.Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicTheme constructs the default theme for terminal coffee shop.
|
||||||
|
func BasicTheme(renderer *lipgloss.Renderer) Theme {
|
||||||
|
t := Theme{renderer: renderer}
|
||||||
|
|
||||||
|
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
|
||||||
|
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
|
||||||
|
t.body = lipgloss.Color("#AAAAAA")
|
||||||
|
t.dim = lipgloss.Color("#666666")
|
||||||
|
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
|
||||||
|
t.highlight = lipgloss.Color("#4682B4")
|
||||||
|
t.brand = lipgloss.Color("#4682B4")
|
||||||
|
t.label = lipgloss.Color("#999999")
|
||||||
|
t.success = lipgloss.Color("#00FF00")
|
||||||
|
t.error = lipgloss.Color("196")
|
||||||
|
|
||||||
|
t.base = renderer.NewStyle().Foreground(t.body)
|
||||||
|
t.form = HuhTheme(t)
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color accessors — use in .Foreground() / .Background() calls.
|
||||||
|
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
|
||||||
|
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
|
||||||
|
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
|
||||||
|
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
|
||||||
|
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
|
||||||
|
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
|
||||||
|
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
|
||||||
|
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
|
||||||
|
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
|
||||||
|
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
|
||||||
|
|
||||||
|
// Style helpers — return a copy of base with the correct foreground.
|
||||||
|
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
|
||||||
|
func (t Theme) Base() lipgloss.Style { return t.base }
|
||||||
|
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
|
||||||
|
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
|
||||||
|
func (t Theme) TextDim() lipgloss.Style { return t.Base().Foreground(t.dim) }
|
||||||
|
func (t Theme) TextHighlight() lipgloss.Style { return t.Base().Foreground(t.highlight) }
|
||||||
|
func (t Theme) TextBrand() lipgloss.Style { return t.Base().Foreground(t.brand) }
|
||||||
|
func (t Theme) TextLabel() lipgloss.Style { return t.Base().Foreground(t.label) }
|
||||||
|
func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.success) }
|
||||||
|
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
|
||||||
|
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
|
||||||
|
|
||||||
|
// PanelError returns a style for the error banner: dark red bg, red fg.
|
||||||
|
func (t Theme) PanelError() lipgloss.Style {
|
||||||
|
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form returns the huh form theme for shipping and payment forms.
|
||||||
|
func (t Theme) Form() *huh.Theme { return t.form }
|
||||||
@@ -8,8 +8,7 @@ import (
|
|||||||
|
|
||||||
// ResizeView renders a message when the terminal is too small.
|
// ResizeView renders a message when the terminal is too small.
|
||||||
func (m Model) ResizeView() string {
|
func (m Model) ResizeView() string {
|
||||||
msg := lipgloss.NewStyle().
|
msg := m.theme.TextDim().
|
||||||
Foreground(lipgloss.Color("#666666")).
|
|
||||||
Align(lipgloss.Center).
|
Align(lipgloss.Center).
|
||||||
Render("Terminal too small.\nPlease resize.")
|
Render("Terminal too small.\nPlease resize.")
|
||||||
|
|
||||||
@@ -74,10 +73,10 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
var content string
|
var content string
|
||||||
if m.Loading {
|
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...")
|
content = loadingStyle.Render("Loading products from API...")
|
||||||
} else if m.ErrorMsg != "" {
|
} else if m.ErrorMsg != "" {
|
||||||
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Background(lipgloss.Color("52")).Padding(0, 1).MarginBottom(1)
|
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||||
errorBanner := errorStyle.Render(m.ErrorMsg)
|
errorBanner := errorStyle.Render(m.ErrorMsg)
|
||||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user