mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
updating the ui fr
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
@@ -22,21 +23,11 @@ func NewAddressHandler() *AddressHandler {
|
||||
// Retrieve all saved addresses for a user
|
||||
func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
fingerprint := r.URL.Query().Get("fingerprint")
|
||||
|
||||
if fingerprint == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION ERROR", "fingerprint is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("ssh_key_fingerprint = ?", fingerprint).First(&user).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var addresses []models.Address
|
||||
db.Where("user_id = ?", user.ID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"addresses": addresses,
|
||||
@@ -46,16 +37,17 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Name string `json:"name"`
|
||||
Street string `json:"street"`
|
||||
Street2 string `json:"street_2"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Zip string `json:"zip"`
|
||||
Country string `json:"country"`
|
||||
Phone string `json:"phone"`
|
||||
Name string `json:"name"`
|
||||
Street string `json:"street"`
|
||||
Street2 string `json:"street_2"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Zip string `json:"zip"`
|
||||
Country string `json:"country"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -63,7 +55,85 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Fingerprint == "" || req.Name == "" || req.Street == "" || req.City == "" req.Zip == "" || req.Country == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "fingerprint, name, street, city, zip, and country are required", nil)
|
||||
if req.Name == "" || req.Street == "" || req.City == "" || req.Zip == "" || req.Country == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "name, street, city, zip, and country are required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
address := models.Address{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Street: req.Street,
|
||||
Street2: req.Street2,
|
||||
City: req.City,
|
||||
State: req.State,
|
||||
Zip: req.Zip,
|
||||
Country: req.Country,
|
||||
Phone: req.Phone,
|
||||
}
|
||||
|
||||
if err := db.Create(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create address", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete a saved address
|
||||
func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid address id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ADDRESS NOT FOUND", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// ownership is guaranteed by the WHERE clause above
|
||||
db.Delete(&address)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "address successfully deleted",
|
||||
})
|
||||
}
|
||||
|
||||
// Mark an address as the users default address
|
||||
|
||||
func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid address id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ADDRESS_NOT_FOUND", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false)
|
||||
|
||||
address.IsDefault = true
|
||||
db.Save(&address)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
@@ -11,11 +12,71 @@ import (
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct{}
|
||||
type AuthHandler struct {
|
||||
jwtManager *auth.JWTManager
|
||||
authFingerprintKey string
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler() *AuthHandler {
|
||||
return &AuthHandler{}
|
||||
func NewAuthHandler(jwtManager *auth.JWTManager, authFingerprintKey string) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
jwtManager: jwtManager,
|
||||
authFingerprintKey: authFingerprintKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Type to represent a request to exchange SSH fingerprint for JWT
|
||||
type TokenRequest struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
// Type to represent the JWT token response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
|
||||
var req TokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// validate shared secret
|
||||
if req.ClientSecret != h.authFingerprintKey {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SECRET", "invalid client secret", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Return if no fingerprint is found
|
||||
if req.Fingerprint == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// look up user by his fingies
|
||||
var user models.User
|
||||
if err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800, // 30 minutes in seconds
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterRequest represents a user registration request
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stripe/stripe-go/v78/charge"
|
||||
"github.com/stripe/stripe-go/v78/customer"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
@@ -30,7 +31,6 @@ type CheckoutCartItem struct {
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
@@ -49,6 +49,7 @@ type CheckoutRequest struct {
|
||||
|
||||
func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req CheckoutRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -56,11 +57,6 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Fingerprint == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "fingerprint is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.StripeToken == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "stripe token is required", nil)
|
||||
return
|
||||
@@ -72,7 +68,7 @@ func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error; err != nil {
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
@@ -20,19 +21,11 @@ func NewOrderHandler() *OrderHandler {
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
|
||||
fingerprint := r.URL.Query().Get("fingerprint")
|
||||
if fingerprint == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint query parameter is required", nil)
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.Where("ssh_key_fingerprint = ?", fingerprint).First(&user).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", user.ID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"orders": orders,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/api/routes"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
@@ -41,8 +42,11 @@ func main() {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
}
|
||||
|
||||
// Init JWT manager (same secret + duration as SSH server)
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Setup routes
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey)
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
|
||||
66
api/middleware/auth.go
Normal file
66
api/middleware/auth.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userIDKey contextKey = "userID"
|
||||
|
||||
func Auth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler {
|
||||
// Auth validates JWT tokens on all requests.
|
||||
// if a valid token is present, store an userID in context
|
||||
// if no token, continue as public (no userID).
|
||||
// If invalid token, returns 401
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// No token - public req
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Expect "Bearer <token>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_AUTH_BEARER", "authorization header must be Bearer <token>", nil)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtManager.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "INVALID_TOKEN", "invalid or expired token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reject requests without a valid user context.
|
||||
// User on protected route groups.
|
||||
func RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if UserIDFromContext(r.Context()) == 0 {
|
||||
utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) uint {
|
||||
if id, ok := ctx.Value(userIDKey).(uint); ok {
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -5,23 +5,26 @@ import (
|
||||
|
||||
"terminalShop/api/handlers"
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/auth"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(version string, stripeSecretKey string) *chi.Mux {
|
||||
func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply global middleware
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Recovery)
|
||||
r.Use(middleware.Auth(jwtManager))
|
||||
|
||||
// Initialize handlers
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
checkoutHandler := handlers.NewCheckoutHandler(stripeSecretKey)
|
||||
orderHandler := handlers.NewOrderHandler()
|
||||
addressHandler := handlers.NewAddressHandler()
|
||||
|
||||
// API v1 routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
@@ -32,20 +35,35 @@ func SetupRoutes(version string, stripeSecretKey string) *chi.Mux {
|
||||
// Authentication endpoints (SSH key-based)
|
||||
r.Post("/auth/register", authHandler.RegisterWithSSHKey) // POST /api/v1/auth/register
|
||||
r.Get("/auth/user", authHandler.GetUserBySSHKey) // GET /api/v1/auth/user?fingerprint=xxx
|
||||
r.Post("/auth/token", authHandler.GetToken)
|
||||
|
||||
// Product endpoints (full CRUD)
|
||||
r.Route("/products", func(r chi.Router) {
|
||||
r.Get("/", productHandler.GetProducts) // GET /api/v1/products
|
||||
r.Post("/", productHandler.CreateProduct) // POST /api/v1/products
|
||||
r.Get("/{id}", productHandler.GetProduct) // GET /api/v1/products/:id
|
||||
r.Put("/{id}", productHandler.UpdateProduct) // PUT /api/v1/products/:id
|
||||
r.Delete("/{id}", productHandler.DeleteProduct) // DELETE /api/v1/products/:id
|
||||
r.Get("/products", productHandler.GetProducts) // Public
|
||||
r.Get("/products/{id}", productHandler.GetProduct) // Public
|
||||
|
||||
// Protected routes - requires a valid JWT
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth)
|
||||
|
||||
// Admin-only product management (TODO: add admin check)
|
||||
r.Post("/products", productHandler.CreateProduct)
|
||||
r.Put("/products/{id}", productHandler.UpdateProduct)
|
||||
r.Delete("/products/{id}", productHandler.DeleteProduct)
|
||||
|
||||
// Checkout
|
||||
r.Post("/checkout", checkoutHandler.Checkout)
|
||||
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
|
||||
// Addresses
|
||||
r.Route("/addresses", func(r chi.Router) {
|
||||
r.Get("/", addressHandler.GetAddresses)
|
||||
r.Post("/", addressHandler.CreateAddress)
|
||||
r.Delete("/{id}", addressHandler.DeleteAddress)
|
||||
r.Put("/{id}/default", addressHandler.SetDefaultAddress)
|
||||
})
|
||||
})
|
||||
// Checkout endpoint
|
||||
r.Post("/checkout", checkoutHandler.Checkout)
|
||||
|
||||
// Order endpoints
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
8
main.go
8
main.go
@@ -34,15 +34,17 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
pubKey := s.PublicKey()
|
||||
|
||||
var user *models.User
|
||||
var token string
|
||||
|
||||
if pubKey != nil {
|
||||
// Authenticate with SSH key (auto-creates user on first connect!)
|
||||
authenticatedUser, _, err := authService.AuthenticateSSHKey(pubKey)
|
||||
authenticatedUser, jwtToken, err := authService.AuthenticateSSHKey(pubKey)
|
||||
if err != nil {
|
||||
log.Printf("Auth error: %v", err)
|
||||
// Continue as guest
|
||||
} else {
|
||||
user = authenticatedUser
|
||||
token = jwtToken
|
||||
displayName := user.SSHKeyFingerprint[:16]
|
||||
if user.Name != "" {
|
||||
displayName = user.Name
|
||||
@@ -52,7 +54,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
}
|
||||
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(user, false, s.PublicKey())
|
||||
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Initialize JWT manager (uses same secret as API)
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 24*time.Hour)
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Initialize SSH auth service
|
||||
authService = auth.NewSSHAuthService(db, jwtManager)
|
||||
|
||||
@@ -14,18 +14,28 @@ import (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL string) *Client {
|
||||
func NewClient(baseURL string, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest helper executes an HTTP request with the authorization header set
|
||||
func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
return c.HTTPClient.Do(req)
|
||||
}
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -52,7 +62,11 @@ type ProductsResponse struct {
|
||||
func (c *Client) GetProducts() ([]models.Coffee, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/products", c.BaseURL)
|
||||
|
||||
resp, err := c.HTTPClient.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch products: %w", err)
|
||||
}
|
||||
@@ -78,7 +92,11 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
|
||||
func (c *Client) Health() error {
|
||||
url := fmt.Sprintf("%s/api/v1/health", c.BaseURL)
|
||||
|
||||
resp, err := c.HTTPClient.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
@@ -122,7 +140,13 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register user: %w", err)
|
||||
}
|
||||
@@ -145,7 +169,6 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
||||
|
||||
// CheckoutRequest represents the checkout payload sent to the API
|
||||
type CheckoutRequest struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
@@ -183,15 +206,21 @@ type OrderResponse struct {
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Checkout(req CheckoutRequest) (*models.Order, error) {
|
||||
func (c *Client) Checkout(checkoutReq CheckoutRequest) (*models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/checkout", c.BaseURL)
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
jsonData, err := json.Marshal(checkoutReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal checkout request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checkout request failed: %w", err)
|
||||
}
|
||||
@@ -212,10 +241,15 @@ func (c *Client) Checkout(req CheckoutRequest) (*models.Order, error) {
|
||||
return &checkoutResp.Data.Order, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetOrders(fingerprint string) ([]models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders?fingerprint=%s", c.BaseURL, fingerprint)
|
||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
|
||||
|
||||
resp, err := c.HTTPClient.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch orders: %w", err)
|
||||
}
|
||||
@@ -234,3 +268,117 @@ func (c *Client) GetOrders(fingerprint string) ([]models.Order, error) {
|
||||
}
|
||||
return ordersResp.Data.Orders, nil
|
||||
}
|
||||
|
||||
// The Addresses API response
|
||||
type AddressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Addresses []models.Address `json:"addresses"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetAddresses() ([]models.Address, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/addresses", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
var addrResp AddressResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode addresses response: %w", err)
|
||||
}
|
||||
|
||||
if !addrResp.Success {
|
||||
if addrResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", addrResp.Error.Code, addrResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch addresses")
|
||||
}
|
||||
|
||||
return addrResp.Data.Addresses, nil
|
||||
}
|
||||
|
||||
type CreateAddressRequest struct {
|
||||
Name string `json:"name"`
|
||||
Street string `json:"street"`
|
||||
Street2 string `json:"street_2"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Zip string `json:"zip"`
|
||||
Country string `json:"country"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/addresses", c.BaseURL)
|
||||
|
||||
jsonData, err := json.Marshal(addrReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal address request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Address models.Address `json:"address"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode address response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create address")
|
||||
}
|
||||
|
||||
return &result.Data.Address, nil
|
||||
}
|
||||
|
||||
// Delete a saved address by ID
|
||||
func (c *Client) DeleteAddress(id uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/addresses/%d", c.BaseURL, id)
|
||||
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
APIPort string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
ShippoAPIKey string
|
||||
APIPort string
|
||||
SSHPort string
|
||||
Environment string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
StripeSecretKey string
|
||||
StripePublicKey string
|
||||
ShippoAPIKey string
|
||||
AuthFingerprintKey string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
@@ -30,14 +31,15 @@ func Load() (*Config, error) {
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
APIPort: getEnvOrDefault("API_PORT", "8080"),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
APIPort: getEnvOrDefault("API_PORT", "8080"),
|
||||
SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "development"),
|
||||
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
|
||||
"fmt"
|
||||
|
||||
"strings"
|
||||
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
@@ -14,13 +12,11 @@ import (
|
||||
|
||||
func (m Model) BuildAccountView() string {
|
||||
leftWidth := 18
|
||||
leftMargin := 4
|
||||
rightWidth := m.WindowWidth - leftWidth - leftMargin - 4
|
||||
rightWidth := m.widthContent - leftWidth - 2 // 2 for gap
|
||||
|
||||
// Build the left panel (menu items)
|
||||
leftPanel := ""
|
||||
for i, item := range models.AccountMenuItems {
|
||||
// Apply background color when selected, white text
|
||||
if m.AccountCursor == i {
|
||||
style := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#4682B4")).
|
||||
@@ -39,10 +35,8 @@ func (m Model) BuildAccountView() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Add left margin to the list
|
||||
leftContainer := lipgloss.NewStyle().
|
||||
Width(leftWidth).
|
||||
MarginLeft(leftMargin)
|
||||
Width(leftWidth)
|
||||
|
||||
// Build the detail view (right panel) based on cursor position
|
||||
detailView := ""
|
||||
@@ -52,14 +46,20 @@ func (m Model) BuildAccountView() string {
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
MarginBottom(2)
|
||||
|
||||
// On small terminals, use full content width
|
||||
detailContentWidth := rightWidth - 2
|
||||
if m.size < large {
|
||||
detailContentWidth = m.widthContent - 2
|
||||
}
|
||||
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA")).
|
||||
Width(rightWidth - 4)
|
||||
Width(detailContentWidth)
|
||||
|
||||
selectedItem := models.AccountMenuItems[m.AccountCursor]
|
||||
switch selectedItem {
|
||||
case "order history":
|
||||
if !m.OrdersLoaded {
|
||||
if !m.OrdersLoaded {
|
||||
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
|
||||
} else if len(m.Orders) == 0 {
|
||||
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.")
|
||||
@@ -68,10 +68,10 @@ func (m Model) BuildAccountView() string {
|
||||
for _, order := range m.Orders {
|
||||
status := strings.ToUpper(string(order.Status))
|
||||
date := order.CreatedAt.Format("Jan 02 2006")
|
||||
total := fmt.Sprintf("$%.2f", float64(order.Total) / 100.0)
|
||||
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
|
||||
lines += contentStyle.Render(fmt.Sprintf("Order #%d %s %s %s", order.ID, date, total, status)) + "\n"
|
||||
for _, item := range order.Items {
|
||||
lines += contentStyle.Render(fmt.Sprintf(" %dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price) / 100.0)) + "\n"
|
||||
lines += contentStyle.Render(fmt.Sprintf(" %dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0)) + "\n"
|
||||
}
|
||||
lines += "\n"
|
||||
}
|
||||
@@ -89,12 +89,19 @@ func (m Model) BuildAccountView() string {
|
||||
detailContainer := lipgloss.NewStyle().
|
||||
Width(rightWidth)
|
||||
|
||||
// Layout the panels side by side
|
||||
mainContent := lipgloss.JoinHorizontal(
|
||||
// Responsive layout: side-by-side on large, stacked on small/medium
|
||||
if m.size < large {
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
leftContainer.Render(leftPanel),
|
||||
detailContainer.Width(m.widthContent).Render(detailView),
|
||||
)
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
leftContainer.Render(leftPanel),
|
||||
" ",
|
||||
detailContainer.Render(detailView),
|
||||
)
|
||||
|
||||
return mainContent
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@ func (m Model) BuildBreadcrumbs() string {
|
||||
sep := sepStyle.Render(" / ")
|
||||
|
||||
// Checkout flow steps
|
||||
labels := []string{"cart", "shipping", "payment", "confirmation"}
|
||||
var labels []string
|
||||
if m.size < large {
|
||||
labels = []string{"cart", "ship", "pay", "confirm"}
|
||||
} else {
|
||||
labels = []string{"cart", "shipping", "payment", "confirmation"}
|
||||
}
|
||||
|
||||
// Get current checkout step from model
|
||||
currentStep := m.CheckoutStep
|
||||
|
||||
@@ -11,18 +11,18 @@ func (m Model) BuildCartView() string {
|
||||
emptyStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
Align(lipgloss.Center).
|
||||
Width(m.WindowWidth).
|
||||
Width(m.widthContent).
|
||||
Padding(2, 0)
|
||||
return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
|
||||
}
|
||||
|
||||
cartItems := ""
|
||||
boxWidth := m.WindowWidth - 20 // Leave some margin
|
||||
boxWidth := m.widthContent - 4 // Leave some margin within the container
|
||||
itemSlice := m.GetCartItemsSlice()
|
||||
|
||||
// Adjust padding based on window height
|
||||
// Adjust padding based on container height
|
||||
boxPadding := 0
|
||||
if m.WindowHeight >= 25 {
|
||||
if m.heightContainer >= 25 {
|
||||
boxPadding = 1
|
||||
}
|
||||
|
||||
@@ -96,9 +96,9 @@ func (m Model) BuildCartView() string {
|
||||
Foreground(lipgloss.Color("#FFFFFF"))
|
||||
}
|
||||
|
||||
// Center the box
|
||||
// Center the box within the container
|
||||
centered := lipgloss.NewStyle().
|
||||
Width(m.WindowWidth).
|
||||
Width(m.widthContent).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
cartItems += centered.Render(itemBox.Render(boxContent))
|
||||
|
||||
@@ -41,7 +41,7 @@ func (m Model) RenderConfirmation() string {
|
||||
labelStyle.Render("Ship to: ") +
|
||||
valueStyle.Render(m.ShippingInfo.Name) + "\n" +
|
||||
labelStyle.Render(" ") +
|
||||
valueStyle.Render(m.ShippingInfo.Street1) + "\n" +
|
||||
valueStyle.Render(m.ShippingInfo.Street) + "\n" +
|
||||
labelStyle.Render(" ") +
|
||||
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,12 @@ func (m Model) BuildFooter() string {
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666"))
|
||||
|
||||
// On small terminals, only show the menu hint
|
||||
if m.size == small {
|
||||
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
|
||||
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
|
||||
}
|
||||
|
||||
var footerText string
|
||||
if m.ViewingAccount {
|
||||
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
|
||||
@@ -104,6 +110,6 @@ func (m Model) BuildFooter() string {
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Align(lipgloss.Center).
|
||||
Width(m.WindowWidth).
|
||||
Width(m.widthContainer).
|
||||
Render(footerText)
|
||||
}
|
||||
|
||||
@@ -16,26 +16,14 @@ func (m Model) BuildHeader() string {
|
||||
}
|
||||
|
||||
// Define colors
|
||||
whiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
|
||||
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
|
||||
boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
|
||||
|
||||
// Build shop tab
|
||||
var shopKeybind, shopName string
|
||||
if !m.ViewingCart && !m.ViewingAccount {
|
||||
// Active: bold white keybind and name
|
||||
shopKeybind = boldWhiteStyle.Render("s")
|
||||
shopName = boldWhiteStyle.Render("shop")
|
||||
} else {
|
||||
// Inactive: bold gray keybind, gray name
|
||||
shopKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("s")
|
||||
shopName = grayStyle.Render("shop")
|
||||
}
|
||||
|
||||
// Build cart tab
|
||||
var cartKeybind, cartName, cartInfo string
|
||||
// Build cart tab (used in all sizes)
|
||||
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
|
||||
|
||||
var cartKeybind, cartName, cartInfo string
|
||||
if m.ViewingCart {
|
||||
// Active: bold white keybind and name
|
||||
cartKeybind = boldWhiteStyle.Render("c")
|
||||
@@ -45,48 +33,59 @@ func (m Model) BuildHeader() string {
|
||||
itemCountText)
|
||||
} else {
|
||||
// Inactive: bold gray keybind, gray name
|
||||
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
|
||||
boldWhitePriceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
cartKeybind = boldGrayStyle.Render("c")
|
||||
cartName = grayStyle.Render("cart")
|
||||
cartInfo = fmt.Sprintf(" %s %s",
|
||||
boldWhitePriceStyle.Render(fmt.Sprintf("$%.2f", total)),
|
||||
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", total)),
|
||||
itemCountText)
|
||||
}
|
||||
cartTab := cartKeybind + " " + cartName + cartInfo
|
||||
|
||||
// Build Account Tabs
|
||||
var accountKeybind, accountName string
|
||||
if m.ViewingAccount {
|
||||
accountKeybind = boldWhiteStyle.Render("a")
|
||||
accountName = boldWhiteStyle.Render("account")
|
||||
} else {
|
||||
accountKeybind = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true).Render("a")
|
||||
accountName = grayStyle.Render("account")
|
||||
var tabsContent string
|
||||
|
||||
switch m.size {
|
||||
case small:
|
||||
// Small: just a mark + Cart
|
||||
mark := boldWhiteStyle.Render("t")
|
||||
tabsContent = mark + " " + cartTab
|
||||
case medium:
|
||||
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
|
||||
logo := boldWhiteStyle.Render("terminal coffee")
|
||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
||||
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
|
||||
default:
|
||||
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|")
|
||||
|
||||
var shopKeybind, shopName string
|
||||
if !m.ViewingCart && !m.ViewingAccount {
|
||||
shopKeybind = boldWhiteStyle.Render("s")
|
||||
shopName = boldWhiteStyle.Render("shop")
|
||||
} else {
|
||||
shopKeybind = boldGrayStyle.Render("s")
|
||||
shopName = grayStyle.Render("shop")
|
||||
}
|
||||
|
||||
var accountKeybind, accountName string
|
||||
if m.ViewingAccount {
|
||||
accountKeybind = boldWhiteStyle.Render("a")
|
||||
accountName = boldWhiteStyle.Render("account")
|
||||
} else {
|
||||
accountKeybind = boldGrayStyle.Render("a")
|
||||
accountName = grayStyle.Render("account")
|
||||
}
|
||||
|
||||
tabsContent = fmt.Sprintf("%s %s %s %s %s %s %s", shopKeybind, shopName, separator, accountKeybind, accountName, separator, cartTab)
|
||||
}
|
||||
|
||||
// Build the tabs with separator
|
||||
separator := whiteStyle.Render("|")
|
||||
tabsContent := fmt.Sprintf("%s %s %s %s %s %s %s %s%s",
|
||||
shopKeybind,
|
||||
shopName,
|
||||
separator,
|
||||
accountKeybind,
|
||||
accountName,
|
||||
separator,
|
||||
cartKeybind,
|
||||
cartName,
|
||||
cartInfo,
|
||||
)
|
||||
|
||||
// Add box with padding (no vertical padding, just horizontal)
|
||||
tabBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
Padding(0, 2).
|
||||
MarginBottom(1)
|
||||
|
||||
// Center the whole thing
|
||||
// Center the whole thing within the container
|
||||
centered := lipgloss.NewStyle().
|
||||
Width(m.WindowWidth).
|
||||
Width(m.widthContainer).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
return centered.Render(tabBox.Render(tabsContent))
|
||||
|
||||
61
pkg/tui/menu.go
Normal file
61
pkg/tui/menu.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) BuildMenuView() string {
|
||||
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA"))
|
||||
|
||||
rowStyle := lipgloss.NewStyle().Padding(0,1)
|
||||
rows := []string{
|
||||
rowStyle.Render(bold.Render("s") + " " + base.Render("shop")),
|
||||
rowStyle.Render(bold.Render("a") + " " + base.Render("account")),
|
||||
rowStyle.Render(bold.Render("c") + " " + base.Render("cart")),
|
||||
rowStyle.Render(""),
|
||||
rowStyle.Render(bold.Render("q") + " " + base.Render("quit")),
|
||||
}
|
||||
|
||||
menuContent := ""
|
||||
for _, row := range rows {
|
||||
menuContent += row + "\n"
|
||||
}
|
||||
|
||||
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
|
||||
logo := logoStyle.Render("terminal coffee")
|
||||
|
||||
modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666")).Padding(1,2).Render(menuContent)
|
||||
|
||||
hint := base.Render("press esc to close")
|
||||
|
||||
itemCount := 0
|
||||
total := 0.0
|
||||
for _, item := range m.Cart {
|
||||
total += float64(item.Quantity) * item.Coffee.Price
|
||||
itemCount += item.Quantity
|
||||
}
|
||||
|
||||
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", total, itemCount))
|
||||
|
||||
assembled := lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
logo,
|
||||
"",
|
||||
modal,
|
||||
"",
|
||||
hint,
|
||||
cartHint,
|
||||
)
|
||||
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
m.viewportHeight,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
assembled,
|
||||
)
|
||||
|
||||
}
|
||||
104
pkg/tui/menu_test.go
Normal file
104
pkg/tui/menu_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"testing"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestModel_BuildMenuView(t *testing.T) {
|
||||
type fields struct {
|
||||
User *models.User
|
||||
IsNewUser bool
|
||||
SSHPublicKey gossh.PublicKey
|
||||
AccessToken string
|
||||
UsernameInput string
|
||||
Username string
|
||||
Coffees []models.Coffee
|
||||
Cursor int
|
||||
Cart map[int]*models.CartItem
|
||||
CartCursor int
|
||||
AccountCursor int
|
||||
CheckoutStep int
|
||||
ScrollOffset int
|
||||
ViewingCart bool
|
||||
ViewingAccount bool
|
||||
viewportWidth int
|
||||
viewportHeight int
|
||||
widthContainer int
|
||||
heightContainer int
|
||||
widthContent int
|
||||
size termSize
|
||||
resizeSeq int
|
||||
pendingWidth int
|
||||
pendingHeight int
|
||||
Loading bool
|
||||
ErrorMsg string
|
||||
APIClient *api.Client
|
||||
ShippingForm *ShippingFormState
|
||||
PaymentForm *PaymentFormState
|
||||
ShippingInfo *models.Address
|
||||
SavedAddresses []models.Address
|
||||
ShippingView int
|
||||
AddressCursor int
|
||||
StripeKey string
|
||||
ShippoKey string
|
||||
Orders []models.Order
|
||||
OrdersLoaded bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want string
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := Model{
|
||||
User: tt.fields.User,
|
||||
IsNewUser: tt.fields.IsNewUser,
|
||||
SSHPublicKey: tt.fields.SSHPublicKey,
|
||||
AccessToken: tt.fields.AccessToken,
|
||||
UsernameInput: tt.fields.UsernameInput,
|
||||
Username: tt.fields.Username,
|
||||
Coffees: tt.fields.Coffees,
|
||||
Cursor: tt.fields.Cursor,
|
||||
Cart: tt.fields.Cart,
|
||||
CartCursor: tt.fields.CartCursor,
|
||||
AccountCursor: tt.fields.AccountCursor,
|
||||
CheckoutStep: tt.fields.CheckoutStep,
|
||||
ScrollOffset: tt.fields.ScrollOffset,
|
||||
ViewingCart: tt.fields.ViewingCart,
|
||||
ViewingAccount: tt.fields.ViewingAccount,
|
||||
viewportWidth: tt.fields.viewportWidth,
|
||||
viewportHeight: tt.fields.viewportHeight,
|
||||
widthContainer: tt.fields.widthContainer,
|
||||
heightContainer: tt.fields.heightContainer,
|
||||
widthContent: tt.fields.widthContent,
|
||||
size: tt.fields.size,
|
||||
resizeSeq: tt.fields.resizeSeq,
|
||||
pendingWidth: tt.fields.pendingWidth,
|
||||
pendingHeight: tt.fields.pendingHeight,
|
||||
Loading: tt.fields.Loading,
|
||||
ErrorMsg: tt.fields.ErrorMsg,
|
||||
APIClient: tt.fields.APIClient,
|
||||
ShippingForm: tt.fields.ShippingForm,
|
||||
PaymentForm: tt.fields.PaymentForm,
|
||||
ShippingInfo: tt.fields.ShippingInfo,
|
||||
SavedAddresses: tt.fields.SavedAddresses,
|
||||
ShippingView: tt.fields.ShippingView,
|
||||
AddressCursor: tt.fields.AddressCursor,
|
||||
StripeKey: tt.fields.StripeKey,
|
||||
ShippoKey: tt.fields.ShippoKey,
|
||||
Orders: tt.fields.Orders,
|
||||
OrdersLoaded: tt.fields.OrdersLoaded,
|
||||
}
|
||||
if got := m.BuildMenuView(); got != tt.want {
|
||||
t.Errorf("Model.BuildMenuView() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
350
pkg/tui/model.go
350
pkg/tui/model.go
@@ -3,9 +3,11 @@ package tui
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
@@ -15,12 +17,33 @@ import (
|
||||
"terminalShop/pkg/shippo"
|
||||
)
|
||||
|
||||
// termSize represents the terminal size category for responsive layout.
|
||||
type termSize int
|
||||
|
||||
const (
|
||||
undersized termSize = iota // viewportWidth < 20 || viewportHeight < 10
|
||||
small // viewportWidth < 50
|
||||
medium // viewportWidth < 80
|
||||
large // viewportWidth >= 80
|
||||
)
|
||||
|
||||
// This msg is sent after a debounce delay to apply a pending resize.
|
||||
// the seq field makes sure that only the tick from the most recent resize is applied.
|
||||
type resizeTickMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// Delay before applying a terminal resize.
|
||||
// this prevents flickering when resizing the terminal
|
||||
const resizeDebounce = 50 * time.Millisecond
|
||||
|
||||
type Model struct {
|
||||
// User authentication
|
||||
User *models.User // Authenticated user (nil if not logged in)
|
||||
IsNewUser bool // True if user needs to register
|
||||
SSHPublicKey gossh.PublicKey // SSH public key for registration
|
||||
UsernameInput string // Input for username during registration
|
||||
AccessToken string
|
||||
UsernameInput string // Input for username during registration
|
||||
|
||||
// Shop state
|
||||
Username string
|
||||
@@ -29,21 +52,38 @@ type Model struct {
|
||||
Cart map[int]*models.CartItem // maps coffee index to cart item
|
||||
CartCursor int // cursor position in cart view
|
||||
AccountCursor int
|
||||
CheckoutStep int // 0=cart, 1=shipping, 2=payment, 3=confirmation
|
||||
ScrollOffset int // scroll position for content
|
||||
WindowWidth int
|
||||
WindowHeight int
|
||||
CheckoutStep int // 0=cart, 1=shipping, 2=payment, 3=confirmation
|
||||
ScrollOffset int // scroll position for content
|
||||
ViewingCart bool // true when viewing cart details
|
||||
ViewingAccount bool
|
||||
Loading bool // true when fetching data from API
|
||||
ErrorMsg string // error message if API fetch fails
|
||||
APIClient *api.Client
|
||||
|
||||
// Menu modal state
|
||||
ShowingMenu bool // true when full-screen menu is showing
|
||||
menuLastCart bool // was viewing cart when menu was opened
|
||||
menuLastAcct bool // was viewing account when menu was opened
|
||||
|
||||
// Layout fields — responsive container system
|
||||
viewportWidth int // raw terminal width
|
||||
viewportHeight int // raw terminal height
|
||||
widthContainer int // constrained outer container width (max 80)
|
||||
heightContainer int // constrained outer container height (max 30)
|
||||
widthContent int // widthContainer - 2 (usable inner width)
|
||||
size termSize // current terminal size category
|
||||
|
||||
// Resize debouncing
|
||||
resizeSeq int // incremented on each WindowSizeMsg
|
||||
pendingWidth int // buffered width from latest resize
|
||||
pendingHeight int // buffered height from latest resize
|
||||
|
||||
Loading bool // true when fetching data from API
|
||||
ErrorMsg string // error message if API fetch fails
|
||||
APIClient *api.Client
|
||||
|
||||
// Checkout state
|
||||
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
|
||||
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
|
||||
ShippingInfo *ShippingFormCompleteMsg // Saved shipping info from completed form
|
||||
SavedAddresses []ShippingFormCompleteMsg // Previously used shipping addresses
|
||||
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
|
||||
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
|
||||
ShippingInfo *models.Address // Selected shipping address for current order
|
||||
SavedAddresses []models.Address // Saved addresses from database
|
||||
ShippingView int
|
||||
AddressCursor int
|
||||
StripeKey string // Stripe publishable key for client-side tokenization
|
||||
@@ -54,6 +94,34 @@ type Model struct {
|
||||
OrdersLoaded bool
|
||||
}
|
||||
|
||||
// updateLayout recalculates all layout variables from the raw viewport dimensions.
|
||||
// This should be called whenever the terminal is resized.
|
||||
func (m *Model) updateLayout(width, height int) {
|
||||
m.viewportWidth = width
|
||||
m.viewportHeight = height
|
||||
|
||||
switch {
|
||||
case width < 20 || height < 10:
|
||||
m.size = undersized
|
||||
m.widthContainer = width
|
||||
m.heightContainer = height
|
||||
case width < 50:
|
||||
m.size = small
|
||||
m.widthContainer = width
|
||||
m.heightContainer = height
|
||||
case width < 80:
|
||||
m.size = medium
|
||||
m.widthContainer = 50
|
||||
m.heightContainer = int(math.Min(float64(height), 30))
|
||||
default:
|
||||
m.size = large
|
||||
m.widthContainer = 80
|
||||
m.heightContainer = int(math.Min(float64(height), 30))
|
||||
}
|
||||
|
||||
m.widthContent = m.widthContainer - 2
|
||||
}
|
||||
|
||||
// ProductsMsg is sent when products are fetched from API
|
||||
type ProductsMsg struct {
|
||||
Products []models.Coffee
|
||||
@@ -80,17 +148,87 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// huh generates internal messages (focus, cursor blink, etc.) from
|
||||
// form.Init() and form.Update() that must reach the form to work.
|
||||
|
||||
if tick, ok := msg.(resizeTickMsg); ok {
|
||||
if tick.seq == m.resizeSeq {
|
||||
m.updateLayout(m.pendingWidth, m.pendingHeight)
|
||||
// Update active form widths to match the new layout
|
||||
if m.ShippingForm != nil {
|
||||
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
|
||||
}
|
||||
if m.PaymentForm != nil {
|
||||
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.ShowingMenu {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
switch keyMsg.String() {
|
||||
case "s":
|
||||
m.ShowingMenu = false
|
||||
m.ViewingCart = false
|
||||
m.ViewingAccount = false
|
||||
m.CheckoutStep = 0
|
||||
m.ShippingForm = nil
|
||||
m.PaymentForm = nil
|
||||
m.ScrollOffset = 0
|
||||
case "a":
|
||||
m.ShowingMenu = false
|
||||
m.ViewingCart = false
|
||||
m.ViewingAccount = true
|
||||
m.CheckoutStep = 0
|
||||
m.ShippingForm = nil
|
||||
m.PaymentForm = nil
|
||||
m.ScrollOffset = 0
|
||||
if !m.OrdersLoaded {
|
||||
return m, m.fetchOrdersCmd()
|
||||
}
|
||||
case "c":
|
||||
m.ShowingMenu = false
|
||||
m.ViewingCart = true
|
||||
m.ViewingAccount = false
|
||||
m.CheckoutStep = 0
|
||||
m.ShippingForm = nil
|
||||
m.PaymentForm = nil
|
||||
m.ScrollOffset = 0
|
||||
case "esc":
|
||||
m.ShowingMenu = false
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.ViewingCart && m.CheckoutStep == 1 {
|
||||
switch msg := msg.(type) {
|
||||
case ShippingFormCompleteMsg:
|
||||
return m, m.validateAddress(msg)
|
||||
|
||||
case ShippoValidatedMsg:
|
||||
m.SavedAddresses = append(m.SavedAddresses, msg.Address)
|
||||
m.ShippingInfo = &msg.Address
|
||||
m.ShippingForm = nil
|
||||
m.CheckoutStep = 2
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
if msg.SaveAddress {
|
||||
return m, tea.Batch(m.PaymentForm.form.Init(), m.saveAddressCmd(msg.Address))
|
||||
}
|
||||
return m, m.PaymentForm.form.Init()
|
||||
|
||||
case AddressesMsg:
|
||||
if msg.Err != nil || len(msg.Addresses) == 0 {
|
||||
m.SavedAddresses = nil
|
||||
m.ShippingView = 1
|
||||
m.ShippingForm = m.InitShippingForm()
|
||||
return m, m.ShippingForm.form.Init()
|
||||
}
|
||||
m.SavedAddresses = msg.Addresses
|
||||
m.ShippingView = 0
|
||||
m.AddressCursor = 0
|
||||
m.ShippingForm = nil
|
||||
return m, nil
|
||||
|
||||
case ShippoValidationErrMsg:
|
||||
if m.ShippingForm != nil {
|
||||
m.ShippingForm.submitting = false
|
||||
@@ -104,8 +242,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ErrorMsg = msg.Message
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.WindowWidth = msg.Width
|
||||
m.WindowHeight = msg.Height
|
||||
m.pendingWidth = msg.Width
|
||||
m.pendingHeight = msg.Height
|
||||
m.resizeSeq++
|
||||
seq := m.resizeSeq
|
||||
return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg {
|
||||
return resizeTickMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// Address list view
|
||||
@@ -128,7 +271,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "enter":
|
||||
if m.AddressCursor < len(m.SavedAddresses) {
|
||||
addr := m.SavedAddresses[m.AddressCursor]
|
||||
m.ShippingInfo = &addr
|
||||
selected := addr // copy so we have a stable pointer
|
||||
m.ShippingInfo = &selected
|
||||
m.CheckoutStep = 2
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
return m, m.PaymentForm.form.Init()
|
||||
@@ -138,10 +282,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.ShippingForm.form.Init()
|
||||
case "d", "x":
|
||||
if m.AddressCursor < len(m.SavedAddresses) {
|
||||
addr := m.SavedAddresses[m.AddressCursor]
|
||||
m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor+1:]...)
|
||||
if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 {
|
||||
m.AddressCursor--
|
||||
}
|
||||
if len(m.SavedAddresses) == 0 {
|
||||
m.ShippingView = 1
|
||||
m.ShippingForm = m.InitShippingForm()
|
||||
return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID))
|
||||
}
|
||||
return m, m.deleteAddressCmd(addr.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,8 +350,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ErrorMsg = msg.Message
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.WindowWidth = msg.Width
|
||||
m.WindowHeight = msg.Height
|
||||
m.pendingWidth = msg.Width
|
||||
m.pendingHeight = msg.Height
|
||||
m.resizeSeq++
|
||||
seq := m.resizeSeq
|
||||
return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg {
|
||||
return resizeTickMsg{seq: seq}
|
||||
})
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "esc" {
|
||||
// Go back to shipping
|
||||
@@ -211,7 +367,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Restore shipping values if we have them
|
||||
if m.ShippingInfo != nil {
|
||||
m.ShippingForm.Name = m.ShippingInfo.Name
|
||||
m.ShippingForm.Street1 = m.ShippingInfo.Street1
|
||||
m.ShippingForm.Street1 = m.ShippingInfo.Street
|
||||
m.ShippingForm.Street2 = m.ShippingInfo.Street2
|
||||
m.ShippingForm.City = m.ShippingInfo.City
|
||||
m.ShippingForm.State = m.ShippingInfo.State
|
||||
@@ -235,11 +391,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.validateAddress(msg)
|
||||
|
||||
case ShippoValidatedMsg:
|
||||
m.SavedAddresses = append(m.SavedAddresses, msg.Address)
|
||||
m.ShippingInfo = &msg.Address
|
||||
m.ShippingForm = nil
|
||||
m.CheckoutStep = 2
|
||||
m.PaymentForm = m.InitPaymentForm()
|
||||
if msg.SaveAddress {
|
||||
return m, tea.Batch(m.PaymentForm.form.Init(), m.saveAddressCmd(msg.Address))
|
||||
}
|
||||
return m, m.PaymentForm.form.Init()
|
||||
|
||||
case ShippoValidationErrMsg:
|
||||
@@ -286,11 +444,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.WindowWidth = msg.Width
|
||||
m.WindowHeight = msg.Height
|
||||
case AddressesMsg:
|
||||
if msg.Err != nil || len(msg.Addresses) == 0 {
|
||||
// No saved addresses (or some type of error), go straight
|
||||
// to form and skip selecting address since we don't have one saved
|
||||
m.SavedAddresses = nil
|
||||
m.ShippingView = 1
|
||||
m.ShippingForm = m.InitShippingForm()
|
||||
return m, m.ShippingForm.form.Init()
|
||||
}
|
||||
m.SavedAddresses = msg.Addresses
|
||||
m.ShippingView = 0
|
||||
m.AddressCursor = 0
|
||||
m.ShippingForm = nil
|
||||
return m, nil
|
||||
|
||||
case AddressDeletedMsg:
|
||||
// Address already removed from the in-memory slice in the key handler.
|
||||
// Nothing to do here unless there is an error :(
|
||||
if msg.Err != nil {
|
||||
m.ErrorMsg = fmt.Sprintf("failed to delete address: %v", msg.Err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.pendingWidth = msg.Width
|
||||
m.pendingHeight = msg.Height
|
||||
m.resizeSeq++
|
||||
seq := m.resizeSeq
|
||||
return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg {
|
||||
return resizeTickMsg{seq: seq}
|
||||
})
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Normal shop mode
|
||||
switch msg.String() {
|
||||
@@ -324,20 +509,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.fetchOrdersCmd()
|
||||
}
|
||||
|
||||
case "m":
|
||||
m.ShowingMenu = true
|
||||
m.menuLastCart = m.ViewingCart
|
||||
m.menuLastAcct = m.ViewingAccount
|
||||
return m, nil
|
||||
|
||||
case "p", "enter":
|
||||
// Proceed to checkout from cart
|
||||
if m.ViewingCart && m.CheckoutStep == 0 && len(m.Cart) > 0 {
|
||||
m.CheckoutStep = 1
|
||||
if len(m.SavedAddresses) > 0 {
|
||||
m.ShippingView = 0
|
||||
m.AddressCursor = 0
|
||||
m.ShippingForm = nil
|
||||
return m, nil
|
||||
}
|
||||
m.ShippingView = 1
|
||||
m.ShippingForm = m.InitShippingForm()
|
||||
return m, m.ShippingForm.form.Init()
|
||||
|
||||
return m, m.fetchAddressesCmd()
|
||||
}
|
||||
|
||||
case "esc":
|
||||
@@ -395,7 +577,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Calculate viewport height
|
||||
// In cart: header=3, breadcrumbs=2, footer=1, margins=2, buffer=1 = 9
|
||||
viewportHeight := m.WindowHeight - 9
|
||||
viewportHeight := m.heightContainer - 9
|
||||
if viewportHeight < 6 {
|
||||
viewportHeight = 6
|
||||
}
|
||||
@@ -481,7 +663,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// ShippoValidatedMsg is sent when Shippo address validation succeeds
|
||||
type ShippoValidatedMsg struct {
|
||||
Address ShippingFormCompleteMsg
|
||||
Address models.Address
|
||||
SaveAddress bool
|
||||
}
|
||||
|
||||
// ShippoValidationErrMsg is sent when Shippo address validation fails
|
||||
@@ -494,7 +677,19 @@ func (m Model) validateAddress(info ShippingFormCompleteMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.ShippoKey == "" {
|
||||
// No Shippo key configured, skip validation
|
||||
return ShippoValidatedMsg{Address: info}
|
||||
return ShippoValidatedMsg{
|
||||
Address: models.Address{
|
||||
Name: info.Name,
|
||||
Street: info.Street1,
|
||||
Street2: info.Street2,
|
||||
City: info.City,
|
||||
State: info.State,
|
||||
Country: info.Country,
|
||||
Zip: info.Zip,
|
||||
Phone: info.Phone,
|
||||
},
|
||||
SaveAddress: info.SaveAddress,
|
||||
}
|
||||
}
|
||||
|
||||
client := shippo.NewClient(m.ShippoKey)
|
||||
@@ -513,9 +708,9 @@ func (m Model) validateAddress(info ShippingFormCompleteMsg) tea.Cmd {
|
||||
}
|
||||
|
||||
return ShippoValidatedMsg{
|
||||
Address: ShippingFormCompleteMsg{
|
||||
Address: models.Address{
|
||||
Name: validated.Name,
|
||||
Street1: validated.Street1,
|
||||
Street: validated.Street1,
|
||||
Street2: validated.Street2,
|
||||
City: validated.City,
|
||||
State: validated.State,
|
||||
@@ -523,6 +718,7 @@ func (m Model) validateAddress(info ShippingFormCompleteMsg) tea.Cmd {
|
||||
Zip: validated.Zip,
|
||||
Phone: validated.Phone,
|
||||
},
|
||||
SaveAddress: info.SaveAddress,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,11 +749,69 @@ func (m Model) fetchOrdersCmd() tea.Cmd {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return OrdersMsg{Err: fmt.Errorf("not authenticated")}
|
||||
}
|
||||
orders, err := m.APIClient.GetOrders(m.User.SSHKeyFingerprint)
|
||||
orders, err := m.APIClient.GetOrders()
|
||||
return OrdersMsg{Orders: orders, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// AddressesMsg is sent when sdaved addresses are fetched from the API
|
||||
type AddressesMsg struct {
|
||||
Addresses []models.Address
|
||||
Err error
|
||||
}
|
||||
|
||||
// AddressSavedMsg is sent when an address is saved to the api
|
||||
type AddressSavedMsg struct {
|
||||
Address models.Address
|
||||
Err error
|
||||
}
|
||||
|
||||
type AddressDeletedMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m Model) fetchAddressesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return AddressesMsg{Err: fmt.Errorf("not authenticated")}
|
||||
}
|
||||
addresses, err := m.APIClient.GetAddresses()
|
||||
return AddressesMsg{Addresses: addresses, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) saveAddressCmd(addr models.Address) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return AddressSavedMsg{Err: fmt.Errorf("not authenticated")}
|
||||
}
|
||||
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
|
||||
Name: addr.Name,
|
||||
Street: addr.Street,
|
||||
Street2: addr.Street2,
|
||||
City: addr.City,
|
||||
State: addr.State,
|
||||
Zip: addr.Zip,
|
||||
Country: addr.Country,
|
||||
Phone: addr.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
return AddressSavedMsg{Err: err}
|
||||
}
|
||||
return AddressSavedMsg{Address: *saved}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) deleteAddressCmd(id uint) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return AddressDeletedMsg{Err: fmt.Errorf("not authenticated")}
|
||||
}
|
||||
err := m.APIClient.DeleteAddress(id)
|
||||
return AddressDeletedMsg{Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Use Stripe's HTTP API directly with the publishable key.
|
||||
@@ -630,7 +884,7 @@ func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd {
|
||||
Last4: tok.Last4,
|
||||
Items: cartItems,
|
||||
ShippingName: m.ShippingInfo.Name,
|
||||
ShippingStreet: m.ShippingInfo.Street1,
|
||||
ShippingStreet: m.ShippingInfo.Street,
|
||||
ShippingStreet2: m.ShippingInfo.Street2,
|
||||
ShippingCity: m.ShippingInfo.City,
|
||||
ShippingState: m.ShippingInfo.State,
|
||||
@@ -639,10 +893,6 @@ func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd {
|
||||
ShippingPhone: m.ShippingInfo.Phone,
|
||||
}
|
||||
|
||||
if m.User != nil {
|
||||
req.Fingerprint = m.User.SSHKeyFingerprint
|
||||
}
|
||||
|
||||
order, err := m.APIClient.Checkout(req)
|
||||
if err != nil {
|
||||
return CheckoutResultMsg{Err: err}
|
||||
@@ -682,7 +932,7 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
|
||||
|
||||
// NewModel creates a new model with default coffee options
|
||||
func NewModel(username string) Model {
|
||||
return Model{
|
||||
m := Model{
|
||||
Username: username,
|
||||
Coffees: []models.Coffee{
|
||||
{
|
||||
@@ -744,19 +994,19 @@ func NewModel(username string) Model {
|
||||
Cart: make(map[int]*models.CartItem),
|
||||
CartCursor: 0,
|
||||
AccountCursor: 0,
|
||||
WindowWidth: 120,
|
||||
WindowHeight: 30,
|
||||
ViewingCart: false,
|
||||
ViewingAccount: false,
|
||||
Loading: true,
|
||||
APIClient: api.NewClient("http://localhost:8080"),
|
||||
APIClient: api.NewClient("http://localhost:8080", ""),
|
||||
StripeKey: os.Getenv("STRIPE_PUBLIC_KEY"),
|
||||
ShippoKey: os.Getenv("SHIPPO_API_KEY"),
|
||||
}
|
||||
m.updateLayout(120, 30)
|
||||
return m
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context
|
||||
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey) Model {
|
||||
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string) Model {
|
||||
username := "guest"
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
@@ -766,6 +1016,8 @@ func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey)
|
||||
m.User = user
|
||||
m.IsNewUser = isNewUser
|
||||
m.SSHPublicKey = pubKey
|
||||
m.AccessToken = token
|
||||
m.APIClient = api.NewClient("http://localhost:8080", token)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
106
pkg/tui/model_test.go
Normal file
106
pkg/tui/model_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/models"
|
||||
"testing"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestModel_updateLayout(t *testing.T) {
|
||||
type fields struct {
|
||||
User *models.User
|
||||
IsNewUser bool
|
||||
SSHPublicKey gossh.PublicKey
|
||||
AccessToken string
|
||||
UsernameInput string
|
||||
Username string
|
||||
Coffees []models.Coffee
|
||||
Cursor int
|
||||
Cart map[int]*models.CartItem
|
||||
CartCursor int
|
||||
AccountCursor int
|
||||
CheckoutStep int
|
||||
ScrollOffset int
|
||||
ViewingCart bool
|
||||
ViewingAccount bool
|
||||
viewportWidth int
|
||||
viewportHeight int
|
||||
widthContainer int
|
||||
heightContainer int
|
||||
widthContent int
|
||||
size termSize
|
||||
resizeSeq int
|
||||
pendingWidth int
|
||||
pendingHeight int
|
||||
Loading bool
|
||||
ErrorMsg string
|
||||
APIClient *api.Client
|
||||
ShippingForm *ShippingFormState
|
||||
PaymentForm *PaymentFormState
|
||||
ShippingInfo *models.Address
|
||||
SavedAddresses []models.Address
|
||||
ShippingView int
|
||||
AddressCursor int
|
||||
StripeKey string
|
||||
ShippoKey string
|
||||
Orders []models.Order
|
||||
OrdersLoaded bool
|
||||
}
|
||||
type args struct {
|
||||
width int
|
||||
height int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Model{
|
||||
User: tt.fields.User,
|
||||
IsNewUser: tt.fields.IsNewUser,
|
||||
SSHPublicKey: tt.fields.SSHPublicKey,
|
||||
AccessToken: tt.fields.AccessToken,
|
||||
UsernameInput: tt.fields.UsernameInput,
|
||||
Username: tt.fields.Username,
|
||||
Coffees: tt.fields.Coffees,
|
||||
Cursor: tt.fields.Cursor,
|
||||
Cart: tt.fields.Cart,
|
||||
CartCursor: tt.fields.CartCursor,
|
||||
AccountCursor: tt.fields.AccountCursor,
|
||||
CheckoutStep: tt.fields.CheckoutStep,
|
||||
ScrollOffset: tt.fields.ScrollOffset,
|
||||
ViewingCart: tt.fields.ViewingCart,
|
||||
ViewingAccount: tt.fields.ViewingAccount,
|
||||
viewportWidth: tt.fields.viewportWidth,
|
||||
viewportHeight: tt.fields.viewportHeight,
|
||||
widthContainer: tt.fields.widthContainer,
|
||||
heightContainer: tt.fields.heightContainer,
|
||||
widthContent: tt.fields.widthContent,
|
||||
size: tt.fields.size,
|
||||
resizeSeq: tt.fields.resizeSeq,
|
||||
pendingWidth: tt.fields.pendingWidth,
|
||||
pendingHeight: tt.fields.pendingHeight,
|
||||
Loading: tt.fields.Loading,
|
||||
ErrorMsg: tt.fields.ErrorMsg,
|
||||
APIClient: tt.fields.APIClient,
|
||||
ShippingForm: tt.fields.ShippingForm,
|
||||
PaymentForm: tt.fields.PaymentForm,
|
||||
ShippingInfo: tt.fields.ShippingInfo,
|
||||
SavedAddresses: tt.fields.SavedAddresses,
|
||||
ShippingView: tt.fields.ShippingView,
|
||||
AddressCursor: tt.fields.AddressCursor,
|
||||
StripeKey: tt.fields.StripeKey,
|
||||
ShippoKey: tt.fields.ShippoKey,
|
||||
Orders: tt.fields.Orders,
|
||||
OrdersLoaded: tt.fields.OrdersLoaded,
|
||||
}
|
||||
m.updateLayout(tt.args.width, tt.args.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,10 +81,10 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false)
|
||||
|
||||
if m.WindowWidth < 80 {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.WindowWidth - 10)
|
||||
if m.size < medium {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
} else {
|
||||
f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.WindowWidth - 20)
|
||||
f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.widthContent)
|
||||
}
|
||||
|
||||
return f
|
||||
@@ -174,12 +174,11 @@ func validatePaymentForm(state *PaymentFormState) string {
|
||||
// UpdatePaymentForm handles updates to the payment form.
|
||||
// Mutates state in place, returns only the tea.Cmd.
|
||||
func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
if msg.Width < 80 {
|
||||
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(msg.Width - 10)
|
||||
if _, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
if m.size < medium {
|
||||
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
} else {
|
||||
state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(msg.Width - 20)
|
||||
state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(m.widthContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,14 +49,15 @@ type ShippingFormState struct {
|
||||
|
||||
// ShippingFormCompleteMsg is sent when the shipping form is completed
|
||||
type ShippingFormCompleteMsg struct {
|
||||
Name string
|
||||
Street1 string
|
||||
Street2 string
|
||||
City string
|
||||
State string
|
||||
Country string
|
||||
Zip string
|
||||
Phone string
|
||||
Name string
|
||||
Street1 string
|
||||
Street2 string
|
||||
City string
|
||||
State string
|
||||
Country string
|
||||
Zip string
|
||||
Phone string
|
||||
SaveAddress bool
|
||||
}
|
||||
|
||||
// ShippingFormErrorMsg is sent when form validation fails at submission time
|
||||
@@ -118,10 +119,10 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
|
||||
WithShowErrors(false).
|
||||
WithShowHelp(false)
|
||||
|
||||
if m.WindowWidth < 80 {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.WindowWidth - 10)
|
||||
if m.size < medium {
|
||||
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
} else {
|
||||
f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.WindowWidth - 20)
|
||||
f = f.WithLayout(huh.LayoutColumns(2)).WithWidth(m.widthContent)
|
||||
}
|
||||
|
||||
return f
|
||||
@@ -191,12 +192,11 @@ func formatPhoneWithDialCode(phone, countryCode string) string {
|
||||
// It mutates state in place (no struct copying) so huh's Value()
|
||||
// pointers remain valid.
|
||||
func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
if msg.Width < 80 {
|
||||
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(msg.Width - 10)
|
||||
if _, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
if m.size < medium {
|
||||
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
|
||||
} else {
|
||||
state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(msg.Width - 20)
|
||||
state.form = state.form.WithLayout(huh.LayoutColumns(2)).WithWidth(m.widthContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,33 +206,28 @@ func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd
|
||||
state.form = f
|
||||
}
|
||||
|
||||
// Check if form is completed (user pressed Enter on last field)
|
||||
// When the form is done, validate and submit (always save address)
|
||||
if state.form.State == huh.StateCompleted && !state.submitting {
|
||||
if errMsg := validateShippingForm(state); errMsg != "" {
|
||||
// Validation failed -- rebuild the form to reset huh's
|
||||
// unexported quitting flag so it renders again.
|
||||
// Values are preserved because state is heap-allocated
|
||||
// and buildShippingForm binds to the same pointer.
|
||||
state.form = m.buildShippingForm(state)
|
||||
return tea.Batch(
|
||||
state.form.Init(),
|
||||
func() tea.Msg { return ShippingFormErrorMsg{Message: errMsg} },
|
||||
)
|
||||
}
|
||||
|
||||
state.submitting = true
|
||||
phone := formatPhoneWithDialCode(state.Phone, state.Country)
|
||||
|
||||
return func() tea.Msg {
|
||||
return ShippingFormCompleteMsg{
|
||||
Name: state.Name,
|
||||
Street1: state.Street1,
|
||||
Street2: state.Street2,
|
||||
City: state.City,
|
||||
State: state.State,
|
||||
Country: state.Country,
|
||||
Zip: state.Zip,
|
||||
Phone: phone,
|
||||
Name: state.Name,
|
||||
Street1: state.Street1,
|
||||
Street2: state.Street2,
|
||||
City: state.City,
|
||||
State: state.State,
|
||||
Country: state.Country,
|
||||
Zip: state.Zip,
|
||||
Phone: phone,
|
||||
SaveAddress: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +256,7 @@ func (m Model) RenderShippingForm(state *ShippingFormState) string {
|
||||
|
||||
title := titleStyle.Render("Shipping Address")
|
||||
form := state.form.View()
|
||||
|
||||
help := helpStyle.Render("enter/tab next • shift+tab prev • esc back")
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
@@ -290,7 +286,7 @@ func (m Model) RenderAddressList() string {
|
||||
}
|
||||
|
||||
name := style.Render(addr.Name)
|
||||
street := labelStyle.Render(addr.Street1)
|
||||
street := labelStyle.Render(addr.Street)
|
||||
if addr.Street2 != "" {
|
||||
street += ", " + labelStyle.Render(addr.Street2)
|
||||
}
|
||||
|
||||
@@ -7,36 +7,45 @@ import (
|
||||
)
|
||||
|
||||
func (m Model) BuildShopView() string {
|
||||
leftWidth := 18 // Narrower list
|
||||
leftMargin := 4 // Minimal left margin
|
||||
rightWidth := m.WindowWidth - leftWidth - leftMargin - 4
|
||||
leftWidth := 18
|
||||
rightWidth := m.widthContent - leftWidth - 2 // 2 for gap
|
||||
|
||||
// On small/medium, items are centered and span the full container width.
|
||||
// On large, items are left-aligned in the narrow left column.
|
||||
itemWidth := leftWidth - 2
|
||||
itemAlign := lipgloss.Left
|
||||
if m.size < large {
|
||||
itemWidth = m.widthContainer
|
||||
itemAlign = lipgloss.Center
|
||||
}
|
||||
|
||||
// Build the left panel (coffee list)
|
||||
leftPanel := ""
|
||||
for i, coffee := range m.Coffees {
|
||||
// Apply background color when selected, white text
|
||||
if m.Cursor == i {
|
||||
style := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(coffee.Color)).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Padding(0, 1).
|
||||
Width(leftWidth - 2).
|
||||
Align(lipgloss.Left)
|
||||
Width(itemWidth).
|
||||
Align(itemAlign)
|
||||
leftPanel += style.Render(coffee.Name) + "\n"
|
||||
} else {
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#AAAAAA")).
|
||||
Padding(0, 1).
|
||||
Width(leftWidth - 2).
|
||||
Align(lipgloss.Left)
|
||||
Width(itemWidth).
|
||||
Align(itemAlign)
|
||||
leftPanel += style.Render(coffee.Name) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add left margin to the list
|
||||
leftContainer := lipgloss.NewStyle().
|
||||
Width(leftWidth).
|
||||
MarginLeft(leftMargin)
|
||||
var leftContainer lipgloss.Style
|
||||
if m.size < large {
|
||||
leftContainer = lipgloss.NewStyle().Width(m.widthContainer)
|
||||
} else {
|
||||
leftContainer = lipgloss.NewStyle().Width(leftWidth)
|
||||
}
|
||||
|
||||
// Build the detail view (right panel)
|
||||
detailView := ""
|
||||
@@ -54,9 +63,15 @@ func (m Model) BuildShopView() string {
|
||||
Foreground(lipgloss.Color(selectedCoffee.Color)).
|
||||
Bold(true)
|
||||
|
||||
// On small terminals, use full content width for description
|
||||
descWidth := rightWidth - 2
|
||||
if m.size < large {
|
||||
descWidth = m.widthContent - 2
|
||||
}
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Width(rightWidth - 4)
|
||||
Width(descWidth)
|
||||
|
||||
quantityStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
@@ -81,12 +96,19 @@ func (m Model) BuildShopView() string {
|
||||
detailContainer := lipgloss.NewStyle().
|
||||
Width(rightWidth)
|
||||
|
||||
// Layout the panels side by side
|
||||
mainContent := lipgloss.JoinHorizontal(
|
||||
// Responsive layout: side-by-side on large, stacked on small/medium
|
||||
if m.size < large {
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
leftContainer.Render(leftPanel),
|
||||
detailContainer.Width(m.widthContainer).Render(detailView),
|
||||
)
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
leftContainer.Render(leftPanel),
|
||||
" ",
|
||||
detailContainer.Render(detailView),
|
||||
)
|
||||
|
||||
return mainContent
|
||||
}
|
||||
|
||||
@@ -6,10 +6,35 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ResizeView renders a message when the terminal is too small.
|
||||
func (m Model) ResizeView() string {
|
||||
msg := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#666666")).
|
||||
Align(lipgloss.Center).
|
||||
Render("Terminal too small.\nPlease resize.")
|
||||
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
m.viewportHeight,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
msg,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.WindowWidth == 0 {
|
||||
m.WindowWidth = 120
|
||||
m.WindowHeight = 30
|
||||
if m.viewportWidth == 0 {
|
||||
m.updateLayout(120, 30)
|
||||
}
|
||||
|
||||
// If the terminal is too small, show a resize message
|
||||
if m.size == undersized {
|
||||
return m.ResizeView()
|
||||
}
|
||||
|
||||
// If the menu modal is showing, render it full-screen (bypass container)
|
||||
if m.ShowingMenu {
|
||||
return m.BuildMenuView()
|
||||
}
|
||||
|
||||
// Build header with cart tab
|
||||
@@ -21,13 +46,11 @@ func (m Model) View() string {
|
||||
// Build main content based on view
|
||||
var content string
|
||||
if m.Loading {
|
||||
// Show loading message
|
||||
loadingStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("205")).
|
||||
Padding(2, 4)
|
||||
content = loadingStyle.Render("Loading products from API...")
|
||||
} else if m.ErrorMsg != "" {
|
||||
// Show error message at top of shop view
|
||||
errorStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Background(lipgloss.Color("52")).
|
||||
@@ -70,23 +93,18 @@ func (m Model) View() string {
|
||||
content = m.BuildShopView()
|
||||
}
|
||||
|
||||
// Calculate content height based on window size
|
||||
// Calculate available content height within the container
|
||||
headerHeight := lipgloss.Height(header)
|
||||
breadcrumbsHeight := 0
|
||||
if breadcrumbs != "" {
|
||||
breadcrumbsHeight = lipgloss.Height(breadcrumbs)
|
||||
}
|
||||
footerHeight := 1
|
||||
|
||||
// Scale margins based on window height
|
||||
marginTop := 1
|
||||
marginBottom := 1
|
||||
|
||||
// Reserve space for header, breadcrumbs (if present), footer, and margins
|
||||
// Add extra buffer to prevent footer from cutting off last item
|
||||
bufferSpace := 1
|
||||
reservedHeight := headerHeight + breadcrumbsHeight + footerHeight + marginTop + marginBottom + bufferSpace
|
||||
availableContentHeight := m.WindowHeight - reservedHeight
|
||||
availableContentHeight := m.heightContainer - reservedHeight
|
||||
|
||||
// Ensure minimum content height
|
||||
if availableContentHeight < 3 {
|
||||
@@ -132,10 +150,10 @@ func (m Model) View() string {
|
||||
// Build footer
|
||||
footer := m.BuildFooter()
|
||||
|
||||
// Combine all app content
|
||||
var appContent string
|
||||
// Assemble all layers vertically within the container
|
||||
var child string
|
||||
if breadcrumbs != "" {
|
||||
appContent = lipgloss.JoinVertical(
|
||||
child = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
header,
|
||||
breadcrumbs,
|
||||
@@ -143,7 +161,7 @@ func (m Model) View() string {
|
||||
footer,
|
||||
)
|
||||
} else {
|
||||
appContent = lipgloss.JoinVertical(
|
||||
child = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
header,
|
||||
contentWithPadding,
|
||||
@@ -151,20 +169,18 @@ func (m Model) View() string {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if content fits in window
|
||||
appHeight := lipgloss.Height(appContent)
|
||||
// Constrain the assembled layout to the container dimensions
|
||||
constrained := lipgloss.NewStyle().
|
||||
MaxWidth(m.widthContainer).
|
||||
MaxHeight(m.heightContainer).
|
||||
Render(child)
|
||||
|
||||
if appHeight <= m.WindowHeight {
|
||||
// Content fits, center it vertically
|
||||
return lipgloss.Place(
|
||||
m.WindowWidth,
|
||||
m.WindowHeight,
|
||||
lipgloss.Left,
|
||||
lipgloss.Center,
|
||||
appContent,
|
||||
)
|
||||
} else {
|
||||
// Content doesn't fit, align to top (no centering)
|
||||
return appContent
|
||||
}
|
||||
// Center the entire container in the terminal viewport
|
||||
return lipgloss.Place(
|
||||
m.viewportWidth,
|
||||
m.viewportHeight,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
constrained,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user