feat: product search screen with catalog add and success feedback

- Add product search screen (/products/search) as primary add flow;
  "Add" button on products list opens search, manual entry remains as fallback
- Add to shelf bottom sheet with AnimatedSwitcher success view (green checkmark)
  and SnackBar confirmation on the search screen via onAdded callback
- Manual add (AddProductScreen) shows SnackBar on success before popping back
- Extend AddProductScreen with optional nutrition fields (calories, protein,
  fat, carbs, fiber); auto-fills from catalog selection and auto-expands section
- Auto-upsert catalog product on backend when nutrition data is provided
  without a primary_product_id, linking the user product to the catalog
- Add fiber_per_100g field to CatalogProduct model and CreateRequest
- Add 16 new L10n keys across all 12 locales (addProduct, addManually,
  searchProducts, quantity, storageDays, addToShelf, nutritionOptional,
  calories, protein, fat, carbs, fiber, productAddedToShelf, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-26 14:04:58 +02:00
parent 16d944c80e
commit 8d33a4eb30
40 changed files with 2167 additions and 74 deletions

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/go-chi/chi/v5"
)
@@ -18,16 +19,24 @@ type UserProductRepository interface {
BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*UserProduct, error)
Update(ctx context.Context, id, userID string, req UpdateRequest) (*UserProduct, error)
Delete(ctx context.Context, id, userID string) error
DeleteAll(ctx context.Context, userID string) error
}
// ProductUpserter creates or updates a catalog product entry.
// Implemented by product.Repository — defined here to avoid import cycles.
type ProductUpserter interface {
Upsert(ctx context.Context, catalogProduct *product.Product) (*product.Product, error)
}
// Handler handles /user-products HTTP requests.
type Handler struct {
repo UserProductRepository
repo UserProductRepository
productUpserter ProductUpserter
}
// NewHandler creates a new Handler.
func NewHandler(repo UserProductRepository) *Handler {
return &Handler{repo: repo}
func NewHandler(repo UserProductRepository, productUpserter ProductUpserter) *Handler {
return &Handler{repo: repo, productUpserter: productUpserter}
}
// List handles GET /user-products.
@@ -47,7 +56,8 @@ func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.R
// Create handles POST /user-products.
func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
requestContext := request.Context()
userID := middleware.UserIDFromCtx(requestContext)
var req CreateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body")
@@ -58,9 +68,40 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http
return
}
userProduct, createError := handler.repo.Create(request.Context(), userID, req)
// Resolve effective primary_product_id (accept legacy mapping_id alias).
primaryProductID := req.PrimaryProductID
if primaryProductID == nil {
primaryProductID = req.MappingID
}
// When nutrition data is provided without an existing catalog link, upsert a catalog
// product so the nutrition info is not silently discarded.
hasNutrition := req.CaloriesPer100g != nil || req.ProteinPer100g != nil ||
req.FatPer100g != nil || req.CarbsPer100g != nil || req.FiberPer100g != nil
if hasNutrition && primaryProductID == nil {
catalogProduct := &product.Product{
CanonicalName: req.Name,
Category: req.Category,
DefaultUnit: &req.Unit,
CaloriesPer100g: req.CaloriesPer100g,
ProteinPer100g: req.ProteinPer100g,
FatPer100g: req.FatPer100g,
CarbsPer100g: req.CarbsPer100g,
FiberPer100g: req.FiberPer100g,
StorageDays: &req.StorageDays,
}
savedProduct, upsertError := handler.productUpserter.Upsert(requestContext, catalogProduct)
if upsertError != nil {
slog.WarnContext(requestContext, "upsert catalog product on manual add", "name", req.Name, "err", upsertError)
// Graceful degradation: continue without catalog link.
} else {
req.PrimaryProductID = &savedProduct.ID
}
}
userProduct, createError := handler.repo.Create(requestContext, userID, req)
if createError != nil {
slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError)
slog.ErrorContext(requestContext, "create user product", "user_id", userID, "err", createError)
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product")
return
}
@@ -113,6 +154,17 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http
writeJSON(responseWriter, http.StatusOK, userProduct)
}
// DeleteAll handles DELETE /user-products.
func (handler *Handler) DeleteAll(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
if deleteError := handler.repo.DeleteAll(request.Context(), userID); deleteError != nil {
slog.ErrorContext(request.Context(), "delete all user products", "user_id", userID, "err", deleteError)
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete all user products")
return
}
responseWriter.WriteHeader(http.StatusNoContent)
}
// Delete handles DELETE /user-products/{id}.
func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())