Skip to content

Commit ddfc005

Browse files
committed
Merge branch 'main' of github.com:bsychen/shopsmart into fix/stylistic-consistency
2 parents 9afe37e + 3ed40af commit ddfc005

File tree

6 files changed

+112
-20
lines changed

6 files changed

+112
-20
lines changed

src/app/api/products/[id]/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getBrand } from '@/lib/brandUtils';
66
async function fetchProductData(id: string){
77
const fields = [
88
"product_name",
9+
"product_name_en",
910
"brands",
1011
"brands_tags",
1112
"categories_properties",
@@ -56,7 +57,12 @@ async function fetchProductData(id: string){
5657
const res = await fetch(`https://world.openfoodfacts.net/api/v2/product/${id}?fields=${encodeURIComponent(fieldsParam)}`);
5758
if (!res.ok) return null;
5859
const data = await res.json();
59-
if (!data.product || !data.product.product_name) return null; // Get or create brandId for this product
60+
if (!data.product || (!data.product.product_name_en && !data.product.product_name)) return null;
61+
62+
// Use product_name_en if available, otherwise fall back to product_name
63+
const productName = data.product.product_name_en || data.product.product_name;
64+
65+
// Get or create brandId for this product
6066
const brandName = data.product.brands ||
6167
(data.product.brands_tags && data.product.brands_tags.length > 0 ? data.product.brands_tags[0] : '') || '';
6268

@@ -77,8 +83,8 @@ async function fetchProductData(id: string){
7783
}
7884

7985
return {
80-
productName: data.product.product_name,
81-
productNameLower: data.product.product_name.toLowerCase(),
86+
productName: productName,
87+
productNameLower: productName.toLowerCase(),
8288
brandName: brandName,
8389
brandId: brandId,
8490
combinedCategory: [...new Set([
@@ -88,6 +94,7 @@ async function fetchProductData(id: string){
8894
...(data.product.main_category ? [data.product.main_category] : []),
8995
...(data.product.main_category_fr ? [data.product.main_category_fr] : [])
9096
])],
97+
categoriesTags: data.product.categories_tags || [],
9198
genericNameLower: (data.product.generic_name || data.product.generic_name_en)
9299
? (data.product.generic_name || data.product.generic_name_en).toLowerCase()
93100
: '',
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { db } from "@/lib/firebaseAdmin";
3+
import { Product } from "@/types/product";
4+
5+
export async function GET(req: NextRequest) {
6+
try {
7+
const { searchParams } = new URL(req.url);
8+
const categoriesTags = searchParams.get('tags');
9+
const currentProductId = searchParams.get('currentId');
10+
11+
if (!categoriesTags) {
12+
return NextResponse.json({ error: "Categories tags parameter is required" }, { status: 400 });
13+
}
14+
15+
// Parse the categories tags from comma-separated string
16+
const tagsArray = categoriesTags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
17+
18+
if (tagsArray.length === 0) {
19+
return NextResponse.json([]);
20+
}
21+
22+
// Get all products that have at least one matching category tag
23+
const productsSnapshot = await db
24+
.collection("products")
25+
.where("categoriesTags", "array-contains-any", tagsArray)
26+
.get();
27+
28+
const products: (Product & { matchCount: number })[] = [];
29+
30+
productsSnapshot.forEach(doc => {
31+
const productData = doc.data() as Product;
32+
33+
// Skip the current product
34+
if (doc.id === currentProductId) {
35+
return;
36+
}
37+
38+
// Calculate how many tags match
39+
const productTags = productData.categoriesTags || [];
40+
const matchCount = tagsArray.filter(tag => productTags.includes(tag)).length;
41+
42+
// Only include products with at least one matching tag
43+
if (matchCount > 0) {
44+
products.push({
45+
id: doc.id,
46+
...productData,
47+
matchCount
48+
});
49+
}
50+
});
51+
52+
// Sort by match count (descending) and limit to 10
53+
const sortedProducts = products
54+
.sort((a, b) => b.matchCount - a.matchCount)
55+
.slice(0, 10)
56+
.map(({ matchCount, ...product }) => product); // Remove matchCount from final result
57+
58+
return NextResponse.json(sortedProducts);
59+
} catch (error) {
60+
console.error("Failed to fetch similar products:", error);
61+
return NextResponse.json({ error: "Failed to fetch similar products" }, { status: 500 });
62+
}
63+
}

src/app/product/[id]/page.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useState, useEffect, use, Suspense, lazy, useMemo } from "react"
44
import { Product } from "@/types/product"
55
import { Review } from "@/types/review"
66
import { ReviewSummary } from "@/types/reviewSummary"
7-
import { getProduct, getProductReviews, getReviewSummary, getBrandById, getProductsWithGenericName, getProductsByBrand, getUserById } from "@/lib/api"
7+
import { getProduct, getProductReviews, getReviewSummary, getBrandById, getProductsByBrand, getUserById, getSimilarProductsByCategories } from "@/lib/api"
88
import { onAuthStateChanged, User } from "firebase/auth"
99
import { auth } from "@/lib/firebaseClient"
1010
import { useRouter } from "next/navigation"
@@ -152,6 +152,11 @@ export default function ProductPage({ params }: { params: Promise<{ id: string }
152152
}>({ min: 0, max: 0, q1: 0, median: 0, q3: 0 });
153153
const [showAllergenWarning, setShowAllergenWarning] = useState(false);
154154
const [allergenWarningDismissed, setAllergenWarningDismissed] = useState(false);
155+
156+
const sameProducts = useMemo(() => {
157+
if (!product) return [];
158+
return [...similarProducts.filter(p => p.categoriesTags.includes(product.categoriesTags[product.categoriesTags.length - 1])), product];
159+
}, [similarProducts, product]);
155160

156161
// Calculate all radar chart scores
157162
const priceScore = product ? getQuartileScore(product.price || 0, priceStats.q1, priceStats.q3) : 3;
@@ -199,11 +204,10 @@ export default function ProductPage({ params }: { params: Promise<{ id: string }
199204
// Filter out the current product and limit to 8 products
200205
setBrandProducts(brandProds.filter(p => p.id !== id).slice(0, 8));
201206
}
202-
// Fetch similar products based on genericName
203-
if (productData.genericNameLower) {
204-
const similar = await getProductsWithGenericName(productData.genericNameLower);
205-
// Filter out the current product and limit to 8 products
206-
setSimilarProducts(similar.filter(p => p.id !== id).slice(0, 8));
207+
// Fetch similar products based on categories_tags
208+
if (productData.categoriesTags && productData.categoriesTags.length > 0) {
209+
const similar = await getSimilarProductsByCategories(productData.categoriesTags, id);
210+
setSimilarProducts(similar);
207211
}
208212
}
209213
setLoading(false);
@@ -320,11 +324,11 @@ export default function ProductPage({ params }: { params: Promise<{ id: string }
320324

321325
// Calculate price statistics when similar products change
322326
useEffect(() => {
323-
if (!product || !similarProducts.length) return;
327+
if (!product || !(sameProducts.length - 1)) return;
324328

325329
const getPrice = (p: Product) => p.price || p.expectedPrice || 0;
326-
const prices = [...similarProducts.map(getPrice), getPrice(product)].filter(p => p > 0);
327-
330+
const prices = [...sameProducts.map(getPrice), getPrice(product)].filter(p => p > 0);
331+
328332
if (prices.length) {
329333
setPriceStats({
330334
min: Math.min(...prices),
@@ -334,7 +338,7 @@ export default function ProductPage({ params }: { params: Promise<{ id: string }
334338
q3: calculateQuartile(prices, 0.75)
335339
});
336340
}
337-
}, [similarProducts, product]);
341+
}, [sameProducts, product]);
338342

339343
if (loading) {
340344
return <LoadingAnimation />;
@@ -427,8 +431,8 @@ export default function ProductPage({ params }: { params: Promise<{ id: string }
427431
brandRating={brandRating}
428432
brandProducts={brandProducts}
429433
priceStats={priceStats}
430-
maxPriceProduct={similarProducts.reduce((max, p) => (!max || (p.price || 0) > (max.price || 0)) ? p : max, null)}
431-
minPriceProduct={similarProducts.reduce((min, p) => (!min || (p.price || 0) < (min.price || 0)) ? p : min, null)}
434+
maxPriceProduct={sameProducts.reduce((max, p) => (!max || (p.price || 0) > (max.price || 0)) ? p : max, null)}
435+
minPriceProduct={sameProducts.reduce((min, p) => (!min || (p.price || 0) < (min.price || 0)) ? p : min, null)}
432436
/>
433437
{(product.alergenInformation && product.alergenInformation.length > 0 || product.labels && product.labels.length > 0 || product.countryOfOriginCode) && (
434438
<div className="w-full">

src/components/TabbedInfoBox.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useRef, useState, useMemo } from "react";
22
import Image from "next/image";
3+
import Link from "next/link";
34
import { Product } from "@/types/product";
45
import { ReviewSummary } from "@/types/reviewSummary";
56
import PriceSpectrum from "./PriceSpectrum";
@@ -372,15 +373,24 @@ const TabbedInfoBox: React.FC<TabbedInfoBoxProps> = ({
372373
backgroundColor: `${colours.content.surface}99` // 60% opacity
373374
}}
374375
>
375-
<div className="flex items-start gap-4">
376+
<Link
377+
href={`/product/${showMinProduct ? minPriceProduct?.id : maxPriceProduct?.id}`}
378+
className="flex items-start gap-4 hover:opacity-80 transition-opacity"
379+
>
376380
<div
377-
className="flex-shrink-0 w-16 h-16 rounded-md shadow-sm border flex items-center justify-center"
381+
className="flex-shrink-0 w-16 h-16 rounded-md shadow-sm border overflow-hidden"
378382
style={{
379383
backgroundColor: colours.content.surface,
380384
borderColor: colours.content.border
381385
}}
382386
>
383-
<span className="text-2xl">{showMinProduct ? '💰' : '💎'}</span>
387+
<Image
388+
src={(showMinProduct ? minPriceProduct?.imageUrl : maxPriceProduct?.imageUrl) || '/placeholder.jpg'}
389+
alt={showMinProduct ? minPriceProduct?.productName || 'Product' : maxPriceProduct?.productName || 'Product'}
390+
width={64}
391+
height={64}
392+
className="w-full h-full object-cover"
393+
/>
384394
</div>
385395
<div className="flex-grow">
386396
<h3
@@ -390,7 +400,7 @@ const TabbedInfoBox: React.FC<TabbedInfoBoxProps> = ({
390400
{showMinProduct ? 'Lowest Price Option' : 'Highest Price Option'}
391401
</h3>
392402
<h4
393-
className="font-semibold text-base mb-1"
403+
className="font-semibold text-base mb-1 hover:underline"
394404
style={{ color: colours.text.primary }}
395405
>
396406
{showMinProduct ? minPriceProduct?.productName : maxPriceProduct?.productName}
@@ -412,7 +422,7 @@ const TabbedInfoBox: React.FC<TabbedInfoBoxProps> = ({
412422
</span>
413423
</div>
414424
</div>
415-
</div>
425+
</Link>
416426
</div>
417427
)}
418428
</div>

src/lib/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,10 @@ export async function getProductsByBrand(brandId: string): Promise<Product[]> {
154154
if (!res.ok) return [];
155155
return await res.json();
156156
}
157+
158+
export async function getSimilarProductsByCategories(categoriesTags: string[], currentProductId: string): Promise<Product[]> {
159+
const tagsParam = categoriesTags.join(',');
160+
const res = await fetch(`/api/products/similar?tags=${encodeURIComponent(tagsParam)}&currentId=${encodeURIComponent(currentProductId)}`);
161+
if (!res.ok) return [];
162+
return await res.json();
163+
}

src/types/product.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface Product{
55
brandName: string;
66
brandId: string;
77
combinedCategory: string[];
8+
categoriesTags: string[];
89
genericNameLower: string;
910
countryOfOriginCode: string;
1011
alergenInformation: string[];

0 commit comments

Comments
 (0)