feat: add product catalog with filters, product detail page with AR support, enhanced homepage

- Backend: add GET /api/products/search (full-text) and GET /api/products/featured endpoints; update GET /:id to include related_products (same category, limit 4)
- Products.tsx: full rewrite with sidebar filters (METI toggle prominent first, category buttons, price bands), sort dropdown, 12-per-page pagination, search bar calling /search endpoint, empty state, responsive 1/2/3 col grid
- ProductDetail.tsx: new page with image, name/name_ja, price, METI badge, craftsman link, description, AR button (amber, only when ar_model_url + ar_eligible=true), ar_ineligible_reason badge, add-to-cart (login-gated), related products grid (max 4), reviews section (max 5, star rating)
- App.tsx: add /products/:id route for ProductDetail
- Home.tsx: category tiles already link to /products?category=<key>; add New Arrivals section fetching /api/products/featured showing 4-col product card grid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:52:36 +00:00
parent ecc0d9ee83
commit a18d0efc6d
5 changed files with 865 additions and 90 deletions

View File

@@ -45,7 +45,50 @@ router.get('/', async (req, res) => {
}
});
// Get product by ID
// Full-text search
router.get('/search', async (req, res) => {
try {
const pool = getPool();
const { q } = req.query;
if (!q || q.trim() === '') {
return res.json([]);
}
const searchTerm = `%${q.trim()}%`;
const { rows } = await pool.query(
`SELECT p.*, c.shop_name, c.meti_certified as craftsman_meti, c.prefecture
FROM products p JOIN craftsmen c ON p.craftsman_id = c.id
WHERE p.is_active = true
AND (p.name ILIKE $1 OR COALESCE(p.name_ja,'') ILIKE $1 OR COALESCE(p.description,'') ILIKE $1)
ORDER BY p.is_featured DESC, p.created_at DESC
LIMIT 20`,
[searchTerm]
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to search products' });
}
});
// Featured products
router.get('/featured', async (req, res) => {
try {
const pool = getPool();
const { rows } = await pool.query(
`SELECT p.*, c.shop_name, c.meti_certified as craftsman_meti, c.prefecture
FROM products p JOIN craftsmen c ON p.craftsman_id = c.id
WHERE p.is_active = true
ORDER BY p.is_featured DESC, p.created_at DESC
LIMIT 8`
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch featured products' });
}
});
// Get product by ID (with related products)
router.get('/:id', async (req, res) => {
try {
const pool = getPool();
@@ -58,6 +101,8 @@ router.get('/:id', async (req, res) => {
if (rows.length === 0) return res.status(404).json({ error: 'Product not found' });
const product = rows[0];
// Get reviews
const reviews = await pool.query(
`SELECT r.*, u.display_name FROM reviews r
@@ -65,9 +110,22 @@ router.get('/:id', async (req, res) => {
WHERE r.product_id = $1 ORDER BY r.created_at DESC LIMIT 10`,
[req.params.id]
);
// Get related products (same craft_category, different product, limit 4)
const related = await pool.query(
`SELECT p.*, c.shop_name, c.meti_certified as craftsman_meti, c.prefecture
FROM products p JOIN craftsmen c ON p.craftsman_id = c.id
WHERE p.is_active = true
AND p.craft_category = $1
AND p.id != $2
ORDER BY p.is_featured DESC, p.created_at DESC
LIMIT 4`,
[product.craft_category, req.params.id]
);
res.json({ ...rows[0], reviews: reviews.rows });
res.json({ ...product, reviews: reviews.rows, related_products: related.rows });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch product' });
}
});