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

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

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

View File

@@ -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>

View File

@@ -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">

View 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>
)
}

View File

@@ -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')
fetchProducts()
}, [fetchProducts])
api.get<Product[]>(`/products?${params.toString()}`)
.then(setProducts)
.catch(console.error)
.finally(() => setLoading(false))
}, [category, metiFilter])
// 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>
<h1 className="text-3xl font-bold mb-1"></h1>
<p className="text-muted-foreground mb-6">Japanese Craft Products</p>
{/* Filters */}
<div className="flex flex-wrap gap-2 mb-6">
{CATEGORIES.map((cat) => (
{/* 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
key={cat.key}
variant={category === cat.key ? 'default' : 'outline'}
size="sm"
onClick={() => setSearchParams(cat.key ? { category: cat.key } : {})}
type="button"
variant="outline"
onClick={() => { setSearchInput(''); setSearchQuery('') }}
>
{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>
)}
</form>
{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>
)}
<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>
)
}

View File

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