Compare commits

...

10 Commits

Author SHA1 Message Date
bad8b3abff style(tui): gofmt main.go and menu.go 2026-05-25 19:05:03 +02:00
6598f63cb2 refactor(tui): port to charm v2
bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly
mechanical chur, new import paths, vewport getter/setter api, view()
returns tea.view now, huh.theme became an interface so the theme package
needed a small reshape. Render plumbing is gone (no more lipgloss.Render
field on the Model or MakeRenderer in main.go). All pages still work
over ssh. setting up for the refund composer next
2026-05-25 19:00:08 +02:00
b404303f84 feat(notify): daily slack reminder for paid orders awaiting labels (#8)
so the ain was that older paid orders were getting buried in the #orders
channel in slack and i would just forget about them. Pin/unpin from
earlier helped, but if i missed pinning one or the channel got noisy, it
was still easy to lose track.

this adds a 24h cron that queries any paid order older than 24h with no
label yet and dumps them all into one #orders message. Each line shows
the order id, how old it is, total, where it is going, and a permalink
back to the orders slack thread so i can hop in and hit Buy Label

Ports the idea from terminal.shops cron/unshipped.ts but out stuch state
is different( we auto-promote paid->shipped on label-buy so
PRE_TRANSIT-stuck can not happen), so the query is "paid + no tracking
number + >24h" instead.

NO orders get a "Bring blocked" tag since i dont have the norwegian org
set up yet and can not actually ship those today. Also fires once 40s
after startup so a deploy does not black out the reminder for a full
day.
2026-05-25 16:45:15 +02:00
48d65c48fa feat(tui): render customer-facing event timeline in order detail veiew (#7)
Pulls events off the new order_events table and shows them as a timeline
  block in the order detail view: Paid, Label created, In transit,
  Delivered (etc) with timestamps. Filters out ops-only events like
  payment_critical, label_orphaned, and tracking_marked_manually so the
  customer never sees internal plumbing.

  DisplayLabel on OrderEvent decides what's customer-visible and what's
  redacted, so the same order_events rows can later power an admin view
  with no filter.
2026-05-24 20:14:14 +02:00
c78021f520 docs(comments): add some comments (#6) 2026-05-24 18:57:58 +02:00
c429c1966e docs(comments): add some comments (#5)
this is the subject of the commit
2026-05-24 18:44:25 +02:00
50e1ba32f3 docs(comments): add some comments (#4) 2026-05-24 12:26:34 +02:00
85b0c53c0a docs(comments): cleanup comments (#3) 2026-05-24 12:02:50 +02:00
366d80a9ee test(dillydallying): testing the ci workflow (#2) 2026-05-23 22:12:46 +02:00
60f52f9851 add ci (#1)
* ci(checks): add pull request workflow and golangci-lint config

* refactor(lint): resolve golangci-lint findings

* style(tui): drop traling blank line for gofmt

* fix(cards): handle crypto/rand.Read error in storeRedirect
2026-05-23 21:43:30 +02:00
48 changed files with 578 additions and 458 deletions

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
pull_request:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Format check
run: |
unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...))
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.4.0
- name: Test (race detector)
run: go test -race ./...

2
.gitignore vendored
View File

@@ -98,6 +98,8 @@ docs/
# Migration state (if using a migration tool that creates state files)
migrations/*.state
gh-dash/
loadtest/run.pid

16
.golangci.yml Normal file
View File

@@ -0,0 +1,16 @@
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
exclusions:
paths:
- terminal-shop-source

View File

@@ -13,7 +13,7 @@ import (
func setupAddressTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_addresses.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
func TestGetAddressesEmpty(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewAddressHandler("", "", "")
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
Addresses []models.Address `json:"addresses"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
@@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) {
func TestGetAddressesWithData(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()

View File

@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
t.Helper()
testDB := "test_auth.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
func TestGetTokenSuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
user := createAuthTestUser(t, "SHA256:testfingerprint123")
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
func TestGetTokenBadSecret(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
func TestGetTokenMissingFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
func TestGetTokenUnknownFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
func TestGetTokenInvalidJSON(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
@@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) {
func TestRegisterWithSSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:existingfp")
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
func TestGetUserBySSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:lookupfp")
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
func TestGetUserBySSHKeyNotFound(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
@@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) {
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)

View File

@@ -30,14 +30,16 @@ var (
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL string) string {
func storeRedirect(stripeURL string) (string, error) {
b := make([]byte, 16)
rand.Read(b)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
payRedirectsMu.Lock()
payRedirects[token] = stripeURL
payRedirectsMu.Unlock()
return token
return token, nil
}
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
@@ -166,7 +168,11 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return
}
token := storeRedirect(sess.URL)
token, err := storeRedirect(sess.URL)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})

View File

@@ -20,7 +20,7 @@ import (
func setupCartTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_cart.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -62,7 +62,7 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request {
func TestGetCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -101,7 +101,7 @@ func TestGetCartEmpty(t *testing.T) {
func TestSetItemAndGetCart(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -157,7 +157,7 @@ func TestSetItemAndGetCart(t *testing.T) {
func TestSetItemUpdateQuantity(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -190,7 +190,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
@@ -202,7 +202,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
func TestSetItemRemove(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -232,7 +232,7 @@ func TestSetItemRemove(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
@@ -241,7 +241,7 @@ func TestSetItemRemove(t *testing.T) {
func TestSetItemInvalidProduct(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -258,7 +258,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
func TestSetItemMissingCoffeeID(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -275,7 +275,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
func TestClearCart(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -310,7 +310,7 @@ func TestClearCart(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
@@ -319,7 +319,7 @@ func TestClearCart(t *testing.T) {
func TestSetAddress(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -358,7 +358,7 @@ func TestSetAddress(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
@@ -367,7 +367,7 @@ func TestSetAddress(t *testing.T) {
func TestSetAddressWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -402,7 +402,7 @@ func TestSetAddressWrongUser(t *testing.T) {
func TestSetCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -440,7 +440,7 @@ func TestSetCard(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
@@ -449,7 +449,7 @@ func TestSetCard(t *testing.T) {
func TestSetCardWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -482,7 +482,7 @@ func TestSetCardWrongUser(t *testing.T) {
func TestConvertCartMissingAddress(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -505,7 +505,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
func TestConvertCartMissingCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -536,7 +536,7 @@ func TestConvertCartMissingCard(t *testing.T) {
func TestConvertCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -553,7 +553,7 @@ func TestConvertCartEmpty(t *testing.T) {
func TestMultipleItems(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
@@ -583,7 +583,7 @@ func TestMultipleItems(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 3 {
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
@@ -595,7 +595,7 @@ func TestMultipleItems(t *testing.T) {
func TestCartIsolation(t *testing.T) {
testDB, user1 := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -638,7 +638,7 @@ func TestCartIsolation(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
@@ -655,7 +655,7 @@ func TestCartIsolation(t *testing.T) {
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))

View File

@@ -5,11 +5,12 @@ import (
"net/http"
"os"
"strconv"
"time"
"terminalShop/pkg/bring"
"terminalShop/pkg/notify"
"terminalShop/pkg/shipping"
"terminalShop/pkg/shippo"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
@@ -20,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
)
type OrderHandler struct {
@@ -43,7 +45,10 @@ func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"orders": orders,

View File

@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
testDB := "test_products.db"
// Remove if exists
os.Remove(testDB)
_ = os.Remove(testDB)
// Reset singleton
database.ResetForTesting()
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
func TestGetProduct(t *testing.T) {
testDB := setupTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewProductHandler()
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
func TestGetProductNotFound(t *testing.T) {
testDB := setupTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewProductHandler()
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
func TestGetProductsEmptyDatabase(t *testing.T) {
testDB := "test_empty.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Create database but don't seed

View File

@@ -7,6 +7,7 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
@@ -75,3 +76,26 @@ func ReconcileOrders(stripeKey string) {
}
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)
err := db.Where(
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
models.OrderStatusPaid, cutoff,
).Order("created_at ASC").Find(&orders).Error
if err != nil {
reconcileLog().Error("unshipped query failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("unshipped orders found", "count", len(orders))
notify.SlackUnshippedReminder(orders)
}

View File

@@ -133,7 +133,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result struct {
Data struct {
@@ -201,7 +201,7 @@ func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePay
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
return
@@ -245,7 +245,7 @@ func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
slog.Warn("slack mark_status: response_url edit failed", "error", err)
return
}
resp.Body.Close()
_ = resp.Body.Close()
}
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
@@ -264,7 +264,7 @@ func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
}
mac := hmac.New(sha256.New, []byte(h.signingSecret))
fmt.Fprintf(mac, "v0:%d:%s", ts, body)
_, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body)
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))

View File

@@ -7,6 +7,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
)
type ViewHandler struct{}
@@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at DESC").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at DESC").Find(&orders)
var cartItems []models.CartItem
if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)

View File

@@ -40,7 +40,8 @@ func main() {
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
defer func() { _ = database.Close() }()
// Run migrations
if err := database.Migrate(db); err != nil {
@@ -61,7 +62,7 @@ func main() {
if err != nil {
log.Printf("tracing init failed (non-fatal): %v", err)
} else {
defer shutdown(context.Background())
defer func() { _ = shutdown(context.Background()) }()
}
stripeclient.InitOTel()
@@ -112,6 +113,21 @@ func main() {
}
}()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received

View File

@@ -59,7 +59,7 @@ func SetupRoutes(
r.Get("/t/{id}", handlers.TrackingRedirect)
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
r.Handle("/metrics", middleware.MetricsHandler())

55
go.mod
View File

@@ -1,14 +1,14 @@
module terminalShop
go 1.25.7
go 1.25.9
require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
charm.land/wish/v2 v2.0.1
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
@@ -23,7 +23,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.50.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
@@ -32,38 +32,35 @@ require (
)
require (
charm.land/log/v2 v2.0.0 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/log v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/conpty v0.1.1 // indirect
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/input v0.3.4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
@@ -78,15 +75,11 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
@@ -104,11 +97,11 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect

124
go.sum
View File

@@ -1,3 +1,15 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
@@ -10,10 +22,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
@@ -22,52 +32,38 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -75,8 +71,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
@@ -91,8 +85,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -147,25 +141,17 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
@@ -247,10 +233,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -273,19 +259,17 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=

16
main.go
View File

@@ -11,11 +11,11 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"charm.land/wish/v2"
"charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
@@ -36,9 +36,6 @@ var (
)
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session
pubKey := s.PublicKey()
var fingerprint, pubKeyStr string
@@ -47,9 +44,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
}
// Create TUI model with user context (no registration screen needed!)
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, []tea.ProgramOption{tea.WithAltScreen()}
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, nil
}
func main() {

View File

@@ -80,7 +80,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
if err != nil {
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
@@ -137,7 +137,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch products: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -167,7 +167,7 @@ func (c *Client) Health() error {
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -217,7 +217,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
if err != nil {
return nil, fmt.Errorf("failed to register user: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var registerResp RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
@@ -302,7 +302,7 @@ func (c *Client) GetCart() (*CartData, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -342,7 +342,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
if err != nil {
return nil, fmt.Errorf("failed to set cart item: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -381,7 +381,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
if err != nil {
return fmt.Errorf("failed to set cart address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -420,7 +420,7 @@ func (c *Client) SetCartCard(cardID uint) error {
if err != nil {
return fmt.Errorf("failed to set cart card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -450,7 +450,7 @@ func (c *Client) ClearCart() error {
if err != nil {
return fmt.Errorf("failed to clear cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -480,7 +480,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
if err != nil {
return nil, fmt.Errorf("failed to convert cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var convertResp ConvertCartResponse
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
@@ -510,7 +510,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch cards: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cardsResp CardsResponse
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
@@ -553,7 +553,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cardResp CardResponse
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
@@ -590,7 +590,7 @@ func (c *Client) CollectCard() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
@@ -619,7 +619,7 @@ func (c *Client) DeleteCard(id uint) error {
if err != nil {
return fmt.Errorf("failed to delete card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
@@ -648,7 +648,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch orders: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var ordersResp OrderResponse
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
@@ -686,7 +686,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var addrResp AddressResponse
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
@@ -732,7 +732,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
if err != nil {
return nil, fmt.Errorf("failed to create address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result struct {
Success bool `json:"success"`
@@ -769,7 +769,7 @@ func (c *Client) DeleteAddress(id uint) error {
if err != nil {
return fmt.Errorf("failed to delete address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
@@ -804,7 +804,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
}

View File

@@ -17,7 +17,7 @@ import (
func setupAuthTestDB(t *testing.T) string {
t.Helper()
testDB := "test_ssh_auth.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
func TestRegisterWithSSHKey(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestIsSSHKeyRegistered(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
}
// After authentication (which auto-creates)
svc.AuthenticateSSHKey(key)
_, _, _ = svc.AuthenticateSSHKey(key)
registered, err = svc.IsSSHKeyRegistered(key)
if err != nil {

View File

@@ -197,7 +197,7 @@ func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out an
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)

View File

@@ -110,7 +110,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
if err != nil {
return nil, fmt.Errorf("bring request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -101,7 +101,7 @@ func Close() error {
// WARNING: Only use this in tests!
func ResetForTesting() {
if db != nil {
Close()
_ = Close()
db = nil
once = sync.Once{}
}

View File

@@ -10,7 +10,7 @@ import (
func TestConnect(t *testing.T) {
// Use a temporary test database
testDB := "test_terminalshop.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
func TestMigrate(t *testing.T) {
testDB := "test_migrate.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
func TestSeed(t *testing.T) {
testDB := "test_seed.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -115,7 +115,7 @@ func TestSeed(t *testing.T) {
func TestGetDB(t *testing.T) {
testDB := "test_getdb.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
// Initialize connection

View File

@@ -71,9 +71,10 @@ type Order struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
}
// TableName specifies the table name for the Order model.

View File

@@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
type OrderEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -14,3 +17,47 @@ type OrderEvent struct {
func (OrderEvent) TableName() string {
return "order_events"
}
func (e *OrderEvent) PayloadMap() map[string]any {
if e.Payload == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
return nil
}
return m
}
func (e *OrderEvent) DisplayLabel() (string, bool) {
switch e.Type {
case "order_paid":
return "Paid", true
case "label_purchased":
return "Label created", true
case "order_delivered":
return "Delivered", true
case "order_refunded":
return "Refunded", true
case "order_failed":
return "Payment failed", true
case "tracking_updated":
status, _ := e.PayloadMap()["status"].(string)
return trackingLabel(status)
}
return "", false
}
func trackingLabel(status string) (string, bool) {
switch status {
case "transit":
return "In transit", true
case "failure":
return "Delivery issue", true
case "returned":
return "Returned to sender", true
case "pre_transit":
return "Label scanned by carrier", true
}
return "", false
}

View File

@@ -1,3 +1,4 @@
// Package notify provides a simple interface to send notifications to Slack.
package notify
import (
@@ -14,8 +15,10 @@ import (
"terminalShop/pkg/models"
)
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars
func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100)
}
@@ -32,7 +35,7 @@ func postSlackMessage(token string, payload map[string]any) (string, bool) {
slog.Warn("slack post failed", "error", err)
return "", false
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
@@ -66,7 +69,7 @@ func slackPinCall(token, endpoint, channel, ts, benignErr string) bool {
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
return false
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
@@ -261,4 +264,44 @@ func markStatusActionBlock(orderID uint) map[string]any {
}
}
var _ = time.Second
func SlackUnshippedReminder(orders []models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" || len(orders) == 0 {
return
}
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
for _, order := range orders {
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"text": strings.Join(lines, "\n"),
})
}
func unshippedOrderLine(order models.Order, channel, workspace string) string {
age := time.Since(order.CreatedAt).Round(time.Hour)
location := order.ShippingCountry
if order.ShippingCity != "" {
location = order.ShippingCity + ", " + order.ShippingCountry
}
label := fmt.Sprintf("Order #%d", order.ID)
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
}
tag := ""
if order.ShippingCountry == "NO" {
tag = " _(NO * Bring blocked)_"
}
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
}

View File

@@ -270,7 +270,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body any, out
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
@@ -432,7 +432,7 @@ func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, e
if err != nil {
return nil, fmt.Errorf("shippo request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -1,9 +1,9 @@
package tui
import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
@@ -33,12 +33,12 @@ func (m Model) updateAccountViewport() Model {
}
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(detailWidth, availableHeight)
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
m.account.detailViewport.Width = detailWidth
m.account.detailViewport.Height = availableHeight
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight)
}
return m
@@ -128,8 +128,8 @@ func (m Model) scrollToAccountDetailItem() Model {
}
targetY := headerOffset + (selectedIndex * itemHeight)
vpH := m.account.detailViewport.Height
offset := m.account.detailViewport.YOffset
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
@@ -324,7 +324,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch selectedItem {
case "order history", "active orders":
if len(m.currentOrderList()) > 0 {
if m.account.orderViewState == 0 {
switch m.account.orderViewState {
case 0:
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.detailViewport.GotoTop()
@@ -334,9 +335,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else if m.account.orderViewState == 1 {
// Save scroll position before entering detail view
m.account.orderYOffset = m.account.detailViewport.YOffset
case 1:
m.account.orderYOffset = m.account.detailViewport.YOffset()
m.account.orderViewState = 2
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{

View File

@@ -6,7 +6,7 @@ import (
"terminalShop/pkg/models"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
func (m Model) OrdersView(width int) string {
@@ -201,6 +201,24 @@ func (m Model) buildOrderDetailView(order models.Order, _ int) string {
}
}
visible := make([]models.OrderEvent, 0, len(order.Events))
for _, event := range order.Events {
if _, ok := event.DisplayLabel(); ok {
visible = append(visible, event)
}
}
if len(visible) > 0 {
b.WriteString(labelStyle.Render("Timeline"))
b.WriteString("\n")
for _, event := range visible {
label, _ := event.DisplayLabel()
when := event.CreatedAt.Format("Jan 02 3:04 PM")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
@@ -248,4 +266,3 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
}
return m.theme.TextHighlight()
}

View File

@@ -1,7 +1,7 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
func (m Model) BuildBreadcrumbs() string {

View File

@@ -3,9 +3,9 @@ package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
func (m Model) updateCartViewport() Model {
@@ -17,12 +17,12 @@ func (m Model) updateCartViewport() Model {
availH = 1
}
if !m.cart.viewportReady {
m.cart.viewport = viewport.New(m.widthContent, availH)
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true
} else {
m.cart.viewport.Width = m.widthContent
m.cart.viewport.Height = availH
m.cart.viewport.SetWidth(m.widthContent)
m.cart.viewport.SetHeight(availH)
}
return m
}
@@ -116,11 +116,11 @@ func (m Model) CartView() string {
}
itemHeight := 4 + 2*boxPadding + 1
targetY := m.cart.cursor * itemHeight
if targetY < m.cart.viewport.YOffset {
if targetY < m.cart.viewport.YOffset() {
m.cart.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.cart.viewport.YOffset+m.cart.viewport.Height {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height + itemHeight)
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
}
}
return lipgloss.Place(

View File

@@ -5,9 +5,9 @@ import (
"strings"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// RenderConfirmation renders the order confirmation view
@@ -73,12 +73,12 @@ func (m Model) updateConfirmViewport() Model {
availH = 1
}
if !m.confirm.viewportReady {
m.confirm.viewport = viewport.New(m.widthContent, availH)
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
m.confirm.viewportReady = true
} else {
m.confirm.viewport.Width = m.widthContent
m.confirm.viewport.Height = availH
m.confirm.viewport.SetWidth(m.widthContent)
m.confirm.viewport.SetHeight(availH)
}
return m
}
@@ -145,18 +145,18 @@ func (m Model) generateReviewContent() string {
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))
fmt.Fprintf(&view, "%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))
fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4)
}
view.WriteString("\n")
subtotal := m.CalculateSubtotal()
view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal)))
fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n")
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")

View File

@@ -3,7 +3,7 @@ package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
func (m Model) BuildFooter() string {

View File

@@ -3,8 +3,8 @@ package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
func (m Model) BuildHeader() string {
@@ -44,7 +44,7 @@ func (m Model) BuildHeader() string {
tabs = []string{logo, shop, account, cart}
}
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(m.renderer.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
}).Render()

View File

@@ -1,8 +1,8 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
func (m Model) BuildHelpView() string {

View File

@@ -1,9 +1,9 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m Model) BuildMenuView() string {

View File

@@ -7,10 +7,9 @@ import (
"sort"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/stripe/stripe-go/v78"
stripetoken "github.com/stripe/stripe-go/v78/token"
@@ -114,8 +113,7 @@ type Model struct {
APIClient *api.Client
// Rendering
renderer *lipgloss.Renderer
theme theme.Theme
theme theme.Theme
// Checkout flow state (spans multiple pages)
CheckingOut bool
@@ -240,7 +238,6 @@ func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage)
m = m.updateCartViewport()
if m.IsCartEmpty() {
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
@@ -400,23 +397,6 @@ func (m Model) fetchCartCmd() tea.Msg {
return CartFetchedMsg{Cart: cart, Err: err}
}
// fetchProductsCmd fetches products from the API
func (m Model) fetchProductsCmd() tea.Msg {
if m.APIClient == nil {
log.Println("[TUI] APIClient is nil, using fallback products")
return ProductsMsg{Err: nil} // No API client, use fallback
}
log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL)
products, err := m.APIClient.GetProducts()
if err != nil {
log.Printf("[TUI] Failed to fetch products: %v", err)
} else {
log.Printf("[TUI] Loaded %d products from API", len(products))
}
return ProductsMsg{Products: products, Err: err}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.authCmd,
@@ -670,58 +650,6 @@ func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
}
}
// saveCardAndConvert tokenizes the card locally using the Stripe publishable
// key (raw card data never reaches the backend), saves the token, then converts the cart
func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CheckoutResultMsg{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 CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
if err := m.resolveCartAddress(); err != nil {
return CheckoutResultMsg{Err: err}
}
// 3. Set the card on the cart
if err := m.APIClient.SetCartCard(card.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
}
// 4. Convert the cart to an order
order, err := m.APIClient.ConvertCart()
if err != nil {
return CheckoutResultMsg{Err: err}
}
return CheckoutResultMsg{
OrderID: order.ID,
Total: order.Total,
}
}
}
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
@@ -824,18 +752,12 @@ func (m Model) SavedCardsIsEmpty() bool {
return len(m.SavedCards) == 0
}
// NewModel creates a new model using the default renderer.
// NewModel creates a new model.
func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
}
// newModelWithRenderer creates a new model with the given renderer.
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
m := Model{
Username: username,
currentPage: splashPage,
renderer: renderer,
theme: theme.BasicTheme(renderer),
theme: theme.BasicTheme(),
Coffees: []models.Coffee{
{
Name: "Espresso",
@@ -902,8 +824,8 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
}
// NewModelWithAuth creates a new model with user authentication context.
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := newModelWithRenderer("", renderer)
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := NewModel("")
m.Fingerprint = fingerprint
m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
@@ -929,7 +851,7 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
}
func newViewport(w, h int) viewport.Model {
return viewport.New(w, h)
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
}
func (m Model) collectCardCmd() tea.Cmd {
@@ -945,8 +867,10 @@ func (m Model) collectCardCmd() tea.Cmd {
}
}
const ordersPollInterval = 10 * time.Second
const cardPollTimeout = 10 * time.Minute
const (
ordersPollInterval = 10 * time.Second
cardPollTimeout = 10 * time.Minute
)
type OrdersPollTickMsg struct{}

View File

@@ -5,10 +5,10 @@ import (
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
@@ -280,12 +280,12 @@ func (m Model) updatePaymentViewport() Model {
availH = 1
}
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(m.widthContent, availH)
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{}
m.payment.viewportReady = true
} else {
m.payment.viewport.Width = m.widthContent
m.payment.viewport.Height = availH
m.payment.viewport.SetWidth(m.widthContent)
m.payment.viewport.SetHeight(availH)
}
return m
}
@@ -301,7 +301,7 @@ func (m Model) PaymentPageView() string {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place(
m.widthContainer,
m.payment.viewport.Height,
m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(),
)
@@ -310,11 +310,11 @@ func (m Model) PaymentPageView() string {
m.payment.viewport.SetContent(content)
itemHeight := 3
targetY := m.payment.cardCursor * itemHeight
if targetY < m.payment.viewport.YOffset {
if targetY < m.payment.viewport.YOffset() {
m.payment.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.payment.viewport.YOffset+m.payment.viewport.Height {
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height + itemHeight + 1)
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
}
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.viewport.GotoBottom()

View File

@@ -6,10 +6,10 @@ import (
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
// countries defines the available countries with their dial codes.
@@ -262,12 +262,12 @@ func (m Model) updateShippingViewport() Model {
availH = 1
}
if !m.shipping.viewportReady {
m.shipping.viewport = viewport.New(m.widthContent, availH)
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.shipping.viewport.KeyMap = viewport.KeyMap{}
m.shipping.viewportReady = true
} else {
m.shipping.viewport.Width = m.widthContent
m.shipping.viewport.Height = availH
m.shipping.viewport.SetWidth(m.widthContent)
m.shipping.viewport.SetHeight(availH)
}
return m
}
@@ -281,11 +281,11 @@ func (m Model) ShippingPageView() string {
m.shipping.viewport.SetContent(content)
itemHeight := 4
targetY := m.shipping.addressCursor * itemHeight
if targetY < m.shipping.viewport.YOffset {
if targetY < m.shipping.viewport.YOffset() {
m.shipping.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.shipping.viewport.YOffset+m.shipping.viewport.Height {
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height + itemHeight + 1)
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
}
if m.shipping.addressCursor == len(m.SavedAddresses) {
m.shipping.viewport.GotoBottom()

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true
} else {
m.shop.menuViewport.Width = menuWidth
m.shop.menuViewport.Height = availableHeight
m.shop.detailViewport.Width = detailWidth
m.shop.detailViewport.Height = availableHeight
m.shop.menuViewport.SetWidth(menuWidth)
m.shop.menuViewport.SetHeight(availableHeight)
m.shop.detailViewport.SetWidth(detailWidth)
m.shop.detailViewport.SetHeight(availableHeight)
}
return m
}
@@ -226,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2)
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
m.shop.detailViewport.GotoTop()
}
return m

View File

@@ -5,9 +5,9 @@ import (
"terminalShop/pkg/api"
"terminalShop/pkg/models"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
type DelayCompleteMsg struct{}

View File

@@ -3,7 +3,7 @@ package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {

View File

@@ -1,51 +1,50 @@
package theme
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/help"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
// HuhTheme creates a huh form theme using this project's color palette.
func HuhTheme(t Theme) *huh.Theme {
var th huh.Theme
func HuhTheme(t Theme) huh.Theme {
var s huh.Styles
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n")
f := &th.Focused
f.Base = t.renderer.NewStyle().
f := &s.Focused
f.Base = lipgloss.NewStyle().
PaddingLeft(1).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderForeground(t.accent)
f.Title = t.renderer.NewStyle().Foreground(t.body)
f.Description = t.renderer.NewStyle().Foreground(t.body)
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
f.Title = lipgloss.NewStyle().Foreground(t.body)
f.Description = lipgloss.NewStyle().Foreground(t.body)
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
f.SelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
f.Option = t.renderer.NewStyle().Foreground(t.body)
f.SelectedOption = t.renderer.NewStyle().Foreground(t.highlight).Bold(true)
f.MultiSelectSelector = t.renderer.NewStyle().Foreground(t.accent).SetString("> ")
f.SelectedPrefix = t.renderer.NewStyle().Foreground(t.highlight).SetString("[x] ")
f.UnselectedPrefix = t.renderer.NewStyle().Foreground(t.dim).SetString("[ ] ")
f.UnselectedOption = t.renderer.NewStyle().Foreground(t.body)
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
f.Option = lipgloss.NewStyle().Foreground(t.body)
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
th.Help = help.New().Styles
s.Help = help.New().Styles
th.Blurred = copyFieldStyles(*f)
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
th.Blurred.SelectSelector = t.renderer.NewStyle().SetString(" ")
th.Blurred.SelectedOption = t.renderer.NewStyle().Foreground(t.success).Bold(true)
th.Blurred.Option = t.renderer.NewStyle().Foreground(t.dim)
s.Blurred = copyFieldStyles(*f)
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim)
return &th
return huh.ThemeFunc(func(bool) *huh.Styles { return &s })
}
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }

View File

@@ -1,56 +1,55 @@
package theme
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"image/color"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/compat"
)
// Theme holds all colors and styles for the terminal coffee shop TUI.
// Theme holds all colors and styles for the TUI
type Theme struct {
renderer *lipgloss.Renderer
background lipgloss.TerminalColor
border lipgloss.TerminalColor
body lipgloss.TerminalColor
dim lipgloss.TerminalColor
accent lipgloss.TerminalColor
highlight lipgloss.TerminalColor
brand lipgloss.TerminalColor
label lipgloss.TerminalColor
success lipgloss.TerminalColor
error lipgloss.TerminalColor
background color.Color
border color.Color
body color.Color
dim color.Color
accent color.Color
highlight color.Color
brand color.Color
label color.Color
success color.Color
error color.Color
base lipgloss.Style
form *huh.Theme
form huh.Theme
}
// BasicTheme constructs the default theme for terminal coffee shop.
func BasicTheme(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
func BasicTheme() Theme {
t := Theme{}
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#666666")
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")}
t.highlight = lipgloss.Color("#4682B4")
t.brand = lipgloss.Color("#4682B4")
t.label = lipgloss.Color("#999999")
t.success = lipgloss.Color("#00FF00")
t.error = lipgloss.Color("196")
t.base = renderer.NewStyle().Foreground(t.body)
t.base = lipgloss.NewStyle().Foreground(t.body)
t.form = HuhTheme(t)
return t
}
// this should resemble something like catppuccin
func Catppuccin(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
func Catppuccin() Theme {
t := Theme{}
t.background = lipgloss.AdaptiveColor{Dark: "#1e1e2e", Light: "#eff1f5"}
t.border = lipgloss.AdaptiveColor{Dark: "#45475A", Light: "#BCC0CC"}
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#6C7086")
t.accent = lipgloss.Color("#CBA6F7")
@@ -60,25 +59,25 @@ func Catppuccin(renderer *lipgloss.Renderer) Theme {
t.success = lipgloss.Color("#A6E3A1")
t.error = lipgloss.Color("#F38BA8")
t.base = renderer.NewStyle().Foreground(t.body)
t.base = lipgloss.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 }
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
// Color accessors.
func (t Theme) Background() color.Color { return t.background }
func (t Theme) Border() color.Color { return t.border }
func (t Theme) Body() color.Color { return t.body }
func (t Theme) Dim() color.Color { return t.dim }
func (t Theme) Accent() color.Color { return t.accent }
func (t Theme) Highlight() color.Color { return t.highlight }
func (t Theme) Brand() color.Color { return t.brand }
func (t Theme) Label() color.Color { return t.label }
func (t Theme) Success() color.Color { return t.success }
func (t Theme) Error() color.Color { return t.error }
// Style helpers — return a copy of base with the correct foreground.
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
// Style helpers.
func (t Theme) Base() lipgloss.Style { return t.base }
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
@@ -90,10 +89,8 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
// PanelError returns a style for the error banner: dark red bg, red fg.
func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
}
// Form returns the huh form theme for shipping and payment forms.
func (t Theme) Form() *huh.Theme { return t.form }
func (t Theme) Form() huh.Theme { return t.form }

View File

@@ -6,7 +6,7 @@ import (
"terminalShop/pkg/models"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

View File

@@ -1,7 +1,8 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ResizeView renders a message when the terminal is too small.
@@ -19,7 +20,13 @@ func (m Model) ResizeView() string {
)
}
func (m Model) View() string {
func (m Model) View() tea.View {
v := tea.NewView(m.renderString())
v.AltScreen = true
return v
}
func (m Model) renderString() string {
if m.viewportWidth == 0 {
m.updateLayout(120, 30)
}

View File

@@ -35,7 +35,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
Data: data,
}
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}
// RespondError sends an error JSON response
@@ -52,7 +52,7 @@ func RespondError(w http.ResponseWriter, statusCode int, code, message string, d
},
}
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}
// HealthData represents health check data

View File

@@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error {
func IsDigits(name string) ErrorHandler {
return func(str string) error {
for _, c := range str {
if !(c >= '0' && c <= '9') {
if c < '0' || c > '9' {
return fmt.Errorf("[%s] expected only digits but got %s", name, str)
}
}