9 Commits

Author SHA1 Message Date
727fb978fa add staging environment setup 2026-04-12 11:10:13 +02:00
157a752540 fix: added user verification to continue checkout after adding card
when the user added their card it would instantly complete the order. this could be unwanted so now i have added an extra input requirement to confirm the payment after adding the card
2026-04-11 20:03:17 +02:00
de9120d1eb fix: Caddyfile domain and Stripe webhook API version mismatch
- Update Caddyfile from placeholder api.yourdomain.com to api.sshops.uk
- Use ConstructEventWithOptions with IgnoreAPIVersionMismatch to handle
  Stripe account API version (2026-01-28.clover) vs stripe-go v78 (2024-04-10)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:10:06 +02:00
77a044e11d bruh 2026-04-11 11:37:11 +02:00
40dccc8139 something 2026-04-11 11:10:37 +02:00
ac04676abb adding catppuccin theme 2026-04-11 11:05:16 +02:00
53e443e93d updating 2026-04-09 19:31:55 +02:00
6dba7c6dc6 fix: output api binary to /tmp to avoid conflict with api/ directory 2026-04-09 19:15:58 +02:00
c12b0dcbc2 update 2026-04-09 19:11:39 +02:00
13 changed files with 220 additions and 44 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.env.local
.env.production
.env.example
.env.prod
# Build artifacts
*.exe

View File

@@ -1,3 +1,7 @@
api.yourdomain.com {
api.sshops.uk {
reverse_proxy api:8080
}
staging-api.sshops.uk {
reverse_proxy host.docker.internal:8081
}

View File

@@ -4,10 +4,10 @@ RUN apk add --no-cache gcc musl-dev
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o api ./api/main.go
RUN CGO_ENABLED=1 GOOS=linux go build -o /tmp/api ./api/main.go
FROM alpine:3.21
RUN apk add --no-cache libc6-compat
WORKDIR /root/
COPY --from=builder /app/api .
COPY --from=builder /tmp/api ./api
CMD ["./api"]

View File

@@ -48,7 +48,10 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEvent(body, sig, h.webhookSecret); err != nil {
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
}); err != nil {
log.Printf("[webhook] signature verification failed: %v", err)
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
return
}

View File

@@ -33,6 +33,8 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api

View File

@@ -0,0 +1,24 @@
services:
api:
build:
context: .
dockerfile: Dockerfile.api
restart: unless-stopped
env_file: .env.staging
volumes:
- ./data-staging:/data
ports:
- "8081:8080"
ssh:
build:
context: .
dockerfile: Dockerfile.ssh
restart: unless-stopped
env_file: .env.staging
ports:
- "2222:22"
volumes:
- ./.ssh:/root/.ssh
depends_on:
- api

View File

@@ -61,6 +61,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
reqBody := TokenRequest{
Fingerprint: fingerprint,
SSHPublicKey: pubKeyStr,
ClientSecret: clientSecret,
}

View File

