Files
terminalShop-old/pkg/tui/account.go

466 lines
16 KiB
Go

package tui
import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
// accountBodyHeight returns the inner content height of the account body
// panels, mirroring the boxed shop layout: header + footer + 2 blank gap
// rows + 2 panel border rows are reserved.
func (m Model) accountBodyHeight() int {
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
h := m.heightContainer - headerHeight - footerHeight - 4
if h < 3 {
h = 3
}
return h
}
// accountDetailWidth returns the width of the unboxed detail area: the chrome
// 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
}
// accountTitleStyle styles a section title for the detail area: centered
// across the full detail width so it sits over the content below it.
func accountTitleStyle(width int) lipgloss.Style {
return pWht.Bold(true).Width(width).Align(lipgloss.Center)
}
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// The viewport spans the unboxed detail area to the right of the menu panel.
func (m Model) updateAccountViewport() Model {
detailW := m.accountDetailWidth()
// +1: no panel on the detail side, but one row is spent on the leading
// newline that aligns the content with the menu panel's first item row.
availableHeight := m.accountBodyHeight() + 1
if m.size < large {
// Stacked: the menu panel sits above the detail area
menuPanelHeight := len(models.AccountMenuItems) + 2
availableHeight -= menuPanelHeight
if availableHeight < 3 {
availableHeight = 3
}
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailW), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.SetWidth(detailW)
m.account.detailViewport.SetHeight(availableHeight)
}
return m
}
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
detailContentWidth := m.accountDetailWidth()
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 := accountTitleStyle(detailContentWidth).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() string {
_, menuW, _, _ := m.shopLayout()
// Left panel: menu items, pill highlight like the shop chrome
var menu []string
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Background(cPill).Foreground(cWhite).Bold(true).
Render(" "+item))
} else {
menu = append(menu, lipgloss.NewStyle().Width(menuW).
Render(" "+pBody.Render(item)))
}
}
menuContent := lipgloss.JoinVertical(lipgloss.Left, menu...)
// 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: menu panel beside the unboxed detail area on large,
// stacked on small/medium. The detail side draws its own item boxes.
var body string
if m.size < large {
body = lipgloss.JoinVertical(
lipgloss.Left,
panel(menuW, 0, menuContent),
detailRendered,
)
} else {
bodyH := m.accountBodyHeight()
// Leading newline drops the detail content one row so its title
// lines up with the menu panel's first item, not its top border.
body = lipgloss.JoinHorizontal(
lipgloss.Top,
panel(menuW, bodyH, menuContent),
" ",
"\n"+detailRendered,
)
}
// Blank rows separate the body from the header and footer boxes.
return "\n" + body + "\n"
}
func (m Model) AboutView(width int) string {
titleStyle := accountTitleStyle(width).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 "r":
if m.account.orderViewState == 2 {
if order, ok := m.selectedRefundableOrder(); ok {
return m.OpenRefundComposer(order.ID)
}
}
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()
footer := []footerCommand{
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "q", value: "quit"},
}
if _, ok := m.selectedRefundableOrder(); ok {
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
}
m.footer = footer
}
}
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
}