mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 727fb978fa | |||
| 157a752540 | |||
| de9120d1eb | |||
| 77a044e11d | |||
| 40dccc8139 | |||
| ac04676abb | |||
| 53e443e93d | |||
| 6dba7c6dc6 | |||
| c12b0dcbc2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
.env.local
|
||||
.env.production
|
||||
.env.example
|
||||
.env.prod
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
api.yourdomain.com {
|
||||
api.sshops.uk {
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
|
||||
staging-api.sshops.uk {
|
||||
reverse_proxy host.docker.internal:8081
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
24
docker-compose.staging.yml
Normal file
24
docker-compose.staging.yml
Normal 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
|
||||
@@ -61,6 +61,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
|
||||
|
||||
reqBody := TokenRequest{
|
||||
Fingerprint: fingerprint,
|
||||
SSHPublicKey: pubKeyStr,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user