Compare commits
2 Commits
docs/packa
...
fix/temp-d
| Author | SHA1 | Date | |
|---|---|---|---|
| 88bbdd2c5d | |||
| efa2d83cab |
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -22,15 +22,6 @@ jobs:
|
|||||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||||
port: ${{ secrets.SERVER_PORT }}
|
port: ${{ secrets.SERVER_PORT }}
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
|
||||||
cd ~/terminalShop
|
cd ~/terminalShop
|
||||||
git fetch origin master
|
git pull
|
||||||
git reset --hard origin/master
|
|
||||||
deployed=$(git rev-parse HEAD)
|
|
||||||
expected="${{ github.sha }}"
|
|
||||||
if [ "$deployed" != "$expected" ]; then
|
|
||||||
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
echo "deploy verified: HEAD=$deployed"
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
docs/sca-psd2-compliance.md
|
docs/sca-psd2-compliance.md
|
||||||
accept-a-payment/
|
accept-a-payment/
|
||||||
codemap/
|
|
||||||
|
|
||||||
|
|
||||||
FULFILLMENT.md
|
FULFILLMENT.md
|
||||||
|
|||||||
@@ -71,4 +71,3 @@ uses SQLite by default so there's nothing to spin up.
|
|||||||
## inspiration
|
## inspiration
|
||||||
|
|
||||||
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
||||||
# something
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Command api is the HTTP backend: it boots the router, bolts on all the
|
|
||||||
// middleware, and answers the requests the TUI throws at it.
|
|
||||||
package main
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package handlers is where the real work happens, one function per endpoint:
|
|
||||||
// carts, orders, products, addresses, cards, webhooks, and the rest.
|
|
||||||
package handlers
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// Package middleware holds the stuff that runs on every request before a
|
|
||||||
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
|
|
||||||
// recovery net for panics.
|
|
||||||
package middleware
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package routes is the switchboard: it maps every URL to the handler that
|
|
||||||
// knows what to do with it.
|
|
||||||
package routes
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
|
|
||||||
// about and detaches them. Run it with -dry-run first if you're nervous.
|
|
||||||
package main
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package api is the client side of the house: it talks to our HTTP backend
|
|
||||||
// so other Go code doesn't have to hand-roll requests.
|
|
||||||
package api
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package auth sorts out who you are: logging in with an SSH key and minting
|
|
||||||
// the JWT you carry around afterwards.
|
|
||||||
package auth
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package bring talks to Bring, the Norwegian postal service, for booking
|
|
||||||
// shipments and printing labels.
|
|
||||||
package bring
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package config reads everything out of the environment so secrets and
|
|
||||||
// settings stay out of the source code.
|
|
||||||
package config
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package database opens the SQLite connection, runs the migrations, and
|
|
||||||
// drops in some seed data so the shop isn't empty on a fresh boot.
|
|
||||||
package database
|
|
||||||
@@ -26,7 +26,6 @@ func Seed(db *gorm.DB) error {
|
|||||||
{
|
{
|
||||||
Name: "Espresso",
|
Name: "Espresso",
|
||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
RoastLevel: 5,
|
|
||||||
Ounces: 2,
|
Ounces: 2,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 1800, // $18.00
|
Price: 1800, // $18.00
|
||||||
@@ -36,7 +35,6 @@ func Seed(db *gorm.DB) error {
|
|||||||
{
|
{
|
||||||
Name: "Americano",
|
Name: "Americano",
|
||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
RoastLevel: 3,
|
|
||||||
Ounces: 8,
|
Ounces: 8,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 2200, // $22.00
|
Price: 2200, // $22.00
|
||||||
@@ -46,7 +44,6 @@ func Seed(db *gorm.DB) error {
|
|||||||
{
|
{
|
||||||
Name: "Cappuccino",
|
Name: "Cappuccino",
|
||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
RoastLevel: 3,
|
|
||||||
Ounces: 6,
|
Ounces: 6,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 2200, // $22.00
|
Price: 2200, // $22.00
|
||||||
@@ -56,7 +53,6 @@ func Seed(db *gorm.DB) error {
|
|||||||
{
|
{
|
||||||
Name: "Latte",
|
Name: "Latte",
|
||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
RoastLevel: 2,
|
|
||||||
Ounces: 12,
|
Ounces: 12,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 2500, // $25.00
|
Price: 2500, // $25.00
|
||||||
@@ -66,7 +62,6 @@ func Seed(db *gorm.DB) error {
|
|||||||
{
|
{
|
||||||
Name: "Mocha",
|
Name: "Mocha",
|
||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
RoastLevel: 4,
|
|
||||||
Ounces: 12,
|
Ounces: 12,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 3000, // $30.00
|
Price: 3000, // $30.00
|
||||||
@@ -76,7 +71,6 @@ func Seed(db *gorm.DB) error {
|
|||||||
{
|
{
|
||||||
Name: "Macchiato",
|
Name: "Macchiato",
|
||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
RoastLevel: 4,
|
|
||||||
Ounces: 3,
|
Ounces: 3,
|
||||||
BeanType: "Robusta Blend",
|
BeanType: "Robusta Blend",
|
||||||
Price: 1800, // $18.00
|
Price: 1800, // $18.00
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type Coffee struct {
|
|||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"size:255;not null" json:"name"`
|
Name string `gorm:"size:255;not null" json:"name"`
|
||||||
RoastType string `gorm:"size:100" json:"roast_type"`
|
RoastType string `gorm:"size:100" json:"roast_type"`
|
||||||
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
|
|
||||||
Ounces int `json:"ounces"`
|
Ounces int `json:"ounces"`
|
||||||
BeanType string `gorm:"size:100" json:"bean_type"`
|
BeanType string `gorm:"size:100" json:"bean_type"`
|
||||||
Price int `gorm:"not null" json:"price"`
|
Price int `gorm:"not null" json:"price"`
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package models is the shared vocabulary of the app: coffee, carts, orders,
|
|
||||||
// addresses, cards, users, and the other nouns everything passes around.
|
|
||||||
package models
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package observability sets up tracing and structured logs so that when
|
|
||||||
// something breaks at 3am you stand a chance of figuring out why.
|
|
||||||
package observability
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package shipping holds the plain shipping types that the rest of the app
|
|
||||||
// passes back and forth.
|
|
||||||
package shipping
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Package shippo talks to the Shippo API for shipping rates and labels.
|
|
||||||
package shippo
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
|
|
||||||
// baked in) so everyone else can just make calls.
|
|
||||||
package stripeclient
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
@@ -10,54 +8,36 @@ import (
|
|||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// accountBodyHeight returns the inner content height of the account body
|
// updateAccountViewport initializes or resizes the detail viewport for the account page.
|
||||||
// panels. The body is quantized to whole order cards so the menu panel's
|
// Follows the reference pattern: each page owns its own viewport(s).
|
||||||
// bottom border lines up exactly with the last visible card; the two gap
|
func (m Model) updateAccountViewport() Model {
|
||||||
// rows around the body host the up/down scroll arrows.
|
// Match the reference: only subtract header + footer, no extra margins
|
||||||
func (m Model) accountBodyHeight() int {
|
// (account is a viewport page — views.go renders it without margin padding)
|
||||||
headerHeight := lipgloss.Height(m.BuildHeader())
|
headerHeight := lipgloss.Height(m.BuildHeader())
|
||||||
footerHeight := lipgloss.Height(m.BuildFooter())
|
footerHeight := lipgloss.Height(m.BuildFooter())
|
||||||
budget := m.heightContainer - headerHeight - footerHeight - 2
|
verticalMarginHeight := headerHeight + footerHeight
|
||||||
cards := budget / orderCardHeight
|
|
||||||
if cards < 1 {
|
|
||||||
cards = 1
|
|
||||||
}
|
|
||||||
return cards*orderCardHeight - 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// accountDetailWidth returns the width of the unboxed detail area: the chrome
|
availableHeight := m.heightContainer - verticalMarginHeight
|
||||||
// width minus the menu panel and the gap. The detail side has no outer panel
|
|
||||||
// (its content draws its own boxes), so it reclaims the border + padding.
|
|
||||||
func (m Model) accountDetailWidth() int {
|
|
||||||
contentW, menuW, _, _ := m.shopLayout()
|
|
||||||
if m.size < large {
|
|
||||||
return contentW
|
|
||||||
}
|
|
||||||
return contentW - (menuW + 4) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAccountViewport initializes or resizes the detail viewport for the account page.
|
leftWidth := 18
|
||||||
// The viewport spans the unboxed detail area to the right of the menu panel.
|
rightWidth := m.widthContent - leftWidth - 2
|
||||||
func (m Model) updateAccountViewport() Model {
|
detailWidth := rightWidth
|
||||||
detailW := m.accountDetailWidth()
|
|
||||||
// +2: no panel on the detail side, so the viewport spans the same rows
|
|
||||||
// as the menu panel including its border rows — tops and bottoms align.
|
|
||||||
availableHeight := m.accountBodyHeight() + 2
|
|
||||||
if m.size < large {
|
if m.size < large {
|
||||||
// Stacked: the menu panel sits above the detail area
|
detailWidth = m.widthContent
|
||||||
menuPanelHeight := len(models.AccountMenuItems) + 2
|
// In stacked mode, reduce available height by the left panel
|
||||||
availableHeight -= menuPanelHeight
|
leftPanelHeight := len(models.AccountMenuItems) + 1
|
||||||
|
availableHeight -= leftPanelHeight
|
||||||
|
}
|
||||||
if availableHeight < 3 {
|
if availableHeight < 3 {
|
||||||
availableHeight = 3
|
availableHeight = 3
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !m.account.viewportReady {
|
if !m.account.viewportReady {
|
||||||
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
|
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
|
||||||
m.account.detailViewport.KeyMap = modifiedKeyMap
|
m.account.detailViewport.KeyMap = modifiedKeyMap
|
||||||
m.account.viewportReady = true
|
m.account.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.account.detailViewport.SetWidth(detailW)
|
m.account.detailViewport.SetWidth(detailWidth)
|
||||||
m.account.detailViewport.SetHeight(availableHeight)
|
m.account.detailViewport.SetHeight(availableHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +46,12 @@ func (m Model) updateAccountViewport() Model {
|
|||||||
|
|
||||||
// getAccountDetailContent generates the content string for the current detail panel.
|
// getAccountDetailContent generates the content string for the current detail panel.
|
||||||
func (m Model) getAccountDetailContent() string {
|
func (m Model) getAccountDetailContent() string {
|
||||||
detailContentWidth := m.accountDetailWidth()
|
leftWidth := 18
|
||||||
|
rightWidth := m.widthContent - leftWidth - 2
|
||||||
|
detailContentWidth := rightWidth - 2
|
||||||
|
if m.size < large {
|
||||||
|
detailContentWidth = m.widthContent - 2
|
||||||
|
}
|
||||||
|
|
||||||
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
|
||||||
return ""
|
return ""
|
||||||
@@ -83,6 +68,7 @@ func (m Model) getAccountDetailContent() string {
|
|||||||
case "cards":
|
case "cards":
|
||||||
return m.CardsView(detailContentWidth)
|
return m.CardsView(detailContentWidth)
|
||||||
case "faq":
|
case "faq":
|
||||||
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||||
questionStyle := m.theme.TextHighlight().Bold(true)
|
questionStyle := m.theme.TextHighlight().Bold(true)
|
||||||
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
contentStyle := m.theme.TextBody().Width(detailContentWidth)
|
||||||
faqContent := ""
|
faqContent := ""
|
||||||
@@ -93,7 +79,7 @@ func (m Model) getAccountDetailContent() string {
|
|||||||
faqContent += "\n\n"
|
faqContent += "\n\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
detailView := faqContent
|
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
|
||||||
if !m.account.faqFocused {
|
if !m.account.faqFocused {
|
||||||
hintStyle := m.theme.TextDim()
|
hintStyle := m.theme.TextDim()
|
||||||
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
|
||||||
@@ -117,25 +103,19 @@ func (m Model) scrollToAccountDetailItem() Model {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case m.account.orderViewState == 1:
|
case m.account.orderViewState == 1:
|
||||||
// Order list uses whole-card windowing instead of viewport scroll:
|
// No title in list mode, cards joined with JoinVertical (no gaps)
|
||||||
// shift the window just enough to keep the cursor's card visible.
|
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
|
||||||
page := m.orderListPageSize()
|
|
||||||
if m.account.orderCursor < m.account.orderWindowStart {
|
|
||||||
m.account.orderWindowStart = m.account.orderCursor
|
|
||||||
}
|
|
||||||
if m.account.orderCursor >= m.account.orderWindowStart+page {
|
|
||||||
m.account.orderWindowStart = m.account.orderCursor - page + 1
|
|
||||||
}
|
|
||||||
m.account.detailViewport.GotoTop()
|
|
||||||
return m
|
|
||||||
case m.account.addressListFocused:
|
|
||||||
// Titles were removed from the detail views, lists start at the top
|
|
||||||
headerOffset = 0
|
headerOffset = 0
|
||||||
|
itemHeight = 4
|
||||||
|
itemCount = len(m.currentOrderList())
|
||||||
|
selectedIndex = m.account.orderCursor
|
||||||
|
case m.account.addressListFocused:
|
||||||
|
headerOffset = 3
|
||||||
itemHeight = 5
|
itemHeight = 5
|
||||||
itemCount = len(m.SavedAddresses)
|
itemCount = len(m.SavedAddresses)
|
||||||
selectedIndex = m.account.addressCursor
|
selectedIndex = m.account.addressCursor
|
||||||
case m.account.cardListFocused:
|
case m.account.cardListFocused:
|
||||||
headerOffset = 0
|
headerOffset = 3
|
||||||
itemHeight = 4
|
itemHeight = 4
|
||||||
itemCount = len(m.SavedCards)
|
itemCount = len(m.SavedCards)
|
||||||
selectedIndex = m.account.cardCursor
|
selectedIndex = m.account.cardCursor
|
||||||
@@ -163,30 +143,23 @@ func (m Model) scrollToAccountDetailItem() Model {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) BuildAccountView() string {
|
func (m Model) BuildAccountView(availableHeight int) string {
|
||||||
_, menuW, _, _ := m.shopLayout()
|
leftWidth := 18
|
||||||
|
|
||||||
// Left panel: menu items. The highlight is blue while the user navigates
|
// Build the left panel (menu items)
|
||||||
// the menu, and falls back to the gray pill when focus has moved into the
|
leftPanel := ""
|
||||||
// detail area (browsing orders, managing addresses/cards, scrolling faq).
|
|
||||||
focusedInside := m.account.orderViewState > 0 || m.account.addressListFocused ||
|
|
||||||
m.account.cardListFocused || m.account.faqFocused
|
|
||||||
pillBg := m.theme.Highlight()
|
|
||||||
if focusedInside {
|
|
||||||
pillBg = cPill
|
|
||||||
}
|
|
||||||
var menu []string
|
|
||||||
for i, item := range models.AccountMenuItems {
|
for i, item := range models.AccountMenuItems {
|
||||||
if m.account.cursor == i {
|
if m.account.cursor == i {
|
||||||
menu = append(menu, lipgloss.NewStyle().Width(menuW).
|
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
||||||
Background(pillBg).Foreground(cWhite).Bold(true).
|
leftPanel += style.Render(item) + "\n"
|
||||||
Render(" "+item))
|
|
||||||
} else {
|
} else {
|
||||||
menu = append(menu, lipgloss.NewStyle().Width(menuW).
|
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
|
||||||
Render(" "+pBody.Render(item)))
|
leftPanel += style.Render(item) + "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
|
|
||||||
|
leftContainer := m.theme.Base().
|
||||||
|
Width(leftWidth)
|
||||||
|
|
||||||
// Generate detail content and set it on the viewport
|
// Generate detail content and set it on the viewport
|
||||||
detailContent := m.getAccountDetailContent()
|
detailContent := m.getAccountDetailContent()
|
||||||
@@ -195,43 +168,25 @@ func (m Model) BuildAccountView() string {
|
|||||||
// Viewport handles scrolling/clipping — no manual line-slicing needed
|
// Viewport handles scrolling/clipping — no manual line-slicing needed
|
||||||
detailRendered := m.account.detailViewport.View()
|
detailRendered := m.account.detailViewport.View()
|
||||||
|
|
||||||
// Responsive layout: menu panel beside the unboxed detail area on large,
|
// Responsive layout: side-by-side on large, stacked on small/medium
|
||||||
// stacked on small/medium. The detail side draws its own item boxes.
|
|
||||||
var body string
|
|
||||||
if m.size < large {
|
if m.size < large {
|
||||||
body = lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
panel(menuW, 0, menuContent),
|
leftContainer.Render(leftPanel),
|
||||||
detailRendered,
|
detailRendered,
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
bodyH := m.accountBodyHeight()
|
|
||||||
body = lipgloss.JoinHorizontal(
|
return lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
panel(menuW, bodyH, menuContent),
|
leftContainer.Render(leftPanel),
|
||||||
" ",
|
" ",
|
||||||
detailRendered,
|
detailRendered,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Blank rows separate the body from the header and footer boxes. When the
|
|
||||||
// windowed order list has hidden items, the arrows occupy those gap rows,
|
|
||||||
// centered over the card column, so the cards themselves always span
|
|
||||||
// exactly the menu panel's rows — top and bottom borders aligned.
|
|
||||||
topGap := ""
|
|
||||||
if above := m.orderHiddenAbove(); above > 0 && m.size >= large {
|
|
||||||
topGap = strings.Repeat(" ", menuW+5) +
|
|
||||||
orderScrollIndicator("↑", above, m.accountDetailWidth())
|
|
||||||
}
|
|
||||||
bottomGap := ""
|
|
||||||
if below := m.orderHiddenBelow(); below > 0 && m.size >= large {
|
|
||||||
bottomGap = strings.Repeat(" ", menuW+5) +
|
|
||||||
orderScrollIndicator("↓", below, m.accountDetailWidth())
|
|
||||||
}
|
|
||||||
return topGap + "\n" + body + "\n" + bottomGap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) AboutView(width int) string {
|
func (m Model) AboutView(width int) string {
|
||||||
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||||
contentStyle := m.theme.TextBody().Width(width)
|
contentStyle := m.theme.TextBody().Width(width)
|
||||||
// USE this if wanting to add a corp name later
|
// USE this if wanting to add a corp name later
|
||||||
// accentStyle := m.theme.TextHighlight().Bold(true)
|
// accentStyle := m.theme.TextHighlight().Bold(true)
|
||||||
@@ -253,7 +208,7 @@ func (m Model) AboutView(width int) string {
|
|||||||
width,
|
width,
|
||||||
)) + "\n"
|
)) + "\n"
|
||||||
|
|
||||||
return aboutContent
|
return titleStyle.Render("About") + "\n\n" + aboutContent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
@@ -380,7 +335,6 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
case 0:
|
case 0:
|
||||||
m.account.orderViewState = 1
|
m.account.orderViewState = 1
|
||||||
m.account.orderCursor = 0
|
m.account.orderCursor = 0
|
||||||
m.account.orderWindowStart = 0
|
|
||||||
m.account.detailViewport.GotoTop()
|
m.account.detailViewport.GotoTop()
|
||||||
m.footer = []footerCommand{
|
m.footer = []footerCommand{
|
||||||
{key: "j/k", value: "orders"},
|
{key: "j/k", value: "orders"},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
func (m Model) AddressesView(width int) string {
|
func (m Model) AddressesView(width int) string {
|
||||||
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||||
contentStyle := m.theme.TextBody().Width(width)
|
contentStyle := m.theme.TextBody().Width(width)
|
||||||
|
|
||||||
lines := ""
|
lines := titleStyle.Render("Address") + "\n\n"
|
||||||
if len(m.SavedAddresses) == 0 {
|
if len(m.SavedAddresses) == 0 {
|
||||||
lines += contentStyle.Render("No saved addresses")
|
lines += contentStyle.Render("No saved addresses")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) CardsView(width int) string {
|
func (m Model) CardsView(width int) string {
|
||||||
|
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
|
||||||
contentStyle := m.theme.TextBody().Width(width)
|
contentStyle := m.theme.TextBody().Width(width)
|
||||||
|
|
||||||
lines := ""
|
lines := titleSTyle.Render("Cards") + "\n\n"
|
||||||
if m.SavedCardsIsEmpty() {
|
if m.SavedCardsIsEmpty() {
|
||||||
lines += contentStyle.Render("No saved cards")
|
lines += contentStyle.Render("No saved cards")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) OrdersView(width int) string {
|
func (m Model) OrdersView(width int) string {
|
||||||
return m.orderListView(m.HistoryOrders(), width)
|
return m.orderListView("Order History", m.HistoryOrders(), width)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ActiveOrdersView(width int) string {
|
func (m Model) ActiveOrdersView(width int) string {
|
||||||
return m.orderListView(m.ActiveOrders(), width)
|
return m.orderListView("Active Orders", m.ActiveOrders(), width)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ActiveOrders() []models.Order {
|
func (m Model) ActiveOrders() []models.Order {
|
||||||
@@ -65,95 +65,41 @@ func (m Model) currentOrderList() []models.Order {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// orderCardHeight is the rendered height of one order card:
|
func (m Model) orderListView(title string, orders []models.Order, width int) string {
|
||||||
// border (2) + content lines (2), no vertical padding.
|
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
|
||||||
const orderCardHeight = 4
|
|
||||||
|
|
||||||
// orderListPageSize returns how many whole order cards fit in the detail
|
|
||||||
// area. Both scroll arrows render outside the list, in the gap rows above
|
|
||||||
// and below the body, so the cards use the full viewport height.
|
|
||||||
func (m Model) orderListPageSize() int {
|
|
||||||
n := m.account.detailViewport.Height() / orderCardHeight
|
|
||||||
if n < 1 {
|
|
||||||
n = 1
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderHiddenAbove returns how many order cards are scrolled off above the
|
|
||||||
// windowed list, or 0 when the windowed order list isn't showing.
|
|
||||||
func (m Model) orderHiddenAbove() int {
|
|
||||||
if m.account.orderViewState != 1 || !m.OrdersLoaded {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
orders := m.currentOrderList()
|
|
||||||
if len(orders) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
page := m.orderListPageSize()
|
|
||||||
start := m.account.orderWindowStart
|
|
||||||
if start > len(orders)-page {
|
|
||||||
start = len(orders) - page
|
|
||||||
}
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
return start
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderHiddenBelow returns how many order cards are scrolled off below the
|
|
||||||
// windowed list, or 0 when the windowed order list isn't showing.
|
|
||||||
func (m Model) orderHiddenBelow() int {
|
|
||||||
if m.account.orderViewState == 2 || !m.OrdersLoaded {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
orders := m.currentOrderList()
|
|
||||||
if len(orders) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize())
|
|
||||||
if below < 0 {
|
|
||||||
below = 0
|
|
||||||
}
|
|
||||||
return below
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderScrollIndicator renders the dim "more items" arrow row, centered
|
|
||||||
// across the card width.
|
|
||||||
func orderScrollIndicator(arrow string, count, width int) string {
|
|
||||||
return pDim.Width(width).Align(lipgloss.Center).
|
|
||||||
Render(fmt.Sprintf("%s %d more", arrow, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) orderListView(orders []models.Order, width int) string {
|
|
||||||
contentStyle := m.theme.TextBody().Width(width)
|
contentStyle := m.theme.TextBody().Width(width)
|
||||||
|
|
||||||
if !m.OrdersLoaded {
|
if !m.OrdersLoaded {
|
||||||
return contentStyle.Render("Loading orders...")
|
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
|
||||||
}
|
}
|
||||||
if len(orders) == 0 {
|
if len(orders) == 0 {
|
||||||
return contentStyle.Render("No orders yet")
|
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
|
||||||
}
|
}
|
||||||
if m.account.orderViewState == 2 {
|
if m.account.orderViewState == 2 {
|
||||||
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
|
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whole-card windowing: render only fully-visible cards, never clipping a
|
boxWidth := width - 2
|
||||||
// card mid-border. Both scroll arrows render in BuildAccountView's gap
|
|
||||||
// rows, so the cards span exactly the menu panel's rows.
|
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
|
||||||
page := m.orderListPageSize()
|
var cards []string
|
||||||
start := m.orderHiddenAbove()
|
for i, order := range orders {
|
||||||
end := start + page
|
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
|
||||||
if end > len(orders) {
|
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
|
||||||
end = len(orders)
|
}
|
||||||
|
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
|
||||||
|
|
||||||
|
// In preview mode (viewState 0), show title + hint
|
||||||
|
if m.account.orderViewState == 0 {
|
||||||
|
hintStyle := m.theme.TextDim()
|
||||||
|
return titleStyle.Render(title) + "\n" +
|
||||||
|
cardList + "\n" +
|
||||||
|
hintStyle.Render("enter: browse orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []string
|
// In list browsing mode (viewState 1), just show cards — no title
|
||||||
for i := start; i < end; i++ {
|
// (the left panel menu already shows "order history" as selected)
|
||||||
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
|
return cardList
|
||||||
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildOrderCard renders a single order as a bordered box with fixed height.
|
// buildOrderCard renders a single order as a bordered box with fixed height.
|
||||||
|
|||||||
@@ -9,42 +9,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) updateCartViewport() Model {
|
func (m Model) updateCartViewport() Model {
|
||||||
contentW, _, _, _ := m.shopLayout()
|
|
||||||
headerH := lipgloss.Height(m.BuildHeader())
|
headerH := lipgloss.Height(m.BuildHeader())
|
||||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||||
footerH := lipgloss.Height(m.BuildFooter())
|
footerH := lipgloss.Height(m.BuildFooter())
|
||||||
// 2 = blank gap rows around the cart list
|
availH := m.heightContainer - headerH - footerH - breadH
|
||||||
availH := m.heightContainer - headerH - footerH - breadH - 2
|
|
||||||
if availH < 1 {
|
if availH < 1 {
|
||||||
availH = 1
|
availH = 1
|
||||||
}
|
}
|
||||||
if !m.cart.viewportReady {
|
if !m.cart.viewportReady {
|
||||||
m.cart.viewport = viewport.New(viewport.WithWidth(contentW), viewport.WithHeight(availH))
|
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||||
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
m.cart.viewport.KeyMap = viewport.KeyMap{}
|
||||||
m.cart.viewportReady = true
|
m.cart.viewportReady = true
|
||||||
} else {
|
} else {
|
||||||
m.cart.viewport.SetWidth(contentW)
|
m.cart.viewport.SetWidth(m.widthContent)
|
||||||
m.cart.viewport.SetHeight(availH)
|
m.cart.viewport.SetHeight(availH)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) generateCartContent() string {
|
func (m Model) generateCartContent() string {
|
||||||
contentW, _, _, _ := m.shopLayout()
|
|
||||||
|
|
||||||
if m.IsCartEmpty() {
|
if m.IsCartEmpty() {
|
||||||
emptyStyle := m.theme.TextDim().
|
emptyStyle := m.theme.TextDim().
|
||||||
Align(lipgloss.Center).
|
Align(lipgloss.Center).
|
||||||
Width(contentW).
|
Width(m.widthContent).
|
||||||
Padding(2, 0)
|
Padding(2, 0)
|
||||||
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
||||||
return render
|
return render
|
||||||
}
|
}
|
||||||
|
|
||||||
cartItems := ""
|
cartItems := ""
|
||||||
// Cart cards span the full chrome width so they line up with the
|
boxWidth := m.widthContent - 4
|
||||||
// header and footer boxes.
|
|
||||||
boxWidth := contentW
|
|
||||||
itemSlice := m.GetCartItemsSlice()
|
itemSlice := m.GetCartItemsSlice()
|
||||||
|
|
||||||
boxPadding := 0
|
boxPadding := 0
|
||||||
@@ -100,7 +94,7 @@ func (m Model) generateCartContent() string {
|
|||||||
boxContent = m.theme.TextAccent().Render(boxContent)
|
boxContent = m.theme.TextAccent().Render(boxContent)
|
||||||
}
|
}
|
||||||
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
|
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
|
||||||
cartItems += box
|
cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
|
||||||
|
|
||||||
if idx < len(itemSlice)-1 {
|
if idx < len(itemSlice)-1 {
|
||||||
cartItems += "\n"
|
cartItems += "\n"
|
||||||
@@ -129,9 +123,12 @@ func (m Model) CartView() string {
|
|||||||
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Blank rows separate the list from the chrome; no Place wrapper so the
|
return lipgloss.Place(
|
||||||
// cards stay left-aligned with the header and footer boxes.
|
m.widthContainer,
|
||||||
return "\n" + m.cart.viewport.View() + "\n"
|
lipgloss.Height(m.cart.viewport.View()),
|
||||||
|
lipgloss.Center, lipgloss.Center,
|
||||||
|
m.cart.viewport.View(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package tui is the terminal UI you see over SSH: the shop, the cart, your
|
|
||||||
// account, and the checkout flow, all drawn with Bubbletea.
|
|
||||||
package tui
|
|
||||||
@@ -6,26 +6,20 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildFooter renders the bottom chrome bar with the current page keybinds.
|
|
||||||
// Layout ported from the design prototype at ~/programming/test/shop.go.
|
|
||||||
func (m Model) BuildFooter() string {
|
func (m Model) BuildFooter() string {
|
||||||
_, _, _, wideW := m.shopLayout()
|
keybindStyle := m.theme.TextDim().Bold(true)
|
||||||
|
descStyle := m.theme.TextDim()
|
||||||
|
|
||||||
item := func(k, v string) string {
|
|
||||||
return pBody.Render(k) + " " + pDim.Render(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
var keys string
|
|
||||||
if m.size == small {
|
if m.size == small {
|
||||||
keys = item("m", "menu")
|
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
|
||||||
} else {
|
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
|
||||||
parts := make([]string, 0, len(m.footer))
|
|
||||||
for _, cmd := range m.footer {
|
|
||||||
parts = append(parts, item(cmd.key, cmd.value))
|
|
||||||
}
|
|
||||||
keys = strings.Join(parts, pDim.Render(" · "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
row := lipgloss.NewStyle().Width(wideW).Align(lipgloss.Center).Render(keys)
|
parts := []string{}
|
||||||
return panel(wideW, 0, row)
|
for _, cmd := range m.footer {
|
||||||
|
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
|
||||||
|
}
|
||||||
|
footerText := strings.Join(parts, " ")
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,72 +2,57 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
"charm.land/lipgloss/v2/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cartBlendColor mixes the colors of all cart items, weighted by quantity,
|
|
||||||
// so the header price tints toward whatever dominates the cart.
|
|
||||||
func (m Model) cartBlendColor() color.Color {
|
|
||||||
var rSum, gSum, bSum, n int
|
|
||||||
for _, item := range m.Cart {
|
|
||||||
r, g, b, ok := parseHexColor(item.Coffee.Color)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rSum += r * item.Quantity
|
|
||||||
gSum += g * item.Quantity
|
|
||||||
bSum += b * item.Quantity
|
|
||||||
n += item.Quantity
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return cWhite
|
|
||||||
}
|
|
||||||
return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", rSum/n, gSum/n, bSum/n))
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildHeader renders the top chrome bar: brand, tab pills, and cart summary.
|
|
||||||
// Layout ported from the design prototype at ~/programming/test/shop.go.
|
|
||||||
func (m Model) BuildHeader() string {
|
func (m Model) BuildHeader() string {
|
||||||
_, _, _, wideW := m.shopLayout()
|
bold := m.theme.TextAccent().Bold(true).Render
|
||||||
|
accent := m.theme.TextAccent().Render
|
||||||
|
base := m.theme.Base().Render
|
||||||
|
cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ")
|
||||||
|
|
||||||
brand := pWht.Bold(true).Render("terminal")
|
mark := bold("t") + cursor
|
||||||
|
logo := bold("terminal")
|
||||||
|
menu := bold("m") + base(" ☰")
|
||||||
|
|
||||||
pill := lipgloss.NewStyle().Background(cPill).Foreground(cWhite).Bold(true).Padding(0, 1)
|
tab := func(key, name string, active bool) string {
|
||||||
tab := func(key, label string, active bool) string {
|
|
||||||
if active {
|
if active {
|
||||||
return pill.Render(label)
|
return accent(key + " " + name)
|
||||||
}
|
}
|
||||||
return pDim.Render(key+" ") + pBody.Render(label)
|
return accent(key) + base(" "+name)
|
||||||
}
|
}
|
||||||
|
shop := tab("s", "shop", m.currentPage == shopPage)
|
||||||
|
account := tab("a", "account", m.currentPage == accountPage)
|
||||||
|
|
||||||
var left string
|
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
|
||||||
if m.size == small {
|
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
|
||||||
left = brand
|
cartLabel := base(" cart")
|
||||||
} else {
|
|
||||||
left = strings.Join([]string{
|
|
||||||
brand,
|
|
||||||
tab("s", "shop", m.currentPage == shopPage),
|
|
||||||
tab("a", "account", m.currentPage == accountPage),
|
|
||||||
}, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
cartLabel := pDim.Render("cart ")
|
|
||||||
if m.inCartFlow() {
|
if m.inCartFlow() {
|
||||||
cartLabel = pWht.Bold(true).Render("cart ")
|
cartLabel = accent(" cart")
|
||||||
}
|
}
|
||||||
cart := cartLabel +
|
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
|
||||||
lipgloss.NewStyle().Foreground(m.cartBlendColor()).Bold(true).
|
|
||||||
Render(fmt.Sprintf("$%d", m.CalculateSubtotal()/100)) +
|
|
||||||
pDim.Render(fmt.Sprintf(" · %d", m.CartItemCount()))
|
|
||||||
|
|
||||||
gap := wideW - lipgloss.Width(left) - lipgloss.Width(cart)
|
var tabs []string
|
||||||
if gap < 1 {
|
switch m.size {
|
||||||
gap = 1
|
case small:
|
||||||
|
tabs = []string{mark, cart}
|
||||||
|
case medium:
|
||||||
|
tabs = []string{menu, logo, cart}
|
||||||
|
default:
|
||||||
|
tabs = []string{logo, shop, account, cart}
|
||||||
}
|
}
|
||||||
row := left + strings.Repeat(" ", gap) + cart
|
|
||||||
|
|
||||||
return panel(wideW, 0, row)
|
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
|
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
return lipgloss.Place(
|
||||||
|
m.widthContainer,
|
||||||
|
lipgloss.Height(tbl),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
tbl,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ type Model struct {
|
|||||||
|
|
||||||
type shopState struct {
|
type shopState struct {
|
||||||
selected int
|
selected int
|
||||||
|
menuViewport viewport.Model
|
||||||
|
detailViewport viewport.Model
|
||||||
|
viewportsReady bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type splashState struct {
|
type splashState struct {
|
||||||
@@ -182,7 +185,6 @@ type accountState struct {
|
|||||||
cursor int
|
cursor int
|
||||||
orderViewState int
|
orderViewState int
|
||||||
orderCursor int
|
orderCursor int
|
||||||
orderWindowStart int // first fully-visible card in the windowed order list
|
|
||||||
orderYOffset int // saved scroll pos when entering order detail
|
orderYOffset int // saved scroll pos when entering order detail
|
||||||
faqFocused bool
|
faqFocused bool
|
||||||
addressListFocused bool
|
addressListFocused bool
|
||||||
@@ -238,6 +240,7 @@ func (m Model) SwitchPage(p page) Model {
|
|||||||
|
|
||||||
func (m Model) ShopSwitch() (Model, tea.Cmd) {
|
func (m Model) ShopSwitch() (Model, tea.Cmd) {
|
||||||
m = m.SwitchPage(shopPage)
|
m = m.SwitchPage(shopPage)
|
||||||
|
m = m.updateShopViewports()
|
||||||
m.footer = []footerCommand{
|
m.footer = []footerCommand{
|
||||||
{key: "j/k", value: "products"},
|
{key: "j/k", value: "products"},
|
||||||
{key: "+/-", value: "qty"},
|
{key: "+/-", value: "qty"},
|
||||||
@@ -262,12 +265,13 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
|
|||||||
{key: "q", value: "quit"},
|
{key: "q", value: "quit"},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Keep this list short enough to fit on one footer line.
|
|
||||||
m.footer = []footerCommand{
|
m.footer = []footerCommand{
|
||||||
{key: "j/k", value: "items"},
|
{key: "j/k", value: "items"},
|
||||||
{key: "+/-", value: "qty"},
|
{key: "+/-", value: "qty"},
|
||||||
{key: "enter", value: "checkout"},
|
{key: "p/enter", value: "checkout"},
|
||||||
{key: "s", value: "shop"},
|
{key: "s", value: "shop"},
|
||||||
|
{key: "a", value: "account"},
|
||||||
|
{key: "pgup/pgdn", value: "scroll"},
|
||||||
{key: "?", value: "help"},
|
{key: "?", value: "help"},
|
||||||
{key: "q", value: "quit"},
|
{key: "q", value: "quit"},
|
||||||
}
|
}
|
||||||
@@ -837,7 +841,6 @@ func NewModel(username string) Model {
|
|||||||
{
|
{
|
||||||
Name: "Espresso",
|
Name: "Espresso",
|
||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
RoastLevel: 5,
|
|
||||||
Ounces: 2,
|
Ounces: 2,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 350, // $3.50
|
Price: 350, // $3.50
|
||||||
@@ -847,7 +850,6 @@ func NewModel(username string) Model {
|
|||||||
{
|
{
|
||||||
Name: "Latte",
|
Name: "Latte",
|
||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
RoastLevel: 2,
|
|
||||||
Ounces: 12,
|
Ounces: 12,
|
||||||
BeanType: "Arabica Blend",
|
BeanType: "Arabica Blend",
|
||||||
Price: 500, // $5.00
|
Price: 500, // $5.00
|
||||||
@@ -857,7 +859,6 @@ func NewModel(username string) Model {
|
|||||||
{
|
{
|
||||||
Name: "Cappuccino",
|
Name: "Cappuccino",
|
||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
RoastLevel: 3,
|
|
||||||
Ounces: 8,
|
Ounces: 8,
|
||||||
BeanType: "Italian Blend",
|
BeanType: "Italian Blend",
|
||||||
Price: 450, // $4.50
|
Price: 450, // $4.50
|
||||||
@@ -867,7 +868,6 @@ func NewModel(username string) Model {
|
|||||||
{
|
{
|
||||||
Name: "Americano",
|
Name: "Americano",
|
||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
RoastLevel: 4,
|
|
||||||
Ounces: 16,
|
Ounces: 16,
|
||||||
BeanType: "Arabica",
|
BeanType: "Arabica",
|
||||||
Price: 400, // $4.00
|
Price: 400, // $4.00
|
||||||
@@ -877,7 +877,6 @@ func NewModel(username string) Model {
|
|||||||
{
|
{
|
||||||
Name: "Mocha",
|
Name: "Mocha",
|
||||||
RoastType: "Medium Roast",
|
RoastType: "Medium Roast",
|
||||||
RoastLevel: 3,
|
|
||||||
Ounces: 16,
|
Ounces: 16,
|
||||||
BeanType: "Colombian",
|
BeanType: "Colombian",
|
||||||
Price: 550, // $5.50
|
Price: 550, // $5.50
|
||||||
@@ -887,7 +886,6 @@ func NewModel(username string) Model {
|
|||||||
{
|
{
|
||||||
Name: "Macchiato",
|
Name: "Macchiato",
|
||||||
RoastType: "Dark Roast",
|
RoastType: "Dark Roast",
|
||||||
RoastLevel: 4,
|
|
||||||
Ounces: 3,
|
Ounces: 3,
|
||||||
BeanType: "Robusta Blend",
|
BeanType: "Robusta Blend",
|
||||||
Price: 425, // $4.25
|
Price: 425, // $4.25
|
||||||
@@ -932,6 +930,10 @@ func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clien
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newViewport(w, h int) viewport.Model {
|
||||||
|
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) collectCardCmd() tea.Cmd {
|
func (m Model) collectCardCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if m.APIClient == nil {
|
if m.APIClient == nil {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Palette and base styles for the boxed shop chrome.
|
|
||||||
// Ported from the design prototype at ~/programming/test/shop.go.
|
|
||||||
var (
|
|
||||||
cWhite = lipgloss.Color("#EAEAEA")
|
|
||||||
cBody = lipgloss.Color("#9A9A9A")
|
|
||||||
cDim = lipgloss.Color("#5A5A5A")
|
|
||||||
cLine = lipgloss.Color("#3A3A3A")
|
|
||||||
cInk = lipgloss.Color("#161616") // text on a light colored block
|
|
||||||
cGreen = lipgloss.Color("#4FB477")
|
|
||||||
cRed = lipgloss.Color("#E06C6C")
|
|
||||||
cGold = lipgloss.Color("#D0962E") // single accent
|
|
||||||
cPill = lipgloss.Color("#2E2E2E") // active-tab pill bg
|
|
||||||
|
|
||||||
pBody = lipgloss.NewStyle().Foreground(cBody)
|
|
||||||
pDim = lipgloss.NewStyle().Foreground(cDim)
|
|
||||||
pWht = lipgloss.NewStyle().Foreground(cWhite)
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseHexColor splits a "#RRGGBB" string into its channel values.
|
|
||||||
func parseHexColor(s string) (r, g, b int, ok bool) {
|
|
||||||
s = strings.TrimPrefix(s, "#")
|
|
||||||
if len(s) != 6 {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseUint(s, 16, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
return int(v >> 16), int((v >> 8) & 0xFF), int(v & 0xFF), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// contrastColor returns ink for light backgrounds and white for dark ones,
|
|
||||||
// using perceived luminance so text stays readable on any product color.
|
|
||||||
func contrastColor(hex string) color.Color {
|
|
||||||
r, g, b, ok := parseHexColor(hex)
|
|
||||||
if !ok {
|
|
||||||
return cWhite
|
|
||||||
}
|
|
||||||
lum := (299*r + 587*g + 114*b) / 1000
|
|
||||||
if lum > 140 {
|
|
||||||
return cInk
|
|
||||||
}
|
|
||||||
return cWhite
|
|
||||||
}
|
|
||||||
|
|
||||||
// panel wraps content in a rounded box. textW is the inner text width and
|
|
||||||
// innerH the inner content height; the total render size is textW+4 by
|
|
||||||
// innerH+2. Ported from the design prototype at ~/programming/test/shop.go,
|
|
||||||
// adjusted for lipgloss v2 where Width/Height include the border.
|
|
||||||
func panel(textW, innerH int, content string) string {
|
|
||||||
s := lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(cLine).
|
|
||||||
Padding(0, 1).
|
|
||||||
Width(textW + 4)
|
|
||||||
if innerH > 0 {
|
|
||||||
s = s.Height(innerH + 2)
|
|
||||||
}
|
|
||||||
return s.Render(content)
|
|
||||||
}
|
|
||||||
@@ -276,7 +276,9 @@ func (m Model) updatePaymentViewport() Model {
|
|||||||
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
breadH := lipgloss.Height(m.BuildBreadcrumbs())
|
||||||
footerH := lipgloss.Height(m.BuildFooter())
|
footerH := lipgloss.Height(m.BuildFooter())
|
||||||
availH := m.heightContainer - headerH - footerH - breadH
|
availH := m.heightContainer - headerH - footerH - breadH
|
||||||
availH = max(availH, 1)
|
if availH < 1 {
|
||||||
|
availH = 1
|
||||||
|
}
|
||||||
if !m.payment.viewportReady {
|
if !m.payment.viewportReady {
|
||||||
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
|
||||||
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
m.payment.viewport.KeyMap = viewport.KeyMap{}
|
||||||
@@ -301,7 +303,7 @@ func (m Model) PaymentPageView() string {
|
|||||||
m.widthContainer,
|
m.widthContainer,
|
||||||
m.payment.viewport.Height(),
|
m.payment.viewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
m.renderPaymentHTTPSView(),
|
m.renderPaymentHttpsView(),
|
||||||
)
|
)
|
||||||
} else if m.payment.view == 0 && m.payment.form == nil {
|
} else if m.payment.view == 0 && m.payment.form == nil {
|
||||||
content = m.RenderCardList()
|
content = m.RenderCardList()
|
||||||
@@ -372,7 +374,7 @@ func (m Model) RenderCardList() string {
|
|||||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderPaymentHTTPSView() string {
|
func (m Model) renderPaymentHttpsView() string {
|
||||||
baseStyle := m.theme.TextLabel()
|
baseStyle := m.theme.TextLabel()
|
||||||
accentStyle := m.theme.TextHighlight()
|
accentStyle := m.theme.TextHighlight()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package qrfefe turns text into a QR code drawn with terminal block
|
|
||||||
// characters, handy for showing links you can scan right off the screen.
|
|
||||||
package qrfefe
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
|
||||||
|
|
||||||
// TestRenderShopSmoke prints the rendered shop frame so chrome alignment can
|
|
||||||
// be inspected, and asserts that the header, body, and footer boxes share the
|
|
||||||
// same left and right edges.
|
|
||||||
func TestRenderShopSmoke(t *testing.T) {
|
|
||||||
m := NewModel("smoke")
|
|
||||||
m.Loading = false
|
|
||||||
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 4}
|
|
||||||
m = m.updateLayout(100, 40)
|
|
||||||
m, _ = m.ShopSwitch()
|
|
||||||
|
|
||||||
out := m.renderString()
|
|
||||||
plain := ansiRE.ReplaceAllString(out, "")
|
|
||||||
t.Log("\n" + plain)
|
|
||||||
|
|
||||||
// Collect left/right border columns of every box-drawing row.
|
|
||||||
var lefts, rights []int
|
|
||||||
for _, line := range strings.Split(plain, "\n") {
|
|
||||||
runes := []rune(line)
|
|
||||||
first, last := -1, -1
|
|
||||||
for i, r := range runes {
|
|
||||||
if strings.ContainsRune("╭╮╰╯│", r) {
|
|
||||||
if first == -1 {
|
|
||||||
first = i
|
|
||||||
}
|
|
||||||
last = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if first != -1 {
|
|
||||||
lefts = append(lefts, first)
|
|
||||||
rights = append(rights, last)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lefts) == 0 {
|
|
||||||
t.Fatal("no box-drawing rows found")
|
|
||||||
}
|
|
||||||
for i := range lefts {
|
|
||||||
if lefts[i] != lefts[0] {
|
|
||||||
t.Errorf("left edge misaligned: row %d at col %d, expected %d", i, lefts[i], lefts[0])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if rights[i] != rights[0] {
|
|
||||||
t.Errorf("right edge misaligned: row %d at col %d, expected %d", i, rights[i], rights[0])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenderAccountSmoke verifies the account page boxes stay within the
|
|
||||||
// header/footer chrome bounds. The detail area is unboxed, so inner boxes may
|
|
||||||
// be narrower than the chrome but must never poke outside it.
|
|
||||||
func TestRenderAccountSmoke(t *testing.T) {
|
|
||||||
m := NewModel("smoke")
|
|
||||||
m.Loading = false
|
|
||||||
m = m.updateLayout(100, 40)
|
|
||||||
m, _ = m.AccountSwitch()
|
|
||||||
|
|
||||||
plain := ansiRE.ReplaceAllString(m.renderString(), "")
|
|
||||||
t.Log("\n" + plain)
|
|
||||||
|
|
||||||
var lefts, rights []int
|
|
||||||
for _, line := range strings.Split(plain, "\n") {
|
|
||||||
runes := []rune(line)
|
|
||||||
first, last := -1, -1
|
|
||||||
for i, r := range runes {
|
|
||||||
if strings.ContainsRune("╭╮╰╯│", r) {
|
|
||||||
if first == -1 {
|
|
||||||
first = i
|
|
||||||
}
|
|
||||||
last = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if first != -1 {
|
|
||||||
lefts = append(lefts, first)
|
|
||||||
rights = append(rights, last)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lefts) == 0 {
|
|
||||||
t.Fatal("no box-drawing rows found")
|
|
||||||
}
|
|
||||||
// Row 0 is the header box: it defines the chrome bounds.
|
|
||||||
for i := range lefts {
|
|
||||||
if lefts[i] < lefts[0] || rights[i] > rights[0] {
|
|
||||||
t.Errorf("box outside chrome: row %d spans %d-%d, chrome is %d-%d",
|
|
||||||
i, lefts[i], rights[i], lefts[0], rights[0])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenderOrderWindowing verifies the windowed order list: whole cards
|
|
||||||
// only, with ↑/↓ arrow rows marking hidden items.
|
|
||||||
func TestRenderOrderWindowing(t *testing.T) {
|
|
||||||
m := NewModel("smoke")
|
|
||||||
m.Loading = false
|
|
||||||
m.OrdersLoaded = true
|
|
||||||
for i := 1; i <= 9; i++ {
|
|
||||||
m.Orders = append(m.Orders, models.Order{
|
|
||||||
ID: uint(9000 + i),
|
|
||||||
Status: models.OrderStatusPaid,
|
|
||||||
Total: 1000 + i*100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
m = m.updateLayout(100, 40)
|
|
||||||
m, _ = m.AccountSwitch()
|
|
||||||
|
|
||||||
// Preview mode: window starts at the top, arrow only below.
|
|
||||||
plain := ansiRE.ReplaceAllString(m.renderString(), "")
|
|
||||||
t.Log("\n" + plain)
|
|
||||||
if !strings.Contains(plain, "↓ 4 more") {
|
|
||||||
t.Errorf("preview mode: expected '↓ 4 more' indicator")
|
|
||||||
}
|
|
||||||
if strings.Contains(plain, "↑") {
|
|
||||||
t.Errorf("preview mode: unexpected up arrow")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse mode scrolled to the middle: arrows on both ends.
|
|
||||||
m.account.orderViewState = 1
|
|
||||||
m.account.orderCursor = 6
|
|
||||||
m = m.scrollToAccountDetailItem()
|
|
||||||
plain = ansiRE.ReplaceAllString(m.renderString(), "")
|
|
||||||
t.Log("\n" + plain)
|
|
||||||
if !strings.Contains(plain, "↑ 2 more") || !strings.Contains(plain, "↓ 2 more") {
|
|
||||||
t.Errorf("browse mode: expected '↑ 2 more' and '↓ 2 more' indicators")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenderCartSmoke verifies the cart page cards line up with the chrome.
|
|
||||||
func TestRenderCartSmoke(t *testing.T) {
|
|
||||||
m := NewModel("smoke")
|
|
||||||
m.Loading = false
|
|
||||||
// Seed coffees carry no IDs, so assign them for distinct cart keys
|
|
||||||
for i := range m.Coffees {
|
|
||||||
m.Coffees[i].ID = uint(i + 1)
|
|
||||||
}
|
|
||||||
m.Cart[m.Coffees[0].ID] = &models.CartItem{CoffeeID: m.Coffees[0].ID, Coffee: m.Coffees[0], Quantity: 2}
|
|
||||||
m.Cart[m.Coffees[1].ID] = &models.CartItem{CoffeeID: m.Coffees[1].ID, Coffee: m.Coffees[1], Quantity: 1}
|
|
||||||
m = m.updateLayout(100, 40)
|
|
||||||
m, _ = m.CartSwitch()
|
|
||||||
|
|
||||||
plain := ansiRE.ReplaceAllString(m.renderString(), "")
|
|
||||||
t.Log("\n" + plain)
|
|
||||||
|
|
||||||
var lefts, rights []int
|
|
||||||
for _, line := range strings.Split(plain, "\n") {
|
|
||||||
runes := []rune(line)
|
|
||||||
first, last := -1, -1
|
|
||||||
for i, r := range runes {
|
|
||||||
if strings.ContainsRune("╭╮╰╯│", r) {
|
|
||||||
if first == -1 {
|
|
||||||
first = i
|
|
||||||
}
|
|
||||||
last = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if first != -1 {
|
|
||||||
lefts = append(lefts, first)
|
|
||||||
rights = append(rights, last)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(lefts) == 0 {
|
|
||||||
t.Fatal("no box-drawing rows found")
|
|
||||||
}
|
|
||||||
// Every box row (header, cards, footer) must share the chrome edges.
|
|
||||||
for i := range lefts {
|
|
||||||
if lefts[i] != lefts[0] || rights[i] != rights[0] {
|
|
||||||
t.Errorf("box edge misaligned: row %d spans %d-%d, expected %d-%d",
|
|
||||||
i, lefts[i], rights[i], lefts[0], rights[0])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenderShopSmokeStacked prints the stacked (medium/small) layouts.
|
|
||||||
func TestRenderShopSmokeStacked(t *testing.T) {
|
|
||||||
for _, size := range [][2]int{{60, 32}, {45, 30}} {
|
|
||||||
m := NewModel("smoke")
|
|
||||||
m.Loading = false
|
|
||||||
m = m.updateLayout(size[0], size[1])
|
|
||||||
m, _ = m.ShopSwitch()
|
|
||||||
t.Logf("--- %dx%d ---\n%s", size[0], size[1], ansiRE.ReplaceAllString(m.renderString(), ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
253
pkg/tui/shop.go
253
pkg/tui/shop.go
@@ -4,173 +4,163 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"terminalShop/pkg/models"
|
"terminalShop/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
func (m Model) calculateShopWidths() (int, int) {
|
||||||
// shopBodyInnerHeight fixes the inner height of the shop body panels so
|
menuWidth := 14
|
||||||
// the detail box never resizes with the description.
|
if len(m.Coffees) == 0 {
|
||||||
shopBodyInnerHeight = 13
|
|
||||||
// shopDescLines pins the description region to an exact line count.
|
|
||||||
shopDescLines = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
// shopLayout returns the shared chrome widths, all derived from one content
|
|
||||||
// width so the header, body panels, and footer line up exactly.
|
|
||||||
// Math ported from the design prototype at ~/programming/test/shop.go:
|
|
||||||
// menu(menuW+4) + gap(1) + detail(detailW+4) = contentW = wideW + 4.
|
|
||||||
func (m Model) shopLayout() (contentW, menuW, detailW, wideW int) {
|
|
||||||
contentW = m.widthContent
|
|
||||||
contentW = min(contentW, 74)
|
|
||||||
wideW = contentW - 4
|
|
||||||
if m.size < large {
|
if m.size < large {
|
||||||
// Stacked layout: both panels span the full content width.
|
return m.widthContent, m.widthContent
|
||||||
return contentW, wideW, wideW, wideW
|
|
||||||
}
|
}
|
||||||
menuW = 16
|
return menuWidth, m.widthContent - menuWidth
|
||||||
detailW = contentW - 25
|
}
|
||||||
return contentW, menuW, detailW, wideW
|
for _, c := range m.Coffees {
|
||||||
|
if w := lipgloss.Width(c.Name); w > menuWidth {
|
||||||
|
menuWidth = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if menuWidth > 0 {
|
||||||
|
menuWidth += 4
|
||||||
|
}
|
||||||
|
if m.size < large {
|
||||||
|
menuWidth = m.widthContent
|
||||||
|
}
|
||||||
|
detailWidth := m.widthContent - menuWidth
|
||||||
|
if m.size < large {
|
||||||
|
detailWidth = m.widthContent
|
||||||
|
}
|
||||||
|
return menuWidth, detailWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
// roastLevel maps a roast type to a 1-5 intensity for the roast meter.
|
func (m Model) updateShopViewports() Model {
|
||||||
// It is the fallback for products stored before the roast_level column existed.
|
headerHeight := lipgloss.Height(m.BuildHeader())
|
||||||
func roastLevel(roastType string) int {
|
footerHeight := lipgloss.Height(m.BuildFooter())
|
||||||
rt := strings.ToLower(roastType)
|
availableHeight := m.heightContainer - headerHeight - footerHeight
|
||||||
switch {
|
|
||||||
case strings.Contains(rt, "dark"):
|
if len(m.Coffees) == 0 {
|
||||||
return 5
|
return m
|
||||||
case strings.Contains(rt, "light"):
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menuWidth, detailWidth := m.calculateShopWidths()
|
||||||
|
|
||||||
|
if !m.shop.viewportsReady {
|
||||||
|
m.shop.menuViewport = newViewport(menuWidth, availableHeight)
|
||||||
|
m.shop.menuViewport.KeyMap = viewport.KeyMap{} // menu nav is manual
|
||||||
|
m.shop.detailViewport = newViewport(detailWidth, availableHeight)
|
||||||
|
m.shop.detailViewport.KeyMap = modifiedKeyMap
|
||||||
|
m.shop.viewportsReady = true
|
||||||
|
} else {
|
||||||
|
m.shop.menuViewport.SetWidth(menuWidth)
|
||||||
|
m.shop.menuViewport.SetHeight(availableHeight)
|
||||||
|
m.shop.detailViewport.SetWidth(detailWidth)
|
||||||
|
m.shop.detailViewport.SetHeight(availableHeight)
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// clampLines pads or truncates rendered text to exactly n lines so regions
|
func (m Model) getShopMenuContent() string {
|
||||||
// like the description never change the panel height.
|
menuWidth, _ := m.calculateShopWidths()
|
||||||
func clampLines(s string, n int) string {
|
|
||||||
lines := strings.Split(s, "\n")
|
|
||||||
if len(lines) > n {
|
|
||||||
lines = lines[:n]
|
|
||||||
}
|
|
||||||
for len(lines) < n {
|
|
||||||
lines = append(lines, "")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// roastMeter renders the strength dots, e.g. ● ● ● ○ ○
|
selectedColor := m.theme.Highlight()
|
||||||
func roastMeter(n int) string {
|
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
|
||||||
n = max(0, min(n, 5))
|
if c := m.Coffees[m.shop.selected].Color; c != "" {
|
||||||
on := lipgloss.NewStyle().Foreground(cGold).Render(strings.Repeat("● ", n))
|
selectedColor = lipgloss.Color(c)
|
||||||
off := pDim.Render(strings.Repeat("○ ", 5-n))
|
}
|
||||||
return strings.TrimRight(on+off, " ")
|
}
|
||||||
}
|
|
||||||
|
var normal, highlighted lipgloss.Style
|
||||||
|
if m.size < large {
|
||||||
|
menuWidth = m.widthContent
|
||||||
|
normal = m.theme.TextBody().
|
||||||
|
Width(menuWidth - 1).Align(lipgloss.Center)
|
||||||
|
highlighted = m.theme.TextAccent().
|
||||||
|
Width(menuWidth - 1).Align(lipgloss.Center).
|
||||||
|
Background(selectedColor).Bold(true)
|
||||||
|
} else {
|
||||||
|
normal = m.theme.TextBody().
|
||||||
|
Width(menuWidth+2).Padding(0, 1)
|
||||||
|
highlighted = m.theme.TextAccent().
|
||||||
|
Width(menuWidth+2).Padding(0, 1).
|
||||||
|
Background(selectedColor).Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) shopMenuContent(w int) string {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for i, c := range m.Coffees {
|
for i, c := range m.Coffees {
|
||||||
if i == m.shop.selected {
|
if i == m.shop.selected {
|
||||||
b.WriteString(lipgloss.NewStyle().Width(w).
|
b.WriteString(highlighted.Render(c.Name) + "\n")
|
||||||
Background(lipgloss.Color(c.Color)).
|
|
||||||
Foreground(contrastColor(c.Color)).
|
|
||||||
Bold(true).
|
|
||||||
Render(" · " + c.Name))
|
|
||||||
} else {
|
} else {
|
||||||
dot := lipgloss.NewStyle().Foreground(lipgloss.Color(c.Color)).Render("·")
|
b.WriteString(normal.Render(c.Name) + "\n")
|
||||||
b.WriteString(lipgloss.NewStyle().Width(w).
|
|
||||||
Render(" " + dot + " " + pBody.Render(c.Name)))
|
|
||||||
}
|
|
||||||
if i < len(m.Coffees)-1 {
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b.String()
|
return lipgloss.NewStyle().Padding(0, 1).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) shopDetailContent(w int) string {
|
func (m Model) getShopDetailContent() string {
|
||||||
|
if len(m.Coffees) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
coffee := m.Coffees[m.shop.selected]
|
coffee := m.Coffees[m.shop.selected]
|
||||||
|
_, detailWidth := m.calculateShopWidths()
|
||||||
|
if m.size >= large {
|
||||||
|
detailWidth -= 2
|
||||||
|
}
|
||||||
|
|
||||||
quantity := 0
|
quantity := 0
|
||||||
if item, exists := m.Cart[coffee.ID]; exists {
|
if item, exists := m.Cart[coffee.ID]; exists {
|
||||||
quantity = item.Quantity
|
quantity = item.Quantity
|
||||||
}
|
}
|
||||||
|
|
||||||
name := pWht.Bold(true).Render(coffee.Name)
|
detail := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
m.theme.TextAccent().Bold(true).Render(coffee.Name),
|
||||||
sep := pDim.Render(" · ")
|
m.theme.TextBody().Render(
|
||||||
spec := pBody.Render(strings.ToUpper(coffee.RoastType)) + sep +
|
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
|
||||||
pDim.Render(fmt.Sprintf("%dOZ", coffee.Ounces)) + sep +
|
),
|
||||||
pDim.Render(strings.ToUpper(coffee.BeanType))
|
|
||||||
|
|
||||||
level := coffee.RoastLevel
|
|
||||||
if level < 1 || level > 5 {
|
|
||||||
level = roastLevel(coffee.RoastType)
|
|
||||||
}
|
|
||||||
strength := pDim.Render("roast ") + roastMeter(level)
|
|
||||||
|
|
||||||
price := lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).
|
|
||||||
Render(fmt.Sprintf("$%d.%02d", coffee.Price/100, coffee.Price%100)) +
|
|
||||||
pDim.Render(" usd")
|
|
||||||
|
|
||||||
// Stock is not modeled in the backend yet, so the indicator is static.
|
|
||||||
stock := lipgloss.NewStyle().Foreground(cGreen).Render("● in stock")
|
|
||||||
|
|
||||||
desc := clampLines(pBody.Width(w).Render(coffee.Description), shopDescLines)
|
|
||||||
|
|
||||||
// stepper: git-diff coloring — red minus, green plus
|
|
||||||
minus := lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("−")
|
|
||||||
plus := lipgloss.NewStyle().Foreground(cGreen).Bold(true).Render("+")
|
|
||||||
count := lipgloss.NewStyle().Foreground(cWhite).Bold(true).
|
|
||||||
Padding(0, 2).Render(fmt.Sprintf("%d", quantity))
|
|
||||||
stepper := pDim.Render("qty ") + minus + count + plus
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
|
||||||
name,
|
|
||||||
spec,
|
|
||||||
strength,
|
|
||||||
"",
|
"",
|
||||||
price+" "+stock,
|
lipgloss.NewStyle().Foreground(lipgloss.Color(coffee.Color)).Bold(true).Render(
|
||||||
|
fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
|
||||||
|
),
|
||||||
"",
|
"",
|
||||||
desc,
|
m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
|
||||||
"",
|
"",
|
||||||
stepper,
|
fmt.Sprintf("- %d +", quantity),
|
||||||
)
|
)
|
||||||
|
return lipgloss.NewStyle().Width(detailWidth).Render(detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ShopView() string {
|
func (m Model) ShopView() string {
|
||||||
|
if !m.shop.viewportsReady {
|
||||||
|
m = m.updateShopViewports()
|
||||||
|
}
|
||||||
if len(m.Coffees) == 0 {
|
if len(m.Coffees) == 0 {
|
||||||
return lipgloss.Place(m.widthContent, m.heightContainer,
|
return lipgloss.Place(m.widthContent, m.heightContainer,
|
||||||
lipgloss.Center, lipgloss.Center, "No products available.")
|
lipgloss.Center, lipgloss.Center, "No products available.")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, menuW, detailW, _ := m.shopLayout()
|
m.shop.menuViewport.SetContent(m.getShopMenuContent())
|
||||||
|
m.shop.detailViewport.SetContent(m.getShopDetailContent())
|
||||||
|
|
||||||
mc := m.shopMenuContent(menuW)
|
|
||||||
dc := m.shopDetailContent(detailW)
|
|
||||||
|
|
||||||
var body string
|
|
||||||
if m.size < large {
|
if m.size < large {
|
||||||
body = lipgloss.JoinVertical(lipgloss.Left,
|
return lipgloss.JoinVertical(lipgloss.Top,
|
||||||
panel(menuW, 0, mc),
|
m.shop.menuViewport.View(),
|
||||||
panel(detailW, 0, dc),
|
m.shop.detailViewport.View(),
|
||||||
)
|
|
||||||
} else {
|
|
||||||
bodyH := max(shopBodyInnerHeight, lipgloss.Height(mc), lipgloss.Height(dc))
|
|
||||||
body = lipgloss.JoinHorizontal(lipgloss.Top,
|
|
||||||
panel(menuW, bodyH, mc),
|
|
||||||
" ",
|
|
||||||
panel(detailW, bodyH, dc),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
// Blank rows separate the body from the header and footer boxes.
|
m.shop.menuViewport.View(),
|
||||||
return "\n" + body + "\n"
|
" ",
|
||||||
|
m.shop.detailViewport.View(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
if len(m.Coffees) == 0 {
|
if len(m.Coffees) == 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -207,7 +197,16 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
// Pass messages to the detail viewport so pgup/pgdn scroll works
|
||||||
|
m.shop.detailViewport, cmd = m.shop.detailViewport.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
if m.shop.viewportsReady {
|
||||||
|
m.shop.menuViewport.SetContent(m.getShopMenuContent())
|
||||||
|
m.shop.detailViewport.SetContent(m.getShopDetailContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) shopMoveSelected(previous bool) Model {
|
func (m Model) shopMoveSelected(previous bool) Model {
|
||||||
@@ -217,8 +216,18 @@ func (m Model) shopMoveSelected(previous bool) Model {
|
|||||||
} else {
|
} else {
|
||||||
next++
|
next++
|
||||||
}
|
}
|
||||||
next = max(next, 0)
|
if next < 0 {
|
||||||
next = min(next, len(m.Coffees)-1)
|
next = 0
|
||||||
|
}
|
||||||
|
if next >= len(m.Coffees) {
|
||||||
|
next = len(m.Coffees) - 1
|
||||||
|
}
|
||||||
m.shop.selected = next
|
m.shop.selected = next
|
||||||
|
|
||||||
|
if m.shop.viewportsReady {
|
||||||
|
targetY := (m.shop.selected + 2) * 1
|
||||||
|
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
|
||||||
|
m.shop.detailViewport.GotoTop()
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
|
|
||||||
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
|
||||||
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
|
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
|
||||||
base := m.theme.Base().Border(lipgloss.RoundedBorder()).Width(totalWidth)
|
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
|
||||||
|
|
||||||
var style lipgloss.Style
|
var style lipgloss.Style
|
||||||
if selected {
|
if selected {
|
||||||
style = base.BorderForeground(cWhite)
|
style = base.BorderForeground(m.theme.Highlight())
|
||||||
} else {
|
} else {
|
||||||
style = base.BorderForeground(cLine)
|
style = base.BorderForeground(m.theme.Border())
|
||||||
}
|
}
|
||||||
return style.Padding(paddingV, paddingH).Render(padded)
|
return style.Padding(paddingV, paddingH).Render(padded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package theme keeps the colors and styles in one spot so the whole UI
|
|
||||||
// looks like it was built by the same person.
|
|
||||||
package theme
|
|
||||||
@@ -16,6 +16,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if tick.seq == m.resizeSeq {
|
if tick.seq == m.resizeSeq {
|
||||||
m = m.updateLayout(m.pendingWidth, m.pendingHeight)
|
m = m.updateLayout(m.pendingWidth, m.pendingHeight)
|
||||||
switch m.currentPage {
|
switch m.currentPage {
|
||||||
|
case shopPage:
|
||||||
|
m = m.updateShopViewports()
|
||||||
case cartPage:
|
case cartPage:
|
||||||
m = m.updateCartViewport()
|
m = m.updateCartViewport()
|
||||||
case shippingPage:
|
case shippingPage:
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ func (m Model) renderString() string {
|
|||||||
if breadcrumbs != "" {
|
if breadcrumbs != "" {
|
||||||
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
|
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
|
||||||
}
|
}
|
||||||
footer := m.BuildFooter()
|
footerHeight := 1
|
||||||
footerHeight := lipgloss.Height(footer)
|
|
||||||
marginTop := 1
|
marginTop := 1
|
||||||
marginBottom := 1
|
marginBottom := 1
|
||||||
bufferSpace := 1
|
bufferSpace := 1
|
||||||
@@ -88,15 +87,17 @@ func (m Model) renderString() string {
|
|||||||
errorStyle = errorStyle.Width(m.widthContent - 4)
|
errorStyle = errorStyle.Width(m.widthContent - 4)
|
||||||
}
|
}
|
||||||
errorBanner := errorStyle.Render(m.error.message)
|
errorBanner := errorStyle.Render(m.error.message)
|
||||||
content = errorBanner + "\n" + m.buildPageContent()
|
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
} else if m.notice != nil {
|
} else if m.notice != nil {
|
||||||
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
|
||||||
noticeBanner := noticeStyle.Render(m.notice.message)
|
noticeBanner := noticeStyle.Render(m.notice.message)
|
||||||
content = noticeBanner + "\n" + m.buildPageContent()
|
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
|
||||||
} else {
|
} else {
|
||||||
content = m.buildPageContent()
|
content = m.buildPageContent(availableContentHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer := m.BuildFooter()
|
||||||
|
|
||||||
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
|
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
|
||||||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
|
m.currentPage == shippingPage || m.currentPage == paymentPage ||
|
||||||
m.currentPage == confirmPage || m.currentPage == accountPage
|
m.currentPage == confirmPage || m.currentPage == accountPage
|
||||||
@@ -136,10 +137,10 @@ func (m Model) renderString() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) buildPageContent() string {
|
func (m Model) buildPageContent(height int) string {
|
||||||
switch m.currentPage {
|
switch m.currentPage {
|
||||||
case accountPage:
|
case accountPage:
|
||||||
return m.BuildAccountView()
|
return m.BuildAccountView(height)
|
||||||
case shippingPage:
|
case shippingPage:
|
||||||
return m.ShippingPageView()
|
return m.ShippingPageView()
|
||||||
case paymentPage:
|
case paymentPage:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package utils is the catch-all drawer for tiny HTTP helpers that didn't
|
|
||||||
// earn a package of their own.
|
|
||||||
package utils
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Package validate gives user input a once-over before the rest of the app
|
|
||||||
// trusts it.
|
|
||||||
package validate
|
|
||||||
Reference in New Issue
Block a user