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:
@@ -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() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/products/:id" element={<ProductDetail />} />
|
||||
<Route path="/craftsmen" element={<CraftsmenList />} />
|
||||
<Route path="/craftsmen/:id" element={<CraftsmanProfile />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -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<FeaturedProduct[]>([])
|
||||
const [featuredLoading, setFeaturedLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<FeaturedProduct[]>('/products/featured')
|
||||
.then(setFeaturedProducts)
|
||||
.catch(console.error)
|
||||
.finally(() => setFeaturedLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero */}
|
||||
@@ -57,6 +85,64 @@ export function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* New Arrivals */}
|
||||
<section className="py-12 bg-muted/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">New Arrivals</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">新着・注目の商品</p>
|
||||
</div>
|
||||
<Link to="/products">
|
||||
<Button variant="outline" size="sm">すべて見る</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{featuredLoading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array(4).fill(0).map((_, i) => (
|
||||
<div key={i} className="bg-muted rounded-lg h-64 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : featuredProducts.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">商品がありません</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{featuredProducts.slice(0, 4).map((product) => (
|
||||
<Link key={product.id} to={`/products/${product.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full flex flex-col">
|
||||
<div className="aspect-square bg-amber-50 rounded-t-lg overflow-hidden flex-shrink-0">
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-5xl">
|
||||
🏺
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3 flex flex-col flex-1">
|
||||
{(product.meti_certified || product.craftsman_meti) && (
|
||||
<Badge variant="meti" className="text-xs mb-1 w-fit">伝統的工芸品</Badge>
|
||||
)}
|
||||
<p className="font-medium text-sm line-clamp-2 flex-1">{product.name}</p>
|
||||
{product.name_ja && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{product.name_ja}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{product.shop_name}</p>
|
||||
<p className="font-bold text-primary mt-2">{formatPrice(product.price)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* METI Certification Feature */}
|
||||
<section className="bg-amber-50 py-12">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
|
||||
373
client/src/pages/ProductDetail.tsx
Normal file
373
client/src/pages/ProductDetail.tsx
Normal file
@@ -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<string, string> = {
|
||||
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 (
|
||||
<span className="text-amber-500 text-sm">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i}>{i < rating ? '★' : '☆'}</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RelatedProductCard({ product }: { product: RelatedProduct }) {
|
||||
return (
|
||||
<Link to={`/products/${product.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full flex flex-col">
|
||||
<div className="aspect-square bg-muted rounded-t-lg overflow-hidden flex-shrink-0">
|
||||
{product.images?.[0] ? (
|
||||
<img src={product.images[0]} alt={product.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-4xl bg-amber-50">
|
||||
🏺
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3 flex flex-col flex-1">
|
||||
{(product.meti_certified || product.craftsman_meti) && (
|
||||
<Badge variant="meti" className="text-xs mb-1 w-fit">伝統的工芸品</Badge>
|
||||
)}
|
||||
<p className="font-medium text-sm line-clamp-2 flex-1">{product.name}</p>
|
||||
{product.name_ja && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{product.name_ja}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{product.shop_name}</p>
|
||||
<p className="font-bold text-primary mt-2">{formatPrice(product.price)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { user } = useAuthStore()
|
||||
const [product, setProduct] = useState<Product | null>(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<Product>(`/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 (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="aspect-square bg-muted rounded-xl animate-pulse" />
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 bg-muted rounded animate-pulse" />
|
||||
<div className="h-6 bg-muted rounded w-1/2 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-3/4 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (notFound || !product) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-24 text-center">
|
||||
<div className="text-5xl mb-4">🔍</div>
|
||||
<h1 className="text-2xl font-bold mb-2">商品が見つかりません</h1>
|
||||
<p className="text-muted-foreground mb-6">Product not found (404)</p>
|
||||
<Link to="/products">
|
||||
<Button>商品一覧へ戻る</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-sm text-muted-foreground mb-6 flex items-center gap-2">
|
||||
<Link to="/" className="hover:text-foreground">ホーム</Link>
|
||||
<span>/</span>
|
||||
<Link to="/products" className="hover:text-foreground">商品一覧</Link>
|
||||
{product.craft_category && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to={`/products?category=${product.craft_category}`}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{CATEGORY_LABEL_MAP[product.craft_category] || product.craft_category}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<span>/</span>
|
||||
<span className="text-foreground line-clamp-1">{product.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Main product section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 mb-14">
|
||||
{/* Image */}
|
||||
<div className="aspect-square bg-amber-50 rounded-xl overflow-hidden border border-border">
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<span className="text-8xl mb-4">🏺</span>
|
||||
<span className="text-sm">画像なし</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(product.meti_certified || product.craftsman_meti) && (
|
||||
<Badge variant="meti" className="text-sm px-3 py-1">伝統的工芸品</Badge>
|
||||
)}
|
||||
{product.craft_category && (
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{CATEGORY_LABEL_MAP[product.craft_category] || product.craft_category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold leading-tight">{product.name}</h1>
|
||||
{product.name_ja && (
|
||||
<p className="text-lg text-muted-foreground mt-1">{product.name_ja}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<p className="text-3xl font-bold text-primary">{formatPrice(product.price)}</p>
|
||||
|
||||
{/* Stock */}
|
||||
{product.stock_quantity !== undefined && (
|
||||
<p className={`text-sm ${product.stock_quantity > 0 ? 'text-green-600' : 'text-destructive'}`}>
|
||||
{product.stock_quantity > 0
|
||||
? `在庫あり (${product.stock_quantity}点)`
|
||||
: '在庫なし (Out of Stock)'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Craftsman info */}
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">職人</p>
|
||||
<Link
|
||||
to={`/craftsmen/${product.craftsman_id}`}
|
||||
className="font-semibold text-primary hover:underline text-lg"
|
||||
>
|
||||
{product.shop_name}
|
||||
</Link>
|
||||
{product.prefecture && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{product.prefecture}
|
||||
{product.craft_region && ` · ${product.craft_region}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-1 text-muted-foreground">説明</p>
|
||||
<p className="text-sm leading-relaxed">{product.description}</p>
|
||||
{product.description_ja && (
|
||||
<p className="text-sm leading-relaxed mt-2 text-muted-foreground italic">
|
||||
{product.description_ja}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AR */}
|
||||
{showArButton && (
|
||||
<a
|
||||
href={product.ar_model_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white font-semibold rounded-md transition-colors w-fit text-sm"
|
||||
>
|
||||
<span>AR で確認</span>
|
||||
<span className="text-xs opacity-80">View in AR</span>
|
||||
</a>
|
||||
)}
|
||||
{showArIneligible && (
|
||||
<Badge variant="outline" className="w-fit text-muted-foreground border-muted-foreground/50 text-xs">
|
||||
AR 非対応: {product.ar_ineligible_reason}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Add to Cart */}
|
||||
<div className="mt-2">
|
||||
{user ? (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stock_quantity === 0 || addedToCart}
|
||||
>
|
||||
{addedToCart
|
||||
? 'カートに追加しました'
|
||||
: product.stock_quantity === 0
|
||||
? '在庫なし'
|
||||
: 'カートに追加'}
|
||||
</Button>
|
||||
) : (
|
||||
<div>
|
||||
<Button size="lg" className="w-full" variant="outline" disabled>
|
||||
カートに追加
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground mt-2">
|
||||
ログインが必要です —{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">ログイン</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
{product.related_products && product.related_products.length > 0 && (
|
||||
<section className="mb-14">
|
||||
<h2 className="text-xl font-bold mb-4 border-b pb-2">関連商品</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{product.related_products.slice(0, 4).map(related => (
|
||||
<RelatedProductCard key={related.id} product={related} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Reviews */}
|
||||
{product.reviews && product.reviews.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-bold mb-4 border-b pb-2">
|
||||
レビュー ({product.reviews.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{product.reviews.slice(0, 5).map(review => (
|
||||
<div key={review.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating rating={review.rating} />
|
||||
<span className="text-sm font-medium">{review.display_name || '匿名'}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(review.created_at).toLocaleDateString('ja-JP')}
|
||||
</span>
|
||||
</div>
|
||||
{review.comment && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{review.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{product.reviews && product.reviews.length === 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-bold mb-4 border-b pb-2">レビュー</h2>
|
||||
<p className="text-muted-foreground text-sm">まだレビューがありません。 (No reviews yet.)</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<Link to={`/products/${product.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full flex flex-col">
|
||||
<div className="aspect-square bg-muted rounded-t-lg overflow-hidden flex-shrink-0">
|
||||
{product.images?.[0] ? (
|
||||
<img src={product.images[0]} alt={product.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-5xl bg-amber-50">
|
||||
{CATEGORY_LABEL_MAP[product.craft_category || ''] ? '🏺' : '🎨'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3 flex flex-col flex-1">
|
||||
{(product.meti_certified || product.craftsman_meti) && (
|
||||
<Badge variant="meti" className="text-xs mb-1 w-fit">伝統的工芸品</Badge>
|
||||
)}
|
||||
<p className="font-medium text-sm line-clamp-2 flex-1">{product.name}</p>
|
||||
{product.name_ja && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{product.name_ja}</p>
|
||||
)}
|
||||
{product.craft_category && (
|
||||
<p className="text-xs text-amber-700 mt-0.5">{CATEGORY_LABEL_MAP[product.craft_category]}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{product.shop_name}</p>
|
||||
<p className="font-bold text-primary mt-2">{formatPrice(product.price)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Products() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([])
|
||||
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<Product[]>(`/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<Product[]>(`/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<Product[]>(`/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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Japanese Craft Products</h1>
|
||||
<p className="text-muted-foreground mb-6">伝統工芸品</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Button
|
||||
key={cat.key}
|
||||
variant={category === cat.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSearchParams(cat.key ? { category: cat.key } : {})}
|
||||
>
|
||||
{cat.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={metiFilter ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const p = new URLSearchParams(searchParams)
|
||||
if (metiFilter) p.delete('meti_certified')
|
||||
else p.set('meti_certified', 'true')
|
||||
setSearchParams(p)
|
||||
}}
|
||||
className={metiFilter ? '' : 'border-amber-500 text-amber-600'}
|
||||
>
|
||||
METI認定 Only
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-1">日本の伝統工芸品</h1>
|
||||
<p className="text-muted-foreground mb-6">Japanese Craft Products</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array(8).fill(0).map((_, i) => (
|
||||
<div key={i} className="bg-muted rounded-lg h-64 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">No products found</p>
|
||||
<p className="text-sm mt-2">Try a different category or filter</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} to={`/products/${product.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<div className="aspect-square bg-muted rounded-t-lg overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<img src={product.images[0]} alt={product.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-4xl">🏺</div>
|
||||
)}
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 mb-8 max-w-xl">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="商品を検索... (Search products)"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit">検索</Button>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setSearchInput(''); setSearchQuery('') }}
|
||||
>
|
||||
クリア
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Filter Sidebar */}
|
||||
<aside className="lg:w-56 flex-shrink-0">
|
||||
{/* METI Toggle — prominent, always first */}
|
||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs font-semibold text-amber-800 uppercase tracking-wide mb-3">
|
||||
認定フィルター
|
||||
</p>
|
||||
<button
|
||||
onClick={toggleMeti}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md border-2 font-semibold text-sm transition-all ${
|
||||
metiFilter
|
||||
? 'bg-amber-500 border-amber-500 text-white'
|
||||
: 'bg-white border-amber-400 text-amber-700 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
<span>METI認定のみ</span>
|
||||
<span className="text-xs">伝統的工芸品</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
カテゴリー
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => selectCategory('')}
|
||||
className={`text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
!category
|
||||
? 'bg-primary text-primary-foreground font-semibold'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
すべて
|
||||
</button>
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.key}
|
||||
onClick={() => selectCategory(cat.key)}
|
||||
className={`text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
category === cat.key
|
||||
? 'bg-primary text-primary-foreground font-semibold'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
価格帯
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{PRICE_BANDS.map(band => (
|
||||
<button
|
||||
key={band.key}
|
||||
onClick={() => updateParam('price', band.key)}
|
||||
className={`text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
priceBand === band.key
|
||||
? 'bg-primary text-primary-foreground font-semibold'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
{band.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Sort + Count bar */}
|
||||
<div className="flex items-center justify-between mb-4 gap-4 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? '読み込み中...' : `${filtered.length} 件の商品`}
|
||||
{searchQuery && <span className="ml-2 text-amber-700">「{searchQuery}」の検索結果</span>}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">並び替え:</span>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={e => updateParam('sort', e.target.value)}
|
||||
className="border border-input rounded-md px-2 py-1 text-sm bg-background"
|
||||
>
|
||||
{SORT_OPTIONS.map(opt => (
|
||||
<option key={opt.key} value={opt.key}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array(6).fill(0).map((_, i) => (
|
||||
<div key={i} className="bg-muted rounded-lg h-72 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : paginated.length === 0 ? (
|
||||
<div className="text-center py-24 text-muted-foreground">
|
||||
<div className="text-5xl mb-4">🔍</div>
|
||||
<p className="text-lg font-semibold mb-2">商品が見つかりませんでした</p>
|
||||
<p className="text-sm">No products match your current filters.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-6"
|
||||
onClick={() => {
|
||||
setSearchParams({})
|
||||
setSearchInput('')
|
||||
setSearchQuery('')
|
||||
}}
|
||||
>
|
||||
フィルターをリセット
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map(product => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
前へ
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
次へ
|
||||
</Button>
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
{(product.meti_certified || product.craftsman_meti) && (
|
||||
<Badge variant="meti" className="text-xs mb-1">伝統的工芸品</Badge>
|
||||
)}
|
||||
<p className="font-medium text-sm line-clamp-2">{product.name}</p>
|
||||
{product.name_ja && <p className="text-xs text-muted-foreground">{product.name_ja}</p>}
|
||||
<p className="text-xs text-muted-foreground mt-1">{product.shop_name}</p>
|
||||
<p className="font-bold text-primary mt-2">¥{product.price.toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user