mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
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):**
|
||||
- [ ] Splash/loading screen on connect
|
||||
- [ ] Animated logo with blinking cursor
|
||||
- [ ] Subscriptions (recurring orders) — subscribe, manage, cancel
|
||||
- [ ] API token management page
|
||||
- [ ] OAuth app management 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) {
|
||||
renderer := bubbletea.MakeRenderer(s)
|
||||
|
||||
// Extract SSH public key from session
|
||||
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!)
|
||||
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func (Coffee) TableName() string {
|
||||
return "coffees"
|
||||
}
|
||||
|
||||
// AccountMenuItems something
|
||||
// AccountMenuItems
|
||||
var AccountMenuItems = []string{
|
||||
"order history",
|
||||
"faq",
|
||||
|
||||
@@ -19,15 +19,15 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
||||
for i, item := range models.AccountMenuItems {
|
||||
if m.AccountCursor == i {
|
||||
style := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#4682B4")).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(m.theme.Highlight()).
|
||||
Foreground(m.theme.Accent()).
|
||||
Padding(0, 1).
|
||||
Width(leftWidth - 2).
|
||||
Align(lipgloss.Left)
|
||||
leftPanel += style.Render(item) + "\n"
|
||||
} else {
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA")).
|
||||
Foreground(m.theme.Body()).
|
||||
Padding(0, 1).
|
||||
Width(leftWidth - 2).
|
||||
Align(lipgloss.Left)
|
||||
@@ -41,10 +41,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
||||
// Build the detail view (right panel) based on cursor position
|
||||
detailView := ""
|
||||
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) {
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
MarginBottom(2)
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
|
||||
// On small terminals, use full content width
|
||||
detailContentWidth := rightWidth - 2
|
||||
@@ -52,9 +49,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
||||
detailContentWidth = m.widthContent - 2
|
||||
}
|
||||
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA")).
|
||||
Width(detailContentWidth)
|
||||
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||
|
||||
selectedItem := models.AccountMenuItems[m.AccountCursor]
|
||||
switch selectedItem {
|
||||
@@ -84,15 +79,13 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
||||
}
|
||||
|
||||
if m.OrderViewState == 0 {
|
||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
hintStyle := m.theme.TextDim()
|
||||
lines += "\n" + hintStyle.Render("enter: browse orders")
|
||||
}
|
||||
detailView = lines
|
||||
}
|
||||
case "faq":
|
||||
questionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||
|
||||
faqContent := ""
|
||||
for i, faq := range m.FAQs {
|
||||
@@ -104,16 +97,12 @@ func (m Model) BuildAccountView(availableHeight int) string {
|
||||
}
|
||||
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
if !m.FaqFocused {
|
||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
hintStyle := m.theme.TextDim()
|
||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||
}
|
||||
case "about":
|
||||
accentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
userStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("C6DDF0")).
|
||||
Bold(true)
|
||||
accentStyle := m.theme.TextHighlight().Bold(true)
|
||||
userStyle := m.theme.TextAccent().Bold(true)
|
||||
|
||||
aboutContent := contentStyle.Render(wordWrap(
|
||||
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
|
||||
@@ -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.
|
||||
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
|
||||
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
|
||||
nameStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
nameStyle := m.theme.TextAccent().Bold(true)
|
||||
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
dimStyle := m.theme.TextDim()
|
||||
|
||||
statusStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
statusStyle := m.theme.TextHighlight().Bold(true)
|
||||
|
||||
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
|
||||
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
|
||||
@@ -254,13 +238,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
|
||||
if isSelected {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), true).
|
||||
BorderForeground(lipgloss.Color("#FFFFFF")).
|
||||
BorderForeground(m.theme.Accent()).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth)
|
||||
} else {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("#666666")).
|
||||
BorderForeground(m.theme.Border()).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth)
|
||||
}
|
||||
@@ -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.
|
||||
func (m Model) buildOrderDetailView(order models.Order, width int) string {
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
MarginBottom(1)
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
labelStyle := m.theme.TextHighlight().Bold(true)
|
||||
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA"))
|
||||
valueStyle := m.theme.TextBody()
|
||||
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
dimStyle := m.theme.TextDim()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -366,12 +343,8 @@ func (m Model) computeFaqScrollMax() int {
|
||||
detailContentWidth = m.widthContent - 2
|
||||
}
|
||||
|
||||
questionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#4682B4")).
|
||||
Bold(true)
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA")).
|
||||
Width(detailContentWidth)
|
||||
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||
|
||||
faqContent := ""
|
||||
for i, faq := range m.FAQs {
|
||||
@@ -382,10 +355,7 @@ func (m Model) computeFaqScrollMax() int {
|
||||
}
|
||||
}
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
MarginBottom(2)
|
||||
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||
|
||||
totalLines := len(strings.Split(detailView, "\n"))
|
||||
|
||||
@@ -10,15 +10,9 @@ func (m Model) BuildBreadcrumbs() string {
|
||||
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(" / ")
|
||||
|
||||
|
||||
@@ -29,8 +29,7 @@ func (m Model) updateCartViewport() Model {
|
||||
|
||||
func (m Model) generateCartContent() string {
|
||||
if len(m.Cart) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
emptyStyle := m.theme.TextDim().
|
||||
Align(lipgloss.Center).
|
||||
Width(m.widthContent).
|
||||
Padding(2, 0)
|
||||
@@ -49,23 +48,17 @@ func (m Model) generateCartContent() string {
|
||||
for idx, item := range itemSlice {
|
||||
itemTotal := item.Quantity * item.Coffee.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()
|
||||
|
||||
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)
|
||||
|
||||
nameText := nameStyle.Render(item.Coffee.Name)
|
||||
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100))
|
||||
@@ -97,18 +90,18 @@ func (m Model) generateCartContent() string {
|
||||
if idx == m.CartCursor {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), true).
|
||||
BorderForeground(lipgloss.Color("#FFFFFF")).
|
||||
BorderForeground(m.theme.Accent()).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Foreground(m.theme.Accent()).
|
||||
Bold(true)
|
||||
} else {
|
||||
itemBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("#666666")).
|
||||
BorderForeground(m.theme.Border()).
|
||||
Padding(boxPadding, 2).
|
||||
Width(boxWidth).
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
Foreground(m.theme.Accent())
|
||||
}
|
||||
|
||||
centered := lipgloss.NewStyle().
|
||||
|
||||
@@ -3,8 +3,6 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -12,52 +10,42 @@ import (
|
||||
|
||||
// RenderConfirmation renders the order confirmation view
|
||||
func (m Model) RenderConfirmation() string {
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(0, 0, 1, 0)
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
|
||||
sectionStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#CCCCCC")).
|
||||
Padding(0, 0, 1, 2)
|
||||
sectionStyle := m.theme.TextBody().Padding(0, 0, 1, 2)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
labelStyle := m.theme.TextDim()
|
||||
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
valueStyle := m.theme.TextAccent()
|
||||
|
||||
successStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00FF00")).
|
||||
Bold(true).
|
||||
Padding(1, 0)
|
||||
successStyle := m.theme.TextSuccess().Bold(true).Padding(1, 0)
|
||||
|
||||
title := titleStyle.Render("Order Confirmation")
|
||||
|
||||
// Shipping summary
|
||||
shippingSection := ""
|
||||
if m.ShippingInfo != nil {
|
||||
if m.ConfirmShipping != nil {
|
||||
shippingSection = sectionStyle.Render(
|
||||
labelStyle.Render("Ship to: ") +
|
||||
valueStyle.Render(m.ShippingInfo.Name) + "\n" +
|
||||
valueStyle.Render(m.ConfirmShipping.Name) + "\n" +
|
||||
labelStyle.Render(" ") +
|
||||
valueStyle.Render(m.ShippingInfo.Street) + "\n" +
|
||||
valueStyle.Render(m.ConfirmShipping.Street) + "\n" +
|
||||
labelStyle.Render(" ") +
|
||||
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)),
|
||||
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.Country)),
|
||||
)
|
||||
}
|
||||
|
||||
// Cart summary
|
||||
// Items summary
|
||||
cartSection := ""
|
||||
total := 0
|
||||
items := m.GetCartItemsSlice()
|
||||
for _, item := range items {
|
||||
for _, item := range m.ConfirmItems {
|
||||
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
|
||||
cartSection += sectionStyle.Render(line) + "\n"
|
||||
total += item.Coffee.Price * item.Quantity
|
||||
}
|
||||
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!")
|
||||
@@ -122,10 +110,10 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
m = m.SwitchPage(shopPage)
|
||||
m.ShippingInfo = nil
|
||||
m.CheckingOut = false
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.CartCursor = 0
|
||||
m.ConfirmTotal = 0
|
||||
m.ConfirmItems = nil
|
||||
m.ConfirmShipping = nil
|
||||
m = m.resetPageState()
|
||||
return m, func() tea.Msg {
|
||||
if m.APIClient != nil {
|
||||
|
||||
@@ -8,13 +8,10 @@ import (
|
||||
|
||||
func (m Model) BuildFooter() string {
|
||||
// Style for keybinds (bold)
|
||||
keybindStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
Bold(true)
|
||||
keybindStyle := m.theme.TextDim().Bold(true)
|
||||
|
||||
// Style for descriptions (normal)
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
descStyle := m.theme.TextDim()
|
||||
|
||||
// On small terminals, only show the menu hint
|
||||
if m.size == small {
|
||||
|
||||
@@ -16,9 +16,9 @@ func (m Model) BuildHeader() string {
|
||||
}
|
||||
|
||||
// Define colors
|
||||
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
|
||||
boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
|
||||
grayStyle := m.theme.TextBody()
|
||||
boldWhiteStyle := m.theme.TextAccent().Bold(true)
|
||||
boldGrayStyle := m.theme.TextBody().Bold(true)
|
||||
|
||||
// Build cart tab (used in all sizes)
|
||||
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
|
||||
@@ -51,10 +51,10 @@ func (m Model) BuildHeader() string {
|
||||
case medium:
|
||||
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
|
||||
logo := boldWhiteStyle.Render("terminal coffee")
|
||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
||||
separator := m.theme.TextAccent().Render("|")
|
||||
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
|
||||
default:
|
||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
||||
separator := m.theme.TextAccent().Render("|")
|
||||
|
||||
var shopKeybind, shopName string
|
||||
if m.currentPage == shopPage {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -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")
|
||||
|
||||
modal := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("#666666")).
|
||||
BorderForeground(m.theme.Border()).
|
||||
Padding(1, 2).
|
||||
Render(content)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
|
||||
func (m Model) BuildMenuView() string {
|
||||
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
bold := m.theme.TextAccent().Bold(true)
|
||||
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
|
||||
base := m.theme.TextBody()
|
||||
|
||||
rowStyle := lipgloss.NewStyle().Padding(0,1)
|
||||
rows := []string{
|
||||
@@ -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")
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
stripetoken "github.com/stripe/stripe-go/v78/token"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/tui/theme"
|
||||
)
|
||||
|
||||
// termSize represents the terminal size category for responsive layout.
|
||||
@@ -101,6 +103,10 @@ type Model struct {
|
||||
ErrorMsg string // error message if API fetch fails
|
||||
APIClient *api.Client
|
||||
|
||||
// Rendering
|
||||
renderer *lipgloss.Renderer
|
||||
theme theme.Theme
|
||||
|
||||
// Checkout state
|
||||
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping 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
|
||||
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 {
|
||||
@@ -610,11 +621,18 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
|
||||
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 {
|
||||
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
|
||||
}
|
||||
|
||||
// newModelWithRenderer creates a new model with the given renderer.
|
||||
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
|
||||
m := Model{
|
||||
Username: username,
|
||||
currentPage: splashPage,
|
||||
renderer: renderer,
|
||||
theme: theme.BasicTheme(renderer),
|
||||
Coffees: []models.Coffee{
|
||||
{
|
||||
Name: "Espresso",
|
||||
@@ -684,9 +702,9 @@ func NewModel(username string) Model {
|
||||
return m
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context
|
||||
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := NewModel("")
|
||||
// NewModelWithAuth creates a new model with user authentication context.
|
||||
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
|
||||
m := newModelWithRenderer("", renderer)
|
||||
m.Fingerprint = fingerprint
|
||||
m.SSHPublicKeyStr = pubKeyStr
|
||||
m.AuthFingerprintKey = clientSecret
|
||||
|
||||
@@ -99,7 +99,8 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
|
||||
),
|
||||
).
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false)
|
||||
WithShowHelp(false).
|
||||
WithTheme(m.theme.Form())
|
||||
|
||||
if m.size < medium {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
@@ -236,17 +237,10 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
|
||||
// RenderPaymentForm renders the payment form view
|
||||
func (m Model) RenderPaymentForm(state *PaymentFormState) string {
|
||||
if state.submitting {
|
||||
loadingStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(1)
|
||||
return loadingStyle.Render("Processing payment...")
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing payment...")
|
||||
}
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(0, 0, 1, 0)
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
|
||||
cardLast4 := ""
|
||||
if len(state.CardNumber) >= 4 {
|
||||
@@ -330,10 +324,10 @@ func (m Model) PaymentPageView() string {
|
||||
}
|
||||
|
||||
func (m Model) RenderCardList() string {
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
|
||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
activeStyle := m.theme.TextAccent().Bold(true)
|
||||
inactiveStyle := m.theme.TextDim()
|
||||
labelStyle := m.theme.TextLabel()
|
||||
|
||||
title := titleStyle.Render("Select Payment Method")
|
||||
|
||||
@@ -378,8 +372,8 @@ func (m Model) RenderCardList() string {
|
||||
}
|
||||
|
||||
func (m Model) renderPaymentHttpsView() string {
|
||||
baseStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
||||
accentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4"))
|
||||
baseStyle := m.theme.TextLabel()
|
||||
accentStyle := m.theme.TextHighlight()
|
||||
|
||||
if m.CollectURL == nil {
|
||||
return baseStyle.Render(" generating payment link...")
|
||||
@@ -436,11 +430,19 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
return m, m.PaymentForm.form.Init()
|
||||
}
|
||||
m = m.SwitchPage(confirmPage)
|
||||
m.OrdersLoaded = false
|
||||
// Snapshot order details before clearing cart/shipping
|
||||
m.ConfirmTotal = msg.Total
|
||||
for _, item := range m.GetCartItemsSlice() {
|
||||
m.ConfirmItems = append(m.ConfirmItems, *item)
|
||||
}
|
||||
m.ConfirmShipping = m.ShippingInfo
|
||||
|
||||
// Clear cart and shipping immediately so they don't appear stale elsewhere
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.CartCursor = 0
|
||||
m.ShippingInfo = nil
|
||||
m.OrdersLoaded = false
|
||||
m = m.SwitchPage(confirmPage)
|
||||
return m, nil
|
||||
|
||||
case PaymentFormErrorMsg:
|
||||
|
||||
@@ -121,7 +121,8 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
||||
),
|
||||
).
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false)
|
||||
WithShowHelp(false).
|
||||
WithTheme(m.theme.Form())
|
||||
|
||||
if m.size < medium {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
@@ -241,19 +242,10 @@ func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd
|
||||
// RenderShippingForm renders the shipping form view
|
||||
func (m Model) RenderShippingForm(state *ShippingFormState) string {
|
||||
if state.submitting {
|
||||
loadingStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(1)
|
||||
return loadingStyle.Render("Processing shipping information...")
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing shipping information...")
|
||||
}
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(0, 0, 1, 0)
|
||||
|
||||
title := titleStyle.Render("Shipping Address")
|
||||
title := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0).Render("Shipping Address")
|
||||
form := state.form.View()
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
@@ -312,10 +304,10 @@ func (m Model) ShippingPageView() string {
|
||||
}
|
||||
|
||||
func (m Model) RenderAddressList() string {
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0)
|
||||
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
|
||||
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
|
||||
activeStyle := m.theme.TextAccent().Bold(true)
|
||||
inactiveStyle := m.theme.TextDim()
|
||||
labelStyle := m.theme.TextLabel()
|
||||
|
||||
title := titleStyle.Render("Selected Shipping Address")
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func (m Model) updateShopViewports() Model {
|
||||
func (m Model) getShopMenuContent() string {
|
||||
menuWidth, _ := m.calculateShopWidths()
|
||||
|
||||
selectedColor := lipgloss.Color("#4682B4")
|
||||
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)
|
||||
@@ -76,19 +76,17 @@ func (m Model) getShopMenuContent() string {
|
||||
var normal, highlighted lipgloss.Style
|
||||
if m.size < large {
|
||||
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).
|
||||
Foreground(lipgloss.Color("#AAAAAA"))
|
||||
highlighted = lipgloss.NewStyle().
|
||||
Width(menuWidth - 1).Align(lipgloss.Center).
|
||||
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
Background(selectedColor).Bold(true)
|
||||
} else {
|
||||
normal = lipgloss.NewStyle().
|
||||
normal = m.theme.TextBody().
|
||||
Width(menuWidth + 2).Padding(0, 1)
|
||||
highlighted = m.theme.TextAccent().
|
||||
Width(menuWidth + 2).Padding(0, 1).
|
||||
Foreground(lipgloss.Color("#AAAAAA"))
|
||||
highlighted = lipgloss.NewStyle().
|
||||
Width(menuWidth + 2).Padding(0, 1).
|
||||
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
Background(selectedColor).Bold(true)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
@@ -118,8 +116,8 @@ func (m Model) getShopDetailContent() string {
|
||||
}
|
||||
|
||||
detail := lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Render(coffee.Name),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render(
|
||||
m.theme.TextAccent().Bold(true).Render(coffee.Name),
|
||||
m.theme.TextBody().Render(
|
||||
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),
|
||||
),
|
||||
"",
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Width(detailWidth).Render(coffee.Description),
|
||||
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
|
||||
"",
|
||||
fmt.Sprintf("- %d +", quantity),
|
||||
)
|
||||
|
||||
@@ -45,11 +45,11 @@ func (m Model) splashViewInitCmd() tea.Msg {
|
||||
}
|
||||
|
||||
func (m Model) SplashView() string {
|
||||
accent := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
|
||||
accent := m.theme.TextBody().Bold(true)
|
||||
|
||||
var cursor string
|
||||
if m.splashCursor {
|
||||
cursor = lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Render(" ")
|
||||
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
|
||||
} else {
|
||||
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.
|
||||
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.")
|
||||
|
||||
@@ -74,10 +73,10 @@ func (m Model) View() string {
|
||||
|
||||
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)
|
||||
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
|
||||
errorBanner := errorStyle.Render(m.ErrorMsg)
|
||||
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user