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 { Login } from '@/pages/Login'
|
||||||
import { Register } from '@/pages/Register'
|
import { Register } from '@/pages/Register'
|
||||||
import { Products } from '@/pages/Products'
|
import { Products } from '@/pages/Products'
|
||||||
|
import { ProductDetail } from '@/pages/ProductDetail'
|
||||||
import { CraftsmenList } from '@/pages/CraftsmenList'
|
import { CraftsmenList } from '@/pages/CraftsmenList'
|
||||||
import { CraftsmanProfile } from '@/pages/CraftsmanProfile'
|
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -24,8 +24,8 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
|
<Route path="/products/:id" element={<ProductDetail />} />
|
||||||
<Route path="/craftsmen" element={<CraftsmenList />} />
|
<Route path="/craftsmen" element={<CraftsmenList />} />
|
||||||
<Route path="/craftsmen/:id" element={<CraftsmanProfile />} />
|
|
||||||
<Route path="*" element={<Home />} />
|
<Route path="*" element={<Home />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 = [
|
const CATEGORIES = [
|
||||||
{ key: 'ceramics', label: '陶芸・陶磁器', english: 'Ceramics', emoji: '🏺' },
|
{ key: 'ceramics', label: '陶芸・陶磁器', english: 'Ceramics', emoji: '🏺' },
|
||||||
{ key: 'lacquerware', label: '漆器', english: 'Lacquerware', emoji: '🏮' },
|
{ key: 'lacquerware', label: '漆器', english: 'Lacquerware', emoji: '🏮' },
|
||||||
@@ -15,7 +29,21 @@ const CATEGORIES = [
|
|||||||
{ key: 'other', label: 'その他工芸', english: 'Other Crafts', emoji: '🎨' },
|
{ 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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
@@ -57,6 +85,64 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* METI Certification Feature */}
|
||||||
<section className="bg-amber-50 py-12">
|
<section className="bg-amber-50 py-12">
|
||||||
<div className="container mx-auto px-4 text-center">
|
<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 { useSearchParams, Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,109 +16,366 @@ interface Product {
|
|||||||
meti_certified?: boolean
|
meti_certified?: boolean
|
||||||
craftsman_meti?: boolean
|
craftsman_meti?: boolean
|
||||||
shop_name?: string
|
shop_name?: string
|
||||||
|
prefecture?: string
|
||||||
|
is_featured?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: '', label: 'All' },
|
{ key: 'ceramics', label: '陶芸・陶磁器' },
|
||||||
{ key: 'ceramics', label: 'Ceramics 陶芸' },
|
{ key: 'lacquerware', label: '漆器' },
|
||||||
{ key: 'lacquerware', label: 'Lacquerware 漆器' },
|
{ key: 'textiles', label: '織物・染物' },
|
||||||
{ key: 'textiles', label: 'Textiles 織物' },
|
{ key: 'woodwork_bamboo', label: '木工・竹工芸' },
|
||||||
{ key: 'woodwork_bamboo', label: 'Wood & Bamboo 木工' },
|
{ key: 'washi_calligraphy', label: '和紙・書道具' },
|
||||||
{ key: 'washi_calligraphy', label: 'Washi 和紙' },
|
{ key: 'metalwork', label: '金工・鍛冶' },
|
||||||
{ key: 'metalwork', label: 'Metalwork 金工' },
|
{ key: 'food', label: '食品・特産品' },
|
||||||
{ key: 'food', label: 'Food 食品' },
|
{ key: 'dolls_toys', label: '人形・玩具' },
|
||||||
{ key: 'dolls_toys', label: 'Dolls 人形' },
|
{ key: 'other', label: 'その他工芸' },
|
||||||
{ key: 'other', label: 'Other その他' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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() {
|
export function Products() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [allProducts, setAllProducts] = useState<Product[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
const category = searchParams.get('category') || ''
|
const category = searchParams.get('category') || ''
|
||||||
const metiFilter = searchParams.get('meti_certified') === 'true'
|
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(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
fetchProducts()
|
||||||
const params = new URLSearchParams()
|
}, [fetchProducts])
|
||||||
if (category) params.set('craft_category', category)
|
|
||||||
if (metiFilter) params.set('meti_certified', 'true')
|
// Apply client-side price filter and sort
|
||||||
|
const filtered = sortProducts(filterByPrice(allProducts, priceBand), sortKey)
|
||||||
api.get<Product[]>(`/products?${params.toString()}`)
|
const totalPages = Math.ceil(filtered.length / PAGE_SIZE)
|
||||||
.then(setProducts)
|
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false))
|
function handleSearchSubmit(e: React.FormEvent) {
|
||||||
}, [category, metiFilter])
|
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold mb-2">Japanese Craft Products</h1>
|
<h1 className="text-3xl font-bold mb-1">日本の伝統工芸品</h1>
|
||||||
<p className="text-muted-foreground mb-6">伝統工芸品</p>
|
<p className="text-muted-foreground mb-6">Japanese Craft Products</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>
|
|
||||||
|
|
||||||
{loading ? (
|
{/* Search Bar */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<form onSubmit={handleSearchSubmit} className="flex gap-2 mb-8 max-w-xl">
|
||||||
{Array(8).fill(0).map((_, i) => (
|
<Input
|
||||||
<div key={i} className="bg-muted rounded-lg h-64 animate-pulse" />
|
type="text"
|
||||||
))}
|
placeholder="商品を検索... (Search products)"
|
||||||
</div>
|
value={searchInput}
|
||||||
) : products.length === 0 ? (
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
className="flex-1"
|
||||||
<p className="text-lg">No products found</p>
|
/>
|
||||||
<p className="text-sm mt-2">Try a different category or filter</p>
|
<Button type="submit">検索</Button>
|
||||||
</div>
|
{searchQuery && (
|
||||||
) : (
|
<Button
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
type="button"
|
||||||
{products.map((product) => (
|
variant="outline"
|
||||||
<Link key={product.id} to={`/products/${product.id}`}>
|
onClick={() => { setSearchInput(''); setSearchQuery('') }}
|
||||||
<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] ? (
|
</Button>
|
||||||
<img src={product.images[0]} alt={product.name} className="w-full h-full object-cover" />
|
)}
|
||||||
) : (
|
</form>
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-4xl">🏺</div>
|
|
||||||
)}
|
<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>
|
</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>
|
||||||
</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) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pool = getPool();
|
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' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Product not found' });
|
||||||
|
|
||||||
|
const product = rows[0];
|
||||||
|
|
||||||
// Get reviews
|
// Get reviews
|
||||||
const reviews = await pool.query(
|
const reviews = await pool.query(
|
||||||
`SELECT r.*, u.display_name FROM reviews r
|
`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`,
|
WHERE r.product_id = $1 ORDER BY r.created_at DESC LIMIT 10`,
|
||||||
[req.params.id]
|
[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) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Failed to fetch product' });
|
res.status(500).json({ error: 'Failed to fetch product' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user