feat: add React frontend - homepage, auth, products, craftsmen pages

- React 18 + Vite + TypeScript + TailwindCSS + shadcn/ui components
- Auth pages (login/register) with JWT token management via Zustand store
- Homepage with 9 craft category tiles (ceramics first, lacquerware second)
- METI 伝統的工芸品 badge and featured section on homepage
- Products page with category filters + METI認定 filter
- Craftsmen list page with METI badge display
- Navbar with auth-aware navigation
- Japanese warm color theme (amber/terracotta)
- API proxy config pointing to Express backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:19:30 +00:00
parent 6707c44d31
commit 2fa526075e
28 changed files with 1141 additions and 0 deletions

33
client/src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import { Navbar } from '@/components/Navbar'
import { Home } from '@/pages/Home'
import { Login } from '@/pages/Login'
import { Register } from '@/pages/Register'
import { Products } from '@/pages/Products'
import { CraftsmenList } from '@/pages/CraftsmenList'
import { useAuthStore } from '@/store/auth'
function App() {
const { checkAuth } = useAuthStore()
useEffect(() => {
checkAuth()
}, [checkAuth])
return (
<div className="min-h-screen">
<Navbar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/products" element={<Products />} />
<Route path="/craftsmen" element={<CraftsmenList />} />
<Route path="*" element={<Home />} />
</Routes>
</div>
)
}
export default App

View File

@@ -0,0 +1,47 @@
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui/button'
import { ShoppingBag, User } from 'lucide-react'
export function Navbar() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
return (
<nav className="border-b bg-white sticky top-0 z-50">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<span className="text-2xl font-bold text-primary"></span>
</Link>
<div className="hidden md:flex items-center gap-6 text-sm">
<Link to="/products" className="hover:text-primary transition-colors">Products</Link>
<Link to="/craftsmen" className="hover:text-primary transition-colors">Craftsmen</Link>
<Link to="/products?category=ceramics" className="hover:text-primary transition-colors">Ceramics</Link>
<Link to="/products?category=lacquerware" className="hover:text-primary transition-colors">Lacquerware</Link>
</div>
<div className="flex items-center gap-2">
{user ? (
<>
<Link to="/orders">
<Button variant="ghost" size="icon"><ShoppingBag className="h-5 w-5" /></Button>
</Link>
<Link to="/profile">
<Button variant="ghost" size="icon"><User className="h-5 w-5" /></Button>
</Link>
<Button variant="outline" size="sm" onClick={() => { logout(); navigate('/') }}>
Logout
</Button>
</>
) : (
<>
<Link to="/login"><Button variant="ghost" size="sm">Login</Button></Link>
<Link to="/register"><Button size="sm">Register</Button></Link>
</>
)}
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
meti: "border-transparent bg-primary text-primary-foreground font-bold",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

38
client/src/index.css Normal file
View File

@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 30 20% 98%;
--foreground: 20 14% 10%;
--card: 30 20% 98%;
--card-foreground: 20 14% 10%;
--popover: 30 20% 98%;
--popover-foreground: 20 14% 10%;
--primary: 15 70% 35%;
--primary-foreground: 30 20% 98%;
--secondary: 30 30% 90%;
--secondary-foreground: 20 14% 10%;
--muted: 30 20% 94%;
--muted-foreground: 20 10% 45%;
--accent: 40 60% 50%;
--accent-foreground: 20 14% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 30 20% 98%;
--border: 30 20% 88%;
--input: 30 20% 88%;
--ring: 15 70% 35%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: 'Hiragino Kaku Gothic Pro', 'Noto Sans JP', sans-serif;
}
}

26
client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,26 @@
const API_BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('token')
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (!res.ok) {
const error = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(error.error || 'Request failed')
}
return res.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}

