diff --git a/pkg/shippo/shippo.go b/pkg/shippo/shippo.go index 31f9525..d1d8819 100644 --- a/pkg/shippo/shippo.go +++ b/pkg/shippo/shippo.go @@ -3,7 +3,13 @@ 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 { @@ -115,7 +121,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) { return nil, fmt.Errorf("failed to parse response: %w", err) } - // Only accept addresses that Shippo explicitly validates as valid. + // Only accept addresses that Shippo explicitly validates as valid. // International addressees where Shippo cannot validate are rejected. // Address validation for addresses in other countries need to be added on a per-country API basis. // Going to add Bring validation for Norway next diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 336fc08..1e40392 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -132,9 +132,44 @@ func (m Model) BuildAccountView(availableHeight int) string { } } + // Apply internal scrolling to just the detail view + // Scrolling is needed for: order detail/list and FAQ when focused if m.OrderViewState >= 1 || m.FaqFocused { - m.AccountDetailVP.SetContent(detailView) - detailView = m.AccountDetailVP.View() + 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") } detailContainer := lipgloss.NewStyle(). @@ -316,3 +351,53 @@ func (m Model) buildOrderDetailView(order models.Order, width int) string { return b.String() } + +// computeFaqScrollMax calculates the maximum scroll offset for the FAQ content. +// This renders the FAQ the same way BuildAccountView does, counts the lines, +// and returns the max scroll value. Returns 0 if no scrolling is needed. +func (m Model) computeFaqScrollMax() int { + leftWidth := 18 + rightWidth := m.widthContent - leftWidth - 2 + detailContentWidth := rightWidth - 2 + if m.size < large { + detailContentWidth = m.widthContent - 2 + } + + questionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#4682B4")). + Bold(true) + contentStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + 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" + } + } + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + MarginBottom(2) + detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent + + totalLines := len(strings.Split(detailView, "\n")) + + scrollHeight := m.heightContainer - 7 + if m.size < large { + scrollHeight -= len(models.AccountMenuItems) + 1 + } + if scrollHeight < 3 { + scrollHeight = 3 + } + + maxScroll := totalLines - scrollHeight + if maxScroll < 0 { + maxScroll = 0 + } + return maxScroll +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 8013a40..5b4ca38 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" gossh "golang.org/x/crypto/ssh" @@ -108,9 +107,6 @@ type Model struct { 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. @@ -139,23 +135,6 @@ 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 @@ -239,7 +218,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false - m.AccountDetailVP.GotoTop() case "a": m.ShowingMenu = false m.ViewingCart = false @@ -250,7 +228,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false - m.AccountDetailVP.GotoTop() if !m.OrdersLoaded { return m, m.fetchOrdersCmd() } @@ -264,7 +241,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false - m.AccountDetailVP.GotoTop() case "esc": m.ShowingMenu = false case "?": @@ -748,7 +724,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false - m.AccountDetailVP.GotoTop() case "c": m.ViewingCart = true @@ -759,7 +734,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false - m.AccountDetailVP.GotoTop() case "a": m.ViewingCart = false @@ -770,7 +744,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ScrollOffset = 0 m.OrderViewState = 0 m.FaqFocused = false - m.AccountDetailVP.GotoTop() + if !m.OrdersLoaded { return m, m.fetchOrdersCmd() } @@ -812,7 +786,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.ViewingAccount && m.FaqFocused { m.FaqFocused = false m.ScrollOffset = 0 - m.AccountDetailVP.GotoTop() return m, nil } // Navigate back through order view states @@ -850,8 +823,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "up", "k": if m.ViewingAccount { if m.FaqFocused { - // Scroll FAQ content up via viewport - m.AccountDetailVP.LineUp(3) + // Scroll FAQ content up + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } } else if m.OrderViewState == 1 && m.OrderCursor > 0 { // Navigate up within order list m.OrderCursor-- @@ -869,7 +845,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Navigate up within account tabs m.AccountCursor-- m.ScrollOffset = 0 - m.AccountDetailVP.GotoTop() } } else if !m.ViewingCart && !m.ViewingAccount && m.Cursor > 0 { m.Cursor-- @@ -886,8 +861,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down", "j": if m.ViewingAccount { if m.FaqFocused { - // Scroll FAQ content down via viewport - m.AccountDetailVP.LineDown(3) + // Scroll FAQ content down + m.ScrollOffset += 3 + maxScroll := m.computeFaqScrollMax() + if m.ScrollOffset > maxScroll { + m.ScrollOffset = maxScroll + } } else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 { // Navigate down within order list m.OrderCursor++ @@ -914,7 +893,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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++ @@ -941,15 +919,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "pgup", "ctrl+u": - // Scroll up - m.ScrollOffset -= 3 - if m.ScrollOffset < 0 { - m.ScrollOffset = 0 + if m.ViewingAccount { + // Only scroll when focused into scrollable content + if m.FaqFocused || m.OrderViewState >= 2 { + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } + } + } else { + // Scroll up in shop/cart views + m.ScrollOffset -= 3 + if m.ScrollOffset < 0 { + m.ScrollOffset = 0 + } } case "pgdown", "ctrl+d": - // Scroll down - m.ScrollOffset += 3 + if m.ViewingAccount { + // Only scroll when focused into scrollable content + if m.FaqFocused || m.OrderViewState >= 2 { + m.ScrollOffset += 3 + maxScroll := m.computeFaqScrollMax() + if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll { + m.ScrollOffset = maxScroll + } + } + } else { + // Scroll down in shop/cart views + m.ScrollOffset += 3 + } case "+", "=": if !m.ViewingCart && !m.ViewingAccount { diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 043e290..6543186 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -2,9 +2,7 @@ package tui import ( "strings" - "terminalShop/pkg/models" - "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" ) @@ -86,23 +84,6 @@ 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 { @@ -110,8 +91,6 @@ 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 { @@ -126,23 +105,6 @@ 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 { @@ -169,10 +131,10 @@ func (m Model) View() string { totalLines := len(contentLines) // Skip global scroll when account view handles its own scrolling - // (order list/detail and FAQ focused use the viewport in BuildAccountView) + // (order list/detail and FAQ focused use internal scrolling in BuildAccountView) accountHandlesScroll := m.ViewingAccount && (m.OrderViewState >= 1 || m.FaqFocused) if !accountHandlesScroll { - // Ensure scroll offset is without bounds + // Ensure scroll offset is within bounds if m.ScrollOffset < 0 { m.ScrollOffset = 0 }