diff --git a/api/handlers/addresses.go b/api/handlers/addresses.go index 0488fec..177bc93 100644 --- a/api/handlers/addresses.go +++ b/api/handlers/addresses.go @@ -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, + }) } diff --git a/api/handlers/auth.go b/api/handlers/auth.go index 37c9a18..3363832 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -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 diff --git a/api/handlers/checkout.go b/api/handlers/checkout.go index af2a7b6..52ce8d1 100644 --- a/api/handlers/checkout.go +++ b/api/handlers/checkout.go @@ -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 } diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 001989d..44dda18 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -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, diff --git a/api/main.go b/api/main.go index 038e879..5c1d46e 100644 --- a/api/main.go +++ b/api/main.go @@ -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{ diff --git a/api/middleware/auth.go b/api/middleware/auth.go new file mode 100644 index 0000000..e377a99 --- /dev/null +++ b/api/middleware/auth.go @@ -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 " + 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 ", 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 +} diff --git a/api/routes/routes.go b/api/routes/routes.go index b9e06ab..bba5a88 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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 diff --git a/main.go b/main.go index 8ff7210..598e8ec 100644 --- a/main.go +++ b/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) diff --git a/pkg/api/client.go b/pkg/api/client.go index 0ea2d8f..3b54c40 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -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 +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a11b590..0fdf987 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 9b31ed1..bb66302 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -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 } diff --git a/pkg/tui/breadcrumbs.go b/pkg/tui/breadcrumbs.go index cef311f..f2b1ba2 100644 --- a/pkg/tui/breadcrumbs.go +++ b/pkg/tui/breadcrumbs.go @@ -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 diff --git a/pkg/tui/cart.go b/pkg/tui/cart.go index 02a9b2a..086cca2 100644 --- a/pkg/tui/cart.go +++ b/pkg/tui/cart.go @@ -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)) diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index a3a244d..c5bd607 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -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)), ) diff --git a/pkg/tui/footer.go b/pkg/tui/footer.go index 179efdd..5cf5df0 100644 --- a/pkg/tui/footer.go +++ b/pkg/tui/footer.go @@ -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) } diff --git a/pkg/tui/header.go b/pkg/tui/header.go index ef40679..76cee24 100644 --- a/pkg/tui/header.go +++ b/pkg/tui/header.go @@ -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)) diff --git a/pkg/tui/menu.go b/pkg/tui/menu.go new file mode 100644 index 0000000..bba059f --- /dev/null +++ b/pkg/tui/menu.go @@ -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, + ) + +} diff --git a/pkg/tui/menu_test.go b/pkg/tui/menu_test.go new file mode 100644 index 0000000..0cce043 --- /dev/null +++ b/pkg/tui/menu_test.go @@ -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) + } + }) + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index eafadf8..c6a4baf 100644 --- a/pkg/tui/model.go +++ b/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 } diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go new file mode 100644 index 0000000..5271428 --- /dev/null +++ b/pkg/tui/model_test.go @@ -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) + }) + } +} diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 1a6bc0e..6c81b22 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -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) } } diff --git a/pkg/tui/shipping_form.go b/pkg/tui/shipping_form.go index d6e7d76..97edbdd 100644 --- a/pkg/tui/shipping_form.go +++ b/pkg/tui/shipping_form.go @@ -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) } diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index 2ab88ef..a2dcb4a 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -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 } diff --git a/pkg/tui/views.go b/pkg/tui/views.go index e9cac27..5fbd751 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -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, + ) }