6
client/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
client/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface Craftsman {
id: string
shop_name: string
bio?: string
craft_category?: string
prefecture?: string
meti_certified?: boolean
rating?: number
profile_image_url?: string
}
export function CraftsmenList() {
const [craftsmen, setCraftsmen] = useState<Craftsman[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<Craftsman[]>('/craftsmen')
.then(setCraftsmen)
.catch(console.error)
.finally(() => setLoading(false))
}, [])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Master Craftsmen</h1>
<p className="text-muted-foreground mb-8"></p>
{loading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array(6).fill(0).map((_, i) => <div key={i} className="bg-muted rounded-lg h-48 animate-pulse" />)}
</div>
) : craftsmen.length === 0 ? (
<p className="text-center text-muted-foreground py-16">No craftsmen found yet</p>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{craftsmen.map((c) => (
<Link key={c.id} to={`/craftsmen/${c.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="p-6">
<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">
{c.profile_image_url ? (
<img src={c.profile_image_url} alt={c.shop_name} className="w-full h-full rounded-full object-cover" />
) : '🧑‍🎨'}
</div>
<div>
{c.meti_certified && <Badge variant="meti" className="text-xs mb-1"></Badge>}
<h3 className="font-bold">{c.shop_name}</h3>
{c.prefecture && <p className="text-xs text-muted-foreground">{c.prefecture}</p>}
{c.craft_category && <p className="text-xs text-muted-foreground capitalize">{c.craft_category}</p>}
</div>
</div>
{c.bio && <p className="text-sm text-muted-foreground mt-3 line-clamp-3">{c.bio}</p>}
{c.rating && c.rating > 0 && (
<p className="text-sm mt-2"> {c.rating.toFixed(1)}</p>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
)
}

84
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const CATEGORIES = [
{ key: 'ceramics', label: '陶芸・陶磁器', english: 'Ceramics', emoji: '🏺' },
{ key: 'lacquerware', label: '漆器', english: 'Lacquerware', emoji: '🏮' },
{ key: 'textiles', label: '織物・染物', english: 'Textiles', emoji: '🧵' },
{ key: 'woodwork_bamboo', label: '木工・竹工芸', english: 'Wood & Bamboo', emoji: '🪵' },
{ key: 'washi_calligraphy', label: '和紙・書道具', english: 'Washi & Calligraphy', emoji: '📜' },
{ key: 'metalwork', label: '金工・鍛冶', english: 'Metalwork', emoji: '⚒️' },
{ key: 'food', label: '食品・特産品', english: 'Food Specialties', emoji: '🍱' },
{ key: 'dolls_toys', label: '人形・玩具', english: 'Dolls & Toys', emoji: '🎎' },
{ key: 'other', label: 'その他工芸', english: 'Other Crafts', emoji: '🎨' },
]
export function Home() {
return (
<div className="min-h-screen">
{/* Hero */}
<section className="bg-gradient-to-br from-amber-50 to-orange-100 py-20">
<div className="container mx-auto px-4 text-center">
<Badge className="mb-4 text-sm px-4 py-1">Authentic Japanese Crafts</Badge>
<h1 className="text-5xl font-bold text-gray-900 mb-4">
</h1>
<p className="text-xl text-gray-600 mb-2">Discover Handcrafted Treasures from Japan's Master Artisans</p>
<p className="text-sm text-gray-500 mb-8">直接職人から — Direct from the craftsman's hands</p>
<div className="flex gap-4 justify-center">
<Link to="/products">
<Button size="lg" className="text-base px-8">Shop Now</Button>
</Link>
<Link to="/craftsmen">
<Button variant="outline" size="lg" className="text-base px-8">Meet the Craftsmen</Button>
</Link>
</div>
</div>
</section>
{/* Category Grid */}
<section className="py-16 container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-2">Browse by Craft</h2>
<p className="text-center text-muted-foreground mb-10"></p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{CATEGORIES.map((cat) => (
<Link key={cat.key} to={`/products?category=${cat.key}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer group">
<CardContent className="p-6 text-center">
<div className="text-4xl mb-3">{cat.emoji}</div>
<div className="font-semibold text-sm group-hover:text-primary transition-colors">{cat.english}</div>
<div className="text-xs text-muted-foreground mt-1">{cat.label}</div>
</CardContent>
</Card>
</Link>
))}
</div>
</section>
{/* METI Certification Feature */}
<section className="bg-amber-50 py-12">
<div className="container mx-auto px-4 text-center">
<Badge variant="meti" className="mb-4 text-sm px-6 py-2 text-lg"></Badge>
<h2 className="text-2xl font-bold mb-3">METI Certified Traditional Crafts</h2>
<p className="text-muted-foreground mb-6 max-w-2xl mx-auto">
Products bearing the (Traditional Craft) certification are recognized by Japan's Ministry of Economy, Trade and Industry for exceptional quality and authentic traditional techniques.
</p>
<Link to="/products?meti_certified=true">
<Button variant="outline" className="border-primary text-primary hover:bg-primary hover:text-white">
Browse METI認定 Products
</Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t py-8 mt-16">
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
<p>© 2026 - Japanese Craft Marketplace. All rights reserved.</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { login, isLoading } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await login(email, password)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-amber-50 to-orange-100 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive text-sm rounded-md p-3">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
<p className="text-sm text-center text-muted-foreground">
Don't have an account?{' '}
<Link to="/register" className="text-primary hover:underline">Register</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useState } 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'
interface Product {
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: '', 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 その他' },
]
export function Products() {
const [searchParams, setSearchParams] = useSearchParams()
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const category = searchParams.get('category') || ''
const metiFilter = searchParams.get('meti_certified') === 'true'
useEffect(() => {
setLoading(true)
const params = new URLSearchParams()
if (category) params.set('craft_category', category)
if (metiFilter) params.set('meti_certified', 'true')
api.get<Product[]>(`/products?${params.toString()}`)
.then(setProducts)
.catch(console.error)
.finally(() => setLoading(false))
}, [category, metiFilter])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Japanese Craft Products</h1>
<p className="text-muted-foreground mb-6"></p>
{/* Filters */}
<div className="flex flex-wrap gap-2 mb-6">
{CATEGORIES.map((cat) => (
<Button
key={cat.key}
variant={category === cat.key ? 'default' : 'outline'}
size="sm"
onClick={() => setSearchParams(cat.key ? { category: cat.key } : {})}
>
{cat.label}
</Button>
))}
<Button
variant={metiFilter ? 'default' : 'outline'}
size="sm"
onClick={() => {
const p = new URLSearchParams(searchParams)
if (metiFilter) p.delete('meti_certified')
else p.set('meti_certified', 'true')
setSearchParams(p)
}}
className={metiFilter ? '' : 'border-amber-500 text-amber-600'}
>
METI認定 Only
</Button>
</div>
{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>
<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>
)
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
export function Register() {
const [formData, setFormData] = useState({ email: '', password: '', display_name: '', role: 'buyer' })
const [error, setError] = useState('')
const { register, isLoading } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (formData.password.length < 8) {
setError('Password must be at least 8 characters')
return
}
try {
await register(formData)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-amber-50 to-orange-100 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Join </CardTitle>
<CardDescription>Create your account to start shopping</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive text-sm rounded-md p-3">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="display_name">Display Name</Label>
<Input
id="display_name"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
placeholder="Your name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="you@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Minimum 8 characters"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Account Type</Label>
<select
id="role"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="buyer">Buyer shop for Japanese crafts</option>
<option value="craftsman">Craftsman sell your creations</option>
</select>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
<p className="text-sm text-center text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="text-primary hover:underline">Sign in</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}

69
client/src/store/auth.ts Normal file
View File

@@ -0,0 +1,69 @@
import { create } from 'zustand'
import { api } from '@/lib/api'
interface User {
id: string
email: string
role: string
first_name?: string
last_name?: string
display_name?: string
avatar_url?: string
}
interface AuthState {
user: User | null
token: string | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
register: (data: { email: string; password: string; role?: string; display_name?: string }) => Promise<void>
logout: () => void
checkAuth: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: localStorage.getItem('token'),
isLoading: false,
login: async (email, password) => {
set({ isLoading: true })
try {
const { user, token } = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
localStorage.setItem('token', token)
set({ user, token, isLoading: false })
} catch (err) {
set({ isLoading: false })
throw err
}
},
register: async (data) => {
set({ isLoading: true })
try {
const { user, token } = await api.post<{ user: User; token: string }>('/auth/register', data)
localStorage.setItem('token', token)
set({ user, token, isLoading: false })
} catch (err) {
set({ isLoading: false })
throw err
}
},
logout: () => {
localStorage.removeItem('token')
set({ user: null, token: null })
},
checkAuth: async () => {
const token = localStorage.getItem('token')
if (!token) return
try {
const user = await api.get<User>('/auth/me')
set({ user })
} catch {
localStorage.removeItem('token')
set({ user: null, token: null })
}
},
}))