diff --git a/client/src/App.tsx b/client/src/App.tsx index f650c17..582fed4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,8 +5,8 @@ import { Home } from '@/pages/Home' import { Login } from '@/pages/Login' import { Register } from '@/pages/Register' import { Products } from '@/pages/Products' +import { ProductDetail } from '@/pages/ProductDetail' import { CraftsmenList } from '@/pages/CraftsmenList' -import { CraftsmanProfile } from '@/pages/CraftsmanProfile' import { useAuthStore } from '@/store/auth' function App() { @@ -24,8 +24,8 @@ function App() { } /> } /> } /> + } /> } /> - } /> } /> diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 57ae942..6c30e6b 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -1,8 +1,22 @@ +import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' +import { api } from '@/lib/api' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +interface FeaturedProduct { + id: string + name: string + name_ja?: string + price: number + craft_category?: string + images?: string[] + meti_certified?: boolean + craftsman_meti?: boolean + shop_name?: string +} + const CATEGORIES = [ { key: 'ceramics', label: '陶芸・陶磁器', english: 'Ceramics', emoji: '🏺' }, { key: 'lacquerware', label: '漆器', english: 'Lacquerware', emoji: '🏮' }, @@ -15,7 +29,21 @@ const CATEGORIES = [ { key: 'other', label: 'その他工芸', english: 'Other Crafts', emoji: '🎨' }, ] +function formatPrice(price: number): string { + return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price) +} + export function Home() { + const [featuredProducts, setFeaturedProducts] = useState([]) + const [featuredLoading, setFeaturedLoading] = useState(true) + + useEffect(() => { + api.get('/products/featured') + .then(setFeaturedProducts) + .catch(console.error) + .finally(() => setFeaturedLoading(false)) + }, []) + return (
{/* Hero */} @@ -57,6 +85,64 @@ export function Home() {
+ {/* New Arrivals */} +
+
+
+
+

New Arrivals

+

新着・注目の商品

+
+ + + +
+ + {featuredLoading ? ( +
+ {Array(4).fill(0).map((_, i) => ( +
+ ))} +
+ ) : featuredProducts.length === 0 ? ( +

商品がありません

+ ) : ( +
+ {featuredProducts.slice(0, 4).map((product) => ( + + +
+ {product.images?.[0] ? ( + {product.name} + ) : ( +
+ 🏺 +
+ )} +
+ + {(product.meti_certified || product.craftsman_meti) && ( + 伝統的工芸品 + )} +

{product.name}

+ {product.name_ja && ( +

{product.name_ja}

+ )} +

{product.shop_name}

+

{formatPrice(product.price)}

+
+
+ + ))} +
+ )} +
+
+ {/* METI Certification Feature */}
diff --git a/client/src/pages/ProductDetail.tsx b/client/src/pages/ProductDetail.tsx new file mode 100644 index 0000000..43ef298 --- /dev/null +++ b/client/src/pages/ProductDetail.tsx @@ -0,0 +1,373 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { api } from '@/lib/api' +import { useAuthStore } from '@/store/auth' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' + +interface Review { + id: string + rating: number + comment?: string + display_name?: string + created_at: string +} + +interface RelatedProduct { + id: string + name: string + name_ja?: string + price: number + images?: string[] + shop_name?: string + meti_certified?: boolean + craftsman_meti?: boolean + craft_category?: string +} + +interface Product { + id: string + name: string + name_ja?: string + description?: string + description_ja?: string + price: number + craft_category?: string + images?: string[] + meti_certified?: boolean + craftsman_meti?: boolean + shop_name?: string + craftsman_id: string + prefecture?: string + craft_region?: string + ar_model_url?: string + ar_eligible?: boolean + ar_ineligible_reason?: string + stock_quantity?: number + reviews?: Review[] + related_products?: RelatedProduct[] +} + +const CATEGORY_LABEL_MAP: Record = { + ceramics: '陶芸・陶磁器', + lacquerware: '漆器', + textiles: '織物・染物', + woodwork_bamboo: '木工・竹工芸', + washi_calligraphy: '和紙・書道具', + metalwork: '金工・鍛冶', + food: '食品・特産品', + dolls_toys: '人形・玩具', + other: 'その他工芸', +} + +function formatPrice(price: number): string { + return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price) +} + +function StarRating({ rating }: { rating: number }) { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + {i < rating ? '★' : '☆'} + ))} + + ) +} + +function RelatedProductCard({ product }: { product: RelatedProduct }) { + return ( + + +
+ {product.images?.[0] ? ( + {product.name} + ) : ( +
+ 🏺 +
+ )} +
+ + {(product.meti_certified || product.craftsman_meti) && ( + 伝統的工芸品 + )} +

{product.name}

+ {product.name_ja && ( +

{product.name_ja}

+ )} +

{product.shop_name}

+

{formatPrice(product.price)}

+
+
+ + ) +} + +export function ProductDetail() { + const { id } = useParams<{ id: string }>() + const { user } = useAuthStore() + const [product, setProduct] = useState(null) + const [loading, setLoading] = useState(true) + const [notFound, setNotFound] = useState(false) + const [addedToCart, setAddedToCart] = useState(false) + + useEffect(() => { + if (!id) return + setLoading(true) + setNotFound(false) + api.get(`/products/${id}`) + .then(setProduct) + .catch((err) => { + if (err.message?.includes('not found') || err.message?.includes('404')) { + setNotFound(true) + } else { + console.error(err) + } + }) + .finally(() => setLoading(false)) + }, [id]) + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + if (notFound || !product) { + return ( +
+
🔍
+

商品が見つかりません

+

Product not found (404)

+ + + +
+ ) + } + + const showArButton = product.ar_model_url && product.ar_eligible === true + const showArIneligible = !product.ar_eligible && product.ar_ineligible_reason + + function handleAddToCart() { + if (!user) return + // Cart logic placeholder — show confirmation + setAddedToCart(true) + setTimeout(() => setAddedToCart(false), 2000) + } + + return ( +
+
+ {/* Breadcrumb */} + + + {/* Main product section */} +
+ {/* Image */} +
+ {product.images?.[0] ? ( + {product.name} + ) : ( +
+ 🏺 + 画像なし +
+ )} +
+ + {/* Info */} +
+ {/* Badges */} +
+ {(product.meti_certified || product.craftsman_meti) && ( + 伝統的工芸品 + )} + {product.craft_category && ( + + {CATEGORY_LABEL_MAP[product.craft_category] || product.craft_category} + + )} +
+ + {/* Name */} +
+

{product.name}

+ {product.name_ja && ( +

{product.name_ja}

+ )} +
+ + {/* Price */} +

{formatPrice(product.price)}

+ + {/* Stock */} + {product.stock_quantity !== undefined && ( +

0 ? 'text-green-600' : 'text-destructive'}`}> + {product.stock_quantity > 0 + ? `在庫あり (${product.stock_quantity}点)` + : '在庫なし (Out of Stock)'} +

+ )} + + {/* Craftsman info */} +
+

職人

+ + {product.shop_name} + + {product.prefecture && ( +

+ {product.prefecture} + {product.craft_region && ` · ${product.craft_region}`} +

+ )} +
+ + {/* Description */} + {product.description && ( +
+

説明

+

{product.description}

+ {product.description_ja && ( +

+ {product.description_ja} +

+ )} +
+ )} + + {/* AR */} + {showArButton && ( + + AR で確認 + View in AR + + )} + {showArIneligible && ( + + AR 非対応: {product.ar_ineligible_reason} + + )} + + {/* Add to Cart */} +
+ {user ? ( + + ) : ( +
+ +

+ ログインが必要です —{' '} + ログイン +

+
+ )} +
+
+
+ + {/* Related Products */} + {product.related_products && product.related_products.length > 0 && ( +
+

関連商品

+
+ {product.related_products.slice(0, 4).map(related => ( + + ))} +
+
+ )} + + {/* Reviews */} + {product.reviews && product.reviews.length > 0 && ( +
+

+ レビュー ({product.reviews.length}) +

+
+ {product.reviews.slice(0, 5).map(review => ( +
+
+
+ + {review.display_name || '匿名'} +
+ + {new Date(review.created_at).toLocaleDateString('ja-JP')} + +
+ {review.comment && ( +

{review.comment}

+ )} +
+ ))} +
+
+ )} + + {product.reviews && product.reviews.length === 0 && ( +
+

レビュー

+

まだレビューがありません。 (No reviews yet.)

+
+ )} +
+
+ ) +} diff --git a/client/src/pages/Products.tsx b/client/src/pages/Products.tsx index 20658f8..7608566 100644 --- a/client/src/pages/Products.tsx +++ b/client/src/pages/Products.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { useSearchParams, Link } from 'react-router-dom' import { api } from '@/lib/api' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' interface Product { id: string @@ -15,109 +16,366 @@ interface Product { meti_certified?: boolean craftsman_meti?: boolean shop_name?: string + prefecture?: string + is_featured?: boolean } const CATEGORIES = [ - { key: '', label: 'All' }, - { key: 'ceramics', label: 'Ceramics 陶芸' }, - { key: 'lacquerware', label: 'Lacquerware 漆器' }, - { key: 'textiles', label: 'Textiles 織物' }, - { key: 'woodwork_bamboo', label: 'Wood & Bamboo 木工' }, - { key: 'washi_calligraphy', label: 'Washi 和紙' }, - { key: 'metalwork', label: 'Metalwork 金工' }, - { key: 'food', label: 'Food 食品' }, - { key: 'dolls_toys', label: 'Dolls 人形' }, - { key: 'other', label: 'Other その他' }, + { key: 'ceramics', label: '陶芸・陶磁器' }, + { key: 'lacquerware', label: '漆器' }, + { key: 'textiles', label: '織物・染物' }, + { key: 'woodwork_bamboo', label: '木工・竹工芸' }, + { key: 'washi_calligraphy', label: '和紙・書道具' }, + { key: 'metalwork', label: '金工・鍛冶' }, + { key: 'food', label: '食品・特産品' }, + { key: 'dolls_toys', label: '人形・玩具' }, + { key: 'other', label: 'その他工芸' }, ] +const CATEGORY_LABEL_MAP: Record = { + ceramics: '陶芸・陶磁器', + lacquerware: '漆器', + textiles: '織物・染物', + woodwork_bamboo: '木工・竹工芸', + washi_calligraphy: '和紙・書道具', + metalwork: '金工・鍛冶', + food: '食品・特産品', + dolls_toys: '人形・玩具', + other: 'その他工芸', +} + +const PRICE_BANDS = [ + { key: '', label: 'すべて' }, + { key: 'under3000', label: '¥3,000未満' }, + { key: '3000to10000', label: '¥3,000〜¥10,000' }, + { key: '10000to50000', label: '¥10,000〜¥50,000' }, + { key: 'over50000', label: '¥50,000以上' }, +] + +const SORT_OPTIONS = [ + { key: 'newest', label: '新着順' }, + { key: 'price_asc', label: '価格(安い順)' }, + { key: 'price_desc', label: '価格(高い順)' }, +] + +const PAGE_SIZE = 12 + +function formatPrice(price: number): string { + return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price) +} + +function filterByPrice(products: Product[], band: string): Product[] { + switch (band) { + case 'under3000': return products.filter(p => p.price < 3000) + case '3000to10000': return products.filter(p => p.price >= 3000 && p.price <= 10000) + case '10000to50000': return products.filter(p => p.price > 10000 && p.price <= 50000) + case 'over50000': return products.filter(p => p.price > 50000) + default: return products + } +} + +function sortProducts(products: Product[], sort: string): Product[] { + const sorted = [...products] + switch (sort) { + case 'price_asc': return sorted.sort((a, b) => a.price - b.price) + case 'price_desc': return sorted.sort((a, b) => b.price - a.price) + default: return sorted + } +} + +interface ProductCardProps { + product: Product +} + +function ProductCard({ product }: ProductCardProps) { + return ( + + +
+ {product.images?.[0] ? ( + {product.name} + ) : ( +
+ {CATEGORY_LABEL_MAP[product.craft_category || ''] ? '🏺' : '🎨'} +
+ )} +
+ + {(product.meti_certified || product.craftsman_meti) && ( + 伝統的工芸品 + )} +

{product.name}

+ {product.name_ja && ( +

{product.name_ja}

+ )} + {product.craft_category && ( +

{CATEGORY_LABEL_MAP[product.craft_category]}

+ )} +

{product.shop_name}

+

{formatPrice(product.price)}

+
+
+ + ) +} + export function Products() { const [searchParams, setSearchParams] = useSearchParams() - const [products, setProducts] = useState([]) + const [allProducts, setAllProducts] = useState([]) const [loading, setLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [searchInput, setSearchInput] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const category = searchParams.get('category') || '' const metiFilter = searchParams.get('meti_certified') === 'true' + const priceBand = searchParams.get('price') || '' + const sortKey = searchParams.get('sort') || 'newest' + + // Fetch products (from search or regular listing) + const fetchProducts = useCallback(() => { + setLoading(true) + setCurrentPage(1) + + if (searchQuery.trim()) { + api.get(`/products/search?q=${encodeURIComponent(searchQuery.trim())}`) + .then(setAllProducts) + .catch(console.error) + .finally(() => setLoading(false)) + } else { + const params = new URLSearchParams() + if (category) params.set('craft_category', category) + if (metiFilter) params.set('meti_certified', 'true') + params.set('limit', '200') + + api.get(`/products?${params.toString()}`) + .then(setAllProducts) + .catch(console.error) + .finally(() => setLoading(false)) + } + }, [category, metiFilter, searchQuery]) useEffect(() => { - setLoading(true) - const params = new URLSearchParams() - if (category) params.set('craft_category', category) - if (metiFilter) params.set('meti_certified', 'true') - - api.get(`/products?${params.toString()}`) - .then(setProducts) - .catch(console.error) - .finally(() => setLoading(false)) - }, [category, metiFilter]) + fetchProducts() + }, [fetchProducts]) + + // Apply client-side price filter and sort + const filtered = sortProducts(filterByPrice(allProducts, priceBand), sortKey) + const totalPages = Math.ceil(filtered.length / PAGE_SIZE) + const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearchQuery(searchInput) + } + + function updateParam(key: string, value: string) { + const p = new URLSearchParams(searchParams) + if (value) p.set(key, value) + else p.delete(key) + setSearchParams(p) + setCurrentPage(1) + } + + function toggleMeti() { + const p = new URLSearchParams(searchParams) + if (metiFilter) p.delete('meti_certified') + else p.set('meti_certified', 'true') + setSearchParams(p) + setCurrentPage(1) + } + + function selectCategory(key: string) { + const p = new URLSearchParams(searchParams) + if (key) p.set('category', key) + else p.delete('category') + setSearchParams(p) + setCurrentPage(1) + } return (
-

Japanese Craft Products

-

伝統工芸品

- - {/* Filters */} -
- {CATEGORIES.map((cat) => ( - - ))} - -
+

日本の伝統工芸品

+

Japanese Craft Products

- {loading ? ( -
- {Array(8).fill(0).map((_, i) => ( -
- ))} -
- ) : products.length === 0 ? ( -
-

No products found

-

Try a different category or filter

-
- ) : ( -
- {products.map((product) => ( - - -
- {product.images?.[0] ? ( - {product.name} - ) : ( -
🏺
- )} + {/* Search Bar */} +
+ setSearchInput(e.target.value)} + className="flex-1" + /> + + {searchQuery && ( + + )} +
+ +
+ {/* Filter Sidebar */} + + + {/* Product Grid */} +
+ {/* Sort + Count bar */} +
+

+ {loading ? '読み込み中...' : `${filtered.length} 件の商品`} + {searchQuery && 「{searchQuery}」の検索結果} +

+
+ 並び替え: + +
+
+ + {loading ? ( +
+ {Array(6).fill(0).map((_, i) => ( +
+ ))} +
+ ) : paginated.length === 0 ? ( +
+
🔍
+

商品が見つかりませんでした

+

No products match your current filters.

+ +
+ ) : ( + <> +
+ {paginated.map(product => ( + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + +
- - {(product.meti_certified || product.craftsman_meti) && ( - 伝統的工芸品 - )} -

{product.name}

- {product.name_ja &&

{product.name_ja}

} -

{product.shop_name}

-

¥{product.price.toLocaleString()}

-
- - - ))} + )} + + )}
- )} +
) } diff --git a/src/routes/products.js b/src/routes/products.js index eed233b..ad5b377 100644 --- a/src/routes/products.js +++ b/src/routes/products.js @@ -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' }); } });