@@ -2,6 +2,8 @@ package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -42,7 +44,7 @@ func (m Model) RenderConfirmation() string {
cartSection += sectionStyle.Render(line) + "\n"
}
cartSection += sectionStyle.Render(
labelStyle.Render("Shipping: ") + valueStyle.Render("$0.00"),
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
) + "\n"
cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)),
@@ -122,3 +124,90 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
return nil
}
}
func formatUSD(cents int) string {
return fmt.Sprintf("$%d.%02d", cents/100, cents%100)
}
func (m Model) generateReviewContent() string {
view := strings.Builder{}
if m.ShippingInfo != nil {
view.WriteString(m.ShippingInfo.Name + "\n")
view.WriteString(m.ShippingInfo.Street + "\n")
if m.ShippingInfo.Street2 != "" {
view.WriteString(m.ShippingInfo.Street2 + "\n")
}
view.WriteString(fmt.Sprintf("%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country))
}
view.WriteString("\n")
if m.SelectedCard != nil {
view.WriteString(fmt.Sprintf("cc: **** **** **** %s\n", m.SelectedCard.Last4))
}
view.WriteString("\n")
subtotal := 0
for _, item := range m.GetCartItemsSlice() {
subtotal += item.Coffee.Price * item.Quantity
}
view.WriteString(fmt.Sprintf("subtotal: %\n", formatUSD(subtotal)))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n")
view.WriteString(m.theme.TextDim().Render("press enter to confirm") + "\n")
return m.theme.Base().Padding(0, 1).Render(view.String())
}
func (m Model) ReviewView() string {
if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
}
content := m.generateReviewContent()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
m = m.SwitchPage(paymentPage)
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil
case tea.KeyMsg:
if m.CheckingOut {
return m, nil
}
switch msg.String() {
case "esc":
m = m.SwitchPage(paymentPage)
return m, nil
case "enter":
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
}
return m, nil
}

View File

@@ -36,6 +36,7 @@ const (
cartPage
shippingPage
paymentPage
reviewPage
confirmPage
accountPage
splashPage
@@ -137,9 +138,9 @@ type Model struct {
splashCursor bool // toggles for blinking cursor animation
// Confirmation screen — snapshot populated on checkout, cleared when leaving confirm page
ConfirmTotal int // server-confirmed order total in cents
ConfirmTotal int // server-confirmed order total in cents
ConfirmItems []models.CartItem // cart items at time of purchase
ConfirmShipping *models.Address // shipping address at time of purchase
ConfirmShipping *models.Address // shipping address at time of purchase
}
@@ -169,7 +170,7 @@ func (m Model) inCartFlow() bool {
return m.currentPage == cartPage ||
m.currentPage == shippingPage ||
m.currentPage == paymentPage ||
m.currentPage == confirmPage
m.currentPage == reviewPage
}
func (m Model) checkoutStep() int {
@@ -178,7 +179,7 @@ func (m Model) checkoutStep() int {
return 1
case paymentPage:
return 2
case confirmPage:
case reviewPage:
return 3
default:
return 0
@@ -575,6 +576,37 @@ func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
}
}
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
}
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
Name: stripe.String(form.CardName),
Number: stripe.String(form.CardNumber),
ExpMonth: stripe.String(form.ExpiryMonth),
ExpYear: stripe.String(form.ExpiryYear),
CVC: stripe.String(form.CVC),
AddressZip: stripe.String(form.BillingZip),
},
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
return CardSavedForReviewMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
return CardSavedForReviewMsg{Card: *card}
}
}
// loadCartFromAPI populates the in-memory cart map from an API CartData response
// It matches cart items to local Coffees by ID so the Coffee struct is populated
func (m *Model) loadCartFromAPI(cartData *api.CartData) {

View File

@@ -39,6 +39,13 @@ type PaymentFormCompleteMsg struct {
BillingZip string
}
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
// The user is taken to the review page to confirm before checkout
type CardSavedForReviewMsg struct {
Card models.Card
Err error
}
// PaymentFormErrorMsg is sent when payment form validation fails
type PaymentFormErrorMsg struct {
Message string
@@ -411,44 +418,31 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
case PaymentFormCompleteMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = true
m.CheckingOut = true
return m, m.saveCardAndConvert(msg)
return m, m.saveCardOnlyCmd(msg)
}
return m, nil
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
if len(m.SavedCards) > 0 {
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
// Re-open the form so the user can retry with corrected details.
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
// Snapshot order details before clearing cart/shipping
m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
// Clear cart and shipping immediately so they don't appear stale elsewhere
m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil
case PaymentFormErrorMsg:
m.ErrorMsg = msg.Message
return m, nil
case CardSavedForReviewMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = false
}
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to save card: %w", msg.Err)
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
m.SavedCards = append(m.SavedCards, msg.Card)
selected := msg.Card
m.SelectedCard = &selected
m.PaymentForm = nil
m = m.SwitchPage(reviewPage)
return m, nil
case PollPaymentInitMsg:
url := msg.URL
m.CollectURL = &url
@@ -469,8 +463,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.CollectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
m = m.SwitchPage(reviewPage)
return m, nil
}
// Card list navigation
@@ -497,8 +491,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.CardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.CardCursor]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
m = m.SwitchPage(reviewPage)
return m, nil
}
if m.CardCursor == len(m.SavedCards) {
m.PaymentView = 1

View File

@@ -45,6 +45,26 @@ func BasicTheme(renderer *lipgloss.Renderer) Theme {
return t
}
// this should resemble something like catppuccin
func Catppuccin(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#6C7086")
t.accent = lipgloss.Color("#CBA6F7")
t.highlight = lipgloss.Color("#CBA6F7")
t.brand = lipgloss.Color("#CBA6F7")
t.label = lipgloss.Color("#A6ADC8")
t.success = lipgloss.Color("#A6E3A1")
t.error = lipgloss.Color("#F38BA8")
t.base = renderer.NewStyle().Foreground(t.body)
t.form = HuhTheme(t)
return t
}
// Color accessors — use in .Foreground() / .Background() calls.
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
func (t Theme) Border() lipgloss.TerminalColor { return t.border }

View File

@@ -25,6 +25,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.updatePaymentViewport()
case confirmPage:
m = m.updateConfirmViewport()
case reviewPage:
// no viewport on review page
}
if m.ShippingForm != nil {
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
@@ -187,6 +189,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.ShippingUpdate(msg)
case paymentPage:
return m.PaymentUpdate(msg)
case reviewPage:
return m.ReviewUpdate(msg)
case confirmPage:
return m.ConfirmUpdate(msg)
case accountPage:

View File

@@ -160,6 +160,8 @@ func (m Model) buildPageContent(height int) string {
return m.ShippingPageView()
case paymentPage:
return m.PaymentPageView()
case reviewPage:
return m.ReviewView()
case confirmPage:
return m.ConfirmView()
case cartPage: