updating the ui fr

This commit is contained in:
2026-02-26 10:16:57 +01:00
parent 7f07394537
commit 7ebc5b24de
24 changed files with 1225 additions and 292 deletions

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
View 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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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)),
)

View File

@@ -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)
}

View File

@@ -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
View 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
View 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)
}
})
}
}

View File

@@ -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
View 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)
})
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,
)
}