Files
terminalShop-old/pkg/tui/account.go
marcusDenslow 6598f63cb2 refactor(tui): port to charm v2
bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly
mechanical chur, new import paths, vewport getter/setter api, view()
returns tea.view now, huh.theme became an interface so the theme package
needed a small reshape. Render plumbing is gone (no more lipgloss.Render
field on the Model or MakeRenderer in main.go). All pages still work
over ssh. setting up for the refund composer next
2026-05-25 19:00:08 +02:00

431 lines
14 KiB
Go

package tui
import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// Follows the reference pattern: each page owns its own viewport(s).
func (m Model) updateAccountViewport() Model {
// Match the reference: only subtract header + footer, no extra margins
// (account is a viewport page — views.go renders it without margin padding)
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
verticalMarginHeight := headerHeight + footerHeight
availableHeight := m.heightContainer - verticalMarginHeight
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailWidth := rightWidth
if m.size < large {
detailWidth = m.widthContent
// In stacked mode, reduce available height by the left panel
leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
}
if availableHeight < 3 {
availableHeight = 3
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight)
}
return m
}
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
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) {
return ""
}
selectedItem := models.AccountMenuItems[m.account.cursor]
switch selectedItem {
case "active orders":
return m.ActiveOrdersView(detailContentWidth)
case "order history":
return m.OrdersView(detailContentWidth)
case "addresses":
return m.AddressesView(detailContentWidth)
case "cards":
return m.CardsView(detailContentWidth)
case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
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.account.faqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
}
return detailView
case "about":
return m.AboutView(detailContentWidth)
}
return ""
}
// scrollToAccountDetailItem adjusts the viewport scroll to keep the selected item visible.
// Follows the reference pattern: cursor moves immediately, viewport catches up.
//
// Each sub-page has a title header (e.g. "Order History" + margin + newlines) that
// pushes the first item down. headerOffset accounts for those lines. itemHeight
// must match the actual rendered card height including borders, padding, and the
// inter-card gap ("\n" between cards in the view function).
func (m Model) scrollToAccountDetailItem() Model {
var itemHeight, itemCount, selectedIndex, headerOffset int
switch {
case m.account.orderViewState == 1:
// No title in list mode, cards joined with JoinVertical (no gaps)
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
headerOffset = 0
itemHeight = 4
itemCount = len(m.currentOrderList())
selectedIndex = m.account.orderCursor
case m.account.addressListFocused:
headerOffset = 3
itemHeight = 5
itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor
case m.account.cardListFocused:
headerOffset = 3
itemHeight = 4
itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor
default:
return m
}
if itemCount == 0 {
return m
}
targetY := headerOffset + (selectedIndex * itemHeight)
vpH := m.account.detailViewport.Height()
offset := m.account.detailViewport.YOffset()
// 1-item buffer: scroll when the NEXT item would be off-screen,
// not when the current item hits the very edge
if targetY-itemHeight < offset {
m.account.detailViewport.SetYOffset(targetY - itemHeight)
}
if targetY+itemHeight+itemHeight > offset+vpH {
m.account.detailViewport.SetYOffset(targetY - vpH + itemHeight + itemHeight)
}
return m
}
func (m Model) BuildAccountView(availableHeight int) string {
leftWidth := 18
// Build the left panel (menu items)
leftPanel := ""
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
} else {
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
leftContainer := m.theme.Base().
Width(leftWidth)
// Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent()
m.account.detailViewport.SetContent(detailContent)
// Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View()
// Responsive layout: side-by-side on large, stacked on small/medium
if m.size < large {
return lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
detailRendered,
)
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
detailRendered,
)
}
func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true)
userStyle := m.theme.TextAccent().Bold(true)
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
width,
)) + "\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 += contentStyle.Render(wordWrap(
"This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
width,
)) + "\n"
return titleStyle.Render("About") + "\n\n" + aboutContent
}
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
// Pass non-key messages to viewport (e.g. window resize)
var cmd tea.Cmd
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd
}
selectedItem := ""
if m.account.cursor < len(models.AccountMenuItems) {
selectedItem = models.AccountMenuItems[m.account.cursor]
}
switch keyMsg.String() {
case "up", "k":
// In order detail or FAQ, scroll the viewport directly
if m.account.orderViewState == 2 || m.account.faqFocused {
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m.account.detailViewport.ScrollUp(3)
return m, nil
}
if m.account.addressListFocused && m.account.addressDeleting == nil {
if m.account.addressCursor > 0 {
m.account.addressCursor--
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
if m.account.cardCursor > 0 {
m.account.cardCursor--
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.orderViewState == 1 && m.account.orderCursor > 0 {
m.account.orderCursor--
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
} else if m.account.orderViewState == 0 && m.account.cursor > 0 {
m.account.cursor--
m.account.detailViewport.GotoTop()
}
case "down", "j":
if m.account.orderViewState == 2 || m.account.faqFocused {
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m.account.detailViewport.ScrollDown(3)
return m, nil
}
if m.account.addressListFocused && m.account.addressDeleting == nil {
if m.account.addressCursor < len(m.SavedAddresses)-1 {
m.account.addressCursor++
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
if m.account.cardCursor < len(m.SavedCards)-1 {
m.account.cardCursor++
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.orderViewState == 1 && m.account.orderCursor < len(m.currentOrderList())-1 {
m.account.orderCursor++
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
} else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && m.account.cursor < len(models.AccountMenuItems)-1 {
m.account.cursor++
m.account.detailViewport.GotoTop()
}
case "pgup", "ctrl+u", "pgdown", "ctrl+d":
// Ensure viewport has content before delegating scroll
m.account.detailViewport.SetContent(m.getAccountDetailContent())
var cmd tea.Cmd
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd
case "x", "d":
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
m.account.addressDeleting = &m.account.addressCursor
} else if m.account.cardListFocused && m.account.cardDeleting == nil && m.account.cardCursor < len(m.SavedCards) {
m.account.cardDeleting = &m.account.cardCursor
}
case "y":
if m.account.addressDeleting != nil {
cursor := *m.account.addressDeleting
addr := m.SavedAddresses[cursor]
m.account.addressDeleting = nil
m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...)
if m.account.addressCursor >= len(m.SavedAddresses) && m.account.addressCursor > 0 {
m.account.addressCursor--
}
return m, m.deleteAddressCmd(addr.ID)
} else if m.account.cardDeleting != nil {
cursor := *m.account.cardDeleting
card := m.SavedCards[cursor]
m.account.cardDeleting = nil
m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...)
if m.account.cardCursor >= len(m.SavedCards) && m.account.cardCursor > 0 {
m.account.cardCursor--
}
return m, m.deleteCardCmd(card.ID)
}
case "n":
m.account.addressDeleting = nil
m.account.cardDeleting = nil
case "p", "enter":
switch selectedItem {
case "order history", "active orders":
if len(m.currentOrderList()) > 0 {
switch m.account.orderViewState {
case 0:
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
case 1:
m.account.orderYOffset = m.account.detailViewport.YOffset()
m.account.orderViewState = 2
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "q", value: "quit"},
}
}
}
case "addresses":
if !m.account.addressListFocused {
m.account.addressListFocused = true
m.account.addressCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "cards":
if !m.account.cardListFocused {
m.account.cardListFocused = true
m.account.cardCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "j/k", value: "scroll"},
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "q", value: "quit"},
}
}
}
case "esc":
accountDefaultFooter := []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
if m.account.addressDeleting != nil {
m.account.addressDeleting = nil
} else if m.account.addressListFocused {
m.account.addressListFocused = false
m.account.addressCursor = 0
m.footer = accountDefaultFooter
} else if m.account.cardDeleting != nil {
m.account.cardDeleting = nil
} else if m.account.cardListFocused {
m.account.cardListFocused = false
m.account.cardCursor = 0
m.footer = accountDefaultFooter
} else if m.account.faqFocused {
m.account.faqFocused = false
m.account.detailViewport.GotoTop()
m.footer = accountDefaultFooter
} else if m.account.orderViewState > 0 {
m.account.orderViewState--
if m.account.orderViewState == 1 {
// Set content first so SetYOffset can clamp against correct line count
m.account.detailViewport.SetContent(m.getAccountDetailContent())
if m.account.orderYOffset > 0 {
m.account.detailViewport.SetYOffset(m.account.orderYOffset)
m.account.orderYOffset = 0
}
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else {
m.account.detailViewport.GotoTop()
m.footer = accountDefaultFooter
}
}
}
return m, nil
}