diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..30f430c --- /dev/null +++ b/Procfile @@ -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 diff --git a/README.md b/README.md index 05d3a95..d4cbf05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/gap-analysis.md b/docs/gap-analysis.md new file mode 100644 index 0000000..d2efee3 --- /dev/null +++ b/docs/gap-analysis.md @@ -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) diff --git a/docs/stripe-js-card-form.md b/docs/stripe-js-card-form.md new file mode 100644 index 0000000..0d47c68 --- /dev/null +++ b/docs/stripe-js-card-form.md @@ -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 + + + + + + +
+
+ +
+ + + + +``` + +### 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` diff --git a/docs/theme-implementation.md b/docs/theme-implementation.md new file mode 100644 index 0000000..0dc53a7 --- /dev/null +++ b/docs/theme-implementation.md @@ -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 +``` diff --git a/main.go b/main.go index 915b9c0..d51cbb3 100644 --- a/main.go +++ b/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()} } diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go index e992e1b..eb9550c 100644 --- a/pkg/models/coffee.go +++ b/pkg/models/coffee.go @@ -26,7 +26,7 @@ func (Coffee) TableName() string { return "coffees" } -// AccountMenuItems something +// AccountMenuItems var AccountMenuItems = []string{ "order history", "faq", diff --git a/pkg/tui/account.go b/pkg/tui/account.go index b670f22..5a92bfa 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -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")) diff --git a/pkg/tui/breadcrumbs.go b/pkg/tui/breadcrumbs.go index d0d3d09..189d7f7 100644 --- a/pkg/tui/breadcrumbs.go +++ b/pkg/tui/breadcrumbs.go @@ -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(" / ") diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index deb72a8..d886f83 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -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(). diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 6ea6900..8cd123b 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -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 { - line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price * item.Quantity)/100) + 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 { diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go index 0a2f85c..67a2982 100644 --- a/pkg/tui/footer.go +++ b/pkg/tui/footer.go @@ -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 { diff --git a/pkg/tui/header.go b/pkg/tui/header.go index 93ab651..dcc9278 100644 --- a/pkg/tui/header.go +++ b/pkg/tui/header.go @@ -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 { diff --git a/pkg/tui/help.go b/pkg/tui/help.go index 9c6cc3e..d93c53b 100644 --- a/pkg/tui/help.go +++ b/pkg/tui/help.go @@ -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) diff --git a/pkg/tui/menu.go b/pkg/tui/menu.go index 54170ea..73bed7d 100644 --- a/pkg/tui/menu.go +++ b/pkg/tui/menu.go @@ -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") diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 07c7fb0..18a03a9 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -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 diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 67aa8e1..0150703 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -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: diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index aa4abda..8cec442 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -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") diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 62b7a11..578c15a 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -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), ) diff --git a/pkg/tui/splash.go b/pkg/tui/splash.go index 3e6358d..7550084 100644 --- a/pkg/tui/splash.go +++ b/pkg/tui/splash.go @@ -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 = " " } diff --git a/pkg/tui/theme/huh.go b/pkg/tui/theme/huh.go new file mode 100644 index 0000000..5f28d29 --- /dev/null +++ b/pkg/tui/theme/huh.go @@ -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 } diff --git a/pkg/tui/theme/theme.go b/pkg/tui/theme/theme.go new file mode 100644 index 0000000..b7272a1 --- /dev/null +++ b/pkg/tui/theme/theme.go @@ -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 } diff --git a/pkg/tui/views.go b/pkg/tui/views.go index cf68d5c..1e79f44 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -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 {