Some checks failed
Deploy / deploy (push) Has been cancelled
Reviewed-on: http://gitea/marcusDenslow/terminalShop/pulls/1
442 lines
14 KiB
Go
442 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 "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
|
|
}
|