Skip to main content

Overview

The Customer Products API provides product browsing functionality with personalized features including liked products, customer-specific pricing, and social interactions. This system enhances the shopping experience with recommendations and social proof. Location: convex/customers/products.ts

Get Products

Retrieve products for a store with optional category filtering and customer personalization.
customerId
Id<'customers'>
Customer ID for personalized results (shows liked status, recommendations)
storeId
Id<'stores'>
required
Store ID to get products from
categoryId
Id<'categories'>
Optional category filter to show only products in specific category
const products = await convex.query(api.customers.products.getProducts, {
  customerId: "c123456789",
  storeId: "j123456789",
  categoryId: "k123456789"
});
[
  {
    "_id": "p123456789",
    "name": "Pizza Margherita",
    "description": "Classic Italian pizza with fresh mozzarella, tomatoes, and basil",
    "price": 15.99,
    "stock": 25,
    "stockAlert": 10,
    "prepTime": 20,
    "prepTimeUnit": "minutes",
    "isActive": true,
    "primaryImageUrl": "https://storage.convex.dev/pizza-margherita.jpg",
    "category": {
      "id": "k123456789",
      "name": "Italian Cuisine"
    },
    "isCustomerLikesProduct": true,
    "_creationTime": 1640995200000
  },
  {
    "_id": "p987654321",
    "name": "Garlic Bread",
    "description": "Freshly baked garlic bread with herbs and parmesan",
    "price": 6.99,
    "stock": 50,
    "prepTime": 10,
    "prepTimeUnit": "minutes",
    "isActive": true,
    "primaryImageUrl": "https://storage.convex.dev/garlic-bread.jpg",
    "category": {
      "id": "k123456789",
      "name": "Italian Cuisine"
    },
    "isCustomerLikesProduct": false,
    "_creationTime": 1640994000000
  }
]

Get Liked Products

Retrieve products that a customer has liked.
customerId
Id<'customers'>
Customer ID (defaults to authenticated user)
categoryId
Id<'categories'>
Optional category filter
const likedProducts = await convex.query(api.customers.products.getLikedProducts, {
  customerId: "c123456789"
});
[
  {
    "_id": "p123456789",
    "name": "Pizza Margherita",
    "price": 15.99,
    "primaryImageUrl": "https://storage.convex.dev/pizza-margherita.jpg",
    "category": { "id": "k123456789", "name": "Italian Cuisine" },
    "globalCategoryId": { "id": "g111222333", "name": "Food" },
    "store": { "id": "j123456789", "name": "Mario's Pizza Palace", "logoUrl": "https://storage.convex.dev/mario-logo.jpg" },
    "isCustomerLikesProduct": true
  }
]

Product Browsing Components

Complete product browsing interface with social features:
import { useState } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from './convex/_generated/api';

interface ProductBrowserProps {
  storeId: string;
  customerId?: string;
  onAddToCart: (productId: string, quantity: number) => void;
}

