update: reworked the handling of account tab to use bubbleteas viewport. it didnt work - yet

This commit is contained in:
2026-03-14 01:20:21 +01:00
parent 404ef31c52
commit cb2225f1aa
12 changed files with 450 additions and 71 deletions

49
TODO.md
View File

@@ -1,23 +1,42 @@
# TODO
## In Progress
- [ ] Order detail view — press Enter on an order in account view to see full details
## Done
- [x] Cart persistence across sessions — sync cart to server, fetch on startup
- [x] Token refresh — auto-refresh JWT before expiry
- [x] Help screen — press ? to show keybindings
- [x] Order detail view — press Enter on an order in account view to see full details
## Up Next
- [ ] User profile editing — set name/email from account view
- [x ] Cart persistence across sessions — sync cart to server, fetch on startup
- [ ] Token refresh — auto-refresh JWT before expiry
- [ ] Address update — PUT /api/v1/addresses/:id endpoint
- [x] FAQ page — replace hardcoded 2 Q&As with real content (embedded JSON, ~12 entries)
- [x] About page — replace placeholder with real content/credits
- [ ] Loading spinner — replace plain text with animated bubbles/spinner
- [ ] Help screen — press ? to show keybindings
- [ ] Address update — PUT /api/v1/addresses/:id endpoint
- [ ] Single order endpoint — GET /api/v1/orders/:id
- [ ] User profile editing — PUT /api/v1/profile, set name/email from account view
## Backlog
- [ ] Shipping rate calculation — actual costs from Shippo/Bring, not always $0
- [ ] Product filtering/pagination — filter by roast type, paginate results
- [ ] Single order detail API endpoint — GET /api/v1/orders/:id
- [ ] Price model consistency — Coffee.Price float64 vs Order.Total int cents
- [ ] Test coverage — auth, cards, orders, addresses, bring/shippo, validators
- [ ] Update stale documentation — README, CLAUDE.md phase references
## Feature Gaps (vs Reference)
- [ ] Bulk data preload — single /view/init endpoint to fetch all user data at once
- [ ] Splash screen — logo + auth + data preload on connect
- [ ] Product variants — multiple variants per product (roast types) with separate prices
- [ ] Featured/originals product sections — categorize products in shop view
- [ ] Per-product theme colors — dynamic accent color based on selected product
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle region
- [ ] Stripe webhook — sync card updates from Stripe events
- [ ] Shippo webhook — order tracking status updates
- [ ] Email system — order confirmation, shipped notification emails
## Testing
- [ ] Add tests to every file BOA
- [ ] API client tests — pkg/api/client.go has no tests
- [ ] Card handler tests — api/handlers/cards.go
- [ ] Order handler tests — api/handlers/orders.go
- [ ] Address handler tests — api/handlers/addresses.go
- [ ] TUI tests — model_test.go and menu_test.go are empty skeletons
- [ ] Shippo/Bring integration tests
## Post-Release
- [ ] Subscription model — database table, CRUD endpoints, scheduling
- [ ] Subscription creation flow — TUI: select variant → shipping → payment → confirm
- [ ] Subscription management — TUI: list, detail view, cancel with confirmation
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete in account view
- [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts

View File

@@ -42,6 +42,7 @@ type Address struct {
Phone string
}
type validationMatch struct {
StreetName string `json:"street_name"`
HouseNumber int `json:"house_number"`

View File

@@ -32,4 +32,5 @@ var AccountMenuItems = []string{
"order history",
"faq",
"about",
"something",
}

View File

@@ -3,13 +3,7 @@ package shippo
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const baseURL = "https://api.goshippo.com"
"fmt" "io" "net/http" "time") const baseURL = "https://api.goshippo.com"
// Client handles communication with the Shippo API.
type Client struct {

View File

@@ -89,51 +89,52 @@ func (m Model) BuildAccountView(availableHeight int) string {
detailView = lines
}
case "faq":
detailView = titleStyle.Render("FAQ") + "\n\n" +
contentStyle.Render("Q: How do I place an order?\nA: Browse products with j/k, adjust quantity with +/-, and checkout via the cart.\n\nQ: What payment methods do you accept?\nA: We accept all major credit cards via Stripe.")
questionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
faqContent := ""
for i, faq := range m.FAQs {
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
if i < len(m.FAQs)-1 {
faqContent += "\n\n"
}
}
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.FaqFocused {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
}
case "about":
detailView = titleStyle.Render("About Terminal Coffee Shop") + "\n\n" +
contentStyle.Render("A terminal-based coffee ordering experience.\nBuilt with Go and Bubbletea.")
accentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
userStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("C6DDF0")).
Bold(true)
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
detailContentWidth,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
detailView = titleStyle.Render("About") + "\n\n" + aboutContent
}
}
// Apply internal scrolling to just the detail view
if m.OrderViewState >= 1 {
detailLines := strings.Split(detailView, "\n")
totalLines := len(detailLines)
// In stacked mode, reduce available height by left panel
scrollHeight := availableHeight
if m.size < large {
leftPanelHeight := len(models.AccountMenuItems) + 1
scrollHeight = availableHeight - leftPanelHeight
}
if scrollHeight < 3 {
scrollHeight = 3
}
// Clamp scroll offset locally (don't mutate m since value receiver)
offset := m.ScrollOffset
maxScroll := totalLines - scrollHeight
if maxScroll < 0 {
maxScroll = 0
}
if offset > maxScroll {
offset = maxScroll
}
if offset < 0 {
offset = 0
}
// Slice to visible window
if totalLines > scrollHeight {
end := offset + scrollHeight
if end > totalLines {
end = totalLines
}
detailLines = detailLines[offset:end]
}
detailView = strings.Join(detailLines, "\n")
if m.OrderViewState >= 1 || m.FaqFocused {
m.AccountDetailVP.SetContent(detailView)
detailView = m.AccountDetailVP.View()
}
detailContainer := lipgloss.NewStyle().

54
pkg/tui/faq.go Normal file
View File

@@ -0,0 +1,54 @@
package tui
import (
"embed"
"encoding/json"
"log"
"strings"
)
//go:embed faq.json
var faqData embed.FS
// FAQ represents a single frequently asked question and its answer.
type FAQ struct {
Question string `json:"question"`
Answer string `json:"answer"`
}
// wordWrap breaks a string into lines that fit within maxWidth characters.
func wordWrap(s string, maxWidth int) string {
if maxWidth <= 0 {
return s
}
words := strings.Fields(s)
if len(words) == 0 {
return ""
}
var lines []string
line := words[0]
for _, word := range words[1:] {
if len(line)+1+len(word) > maxWidth {
lines = append(lines, line)
line = word
} else {
line += " " + word
}
}
lines = append(lines, line)
return strings.Join(lines, "\n")
}
// LoadFaqs reads and parses the embedded faq.json file.
func LoadFaqs() []FAQ {
data, err := faqData.ReadFile("faq.json")
if err != nil {
log.Fatalf("failed to read embedded faq.json: %s", err)
}
var faqs []FAQ
if err := json.Unmarshal(data, &faqs); err != nil {
log.Fatalf("failed to unmarshal faq.json: %s", err)
}
return faqs
}

50
pkg/tui/faq.json Normal file
View File

@@ -0,0 +1,50 @@
[
{
"question": "help, i have a question about my order!",
"answer": "send us an email at support@terminal.shop."
},
{
"question": "when was my coffee roasted? the roast date is blank on my bag.",
"answer": "we roast your coffee within 24 hours of shipping, and you'll receive an email once your order ships. we're working on a solution to stamping the roast date on the bags, but so far all attempts have failed (the ink wipes off)."
},
{
"question": "where do you ship?",
"answer": "we ship all of our blends in the US. Unfortunately, we don't ship to other countries at this time. We are actively looking for other small-batch roasters in other countries and regions though!"
},
{
"question": "is your coffee ethically sourced?",
"answer": "absolutely."
},
{
"question": "is ordering via ssh secure?",
"answer": "you bet it is. arguably more secure than your browser. ssh incorporates encryption and authentication via a process called public key cryptography. if that doesn't sound secure we don't know what does."
},
{
"question": "what is cron?",
"answer": "cron is our monthly membership, available only in the US (for now). if you subscribe, you'll receive a new coffee blend every month, along with other surprises both physical and digital."
},
{
"question": "i tried to subscribe to cron, but it says i need a public key?",
"answer": "we don't allow subscriptions without a public key, as you won't be able to ssh back in with the same identity (we treat users without a public key as anonymous). create a key with `ssh-keygen -t rsa` and make sure your ssh client isn't omitting your key."
},
{
"question": "how do you store my data?",
"answer": "we store minimal information about you when you order through our ssh shop: name, shipping address and email, along with a fingerprint generated from your public key. all payment information is stored exclusively by Stripe."
},
{
"question": "do you offer a subscription?",
"answer": "in addition to cron, our monthly membership, you can also subscribe to any of the blends offered in our shop."
},
{
"question": "will Terminal coffee make me a better developer?",
"answer": "legally we cannot guarantee that it will, but..."
},
{
"question": "does coffee from the command line taste better than regular coffee?",
"answer": "it's just science!"
},
{
"question": "is it true your coffee contains the tears of @thdxr?",
"answer": "yes, this is true."
}
]

View File

@@ -23,7 +23,20 @@ func (m Model) BuildFooter() string {
}
var footerText string
if m.ViewingAccount {
if m.FaqFocused {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("scroll"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.ViewingAccount {
switch m.OrderViewState {
case 2:
// Viewing single order detail
@@ -49,7 +62,7 @@ func (m Model) BuildFooter() string {
)
default:
// Account tabs
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("navigate"),
keybindStyle.Render("enter"),
@@ -58,6 +71,8 @@ func (m Model) BuildFooter() string {
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
@@ -124,7 +139,7 @@ func (m Model) BuildFooter() string {
} else if m.ViewingCart && m.CheckoutStep == 0 {
// In cart view, show proceed option
if len(m.Cart) > 0 {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s",
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
@@ -137,11 +152,13 @@ func (m Model) BuildFooter() string {
descStyle.Render("account"),
keybindStyle.Render("pgup/pgdn"),
descStyle.Render("scroll"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
@@ -150,6 +167,8 @@ func (m Model) BuildFooter() string {
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
@@ -165,7 +184,7 @@ func (m Model) BuildFooter() string {
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("products"),
keybindStyle.Render("+/-"),
@@ -174,6 +193,8 @@ func (m Model) BuildFooter() string {
descStyle.Render("cart"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)

105
pkg/tui/help.go Normal file
View File

@@ -0,0 +1,105 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
)
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)
rowStyle := lipgloss.NewStyle().Padding(0, 1)
sections := []struct {
title string
keys []struct{ key, desc string }
}{
{
title: "navigation",
keys: []struct{ key, desc string }{
{"s", "shop"},
{"c", "cart"},
{"a", "account"},
{"m", "menu"},
},
},
{
title: "browsing",
keys: []struct{ key, desc string }{
{"j/k", "move cursor down/up"},
{"+/-", "adjust quantity"},
{"pgup/pgdn", "scroll content"},
},
},
{
title: "cart & checkout",
keys: []struct{ key, desc string }{
{"p/enter", "proceed to checkout"},
{"d/x", "delete address or card"},
{"esc", "go back one step"},
},
},
{
title: "account",
keys: []struct{ key, desc string }{
{"enter", "select / drill in"},
{"esc", "go back"},
},
},
{
title: "forms",
keys: []struct{ key, desc string }{
{"tab", "next field"},
{"enter", "submit form"},
{"esc", "cancel"},
},
},
{
title: "general",
keys: []struct{ key, desc string }{
{"?", "toggle help page"},
{"q", "quit"},
{"ctrl+c", "force quit"},
},
},
}
content := ""
for i, section := range sections {
if i > 0 {
content += "\n"
}
content += rowStyle.Render(header.Render(section.title)) + "\n"
for _, k := range section.keys {
content += rowStyle.Render(bold.Render(k.key)+" "+base.Render(k.desc)) + "\n"
}
}
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
logo := logoStyle.Render("keybindings")
modal := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")).
Padding(1, 2).
Render(content)
hint := base.Render("press esc or ? to close")
assembled := lipgloss.JoinVertical(
lipgloss.Center,
logo,
"",
modal,
"",
hint,
)
return lipgloss.Place(
m.viewportWidth,
m.viewportHeight,
lipgloss.Center,
lipgloss.Center,
assembled,
)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildMenuView() string {
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
@@ -19,11 +20,13 @@ func (m Model) BuildMenuView() string {
rowStyle.Render(bold.Render("q") + " " + base.Render("quit")),
}
menuContent := ""
for _, row := range rows {
menuContent += row + "\n"
}
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
logo := logoStyle.Render("terminal coffee")

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
gossh "golang.org/x/crypto/ssh"
@@ -63,6 +64,7 @@ type Model struct {
// Menu modal state
ShowingMenu bool // true when full-screen menu is showing
ShowingHelp bool // true when help-screen is showing
menuLastCart bool // was viewing cart when menu was opened
menuLastAcct bool // was viewing account when menu was opened
@@ -101,6 +103,14 @@ type Model struct {
OrdersLoaded bool
OrderCursor int // which order is selected in the list
OrderViewState int // 0=preview, 1=browsing list, 2=viewing detail
// Static content
FAQs []FAQ // loaded from embedded faq.json
FaqFocused bool // true when FAQ detail is focused
// Account detail viewport - handles scrolling for the right panel
AccountDetailVP viewport.Model
accountDetailVPReady bool
}
// updateLayout recalculates all layout variables from the raw viewport dimensions.
@@ -129,6 +139,23 @@ func (m *Model) updateLayout(width, height int) {
}
m.widthContent = m.widthContainer - 2
if m.accountDetailVPReady {
vpHeight := m.heightContainer - 7
if m.size < large {
vpHeight -= len(models.AccountMenuItems) + 1
}
if vpHeight < 3 {
vpHeight = 3
}
leftWidth := 18
vpWidth := m.widthContent - leftWidth - 2
if m.size < large {
vpWidth = m.widthContent
}
m.AccountDetailVP.Width = vpWidth
m.AccountDetailVP.Height = vpHeight
}
}
// ProductsMsg is sent when products are fetched from API
@@ -211,6 +238,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AccountDetailVP.GotoTop()
case "a":
m.ShowingMenu = false
m.ViewingCart = false
@@ -220,6 +249,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AccountDetailVP.GotoTop()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
@@ -232,8 +263,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AccountDetailVP.GotoTop()
case "esc":
m.ShowingMenu = false
case "?":
m.ShowingMenu = false
m.ShowingHelp = true
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
// Help Modal
if m.ShowingHelp {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "esc", "?":
m.ShowingHelp = false
case "q", "ctrl+c":
return m, tea.Quit
}
@@ -679,6 +728,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
})
case tea.KeyMsg:
// Toggle help overlay from any view
if msg.String() == "?" {
m.ShowingHelp = true
return m, nil
}
// Normal shop mode
switch msg.String() {
case "ctrl+c", "q":
@@ -692,6 +747,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AccountDetailVP.GotoTop()
case "c":
m.ViewingCart = true
@@ -701,6 +758,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AccountDetailVP.GotoTop()
case "a":
m.ViewingCart = false
@@ -710,6 +769,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
m.AccountDetailVP.GotoTop()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
@@ -734,6 +795,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
// Account view: focus into FAQ for scrolling
if m.ViewingAccount && m.AccountCursor == 1 && !m.FaqFocused {
m.FaqFocused = true
m.ScrollOffset = 0
return m, nil
}
// Proceed to checkout from cart
if m.ViewingCart && m.CheckoutStep == 0 && len(m.Cart) > 0 {
m.CheckoutStep = 1
@@ -741,6 +808,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "esc":
// Unfocus FAQ scrolling
if m.ViewingAccount && m.FaqFocused {
m.FaqFocused = false
m.ScrollOffset = 0
m.AccountDetailVP.GotoTop()
return m, nil
}
// Navigate back through order view states
if m.ViewingAccount && m.OrderViewState > 0 {
m.OrderViewState--
@@ -775,7 +849,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "up", "k":
if m.ViewingAccount {
if m.OrderViewState == 1 && m.OrderCursor > 0 {
if m.FaqFocused {
// Scroll FAQ content up via viewport
m.AccountDetailVP.LineUp(3)
} else if m.OrderViewState == 1 && m.OrderCursor > 0 {
// Navigate up within order list
m.OrderCursor--
// Auto-scroll to keep cursor visible
@@ -791,6 +868,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if m.OrderViewState == 0 && m.AccountCursor > 0 {
// Navigate up within account tabs
m.AccountCursor--
m.ScrollOffset = 0
m.AccountDetailVP.GotoTop()
}
} else if !m.ViewingCart && !m.ViewingAccount && m.Cursor > 0 {
m.Cursor--
@@ -806,7 +885,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "down", "j":
if m.ViewingAccount {
if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 {
if m.FaqFocused {
// Scroll FAQ content down via viewport
m.AccountDetailVP.LineDown(3)
} else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 {
// Navigate down within order list
m.OrderCursor++
// Auto-scroll to keep cursor visible
@@ -831,6 +913,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 {
// Navigate down within account tabs
m.AccountCursor++
m.ScrollOffset = 0
m.AccountDetailVP.GotoTop()
}
} else if !m.ViewingCart && !m.ViewingAccount && m.Cursor < len(m.Coffees)-1 {
m.Cursor++
@@ -1409,6 +1493,7 @@ func NewModel(username string) Model {
Loading: true,
APIClient: api.NewClient("http://localhost:8080", ""),
StripeKey: os.Getenv("STRIPE_PUBLIC_KEY"),
FAQs: LoadFaqs(),
}
m.updateLayout(120, 30)
return m

View File

@@ -2,7 +2,9 @@ package tui
import (
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
@@ -37,6 +39,11 @@ func (m Model) View() string {
return m.BuildMenuView()
}
// If the help modal is showing, render it full-screen (bypass container)
if m.ShowingHelp {
return m.BuildHelpView()
}
// Build header with cart tab
header := m.BuildHeader()
@@ -79,6 +86,23 @@ func (m Model) View() string {
errorBanner := errorStyle.Render("⚠ " + m.ErrorMsg)
if m.ViewingAccount {
if !m.accountDetailVPReady {
leftWidth := 18
vpWidth := m.widthContent - leftWidth - 2
if m.size < large {
vpWidth = m.widthContent
}
vpHeight := availableContentHeight
if m.size < large {
vpHeight -= len(models.AccountMenuItems) + 1
}
if vpHeight < 3 {
vpHeight = 3
}
m.AccountDetailVP = viewport.New(vpWidth, vpHeight)
m.AccountDetailVP.KeyMap = viewport.KeyMap{}
m.accountDetailVPReady = true
}
content = errorBanner + "\n" + m.BuildAccountView(availableContentHeight)
} else if m.ViewingCart && m.CheckoutStep == 1 {
if m.ShippingView == 0 && m.ShippingForm == nil {
@@ -86,6 +110,8 @@ func (m Model) View() string {
} else if m.ShippingForm != nil {
content = errorBanner + "\n" + m.RenderShippingForm(m.ShippingForm)
}
// TODO! THIS NEEDS TO BE REFACTORED - JESUS CHIRST
} else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut {
content = errorBanner + "\n submitting order..."
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentView == 0 && m.PaymentForm == nil {
@@ -100,6 +126,23 @@ func (m Model) View() string {
content = errorBanner + "\n" + m.BuildShopView()
}
} else if m.ViewingAccount {
if !m.accountDetailVPReady {
leftWidth := 18
vpWidth := m.widthContent - leftWidth - 2
if m.size < large {
vpWidth = m.widthContent
}
vpHeight := availableContentHeight
if m.size < large {
vpHeight -= len(models.AccountMenuItems) + 1
}
if vpHeight < 3 {
vpHeight = 3
}
m.AccountDetailVP = viewport.New(vpWidth, vpHeight)
m.AccountDetailVP.KeyMap = viewport.KeyMap{} // we handle keys manually
m.accountDetailVPReady = true
}
content = m.BuildAccountView(availableContentHeight)
} else if m.ViewingCart && m.CheckoutStep == 1 {
if m.ShippingView == 0 && m.ShippingForm == nil {
@@ -126,7 +169,9 @@ func (m Model) View() string {
totalLines := len(contentLines)
// Skip global scroll when account view handles its own scrolling
if !(m.ViewingAccount && m.OrderViewState >= 1) {
// (order list/detail and FAQ focused use the viewport in BuildAccountView)
accountHandlesScroll := m.ViewingAccount && (m.OrderViewState >= 1 || m.FaqFocused)
if !accountHandlesScroll {
// Ensure scroll offset is without bounds
if m.ScrollOffset < 0 {
m.ScrollOffset = 0