diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9bbd1de
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+NODE_ENV=development
+PORT=3000
+DATABASE_URL=postgresql://postgres:postgres@localhost:5432/shokuninmarche
+REDIS_URL=redis://localhost:6379
+JWT_SECRET=your_jwt_secret_here
+JWT_REFRESH_SECRET=your_refresh_secret_here
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1be7db0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+client/node_modules/
+client/dist/
+.env
+*.log
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f6e12f9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+# Stage 1: Build frontend
+FROM node:20-alpine AS frontend-builder
+WORKDIR /app/client
+COPY client/package*.json ./
+RUN npm install
+COPY client/ ./
+RUN npm run build
+
+# Stage 2: Production
+FROM node:20-alpine
+WORKDIR /app
+COPY package*.json ./
+RUN npm install --production
+COPY src/ ./src/
+COPY --from=frontend-builder /app/client/dist ./client/dist
+EXPOSE 3000
+CMD ["node", "server.js"]
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..5de6db7
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ 職人マルシェ - Japanese Craft Marketplace
+
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..235ab57
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "shokuninmarche-client",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^3.3.2",
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-separator": "^1.0.3",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-toast": "^1.1.5",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.0.0",
+ "lucide-react": "^0.294.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-hook-form": "^7.48.2",
+ "react-router-dom": "^6.20.1",
+ "tailwind-merge": "^2.0.0",
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^3.22.4",
+ "zustand": "^4.4.7"
+ },
+ "devDependencies": {
+ "@types/node": "^20.10.0",
+ "@types/react": "^18.2.37",
+ "@types/react-dom": "^18.2.15",
+ "@typescript-eslint/eslint-plugin": "^6.10.0",
+ "@typescript-eslint/parser": "^6.10.0",
+ "@vitejs/plugin-react": "^4.2.0",
+ "autoprefixer": "^10.4.16",
+ "eslint": "^8.53.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.4",
+ "postcss": "^8.4.31",
+ "tailwindcss": "^3.3.5",
+ "typescript": "^5.2.2",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/client/postcss.config.js b/client/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/client/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..3ce897f
--- /dev/null
+++ b/client/src/App.tsx
@@ -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 (
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
+
+export default App
diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx
new file mode 100644
index 0000000..948d792
--- /dev/null
+++ b/client/src/components/Navbar.tsx
@@ -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 (
+
+
+
+
職人マルシェ
+
+
+
+ Products
+ Craftsmen
+ Ceramics
+ Lacquerware
+
+
+
+ {user ? (
+ <>
+
+
+
+
+
+
+ { logout(); navigate('/') }}>
+ Logout
+
+ >
+ ) : (
+ <>
+ Login
+ Register
+ >
+ )}
+
+
+
+ )
+}
diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx
new file mode 100644
index 0000000..e216d04
--- /dev/null
+++ b/client/src/components/ui/badge.tsx
@@ -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, VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return
+}
+
+export { Badge, badgeVariants }
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 0000000..208363e
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -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,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx
new file mode 100644
index 0000000..f04975f
--- /dev/null
+++ b/client/src/components/ui/card.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx
new file mode 100644
index 0000000..1fbd770
--- /dev/null
+++ b/client/src/components/ui/input.tsx
@@ -0,0 +1,23 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/client/src/components/ui/label.tsx b/client/src/components/ui/label.tsx
new file mode 100644
index 0000000..e0d8e04
--- /dev/null
+++ b/client/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..4dcede1
--- /dev/null
+++ b/client/src/index.css
@@ -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;
+ }
+}
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
new file mode 100644
index 0000000..901e204
--- /dev/null
+++ b/client/src/lib/api.ts
@@ -0,0 +1,26 @@
+const API_BASE = '/api'
+
+async function request(path: string, options?: RequestInit): Promise {
+ 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: (path: string) => request(path),
+ post: (path: string, body: unknown) => request(path, { method: 'POST', body: JSON.stringify(body) }),
+ put: (path: string, body: unknown) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
+ delete: (path: string) => request(path, { method: 'DELETE' }),
+}
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts
new file mode 100644
index 0000000..d084cca
--- /dev/null
+++ b/client/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..aa51223
--- /dev/null
+++ b/client/src/main.tsx
@@ -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(
+
+
+
+
+ ,
+)
diff --git a/client/src/pages/CraftsmenList.tsx b/client/src/pages/CraftsmenList.tsx
new file mode 100644
index 0000000..7aa44c5
--- /dev/null
+++ b/client/src/pages/CraftsmenList.tsx
@@ -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([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ api.get('/craftsmen')
+ .then(setCraftsmen)
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [])
+
+ return (
+
+
Master Craftsmen
+
職人たちに会いましょう
+
+ {loading ? (
+
+ {Array(6).fill(0).map((_, i) =>
)}
+
+ ) : craftsmen.length === 0 ? (
+
No craftsmen found yet
+ ) : (
+
+ {craftsmen.map((c) => (
+
+
+
+
+
+ {c.profile_image_url ? (
+
+ ) : '🧑🎨'}
+
+
+ {c.meti_certified &&
伝統的工芸品 }
+
{c.shop_name}
+ {c.prefecture &&
{c.prefecture}
}
+ {c.craft_category &&
{c.craft_category}
}
+
+
+ {c.bio && {c.bio}
}
+ {c.rating && c.rating > 0 && (
+ ⭐ {c.rating.toFixed(1)}
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
new file mode 100644
index 0000000..57ae942
--- /dev/null
+++ b/client/src/pages/Home.tsx
@@ -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 (
+
+ {/* Hero */}
+
+
+
Authentic Japanese Crafts
+
+ 職人マルシェ
+
+
Discover Handcrafted Treasures from Japan's Master Artisans
+
直接職人から — Direct from the craftsman's hands
+
+
+ Shop Now
+
+
+ Meet the Craftsmen
+
+
+
+
+
+ {/* Category Grid */}
+
+ Browse by Craft
+ 伝統工芸のカテゴリー
+
+ {CATEGORIES.map((cat) => (
+
+
+
+ {cat.emoji}
+ {cat.english}
+ {cat.label}
+
+
+
+ ))}
+
+
+
+ {/* METI Certification Feature */}
+
+
+
伝統的工芸品
+
METI Certified Traditional Crafts
+
+ Products bearing the 伝統的工芸品 (Traditional Craft) certification are recognized by Japan's Ministry of Economy, Trade and Industry for exceptional quality and authentic traditional techniques.
+
+
+
+ Browse METI認定 Products
+
+
+
+
+
+ {/* Footer */}
+
+
+ )
+}
diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx
new file mode 100644
index 0000000..f376de4
--- /dev/null
+++ b/client/src/pages/Login.tsx
@@ -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 (
+
+
+
+ 職人マルシェ
+ Sign in to your account
+
+
+
+
+ )
+}
diff --git a/client/src/pages/Products.tsx b/client/src/pages/Products.tsx
new file mode 100644
index 0000000..20658f8
--- /dev/null
+++ b/client/src/pages/Products.tsx
@@ -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([])
+ 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(`/products?${params.toString()}`)
+ .then(setProducts)
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [category, metiFilter])
+
+ return (
+
+
Japanese Craft Products
+
伝統工芸品
+
+ {/* Filters */}
+
+ {CATEGORIES.map((cat) => (
+ setSearchParams(cat.key ? { category: cat.key } : {})}
+ >
+ {cat.label}
+
+ ))}
+ {
+ 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
+
+
+
+ {loading ? (
+
+ {Array(8).fill(0).map((_, i) => (
+
+ ))}
+
+ ) : products.length === 0 ? (
+
+
No products found
+
Try a different category or filter
+
+ ) : (
+
+ {products.map((product) => (
+
+
+
+ {product.images?.[0] ? (
+
+ ) : (
+
🏺
+ )}
+
+
+ {(product.meti_certified || product.craftsman_meti) && (
+ 伝統的工芸品
+ )}
+ {product.name}
+ {product.name_ja && {product.name_ja}
}
+ {product.shop_name}
+ ¥{product.price.toLocaleString()}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/pages/Register.tsx b/client/src/pages/Register.tsx
new file mode 100644
index 0000000..b3a39e0
--- /dev/null
+++ b/client/src/pages/Register.tsx
@@ -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 (
+
+
+
+ Join 職人マルシェ
+ Create your account to start shopping
+
+
+
+
+ )
+}
diff --git a/client/src/store/auth.ts b/client/src/store/auth.ts
new file mode 100644
index 0000000..e102b33
--- /dev/null
+++ b/client/src/store/auth.ts
@@ -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
+ register: (data: { email: string; password: string; role?: string; display_name?: string }) => Promise
+ logout: () => void
+ checkAuth: () => Promise
+}
+
+export const useAuthStore = create((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('/auth/me')
+ set({ user })
+ } catch {
+ localStorage.removeItem('token')
+ set({ user: null, token: null })
+ }
+ },
+}))
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 0000000..f7282c5
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,74 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: [
+ "./index.html",
+ "./src/**/*.{ts,tsx,js,jsx}",
+ ],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+}
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..c20738e
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/client/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..c4d0b8a
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ }
+ }
+ }
+})
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..d64bd40
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,54 @@
+version: '3.8'
+
+services:
+ web:
+ build: .
+ expose:
+ - "3000"
+ environment:
+ - NODE_ENV=production
+ - PORT=3000
+ - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shokuninmarche
+ - REDIS_URL=redis://redis:6379
+ - JWT_SECRET=${JWT_SECRET:-changeme_in_production}
+ - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-changeme_refresh_in_production}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ restart: unless-stopped
+
+ postgres:
+ image: postgres:15-alpine
+ expose:
+ - "5432"
+ environment:
+ - POSTGRES_DB=shokuninmarche
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ expose:
+ - "6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
+ redis_data: