update: reworked the handling of account tab to use bubbleteas viewport. it didnt work - yet
This commit is contained in:
49
TODO.md
49
TODO.md
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@ type Address struct {
|
||||
Phone string
|
||||
}
|
||||
|
||||
|
||||
type validationMatch struct {
|
||||
StreetName string `json:"street_name"`
|
||||
HouseNumber int `json:"house_number"`
|
||||
|
||||
@@ -32,4 +32,5 @@ var AccountMenuItems = []string{
|
||||
"order history",
|
||||
"faq",
|
||||
"about",
|
||||
"something",
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
54
pkg/tui/faq.go
Normal 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
50
pkg/tui/faq.json
Normal 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."
|
||||
}
|
||||
]
|
||||
@@ -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
105
pkg/tui/help.go
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user