Compare commits
2 Commits
928e1125bf
...
2fa526075e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fa526075e | |||
| 6707c44d31 |
6
.env.example
Normal file
6
.env.example
Normal file
@@ -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
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
client/dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -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"]
|
||||||
14
client/index.html
Normal file
14
client/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>職人マルシェ - Japanese Craft Marketplace</title>
|
||||||
|
<meta name="description" content="Discover authentic Japanese crafts directly from master craftsmen" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
client/package.json
Normal file
49
client/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
33
client/src/App.tsx
Normal file
33
client/src/App.tsx
Normal 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
|
||||||
47
client/src/components/Navbar.tsx
Normal file
47
client/src/components/Navbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
client/src/components/ui/badge.tsx
Normal file
29
client/src/components/ui/badge.tsx
Normal 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 }
|
||||||
52
client/src/components/ui/button.tsx
Normal file
52
client/src/components/ui/button.tsx
Normal 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 }
|
||||||
46
client/src/components/ui/card.tsx
Normal file
46
client/src/components/ui/card.tsx
Normal 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 }
|
||||||
23
client/src/components/ui/input.tsx
Normal file
23
client/src/components/ui/input.tsx
Normal 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 }
|
||||||
23
client/src/components/ui/label.tsx
Normal file
23
client/src/components/ui/label.tsx
Normal 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
38
client/src/index.css
Normal 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
26
client/src/lib/api.ts
Normal 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
6
client/src/lib/utils.ts
Normal 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
13
client/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
71
client/src/pages/CraftsmenList.tsx
Normal file
71
client/src/pages/CraftsmenList.tsx
Normal 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
84
client/src/pages/Home.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
client/src/pages/Login.tsx
Normal file
77
client/src/pages/Login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
client/src/pages/Products.tsx
Normal file
123
client/src/pages/Products.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
client/src/pages/Register.tsx
Normal file
101
client/src/pages/Register.tsx
Normal 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
69
client/src/store/auth.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
74
client/tailwind.config.js
Normal file
74
client/tailwind.config.js
Normal file
@@ -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")],
|
||||||
|
}
|
||||||
25
client/tsconfig.json
Normal file
25
client/tsconfig.json
Normal file
@@ -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" }]
|
||||||
|
}
|
||||||
10
client/tsconfig.node.json
Normal file
10
client/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
20
client/vite.config.ts
Normal file
20
client/vite.config.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@@ -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:
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "shokuninmarche",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "職人マルシェ - Japanese Craft Marketplace",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"migrate": "node src/db/migrate.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
server.js
Normal file
58
server.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const compression = require('compression');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
app.use(compression());
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || true,
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
const { connectDB } = require('./src/db/connection');
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
const authRoutes = require('./src/routes/auth');
|
||||||
|
const craftsmenRoutes = require('./src/routes/craftsmen');
|
||||||
|
const productsRoutes = require('./src/routes/products');
|
||||||
|
const ordersRoutes = require('./src/routes/orders');
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/craftsmen', craftsmenRoutes);
|
||||||
|
app.use('/api/products', productsRoutes);
|
||||||
|
app.use('/api/orders', ordersRoutes);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', service: 'shokuninmarche-api', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve React frontend in production
|
||||||
|
app.use(express.static(path.join(__dirname, 'client/dist')));
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'client/dist', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 shokuninmarche server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start server:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
64
src/db/connection.js
Normal file
64
src/db/connection.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
async function connectDB() {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: process.env.NODE_ENV === 'production' && process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost') && !process.env.DATABASE_URL.includes('postgres:')
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
const client = await pool.connect();
|
||||||
|
console.log('✅ PostgreSQL connected');
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
await runMigrations(client);
|
||||||
|
client.release();
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations(client) {
|
||||||
|
// Create migrations tracking table
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
applied_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
|
|
||||||
|
if (!fs.existsSync(migrationsDir)) return;
|
||||||
|
|
||||||
|
const files = fs.readdirSync(migrationsDir).sort();
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.sql')) continue;
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
'SELECT id FROM schema_migrations WHERE filename = $1',
|
||||||
|
[file]
|
||||||
|
);
|
||||||
|
if (rows.length > 0) continue;
|
||||||
|
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO schema_migrations (filename) VALUES ($1)',
|
||||||
|
[file]
|
||||||
|
);
|
||||||
|
console.log(`✅ Migration applied: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { connectDB, getPool };
|
||||||
140
src/db/migrations/001_initial_schema.sql
Normal file
140
src/db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
-- Users table (buyers, craftsmen, admins)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'buyer', -- buyer, craftsman, admin
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
display_name VARCHAR(200),
|
||||||
|
avatar_url TEXT,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Craftsmen profiles
|
||||||
|
CREATE TABLE IF NOT EXISTS craftsmen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
shop_name VARCHAR(255) NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
story TEXT,
|
||||||
|
profile_image_url TEXT,
|
||||||
|
banner_image_url TEXT,
|
||||||
|
location VARCHAR(255),
|
||||||
|
prefecture VARCHAR(100),
|
||||||
|
craft_region VARCHAR(255),
|
||||||
|
regional_designation VARCHAR(255),
|
||||||
|
guild_association VARCHAR(255),
|
||||||
|
meti_certified BOOLEAN DEFAULT FALSE,
|
||||||
|
meti_certification_number VARCHAR(100),
|
||||||
|
meti_craft_category VARCHAR(255),
|
||||||
|
years_of_practice INTEGER,
|
||||||
|
apprenticeship_lineage TEXT,
|
||||||
|
primary_materials TEXT,
|
||||||
|
workshop_size VARCHAR(50), -- solo / small_atelier / cooperative
|
||||||
|
languages_spoken TEXT[],
|
||||||
|
production_method VARCHAR(50), -- fully_handmade / partially_machine_assisted
|
||||||
|
craft_category VARCHAR(50),
|
||||||
|
social_links JSONB,
|
||||||
|
rating DECIMAL(3,2) DEFAULT 0,
|
||||||
|
total_reviews INTEGER DEFAULT 0,
|
||||||
|
total_sales INTEGER DEFAULT 0,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Products table
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
craftsman_id UUID NOT NULL REFERENCES craftsmen(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
name_ja VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
description_ja TEXT,
|
||||||
|
price DECIMAL(12,2) NOT NULL,
|
||||||
|
currency VARCHAR(10) DEFAULT 'JPY',
|
||||||
|
stock_quantity INTEGER DEFAULT 0,
|
||||||
|
craft_category VARCHAR(50),
|
||||||
|
craft_region VARCHAR(255),
|
||||||
|
food_subcategory VARCHAR(50), -- fermented / dried_seafood / confectionery / produce / condiments
|
||||||
|
images TEXT[],
|
||||||
|
ar_model_url TEXT,
|
||||||
|
ar_eligible BOOLEAN DEFAULT TRUE,
|
||||||
|
ar_ineligible_reason VARCHAR(100), -- devotional / ceremonial / other
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_featured BOOLEAN DEFAULT FALSE,
|
||||||
|
meti_certified BOOLEAN DEFAULT FALSE,
|
||||||
|
weight_grams INTEGER,
|
||||||
|
dimensions JSONB,
|
||||||
|
tags TEXT[],
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Orders table
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
buyer_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
status VARCHAR(50) DEFAULT 'pending', -- pending, confirmed, processing, shipped, delivered, cancelled
|
||||||
|
total_amount DECIMAL(12,2) NOT NULL,
|
||||||
|
currency VARCHAR(10) DEFAULT 'JPY',
|
||||||
|
shipping_address JSONB,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Order items
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES products(id),
|
||||||
|
craftsman_id UUID NOT NULL REFERENCES craftsmen(id),
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(12,2) NOT NULL,
|
||||||
|
total_price DECIMAL(12,2) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reviews table
|
||||||
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
buyer_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
craftsman_id UUID NOT NULL REFERENCES craftsmen(id),
|
||||||
|
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Subscriptions table
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tier VARCHAR(50) NOT NULL, -- discovery / artisan / tradition
|
||||||
|
status VARCHAR(50) DEFAULT 'active', -- active, paused, cancelled
|
||||||
|
price_jpy INTEGER NOT NULL, -- 3500, 7500, or 15000
|
||||||
|
started_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
next_billing_date TIMESTAMP,
|
||||||
|
cancelled_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_craftsmen_user_id ON craftsmen(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_craftsmen_craft_category ON craftsmen(craft_category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_craftsmen_meti_certified ON craftsmen(meti_certified);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_craftsman_id ON products(craftsman_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_craft_category ON products(craft_category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_is_active ON products(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_buyer_id ON orders(buyer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_product_id ON reviews(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
29
src/middleware/auth.js
Normal file
29
src/middleware/auth.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
function authenticateToken(req, res, next) {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Access token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET || 'changeme', (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRole(...roles) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user || !roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authenticateToken, requireRole };
|
||||||
102
src/routes/auth.js
Normal file
102
src/routes/auth.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { getPool } = require('../db/connection');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Register
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, role = 'buyer', first_name, last_name, display_name } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 12);
|
||||||
|
const validRole = ['buyer', 'craftsman', 'admin'].includes(role) ? role : 'buyer';
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO users (email, password_hash, role, first_name, last_name, display_name)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, role, first_name, last_name, display_name, created_at`,
|
||||||
|
[email, password_hash, validRole, first_name || null, last_name || null, display_name || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, email: user.email, role: user.role },
|
||||||
|
process.env.JWT_SECRET || 'changeme',
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ user, token });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Register error:', err);
|
||||||
|
res.status(500).json({ error: 'Registration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows } = await pool.query('SELECT * FROM users WHERE email = $1 AND is_active = true', [email]);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, email: user.email, role: user.role },
|
||||||
|
process.env.JWT_SECRET || 'changeme',
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { password_hash, ...userWithoutPassword } = user;
|
||||||
|
res.json({ user: userWithoutPassword, token });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
res.status(500).json({ error: 'Login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
router.get('/me', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT id, email, role, first_name, last_name, display_name, avatar_url, created_at FROM users WHERE id = $1',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to get user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
148
src/routes/craftsmen.js
Normal file
148
src/routes/craftsmen.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getPool } = require('../db/connection');
|
||||||
|
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// List craftsmen
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { craft_category, meti_certified, page = 1, limit = 20 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = `SELECT c.*, u.email, u.display_name as user_display_name
|
||||||
|
FROM craftsmen c JOIN users u ON c.user_id = u.id
|
||||||
|
WHERE c.is_active = true`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (craft_category) {
|
||||||
|
params.push(craft_category);
|
||||||
|
query += ` AND c.craft_category = $${params.length}`;
|
||||||
|
}
|
||||||
|
if (meti_certified === 'true') {
|
||||||
|
query += ` AND c.meti_certified = true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY c.meti_certified DESC, c.rating DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, params);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch 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
|
||||||
|
FROM craftsmen c JOIN users u ON c.user_id = u.id
|
||||||
|
WHERE c.id = $1 AND c.is_active = true`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Craftsman not found' });
|
||||||
|
|
||||||
|
// Get their products
|
||||||
|
const products = await pool.query(
|
||||||
|
'SELECT * FROM products WHERE craftsman_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 10',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ...rows[0], products: products.rows });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch craftsman' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create craftsman profile (for craftsman users)
|
||||||
|
router.post('/', authenticateToken, requireRole('craftsman', 'admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const {
|
||||||
|
shop_name, bio, story, location, prefecture, craft_region,
|
||||||
|
regional_designation, guild_association, meti_certified,
|
||||||
|
meti_certification_number, meti_craft_category, years_of_practice,
|
||||||
|
apprenticeship_lineage, primary_materials, workshop_size,
|
||||||
|
languages_spoken, production_method, craft_category, social_links
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!shop_name) return res.status(400).json({ error: 'shop_name required' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO craftsmen (
|
||||||
|
user_id, shop_name, bio, story, location, prefecture, craft_region,
|
||||||
|
regional_designation, guild_association, meti_certified, meti_certification_number,
|
||||||
|
meti_craft_category, years_of_practice, apprenticeship_lineage, primary_materials,
|
||||||
|
workshop_size, languages_spoken, production_method, craft_category, social_links
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
req.user.id, shop_name, bio, story, location, prefecture, craft_region,
|
||||||
|
regional_designation, guild_association, meti_certified || false,
|
||||||
|
meti_certification_number, meti_craft_category, years_of_practice,
|
||||||
|
apprenticeship_lineage, primary_materials, workshop_size,
|
||||||
|
languages_spoken, production_method, craft_category,
|
||||||
|
social_links ? JSON.stringify(social_links) : null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create craftsman profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update craftsman profile
|
||||||
|
router.put('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
// Check ownership or admin
|
||||||
|
const { rows: existing } = await pool.query(
|
||||||
|
'SELECT * FROM craftsmen WHERE id = $1', [req.params.id]
|
||||||
|
);
|
||||||
|
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (existing[0].user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
shop_name, bio, story, location, prefecture, craft_region,
|
||||||
|
regional_designation, guild_association, meti_certified,
|
||||||
|
years_of_practice, workshop_size, production_method, craft_category, social_links
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE craftsmen SET
|
||||||
|
shop_name = COALESCE($1, shop_name),
|
||||||
|
bio = COALESCE($2, bio),
|
||||||
|
story = COALESCE($3, story),
|
||||||
|
location = COALESCE($4, location),
|
||||||
|
prefecture = COALESCE($5, prefecture),
|
||||||
|
craft_region = COALESCE($6, craft_region),
|
||||||
|
regional_designation = COALESCE($7, regional_designation),
|
||||||
|
guild_association = COALESCE($8, guild_association),
|
||||||
|
meti_certified = COALESCE($9, meti_certified),
|
||||||
|
years_of_practice = COALESCE($10, years_of_practice),
|
||||||
|
workshop_size = COALESCE($11, workshop_size),
|
||||||
|
production_method = COALESCE($12, production_method),
|
||||||
|
craft_category = COALESCE($13, craft_category),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $14 RETURNING *`,
|
||||||
|
[shop_name, bio, story, location, prefecture, craft_region,
|
||||||
|
regional_designation, guild_association, meti_certified,
|
||||||
|
years_of_practice, workshop_size, production_method, craft_category, req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to update craftsman' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
101
src/routes/orders.js
Normal file
101
src/routes/orders.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getPool } = require('../db/connection');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Get my orders
|
||||||
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT o.*, json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', oi.id,
|
||||||
|
'product_id', oi.product_id,
|
||||||
|
'quantity', oi.quantity,
|
||||||
|
'unit_price', oi.unit_price,
|
||||||
|
'total_price', oi.total_price,
|
||||||
|
'product_name', p.name
|
||||||
|
)
|
||||||
|
) as items
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||||
|
LEFT JOIN products p ON p.id = oi.product_id
|
||||||
|
WHERE o.buyer_id = $1
|
||||||
|
GROUP BY o.id
|
||||||
|
ORDER BY o.created_at DESC`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch orders' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { items, shipping_address, notes } = req.body;
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'items required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total and validate stock
|
||||||
|
let totalAmount = 0;
|
||||||
|
const enrichedItems = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT * FROM products WHERE id = $1 AND is_active = true',
|
||||||
|
[item.product_id]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return res.status(400).json({ error: `Product ${item.product_id} not found` });
|
||||||
|
const product = rows[0];
|
||||||
|
if (product.stock_quantity < item.quantity) {
|
||||||
|
return res.status(400).json({ error: `Insufficient stock for ${product.name}` });
|
||||||
|
}
|
||||||
|
const totalPrice = product.price * item.quantity;
|
||||||
|
totalAmount += totalPrice;
|
||||||
|
enrichedItems.push({ ...item, unit_price: product.price, total_price: totalPrice, craftsman_id: product.craftsman_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const { rows: orderRows } = await client.query(
|
||||||
|
`INSERT INTO orders (buyer_id, total_amount, shipping_address, notes)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
|
[req.user.id, totalAmount, JSON.stringify(shipping_address), notes]
|
||||||
|
);
|
||||||
|
const order = orderRows[0];
|
||||||
|
|
||||||
|
for (const item of enrichedItems) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO order_items (order_id, product_id, craftsman_id, quantity, unit_price, total_price)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[order.id, item.product_id, item.craftsman_id, item.quantity, item.unit_price, item.total_price]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2',
|
||||||
|
[item.quantity, item.product_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.status(201).json(order);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create order' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
155
src/routes/products.js
Normal file
155
src/routes/products.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getPool } = require('../db/connection');
|
||||||
|
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = ['ceramics', 'lacquerware', 'textiles', 'woodwork_bamboo', 'washi_calligraphy', 'metalwork', 'food', 'dolls_toys', 'other'];
|
||||||
|
|
||||||
|
// List products
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { craft_category, craftsman_id, meti_certified, search, page = 1, limit = 20 } = req.query;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let query = `SELECT p.*, c.shop_name, c.meti_certified as craftsman_meti
|
||||||
|
FROM products p JOIN craftsmen c ON p.craftsman_id = c.id
|
||||||
|
WHERE p.is_active = true`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (craft_category) {
|
||||||
|
params.push(craft_category);
|
||||||
|
query += ` AND p.craft_category = $${params.length}`;
|
||||||
|
}
|
||||||
|
if (craftsman_id) {
|
||||||
|
params.push(craftsman_id);
|
||||||
|
query += ` AND p.craftsman_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
if (meti_certified === 'true') {
|
||||||
|
query += ` AND (p.meti_certified = true OR c.meti_certified = true)`;
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
query += ` AND (p.name ILIKE $${params.length} OR p.name_ja ILIKE $${params.length} OR p.description ILIKE $${params.length})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY p.is_featured DESC, c.meti_certified DESC, p.created_at DESC`;
|
||||||
|
query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, params);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch products' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get product by ID
|
||||||
|
router.get('/:id', 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, c.craft_region
|
||||||
|
FROM products p JOIN craftsmen c ON p.craftsman_id = c.id
|
||||||
|
WHERE p.id = $1 AND p.is_active = true`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Product not found' });
|
||||||
|
|
||||||
|
// Get reviews
|
||||||
|
const reviews = await pool.query(
|
||||||
|
`SELECT r.*, u.display_name FROM reviews r
|
||||||
|
JOIN users u ON r.buyer_id = u.id
|
||||||
|
WHERE r.product_id = $1 ORDER BY r.created_at DESC LIMIT 10`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ...rows[0], reviews: reviews.rows });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch product' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
router.post('/', authenticateToken, requireRole('craftsman', 'admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Get craftsman profile for this user
|
||||||
|
const { rows: craftsmanRows } = await pool.query(
|
||||||
|
'SELECT id FROM craftsmen WHERE user_id = $1', [req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const craftsmanId = req.user.role === 'admin' && req.body.craftsman_id
|
||||||
|
? req.body.craftsman_id
|
||||||
|
: craftsmanRows[0]?.id;
|
||||||
|
|
||||||
|
if (!craftsmanId) return res.status(400).json({ error: 'Craftsman profile not found' });
|
||||||
|
|
||||||
|
const {
|
||||||
|
name, name_ja, description, description_ja, price, stock_quantity = 0,
|
||||||
|
craft_category, craft_region, food_subcategory, images, ar_model_url,
|
||||||
|
ar_eligible = true, ar_ineligible_reason, meti_certified, tags
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!name || !price) return res.status(400).json({ error: 'name and price required' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO products (
|
||||||
|
craftsman_id, name, name_ja, description, description_ja, price,
|
||||||
|
stock_quantity, craft_category, craft_region, food_subcategory,
|
||||||
|
images, ar_model_url, ar_eligible, ar_ineligible_reason, meti_certified, tags
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
craftsmanId, name, name_ja, description, description_ja, price,
|
||||||
|
stock_quantity, craft_category, craft_region, food_subcategory,
|
||||||
|
images, ar_model_url, ar_eligible, ar_ineligible_reason, meti_certified || false, tags
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create product' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update product
|
||||||
|
router.put('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows: existing } = await pool.query(
|
||||||
|
`SELECT p.*, c.user_id FROM products p JOIN craftsmen c ON p.craftsman_id = c.id WHERE p.id = $1`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (existing[0].user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, name_ja, description, price, stock_quantity, craft_category, is_active } = req.body;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE products SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
name_ja = COALESCE($2, name_ja),
|
||||||
|
description = COALESCE($3, description),
|
||||||
|
price = COALESCE($4, price),
|
||||||
|
stock_quantity = COALESCE($5, stock_quantity),
|
||||||
|
craft_category = COALESCE($6, craft_category),
|
||||||
|
is_active = COALESCE($7, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $8 RETURNING *`,
|
||||||
|
[name, name_ja, description, price, stock_quantity, craft_category, is_active, req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to update product' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user