/**
* Customer-facing Product Tools
* Search products, get recommendations, product details
*/
import { shopifyRequest } from './shopify-client.js';
interface SearchProductsParams {
query: string;
limit?: number;
}
interface GetProductParams {
product_id?: string;
handle?: string;
}
interface GetRecommendationsParams {
product_id?: string;
collection?: string;
limit?: number;
}
interface ProductSales {
id: string;
name: string;
quantity: number;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function stripHtmlTags(html: string | null | undefined): string {
return html?.replace(/<[^>]*>/g, '') || '';
}
function buildProductUrl(handle: string): string {
return `https://${process.env.SHOPIFY_STORE_URL}/products/${handle}`;
}
async function aggregateProductSales(excludeProductId?: string): Promise<ProductSales[]> {
const ordersResponse = await shopifyRequest('orders.json', {
params: {
limit: 250,
status: 'any',
created_at_min: new Date(Date.now() - THIRTY_DAYS_MS).toISOString(),
},
});
const productSales: Record<string, ProductSales> = {};
for (const order of ordersResponse.orders) {
for (const item of order.line_items) {
if (!item.product_id) {
continue;
}
if (excludeProductId && item.product_id === excludeProductId) {
continue;
}
const key = item.product_id;
if (!productSales[key]) {
productSales[key] = { id: item.product_id, name: item.name, quantity: 0 };
}
productSales[key].quantity += item.quantity;
}
}
return Object.values(productSales).sort((a, b) => b.quantity - a.quantity);
}
export async function searchProducts(params: SearchProductsParams) {
const { query, limit = 10 } = params;
// Get all active products and filter by search query
const response = await shopifyRequest('products.json', {
params: {
limit: 250,
status: 'active',
},
});
const queryLower = query.toLowerCase();
const matchesQuery = (product: any): boolean => {
const fields = [
product.title,
product.product_type,
product.tags,
product.vendor,
];
return fields.some((field) => field?.toLowerCase().includes(queryLower));
};
const matchingProducts = response.products
.filter(matchesQuery)
.slice(0, limit)
.map((product: any) => ({
id: product.id,
title: product.title,
handle: product.handle,
description: stripHtmlTags(product.body_html).substring(0, 200),
price: product.variants[0]?.price,
compare_at_price: product.variants[0]?.compare_at_price,
image_url: product.image?.src || null,
available: product.variants.some((v: any) => v.inventory_quantity > 0),
url: buildProductUrl(product.handle),
}));
return {
products: matchingProducts,
count: matchingProducts.length,
query,
};
}
export async function getProductDetails(params: GetProductParams) {
const { product_id, handle } = params;
let product: any;
if (product_id) {
const response = await shopifyRequest(`products/${product_id}.json`);
product = response.product;
} else if (handle) {
// Get all products and find by handle
const response = await shopifyRequest('products.json', {
params: { handle, limit: 1 },
});
if (response.products.length === 0) {
return { success: false, message: 'Product not found' };
}
product = response.products[0];
} else {
return { success: false, message: 'Please provide product_id or handle' };
}
return {
success: true,
product: {
id: product.id,
title: product.title,
handle: product.handle,
description: stripHtmlTags(product.body_html),
vendor: product.vendor,
product_type: product.product_type,
tags: product.tags?.split(',').map((t: string) => t.trim()) || [],
variants: product.variants.map((v: any) => ({
id: v.id,
title: v.title !== 'Default Title' ? v.title : null,
price: v.price,
compare_at_price: v.compare_at_price,
available: v.inventory_quantity > 0,
sku: v.sku,
})),
images: product.images.map((img: any) => ({
src: img.src,
alt: img.alt,
})),
url: buildProductUrl(product.handle),
},
};
}
export async function getProductRecommendations(params: GetRecommendationsParams) {
const { product_id, limit = 5 } = params;
const salesData = await aggregateProductSales(product_id);
const topProductIds = salesData.slice(0, limit).map((p) => p.id);
if (topProductIds.length === 0) {
return { recommendations: [], count: 0 };
}
const productsResponse = await shopifyRequest('products.json', {
params: { ids: topProductIds.join(','), status: 'active' },
});
const recommendations = productsResponse.products.map((product: any) => ({
id: product.id,
title: product.title,
handle: product.handle,
price: product.variants[0]?.price,
image_url: product.image?.src || null,
url: buildProductUrl(product.handle),
}));
return {
recommendations,
count: recommendations.length,
};
}
export async function getBestSellers(params: { limit?: number }) {
const { limit = 10 } = params;
const salesData = await aggregateProductSales();
const topProductIds = salesData.slice(0, limit).map((p) => p.id);
if (topProductIds.length === 0) {
return { best_sellers: [], count: 0 };
}
const productsResponse = await shopifyRequest('products.json', {
params: { ids: topProductIds.join(','), status: 'active' },
});
const bestSellers = productsResponse.products.map((product: any, index: number) => ({
rank: index + 1,
id: product.id,
title: product.title,
handle: product.handle,
price: product.variants[0]?.price,
image_url: product.image?.src || null,
url: buildProductUrl(product.handle),
}));
return {
best_sellers: bestSellers,
count: bestSellers.length,
};
}
export async function getCollections(params: { limit?: number }) {
const { limit = 20 } = params;
const [customCollections, smartCollections] = await Promise.all([
shopifyRequest('custom_collections.json', { params: { limit } }),
shopifyRequest('smart_collections.json', { params: { limit } }),
]);
const allCollections = [
...customCollections.custom_collections,
...smartCollections.smart_collections,
];
const collections = allCollections.map((c: any) => ({
id: c.id,
title: c.title,
handle: c.handle,
description: stripHtmlTags(c.body_html).substring(0, 150),
image_url: c.image?.src || null,
url: `https://${process.env.SHOPIFY_STORE_URL}/collections/${c.handle}`,
}));
return {
collections,
count: collections.length,
};
}