function ProductBrowser({ storeId, customerId, onAddToCart }: ProductBrowserProps) {
  const [selectedCategory, setSelectedCategory] = useState<string>('');
  const [quantities, setQuantities] = useState<Record<string, number>>({});

  const products = useQuery(api.customers.products.getProducts, {
    customerId,
    storeId,
    categoryId: selectedCategory || undefined
  });

  const storeCategories = useQuery(api.shared.categories.getAvailableCategoriesForStore, {
    storeId
  });

  const likeProduct = useMutation(api.customers.feed.likeProduct);
  const unlikeProduct = useMutation(api.customers.feed.unlikeProduct);

  const handleLikeToggle = async (productId: string, isLiked: boolean) => {
    if (!customerId) return;

    try {
      if (isLiked) {
        await unlikeProduct({ productId, customerId });
      } else {
        await likeProduct({ productId, customerId });
      }
    } catch (error) {
      console.error('Failed to toggle like:', error);
    }
  };

  const handleQuantityChange = (productId: string, quantity: number) => {
    setQuantities(prev => ({ ...prev, [productId]: quantity }));
  };

  const handleAddToCart = (productId: string) => {
    const quantity = quantities[productId] || 1;
    onAddToCart(productId, quantity);
    setQuantities(prev => ({ ...prev, [productId]: 1 })); // Reset to 1
  };

  return (
    <div className="space-y-6">
      {/* Category Filter */}
      <div className="bg-white p-4 rounded-lg shadow">
        <div className="flex flex-wrap gap-2">
          <button
            onClick={() => setSelectedCategory('')}
            className={`px-4 py-2 rounded-full text-sm font-medium ${
              selectedCategory === '' 
                ? 'bg-blue-600 text-white' 
                : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
            }`}
          >
            All Products
          </button>
          
          {storeCategories?.map((category) => (
            <button
              key={category._id}
              onClick={() => setSelectedCategory(category._id)}
              className={`px-4 py-2 rounded-full text-sm font-medium ${
                selectedCategory === category._id 
                  ? 'bg-blue-600 text-white' 
                  : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
              }`}
            >
              {category.name}
            </button>
          ))}
        </div>
      </div>

      {/* Products Grid */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {products?.map((product) => (
          <div key={product._id} className="bg-white rounded-lg shadow hover:shadow-lg transition-shadow">
            {/* Product Image */}
            <div className="relative h-48 bg-gray-200 rounded-t-lg overflow-hidden">
              <img
                src={product.primaryImageUrl}
                alt={product.name}
                className="w-full h-full object-cover"
              />
              
              {/* Like Button */}
              {customerId && (
                <button
                  onClick={() => handleLikeToggle(product._id, product.isCustomerLikesProduct)}
                  className={`absolute top-2 right-2 p-2 rounded-full ${
                    product.isCustomerLikesProduct 
                      ? 'bg-red-500 text-white' 
                      : 'bg-white text-gray-600 hover:bg-gray-50'
                  }`}
                >
                  <svg className="w-5 h-5" fill={product.isCustomerLikesProduct ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
                  </svg>
                </button>
              )}

              {/* Stock Badge */}
              {product.stock <= product.stockAlert && (
                <div className="absolute top-2 left-2 px-2 py-1 bg-orange-500 text-white text-xs font-medium rounded">
                  Low Stock
                </div>
              )}
            </div>

            <div className="p-4">
              {/* Product Info */}
              <div className="mb-3">
                <h3 className="font-semibold text-gray-900 mb-1">{product.name}</h3>
                <p className="text-sm text-gray-600 line-clamp-2">{product.description}</p>
                <p className="text-xs text-gray-500 mt-1">{product.category.name}</p>
              </div>

              {/* Price and Prep Time */}
              <div className="flex items-center justify-between mb-3">
                <span className="text-lg font-bold text-gray-900">${product.price.toFixed(2)}</span>
                <span className="text-sm text-gray-600">
                  ⏱️ {product.prepTime} {product.prepTimeUnit}
                </span>
              </div>

              {/* Social Stats */}
              <div className="flex items-center space-x-4 text-xs text-gray-500 mb-3">
                <span>❤️ {product.socialStats?.likesCount || 0}</span>
                <span>📤 {product.socialStats?.sharesCount || 0}</span>
                <span>🛒 {product.socialStats?.ordersCount || 0} orders</span>
              </div>

              {/* Add to Cart */}
              <div className="flex items-center space-x-2">
                <div className="flex items-center border rounded">
                  <button
                    onClick={() => handleQuantityChange(product._id, Math.max(1, (quantities[product._id] || 1) - 1))}
                    className="px-2 py-1 hover:bg-gray-100"
                  >
                    -
                  </button>
                  <span className="px-3 py-1 min-w-[3rem] text-center">
                    {quantities[product._id] || 1}
                  </span>
                  <button
                    onClick={() => handleQuantityChange(product._id, (quantities[product._id] || 1) + 1)}
                    className="px-2 py-1 hover:bg-gray-100"
                  >
                    +
                  </button>
                </div>
                
                <button
                  onClick={() => handleAddToCart(product._id)}
                  disabled={!product.isActive || product.stock === 0}
                  className="flex-1 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
                >
                  {product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}
                </button>
              </div>
            </div>
          </div>
        ))}
      </div>

      {/* Empty State */}
      {products && products.length === 0 && (
        <div className="text-center py-12">
          <div className="w-24 h-24 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
            <svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
            </svg>
          </div>
          <h3 className="text-lg font-medium text-gray-900 mb-2">No products found</h3>
          <p className="text-gray-600">
            {selectedCategory 
              ? 'No products in this category' 
              : 'This store hasn\'t added any products yet'
            }
          </p>
        </div>
      )}
    </div>
  );
}

export default ProductBrowser;

Liked Products Page

Complete liked products management:
import { useQuery, useMutation } from 'convex/react';
import { api } from './convex/_generated/api';

function LikedProductsPage({ customerId }: { customerId: string }) {
  const [cursor, setCursor] = useState<string | null>(null);
  const [allLikedProducts, setAllLikedProducts] = useState<any[]>([]);

  const likedProducts = useQuery(api.customers.products.getLikedProducts, {
    customerId,
    paginationOpts: { numItems: 20, cursor }
  });

  const unlikeProduct = useMutation(api.customers.feed.unlikeProduct);

  useEffect(() => {
    if (likedProducts) {
      if (cursor === null) {
        setAllLikedProducts(likedProducts.page);
      } else {
        setAllLikedProducts(prev => [...prev, ...likedProducts.page]);
      }
    }
  }, [likedProducts, cursor]);

  const handleUnlike = async (productId: string) => {
    try {
      await unlikeProduct({ productId, customerId });
      setAllLikedProducts(prev => prev.filter(p => p._id !== productId));
    } catch (error) {
      console.error('Failed to unlike product:', error);
    }
  };

  const handleLoadMore = () => {
    if (likedProducts && !likedProducts.isDone && likedProducts.continueCursor) {
      setCursor(likedProducts.continueCursor);
    }
  };

  return (
    <div className="max-w-6xl mx-auto space-y-6">
      <div className="text-center">
        <h1 className="text-3xl font-bold text-gray-900">Your Liked Products</h1>
        <p className="text-gray-600 mt-2">
          {allLikedProducts.length} products you've liked
        </p>
      </div>

      {allLikedProducts.length > 0 ? (
        <>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {allLikedProducts.map((product) => (
              <div key={product._id} className="bg-white rounded-lg shadow">
                <div className="relative h-40 bg-gray-200 rounded-t-lg overflow-hidden">
                  <img
                    src={product.primaryImageUrl}
                    alt={product.name}
                    className="w-full h-full object-cover"
                  />
                  <button
                    onClick={() => handleUnlike(product._id)}
                    className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
                  >
                    <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
                      <path d="M6 18L18 6M6 6l12 12" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"/>
                    </svg>
                  </button>
                </div>

                <div className="p-4">
                  <h3 className="font-semibold text-gray-900 mb-1">{product.name}</h3>
                  <p className="text-sm text-gray-600 mb-2">{product.store.name}</p>
                  
                  <div className="flex items-center justify-between">
                    <span className="text-lg font-bold text-gray-900">
                      ${product.price.toFixed(2)}
                    </span>
                    <span className="text-xs text-gray-500">
                      Liked {new Date(product.likedAt).toLocaleDateString()}
                    </span>
                  </div>

                  <button
                    onClick={() => {/* Navigate to store */}}
                    className="w-full mt-3 bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
                  >
                    Visit Store
                  </button>
                </div>
              </div>
            ))}
          </div>

          {likedProducts && !likedProducts.isDone && (
            <div className="text-center">
              <button
                onClick={handleLoadMore}
                className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
              >
                Load More
              </button>
            </div>
          )}
        </>
      ) : (
        <div className="text-center py-12">
          <div className="w-24 h-24 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
            <svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
            </svg>
          </div>
          <h3 className="text-lg font-medium text-gray-900 mb-2">No liked products yet</h3>
          <p className="text-gray-600 mb-4">
            Start exploring stores and like products you're interested in!
          </p>
          <button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
            Explore Stores
          </button>
        </div>
      )}
    </div>
  );
}

export default LikedProductsPage;

Product Personalization

The system provides personalized product experiences:

Like Status

Shows whether the customer has liked each product for quick recognition

Social Proof

Displays likes, shares, and order counts to help customers make decisions

Recommendations

Future: AI-powered recommendations based on liked products and order history

Wishlist

Liked products serve as a wishlist for future purchases

Product Interaction Features

Customers can like/unlike products for wishlist and social features:
// Like a product
await convex.mutation(api.customers.feed.likeProduct, {
  productId: "p123456789",
  customerId: "c123456789"
});

// Unlike a product
await convex.mutation(api.customers.feed.unlikeProduct, {
  productId: "p123456789", 
  customerId: "c123456789"
});
Share products on social platforms:
await convex.mutation(api.customers.feed.share, {
  productId: "p123456789",
  customerId: "c123456789",
  platform: "whatsapp",
  message: "Check out this amazing pizza!"
});
Real-time stock level monitoring:
// Products automatically show:
// - Out of stock badge when stock = 0
// - Low stock warning when stock <= stockAlert
// - Available quantity for cart additions

Error Handling

Status Code: 404
{
  "error": "Store not found",
  "storeId": "j123456789"
}
Status Code: 200 (Success with empty array)
[]
Status Code: 404
{
  "error": "Category not found",
  "categoryId": "k123456789"
}
Product data includes real-time stock levels, social interaction counts, and personalized information like like status for enhanced shopping experience.
Use the social stats (likes, shares, orders) to highlight popular products and help customers discover trending items in each store.