diff --git a/client/src/App.tsx b/client/src/App.tsx index 3ce897f..f650c17 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import { Login } from '@/pages/Login' import { Register } from '@/pages/Register' import { Products } from '@/pages/Products' import { CraftsmenList } from '@/pages/CraftsmenList' +import { CraftsmanProfile } from '@/pages/CraftsmanProfile' import { useAuthStore } from '@/store/auth' function App() { @@ -24,6 +25,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/pages/CraftsmanProfile.tsx b/client/src/pages/CraftsmanProfile.tsx new file mode 100644 index 0000000..09d8715 --- /dev/null +++ b/client/src/pages/CraftsmanProfile.tsx @@ -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 = { + ceramics: '陶芸・陶磁器', + lacquerware: '漆器', + textiles: '織物・染物', + woodwork_bamboo: '木工・竹工芸', + washi_calligraphy: '和紙・書道具', + metalwork: '金工・鍛冶', + food: '食品・特産品', + dolls_toys: '人形・玩具', + other: 'その他工芸', +} + +const WORKSHOP_SIZE_MAP: Record = { + solo: '個人工房', + small_atelier: '小規模工房', + cooperative: '協同組合', +} + +const PRODUCTION_METHOD_MAP: Record = { + 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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!id) return + setLoading(true) + api.get(`/craftsmen/${id}`) + .then(setCraftsman) + .catch((err) => setError(err.message || 'Failed to load craftsman')) + .finally(() => setLoading(false)) + }, [id]) + + if (loading) { + return ( +
+
+
+
+
+ {Array(4).fill(0).map((_, i) => ( +
+ ))} +
+
+ {Array(8).fill(0).map((_, i) => ( +
+ ))} +
+
+
+ ) + } + + if (error || !craftsman) { + return ( +
+

{error || '職人が見つかりませんでした'}

+ + 職人一覧に戻る + +
+ ) + } + + 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 ( +
+ + {/* Back link */} + + ← 職人一覧 + + + {/* Profile Header */} +
+
+ {craftsman.profile_image_url ? ( + {craftsman.shop_name} + ) : ( + 🧑‍🎨 + )} +
+ +
+
+ {craftsman.meti_certified && ( + 伝統的工芸品 + )} + {categoryLabel && ( + {categoryLabel} + )} +
+

{craftsman.shop_name}

+ {craftsman.user_display_name && ( +

{craftsman.user_display_name}

+ )} +
+ {craftsman.prefecture && 📍 {craftsman.prefecture}} + {craftsman.craft_region && · {craftsman.craft_region}} + {craftsman.rating && craftsman.rating > 0 && ( + ⭐ {craftsman.rating.toFixed(1)}{craftsman.review_count ? ` (${craftsman.review_count}件)` : ''} + )} +
+ {craftsman.meti_certified && craftsman.meti_certification_number && ( +

+ 経済産業大臣指定番号: {craftsman.meti_certification_number} + {craftsman.meti_craft_category && ` · ${craftsman.meti_craft_category}`} +

+ )} +
+
+ + {/* Bio / Story */} + {(craftsman.bio || craftsman.story) && ( + + + {craftsman.bio && ( +
+

プロフィール

+

{craftsman.bio}

+
+ )} + {craftsman.story && ( +
+

ものづくりのストーリー

+

{craftsman.story}

+
+ )} +
+
+ )} + + {/* Stats Row */} +
+ {craftsman.years_of_practice != null && ( + + +

{craftsman.years_of_practice}

+

職人歴

+
+
+ )} + {workshopLabel && ( + + +

{workshopLabel}

+

工房規模

+
+
+ )} + {productionLabel && ( + + +

{productionLabel}

+

制作方法

+
+
+ )} +
+ + {/* 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) && ( + + +

詳細情報

+
+ {craftsman.guild_association && ( + <> +
組合・協会
+
{craftsman.guild_association}
+ + )} + {craftsman.apprenticeship_lineage && ( + <> +
師匠・流派
+
{craftsman.apprenticeship_lineage}
+ + )} + {craftsman.primary_materials && ( + <> +
主な素材
+
{craftsman.primary_materials}
+ + )} + {craftsman.regional_designation && ( + <> +
地域指定
+
{craftsman.regional_designation}
+ + )} + {craftsman.languages_spoken && craftsman.languages_spoken.length > 0 && ( + <> +
対応言語
+
+ {craftsman.languages_spoken.map((lang) => ( + {lang} + ))} +
+ + )} + {craftsman.social_links && Object.keys(craftsman.social_links).length > 0 && ( + <> +
SNS・リンク
+
+ {craftsman.social_links.instagram && ( + + Instagram + + )} + {craftsman.social_links.twitter && ( + + Twitter/X + + )} + {craftsman.social_links.facebook && ( + + Facebook + + )} + {craftsman.social_links.website && ( + + ウェブサイト + + )} +
+ + )} +
+
+
+ )} + + {/* Products Grid */} +
+

作品・商品

+ {craftsman.products.length === 0 ? ( +

現在、出品中の商品はありません

+ ) : ( +
+ {craftsman.products.map((product) => ( + +
+ {product.image_url ? ( + {product.name} + ) : ( + 🎎 + )} +
+ +

{product.name}

+

+ ¥{product.price.toLocaleString('ja-JP')} +

+ {product.stock_quantity != null && product.stock_quantity === 0 && ( + 在庫切れ + )} +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/client/src/pages/CraftsmenList.tsx b/client/src/pages/CraftsmenList.tsx index 7aa44c5..27ffd98 100644 --- a/client/src/pages/CraftsmenList.tsx +++ b/client/src/pages/CraftsmenList.tsx @@ -4,6 +4,18 @@ import { api } from '@/lib/api' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +const CATEGORY_MAP: Record = { + ceramics: '陶芸・陶磁器', + lacquerware: '漆器', + textiles: '織物・染物', + woodwork_bamboo: '木工・竹工芸', + washi_calligraphy: '和紙・書道具', + metalwork: '金工・鍛冶', + food: '食品・特産品', + dolls_toys: '人形・玩具', + other: 'その他工芸', +} + interface Craftsman { id: string shop_name: string @@ -11,6 +23,7 @@ interface Craftsman { craft_category?: string prefecture?: string meti_certified?: boolean + years_of_practice?: number rating?: number profile_image_url?: string } @@ -44,20 +57,33 @@ export function CraftsmenList() {
-
+
{c.profile_image_url ? ( {c.shop_name} ) : '🧑‍🎨'}
-
- {c.meti_certified && 伝統的工芸品} -

{c.shop_name}

- {c.prefecture &&

{c.prefecture}

} - {c.craft_category &&

{c.craft_category}

} +
+
+ {c.meti_certified && ( + 伝統的工芸品 + )} + {c.craft_category && ( + + {CATEGORY_MAP[c.craft_category] || c.craft_category} + + )} +
+

{c.shop_name}

+ {c.prefecture && ( +

📍 {c.prefecture}

+ )} + {c.years_of_practice != null && ( +

職人歴 {c.years_of_practice}年

+ )}
{c.bio &&

{c.bio}

} - {c.rating && c.rating > 0 && ( + {c.rating != null && c.rating > 0 && (

⭐ {c.rating.toFixed(1)}

)} diff --git a/src/routes/craftsmen.js b/src/routes/craftsmen.js index b77c449..503aa73 100644 --- a/src/routes/craftsmen.js +++ b/src/routes/craftsmen.js @@ -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 router.get('/:id', async (req, res) => { try { const pool = getPool(); 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 WHERE c.id = $1 AND c.is_active = true`, [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' }); - // Get their products + // Get their last 12 products 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] ); res.json({ ...rows[0], products: products.rows }); } catch (err) { + console.error(err); res.status(500).json({ error: 'Failed to fetch craftsman' }); } });