From 2fa526075ead57cc8735a1923274bbe809f9759c Mon Sep 17 00:00:00 2001 From: tester Date: Sat, 21 Feb 2026 18:19:30 +0000 Subject: [PATCH] feat: add React frontend - homepage, auth, products, craftsmen pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React 18 + Vite + TypeScript + TailwindCSS + shadcn/ui components - Auth pages (login/register) with JWT token management via Zustand store - Homepage with 9 craft category tiles (ceramics first, lacquerware second) - METI 伝統的工芸品 badge and featured section on homepage - Products page with category filters + METI認定 filter - Craftsmen list page with METI badge display - Navbar with auth-aware navigation - Japanese warm color theme (amber/terracotta) - API proxy config pointing to Express backend Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 ++ .gitignore | 5 ++ Dockerfile | 17 ++++ client/index.html | 14 ++++ client/package.json | 49 +++++++++++ client/postcss.config.js | 6 ++ client/src/App.tsx | 33 ++++++++ client/src/components/Navbar.tsx | 47 +++++++++++ client/src/components/ui/badge.tsx | 29 +++++++ client/src/components/ui/button.tsx | 52 ++++++++++++ client/src/components/ui/card.tsx | 46 +++++++++++ client/src/components/ui/input.tsx | 23 ++++++ client/src/components/ui/label.tsx | 23 ++++++ client/src/index.css | 38 +++++++++ client/src/lib/api.ts | 26 ++++++ client/src/lib/utils.ts | 6 ++ client/src/main.tsx | 13 +++ client/src/pages/CraftsmenList.tsx | 71 ++++++++++++++++ client/src/pages/Home.tsx | 84 +++++++++++++++++++ client/src/pages/Login.tsx | 77 +++++++++++++++++ client/src/pages/Products.tsx | 123 ++++++++++++++++++++++++++++ client/src/pages/Register.tsx | 101 +++++++++++++++++++++++ client/src/store/auth.ts | 69 ++++++++++++++++ client/tailwind.config.js | 74 +++++++++++++++++ client/tsconfig.json | 25 ++++++ client/tsconfig.node.json | 10 +++ client/vite.config.ts | 20 +++++ docker-compose.yml | 54 ++++++++++++ 28 files changed, 1141 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/postcss.config.js create mode 100644 client/src/App.tsx create mode 100644 client/src/components/Navbar.tsx create mode 100644 client/src/components/ui/badge.tsx create mode 100644 client/src/components/ui/button.tsx create mode 100644 client/src/components/ui/card.tsx create mode 100644 client/src/components/ui/input.tsx create mode 100644 client/src/components/ui/label.tsx create mode 100644 client/src/index.css create mode 100644 client/src/lib/api.ts create mode 100644 client/src/lib/utils.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/CraftsmenList.tsx create mode 100644 client/src/pages/Home.tsx create mode 100644 client/src/pages/Login.tsx create mode 100644 client/src/pages/Products.tsx create mode 100644 client/src/pages/Register.tsx create mode 100644 client/src/store/auth.ts create mode 100644 client/tailwind.config.js create mode 100644 client/tsconfig.json create mode 100644 client/tsconfig.node.json create mode 100644 client/vite.config.ts create mode 100644 docker-compose.yml 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 ( + + ) +} 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.shop_name} + ) : '🧑‍🎨'} +
+
+ {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

+
+ + + + + + +
+
+
+ + {/* 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. +

+ + + +
+
+ + {/* Footer */} +
+
+

© 2026 職人マルシェ - Japanese Craft Marketplace. All rights reserved.

+
+
+
+ ) +} 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 + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + /> +
+
+ + +

+ Don't have an account?{' '} + Register +

+
+
+
+
+ ) +} 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) => ( + + ))} + +
+ + {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.name} + ) : ( +
🏺
+ )} +
+ + {(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 + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setFormData({ ...formData, display_name: e.target.value })} + placeholder="Your name" + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="you@example.com" + required + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + placeholder="Minimum 8 characters" + required + /> +
+
+ + +
+
+ + +

+ Already have an account?{' '} + Sign in +

+
+
+
+
+ ) +} 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: