feat: add craftsman profile page with featured endpoint and full craftsmen list linking

- Add GET /api/craftsmen/featured endpoint returning top 6 by product count
- Expand GET /api/craftsmen/:id to return all profile fields and last 12 products
- New CraftsmanProfile.tsx page: header, bio/story, stats, extended details, products grid
- Update CraftsmenList.tsx: category translations, years_of_practice, card links to /:id
- Add /craftsmen/:id route to App.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:50:43 +00:00
parent 2fa526075e
commit ecc0d9ee83
4 changed files with 411 additions and 10 deletions

View File

@@ -6,6 +6,7 @@ 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 { 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,6 +25,7 @@ function App() {
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/products" element={<Products />} /> <Route path="/products" element={<Products />} />
<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>

View File

@@ -0,0 +1,343 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
const CATEGORY_MAP: Record<string, string> = {
ceramics: '陶芸・陶磁器',
lacquerware: '漆器',
textiles: '織物・染物',
woodwork_bamboo: '木工・竹工芸',
washi_calligraphy: '和紙・書道具',
metalwork: '金工・鍛冶',
food: '食品・特産品',
dolls_toys: '人形・玩具',
other: 'その他工芸',
}
const WORKSHOP_SIZE_MAP: Record<string, string> = {
solo: '個人工房',
small_atelier: '小規模工房',
cooperative: '協同組合',
}
const PRODUCTION_METHOD_MAP: Record<string, string> = {
fully_handmade: '完全手作り',
partially_machine_assisted: '部分機械使用',
cooperative: '協同制作',
}
interface Product {
id: string
name: string
price: number
description?: string
image_url?: string
stock_quantity?: number
}
interface SocialLinks {
instagram?: string
twitter?: string
facebook?: string
website?: string
[key: string]: string | undefined
}
interface Craftsman {
id: string
shop_name: string
bio?: string
story?: string
location?: string
prefecture?: string
craft_region?: string
regional_designation?: string
guild_association?: string
meti_certified?: boolean
meti_certification_number?: string
meti_craft_category?: string
years_of_practice?: number
apprenticeship_lineage?: string
primary_materials?: string
workshop_size?: string
languages_spoken?: string[]
production_method?: string
craft_category?: string
social_links?: SocialLinks
rating?: number
review_count?: number
is_active?: boolean
profile_image_url?: string
email?: string
user_display_name?: string
products: Product[]
}
export function CraftsmanProfile() {
const { id } = useParams<{ id: string }>()
const [craftsman, setCraftsman] = useState<Craftsman | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
setLoading(true)
api.get<Craftsman>(`/craftsmen/${id}`)
.then(setCraftsman)
.catch((err) => setError(err.message || 'Failed to load craftsman'))
.finally(() => setLoading(false))
}, [id])
if (loading) {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-4xl mx-auto space-y-6">
<div className="h-40 bg-muted rounded-xl animate-pulse" />
<div className="h-24 bg-muted rounded-xl animate-pulse" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array(4).fill(0).map((_, i) => (
<div key={i} className="h-20 bg-muted rounded-lg animate-pulse" />
))}
</div>
<div className="grid md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array(8).fill(0).map((_, i) => (
<div key={i} className="h-48 bg-muted rounded-lg animate-pulse" />
))}
</div>
</div>
</div>
)
}
if (error || !craftsman) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<p className="text-destructive text-lg mb-4">{error || '職人が見つかりませんでした'}</p>
<Link to="/craftsmen" className="text-primary underline underline-offset-4">
</Link>
</div>
)
}
const categoryLabel = craftsman.craft_category ? (CATEGORY_MAP[craftsman.craft_category] || craftsman.craft_category) : null
const workshopLabel = craftsman.workshop_size ? (WORKSHOP_SIZE_MAP[craftsman.workshop_size] || craftsman.workshop_size) : null
const productionLabel = craftsman.production_method ? (PRODUCTION_METHOD_MAP[craftsman.production_method] || craftsman.production_method) : null
return (
<div className="container mx-auto px-4 py-10 max-w-5xl">
{/* Back link */}
<Link to="/craftsmen" className="text-sm text-muted-foreground hover:text-foreground mb-6 inline-block">
&larr;
</Link>
{/* Profile Header */}
<div className="flex flex-col md:flex-row items-start gap-6 mb-8">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center text-4xl flex-shrink-0 overflow-hidden">
{craftsman.profile_image_url ? (
<img
src={craftsman.profile_image_url}
alt={craftsman.shop_name}
className="w-full h-full object-cover"
/>
) : (
<span>🧑🎨</span>
)}
</div>
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2 mb-1">
{craftsman.meti_certified && (
<Badge variant="meti" className="text-xs"></Badge>
)}
{categoryLabel && (
<Badge variant="secondary" className="text-xs">{categoryLabel}</Badge>
)}
</div>
<h1 className="text-3xl font-bold">{craftsman.shop_name}</h1>
{craftsman.user_display_name && (
<p className="text-muted-foreground">{craftsman.user_display_name}</p>
)}
<div className="flex flex-wrap gap-3 mt-2 text-sm text-muted-foreground">
{craftsman.prefecture && <span>📍 {craftsman.prefecture}</span>}
{craftsman.craft_region && <span>· {craftsman.craft_region}</span>}
{craftsman.rating && craftsman.rating > 0 && (
<span> {craftsman.rating.toFixed(1)}{craftsman.review_count ? ` (${craftsman.review_count}件)` : ''}</span>
)}
</div>
{craftsman.meti_certified && craftsman.meti_certification_number && (
<p className="text-xs text-muted-foreground mt-1">
: {craftsman.meti_certification_number}
{craftsman.meti_craft_category && ` · ${craftsman.meti_craft_category}`}
</p>
)}
</div>
</div>
{/* Bio / Story */}
{(craftsman.bio || craftsman.story) && (
<Card className="mb-6">
<CardContent className="p-6 space-y-4">
{craftsman.bio && (
<div>
<h2 className="text-lg font-semibold mb-1"></h2>
<p className="text-sm leading-relaxed text-muted-foreground">{craftsman.bio}</p>
</div>
)}
{craftsman.story && (
<div>
<h2 className="text-lg font-semibold mb-1"></h2>
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-line">{craftsman.story}</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
{craftsman.years_of_practice != null && (
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{craftsman.years_of_practice}<span className="text-base font-normal"></span></p>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
)}
{workshopLabel && (
<Card>
<CardContent className="p-4 text-center">
<p className="text-lg font-semibold">{workshopLabel}</p>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
)}
{productionLabel && (
<Card>
<CardContent className="p-4 text-center">
<p className="text-lg font-semibold">{productionLabel}</p>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
)}
</div>
{/* Extended Details */}
{(craftsman.guild_association || craftsman.apprenticeship_lineage || craftsman.primary_materials ||
(craftsman.languages_spoken && craftsman.languages_spoken.length > 0) ||
(craftsman.social_links && Object.keys(craftsman.social_links).length > 0) ||
craftsman.regional_designation) && (
<Card className="mb-8">
<CardContent className="p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-3 text-sm">
{craftsman.guild_association && (
<>
<dt className="text-muted-foreground font-medium"></dt>
<dd>{craftsman.guild_association}</dd>
</>
)}
{craftsman.apprenticeship_lineage && (
<>
<dt className="text-muted-foreground font-medium"></dt>
<dd>{craftsman.apprenticeship_lineage}</dd>
</>
)}
{craftsman.primary_materials && (
<>
<dt className="text-muted-foreground font-medium"></dt>
<dd>{craftsman.primary_materials}</dd>
</>
)}
{craftsman.regional_designation && (
<>
<dt className="text-muted-foreground font-medium"></dt>
<dd>{craftsman.regional_designation}</dd>
</>
)}
{craftsman.languages_spoken && craftsman.languages_spoken.length > 0 && (
<>
<dt className="text-muted-foreground font-medium"></dt>
<dd className="flex flex-wrap gap-1">
{craftsman.languages_spoken.map((lang) => (
<Badge key={lang} variant="outline" className="text-xs">{lang}</Badge>
))}
</dd>
</>
)}
{craftsman.social_links && Object.keys(craftsman.social_links).length > 0 && (
<>
<dt className="text-muted-foreground font-medium">SNS</dt>
<dd className="flex flex-wrap gap-3">
{craftsman.social_links.instagram && (
<a href={craftsman.social_links.instagram} target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline underline-offset-4">
Instagram
</a>
)}
{craftsman.social_links.twitter && (
<a href={craftsman.social_links.twitter} target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline underline-offset-4">
Twitter/X
</a>
)}
{craftsman.social_links.facebook && (
<a href={craftsman.social_links.facebook} target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline underline-offset-4">
Facebook
</a>
)}
{craftsman.social_links.website && (
<a href={craftsman.social_links.website} target="_blank" rel="noopener noreferrer"
className="text-primary hover:underline underline-offset-4">
</a>
)}
</dd>
</>
)}
</dl>
</CardContent>
</Card>
)}
{/* Products Grid */}
<div className="mb-4">
<h2 className="text-xl font-bold mb-4"></h2>
{craftsman.products.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center"></p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{craftsman.products.map((product) => (
<Card key={product.id} className="overflow-hidden hover:shadow-md transition-shadow">
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
{product.image_url ? (
<img
src={product.image_url}
alt={product.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-3xl">🎎</span>
)}
</div>
<CardContent className="p-3">
<p className="text-sm font-medium line-clamp-2 mb-1">{product.name}</p>
<p className="text-sm font-bold">
¥{product.price.toLocaleString('ja-JP')}
</p>
{product.stock_quantity != null && product.stock_quantity === 0 && (
<Badge variant="secondary" className="text-xs mt-1"></Badge>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -4,6 +4,18 @@ 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'
const CATEGORY_MAP: Record<string, string> = {
ceramics: '陶芸・陶磁器',
lacquerware: '漆器',
textiles: '織物・染物',
woodwork_bamboo: '木工・竹工芸',
washi_calligraphy: '和紙・書道具',
metalwork: '金工・鍛冶',
food: '食品・特産品',
dolls_toys: '人形・玩具',
other: 'その他工芸',
}
interface Craftsman { interface Craftsman {
id: string id: string
shop_name: string shop_name: string
@@ -11,6 +23,7 @@ interface Craftsman {
craft_category?: string craft_category?: string
prefecture?: string prefecture?: string
meti_certified?: boolean meti_certified?: boolean
years_of_practice?: number
rating?: number rating?: number
profile_image_url?: string profile_image_url?: string
} }
@@ -44,20 +57,33 @@ export function CraftsmenList() {
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full"> <Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center text-2xl flex-shrink-0"> <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center text-2xl flex-shrink-0 overflow-hidden">
{c.profile_image_url ? ( {c.profile_image_url ? (
<img src={c.profile_image_url} alt={c.shop_name} className="w-full h-full rounded-full object-cover" /> <img src={c.profile_image_url} alt={c.shop_name} className="w-full h-full rounded-full object-cover" />
) : '🧑‍🎨'} ) : '🧑‍🎨'}
</div> </div>
<div> <div className="min-w-0">
{c.meti_certified && <Badge variant="meti" className="text-xs mb-1"></Badge>} <div className="flex flex-wrap gap-1 mb-1">
<h3 className="font-bold">{c.shop_name}</h3> {c.meti_certified && (
{c.prefecture && <p className="text-xs text-muted-foreground">{c.prefecture}</p>} <Badge variant="meti" className="text-xs"></Badge>
{c.craft_category && <p className="text-xs text-muted-foreground capitalize">{c.craft_category}</p>} )}
{c.craft_category && (
<Badge variant="secondary" className="text-xs">
{CATEGORY_MAP[c.craft_category] || c.craft_category}
</Badge>
)}
</div>
<h3 className="font-bold truncate">{c.shop_name}</h3>
{c.prefecture && (
<p className="text-xs text-muted-foreground">📍 {c.prefecture}</p>
)}
{c.years_of_practice != null && (
<p className="text-xs text-muted-foreground"> {c.years_of_practice}</p>
)}
</div> </div>
</div> </div>
{c.bio && <p className="text-sm text-muted-foreground mt-3 line-clamp-3">{c.bio}</p>} {c.bio && <p className="text-sm text-muted-foreground mt-3 line-clamp-3">{c.bio}</p>}
{c.rating && c.rating > 0 && ( {c.rating != null && c.rating > 0 && (
<p className="text-sm mt-2"> {c.rating.toFixed(1)}</p> <p className="text-sm mt-2"> {c.rating.toFixed(1)}</p>
)} )}
</CardContent> </CardContent>

View File

@@ -34,12 +34,41 @@ router.get('/', async (req, res) => {
} }
}); });
// Featured craftsmen — top 6 by product count (must be BEFORE /:id to avoid conflict)
router.get('/featured', async (req, res) => {
try {
const pool = getPool();
const { rows } = await pool.query(
`SELECT c.*, u.email, u.display_name as user_display_name,
COUNT(p.id) AS product_count
FROM craftsmen c
JOIN users u ON c.user_id = u.id
LEFT JOIN products p ON p.craftsman_id = c.id AND p.is_active = true
WHERE c.is_active = true
GROUP BY c.id, u.email, u.display_name
ORDER BY product_count DESC
LIMIT 6`
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch featured craftsmen' });
}
});
// Get craftsman by ID // Get craftsman by ID
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT c.*, u.email, u.display_name as user_display_name `SELECT c.id, c.shop_name, c.bio, c.story, c.location, c.prefecture,
c.craft_region, c.regional_designation, c.guild_association,
c.meti_certified, c.meti_certification_number, c.meti_craft_category,
c.years_of_practice, c.apprenticeship_lineage, c.primary_materials,
c.workshop_size, c.languages_spoken, c.production_method,
c.craft_category, c.social_links, c.rating, c.review_count,
c.is_active, c.profile_image_url,
u.email, u.display_name as user_display_name
FROM craftsmen c JOIN users u ON c.user_id = u.id FROM craftsmen c JOIN users u ON c.user_id = u.id
WHERE c.id = $1 AND c.is_active = true`, WHERE c.id = $1 AND c.is_active = true`,
[req.params.id] [req.params.id]
@@ -47,14 +76,15 @@ router.get('/:id', async (req, res) => {
if (rows.length === 0) return res.status(404).json({ error: 'Craftsman not found' }); if (rows.length === 0) return res.status(404).json({ error: 'Craftsman not found' });
// Get their products // Get their last 12 products
const products = await pool.query( const products = await pool.query(
'SELECT * FROM products WHERE craftsman_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 10', 'SELECT * FROM products WHERE craftsman_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 12',
[req.params.id] [req.params.id]
); );
res.json({ ...rows[0], products: products.rows }); res.json({ ...rows[0], products: products.rows });
} catch (err) { } catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch craftsman' }); res.status(500).json({ error: 'Failed to fetch craftsman' });
} }
}); });