Compare commits

...

3 Commits

Author SHA1 Message Date
79d3f2d37f Merge remote-tracking branch 'origin/main' 2026-02-21 22:11:08 +00:00
325d2944fe feat: Order & Checkout System - cart, checkout, order history, tracking
- Backend: Add GET /api/orders/:id, PUT /api/orders/:id/cancel, GET /api/orders/:id/tracking with mock Yamato data
- Frontend: Zustand cart store with localStorage persistence (key 'cart')
- Frontend: CartPage (/cart) with quantity controls, item removal, JPY totals
- Frontend: CheckoutPage (/checkout) with shipping address form, auth guard, POST to /api/orders
- Frontend: OrderHistoryPage (/account/orders) with Japanese status labels, auth guard
- Frontend: OrderDetailPage (/account/orders/:id) with cancel button, tracking section, auth guard
- Updated App.tsx with all four new routes
- Updated ProductDetail.tsx to use cart store with View Cart link after adding
- Updated Navbar.tsx with cart icon badge (item count) and 注文履歴 order history link
2026-02-21 22:06:25 +00:00
cc695c6f28 feat: AR Viewer - WebAR with model-viewer, 5 environment presets, placement modes
- Add Google model-viewer 3.5.0 CDN script to client/index.html for WebXR/QuickLook AR
- Create ARViewer.tsx modal component with 5 Japanese environment presets (tatami, modern-japanese, wooden-table, stone-surface, neutral), floor/shelf/wall placement mode selector, WebXR support detection, and small-item scale reference badge
- Replace external AR link in ProductDetail.tsx with in-browser ARViewer modal; auto-detect placement mode from craft_category (textiles=wall, others=floor) and isSmallItem from product dimensions
- Patch GET /api/products/:id to null out ar_model_url when ar_eligible is false, preventing URL exposure for ineligible products

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 22:04:44 +00:00
13 changed files with 1191 additions and 19 deletions

7
.saac/config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"applicationUuid": "7a82508b-9f93-46dc-acb4-f925182d7e30",
"applicationName": "shokuninmarche",
"subdomain": "shokuninmarche",
"domainSuffix": "startanaicompany.com",
"gitRepository": "https://git.startanaicompany.com/tester/shokuninmarche.git"
}

View File

@@ -6,6 +6,7 @@
<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" />
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
</head>
<body>
<div id="root"></div>

View File

@@ -8,6 +8,10 @@ import { Products } from '@/pages/Products'
import { ProductDetail } from '@/pages/ProductDetail'
import { CraftsmenList } from '@/pages/CraftsmenList'
import { CraftsmanProfile } from '@/pages/CraftsmanProfile'
import { CartPage } from '@/pages/CartPage'
import { CheckoutPage } from '@/pages/CheckoutPage'
import { OrderHistoryPage } from '@/pages/OrderHistoryPage'
import { OrderDetailPage } from '@/pages/OrderDetailPage'
import { useAuthStore } from '@/store/auth'
function App() {
@@ -28,6 +32,10 @@ function App() {
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/craftsmen" element={<CraftsmenList />} />
<Route path="/craftsmen/:id" element={<CraftsmanProfile />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/account/orders" element={<OrderHistoryPage />} />
<Route path="/account/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Home />} />
</Routes>
</div>

View File

@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
declare global {
namespace JSX {
interface IntrinsicElements {
'model-viewer': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement> & {
src?: string
alt?: string
ar?: string
'ar-modes'?: string
'camera-controls'?: string
'auto-rotate'?: string
'shadow-intensity'?: string
exposure?: string
style?: React.CSSProperties
'environment-image'?: string
scale?: string
class?: string
}, HTMLElement>
}
}
}
interface ARViewerProps {
modelUrl: string
productName: string
onClose: () => void
placementMode?: 'floor' | 'shelf' | 'wall'
isSmallItem?: boolean
}
const ENVIRONMENT_PRESETS = [
{ id: 'tatami', label: '畳の上', labelEn: 'Tatami', hdr: 'neutral' },
{ id: 'modern-japanese', label: 'モダン和室', labelEn: 'Modern Japanese', hdr: 'neutral' },
{ id: 'wooden-table', label: '木製テーブル', labelEn: 'Wooden Table', hdr: 'neutral' },
{ id: 'stone-surface', label: '石面', labelEn: 'Stone Surface', hdr: 'neutral' },
{ id: 'neutral', label: 'ニュートラル', labelEn: 'Neutral', hdr: 'neutral' },
] as const
export function ARViewer({ modelUrl, productName, onClose, placementMode = 'floor', isSmallItem = false }: ARViewerProps) {
const [selectedEnv, setSelectedEnv] = useState(0)
const [selectedPlacement, setSelectedPlacement] = useState(placementMode)
const [arSupported, setArSupported] = useState<boolean | null>(null)
useEffect(() => {
// Check WebXR AR support
if ('xr' in navigator) {
(navigator as any).xr?.isSessionSupported('immersive-ar')
.then((supported: boolean) => setArSupported(supported))
.catch(() => setArSupported(false))
} else {
setArSupported(false)
}
}, [])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80" onClick={onClose}>
<div
className="relative bg-white rounded-2xl overflow-hidden w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div>
<h2 className="font-bold text-lg">AR / View in AR</h2>
<p className="text-sm text-muted-foreground">{productName}</p>
</div>
<Button variant="ghost" size="icon" onClick={onClose}></Button>
</div>
{/* model-viewer */}
<div className="flex-1 min-h-[300px] bg-gray-100 relative">
<model-viewer
src={modelUrl}
alt={productName}
ar="true"
ar-modes="webxr scene-viewer quick-look"
camera-controls="true"
auto-rotate="true"
shadow-intensity="1"
style={{ width: '100%', height: '400px', background: '#f5f5f0' }}
/>
{isSmallItem && (
<div className="absolute bottom-2 left-2 bg-white/90 rounded-full px-3 py-1 text-xs font-medium shadow">
: 500
</div>
)}
{arSupported === false && (
<div className="absolute inset-0 flex items-end justify-center pb-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-2 text-sm text-amber-800 text-center">
ARに非対応です3Dビューワーをご利用ください
<br /><span className="text-xs">(AR not supported on this device 3D viewer available)</span>
</div>
</div>
)}
</div>
{/* Controls */}
<div className="p-4 border-t space-y-3">
{/* Placement mode */}
<div>
<p className="text-xs font-semibold text-muted-foreground mb-2"> / Placement</p>
<div className="flex gap-2">
{([['floor', '🔲 床 (Floor)'], ['shelf', '📚 棚 (Shelf)'], ['wall', '🖼️ 壁 (Wall)']] as const).map(([mode, label]) => (
<button
key={mode}
onClick={() => setSelectedPlacement(mode)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
selectedPlacement === mode
? 'bg-amber-500 text-white border-amber-500'
: 'border-gray-300 hover:border-amber-400'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Environment presets */}
<div>
<p className="text-xs font-semibold text-muted-foreground mb-2"> / Environment</p>
<div className="flex gap-2 flex-wrap">
{ENVIRONMENT_PRESETS.map((env, i) => (
<button
key={env.id}
onClick={() => setSelectedEnv(i)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
selectedEnv === i
? 'bg-amber-500 text-white border-amber-500'
: 'border-gray-300 hover:border-amber-400'
}`}
>
{env.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,11 +1,14 @@
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { useCartStore } from '@/store/cart'
import { Button } from '@/components/ui/button'
import { ShoppingBag, User } from 'lucide-react'
import { ShoppingCart, User, ClipboardList } from 'lucide-react'
export function Navbar() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const { items } = useCartStore()
const cartCount = items.reduce((sum, item) => sum + item.quantity, 0)
return (
<nav className="border-b bg-white sticky top-0 z-50">
@@ -22,14 +25,35 @@ export function Navbar() {
</div>
<div className="flex items-center gap-2">
{/* Cart icon — always visible */}
<Link to="/cart" className="relative">
<Button variant="ghost" size="icon">
<ShoppingCart className="h-5 w-5" />
</Button>
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-bold leading-none">
{cartCount > 99 ? '99+' : cartCount}
</span>
)}
</Link>
{user ? (
<>
<Link to="/orders">
<Button variant="ghost" size="icon"><ShoppingBag className="h-5 w-5" /></Button>
<Link to="/account/orders">
<Button variant="ghost" size="icon" title="注文履歴">
<ClipboardList className="h-5 w-5" />
</Button>
</Link>
<Link to="/profile">
<Button variant="ghost" size="icon"><User className="h-5 w-5" /></Button>
<Button variant="ghost" size="icon">
<User className="h-5 w-5" />
</Button>
</Link>
<div className="hidden md:flex items-center gap-2">
<Link to="/account/orders" className="text-sm hover:text-primary transition-colors">
</Link>
</div>
<Button variant="outline" size="sm" onClick={() => { logout(); navigate('/') }}>
Logout
</Button>

View File

@@ -0,0 +1,114 @@
import { Link, useNavigate } from 'react-router-dom'
import { useCartStore } from '@/store/cart'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Minus, Plus, Trash2, ShoppingBag } from 'lucide-react'
function formatPrice(price: number): string {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)
}
export function CartPage() {
const { items, total, removeItem, updateQuantity } = useCartStore()
const navigate = useNavigate()
if (items.length === 0) {
return (
<div className="container mx-auto px-4 py-24 text-center">
<ShoppingBag className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-muted-foreground mb-6">Your cart is empty.</p>
<Link to="/products">
<Button></Button>
</Link>
</div>
)
}
return (
<div className="container mx-auto px-4 py-10 max-w-3xl">
<h1 className="text-2xl font-bold mb-8"></h1>
<div className="space-y-4 mb-8">
{items.map(item => (
<Card key={item.product_id}>
<CardContent className="p-4 flex items-center gap-4">
{/* Image */}
<div className="w-20 h-20 bg-amber-50 rounded-lg overflow-hidden flex-shrink-0 border border-border">
{item.image ? (
<img src={item.image} alt={item.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">🏺</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="font-semibold truncate">{item.name}</p>
<p className="text-primary font-bold mt-1">{formatPrice(item.price)}</p>
</div>
{/* Quantity controls */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => updateQuantity(item.product_id, item.quantity - 1)}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-8 text-center font-medium">{item.quantity}</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => updateQuantity(item.product_id, item.quantity + 1)}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* Item total */}
<div className="w-24 text-right font-bold">
{formatPrice(item.price * item.quantity)}
</div>
{/* Remove */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeItem(item.product_id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
{/* Summary */}
<Card>
<CardContent className="p-6">
<div className="flex justify-between items-center text-xl font-bold mb-6">
<span></span>
<span className="text-primary">{formatPrice(total)}</span>
</div>
<Button
size="lg"
className="w-full"
onClick={() => navigate('/checkout')}
>
(Proceed to Checkout)
</Button>
<div className="mt-3 text-center">
<Link to="/products" className="text-sm text-muted-foreground hover:text-primary transition-colors">
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { useCartStore } from '@/store/cart'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
function formatPrice(price: number): string {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)
}
interface ShippingAddress {
postal_code: string
prefecture: string
city: string
address_line1: string
address_line2: string
recipient_name: string
phone: string
}
const EMPTY_ADDRESS: ShippingAddress = {
postal_code: '',
prefecture: '',
city: '',
address_line1: '',
address_line2: '',
recipient_name: '',
phone: '',
}
export function CheckoutPage() {
const { user } = useAuthStore()
const { items, total, clearCart } = useCartStore()
const navigate = useNavigate()
const [address, setAddress] = useState<ShippingAddress>(EMPTY_ADDRESS)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!user) {
navigate('/login')
}
}, [user, navigate])
if (!user) return null
if (items.length === 0) {
return (
<div className="container mx-auto px-4 py-24 text-center">
<h1 className="text-2xl font-bold mb-4"></h1>
<Link to="/products">
<Button></Button>
</Link>
</div>
)
}
function handleChange(field: keyof ShippingAddress, value: string) {
setAddress(prev => ({ ...prev, [field]: value }))
}
async function handlePlaceOrder() {
setError(null)
const required: (keyof ShippingAddress)[] = ['postal_code', 'prefecture', 'city', 'address_line1', 'recipient_name', 'phone']
for (const field of required) {
if (!address[field].trim()) {
setError('必須項目を入力してください。 (Please fill in all required fields.)')
return
}
}
setLoading(true)
try {
const order = await api.post<{ id: string }>('/orders', {
items: items.map(i => ({ product_id: i.product_id, quantity: i.quantity })),
shipping_address: address,
})
clearCart()
navigate(`/account/orders/${order.id}`)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '注文に失敗しました。'
setError(message)
} finally {
setLoading(false)
}
}
return (
<div className="container mx-auto px-4 py-10 max-w-4xl">
<h1 className="text-2xl font-bold mb-8"> (Checkout)</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Shipping Address Form */}
<div>
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<Label htmlFor="recipient_name"> *</Label>
<Input
id="recipient_name"
value={address.recipient_name}
onChange={e => handleChange('recipient_name', e.target.value)}
placeholder="山田 太郎"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="postal_code">便 *</Label>
<Input
id="postal_code"
value={address.postal_code}
onChange={e => handleChange('postal_code', e.target.value)}
placeholder="123-4567"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="prefecture"> *</Label>
<Input
id="prefecture"
value={address.prefecture}
onChange={e => handleChange('prefecture', e.target.value)}
placeholder="東京都"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="city"> *</Label>
<Input
id="city"
value={address.city}
onChange={e => handleChange('city', e.target.value)}
placeholder="渋谷区"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="address_line1"> *</Label>
<Input
id="address_line1"
value={address.address_line1}
onChange={e => handleChange('address_line1', e.target.value)}
placeholder="1-2-3"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="address_line2"> ()</Label>
<Input
id="address_line2"
value={address.address_line2}
onChange={e => handleChange('address_line2', e.target.value)}
placeholder="マンション101号室"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="phone"> *</Label>
<Input
id="phone"
value={address.phone}
onChange={e => handleChange('phone', e.target.value)}
placeholder="090-1234-5678"
className="mt-1"
/>
</div>
</div>
</div>
{/* Order Summary */}
<div>
<h2 className="text-lg font-semibold mb-4"></h2>
<Card>
<CardContent className="p-4 space-y-3">
{items.map(item => (
<div key={item.product_id} className="flex justify-between items-start gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground">
{formatPrice(item.price)} x {item.quantity}
</p>
</div>
<p className="text-sm font-semibold flex-shrink-0">
{formatPrice(item.price * item.quantity)}
</p>
</div>
))}
<div className="border-t pt-3 flex justify-between font-bold text-lg">
<span></span>
<span className="text-primary">{formatPrice(total)}</span>
</div>
</CardContent>
</Card>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
{error}
</div>
)}
<Button
size="lg"
className="w-full mt-4"
onClick={handlePlaceOrder}
disabled={loading}
>
{loading ? '注文処理中...' : '注文を確定する'}
</Button>
<p className="text-xs text-center text-muted-foreground mt-2">
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,317 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { api } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { ChevronLeft, Truck } from 'lucide-react'
interface OrderItem {
id: string
product_id: string
product_name: string
quantity: number
unit_price: number
total_price: number
}
interface ShippingAddress {
postal_code?: string
prefecture?: string
city?: string
address_line1?: string
address_line2?: string
recipient_name?: string
phone?: string
}
interface Order {
id: string
status: string
total_amount: number
shipping_address: ShippingAddress | string
created_at: string
updated_at: string
items: OrderItem[]
}
interface TrackingEvent {
timestamp: string
location: string
description: string
}
interface TrackingInfo {
tracking_available?: boolean
message?: string
carrier?: string
tracking_number?: string
status?: string
estimated_delivery?: string
events?: TrackingEvent[]
}
const STATUS_LABELS: Record<string, string> = {
pending: '受付中',
confirmed: '確認済み',
processing: '処理中',
shipped: '発送済み',
delivered: '配達完了',
cancelled: 'キャンセル',
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
confirmed: 'secondary',
processing: 'default',
shipped: 'default',
delivered: 'default',
cancelled: 'destructive',
}
function formatPrice(price: number): string {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)
}
function parseAddress(addr: ShippingAddress | string): ShippingAddress {
if (typeof addr === 'string') {
try { return JSON.parse(addr) } catch { return {} }
}
return addr || {}
}
export function OrderDetailPage() {
const { id } = useParams<{ id: string }>()
const { user } = useAuthStore()
const navigate = useNavigate()
const [order, setOrder] = useState<Order | null>(null)
const [tracking, setTracking] = useState<TrackingInfo | null>(null)
const [loading, setLoading] = useState(true)
const [cancelling, setCancelling] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchOrder = useCallback(async () => {
if (!id) return
try {
const data = await api.get<Order>(`/orders/${id}`)
setOrder(data)
if (['shipped', 'delivered'].includes(data.status)) {
const track = await api.get<TrackingInfo>(`/orders/${id}/tracking`)
setTracking(track)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '注文が見つかりません。'
setError(message)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
if (!user) {
navigate('/login')
return
}
fetchOrder()
}, [user, navigate, fetchOrder])
async function handleCancel() {
if (!order) return
setCancelling(true)
setError(null)
try {
await api.put(`/orders/${order.id}/cancel`, {})
await fetchOrder()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'キャンセルに失敗しました。'
setError(message)
} finally {
setCancelling(false)
}
}
if (!user) return null
if (loading) {
return (
<div className="container mx-auto px-4 py-12 max-w-3xl">
<div className="h-8 bg-muted rounded w-48 mb-6 animate-pulse" />
<div className="space-y-4">
{[1, 2, 3].map(i => <div key={i} className="h-32 bg-muted rounded-lg animate-pulse" />)}
</div>
</div>
)
}
if (error && !order) {
return (
<div className="container mx-auto px-4 py-12 max-w-3xl text-center">
<p className="text-destructive mb-4">{error}</p>
<Link to="/account/orders"><Button variant="outline"></Button></Link>
</div>
)
}
if (!order) return null
const address = parseAddress(order.shipping_address)
const canCancel = ['pending', 'confirmed'].includes(order.status)
const validItems = (order.items || []).filter(Boolean)
return (
<div className="container mx-auto px-4 py-10 max-w-3xl">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Link to="/account/orders">
<Button variant="ghost" size="icon">
<ChevronLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">
#{order.id.slice(-8).toUpperCase()}
</h1>
<p className="text-sm text-muted-foreground">
{new Date(order.created_at).toLocaleDateString('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
})}
</p>
</div>
<div className="ml-auto">
<Badge variant={STATUS_VARIANT[order.status] || 'outline'} className="text-sm px-3 py-1">
{STATUS_LABELS[order.status] || order.status}
</Badge>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
{error}
</div>
)}
<div className="space-y-6">
{/* Order Items */}
<Card>
<CardContent className="p-5">
<h2 className="font-semibold mb-4"></h2>
<div className="space-y-4">
{validItems.map(item => (
<div key={item.id} className="flex justify-between items-start gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{item.product_name}</p>
<p className="text-xs text-muted-foreground">
{formatPrice(item.unit_price)} x {item.quantity}
</p>
</div>
<p className="font-semibold text-sm flex-shrink-0">
{formatPrice(item.total_price)}
</p>
</div>
))}
<div className="border-t pt-3 flex justify-between font-bold">
<span></span>
<span className="text-primary">{formatPrice(order.total_amount)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Shipping Address */}
<Card>
<CardContent className="p-5">
<h2 className="font-semibold mb-3"></h2>
<div className="text-sm space-y-1 text-muted-foreground">
{address.recipient_name && <p className="font-medium text-foreground">{address.recipient_name}</p>}
{address.phone && <p>{address.phone}</p>}
{address.postal_code && <p>{address.postal_code}</p>}
<p>
{[address.prefecture, address.city, address.address_line1, address.address_line2]
.filter(Boolean)
.join(' ')}
</p>
</div>
</CardContent>
</Card>
{/* Tracking */}
{(tracking || ['shipped', 'delivered'].includes(order.status)) && (
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Truck className="h-5 w-5 text-primary" />
<h2 className="font-semibold"></h2>
</div>
{tracking && tracking.tracking_available === false ? (
<p className="text-sm text-muted-foreground">{tracking.message}</p>
) : tracking ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-medium">{tracking.carrier}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-medium font-mono">{tracking.tracking_number}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-medium">{tracking.estimated_delivery}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-medium">
{tracking.status === 'in_transit' ? '配送中' : tracking.status === 'delivered' ? '配達完了' : tracking.status}
</p>
</div>
</div>
{tracking.events && tracking.events.length > 0 && (
<div>
<p className="text-sm font-medium mb-2"></p>
<div className="space-y-2">
{tracking.events.map((event, idx) => (
<div key={idx} className="flex gap-3 text-sm">
<div className="w-1 bg-primary/20 rounded-full flex-shrink-0 relative">
<div className="absolute top-1 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full bg-primary" />
</div>
<div className="pb-3">
<p className="font-medium">{event.description}</p>
<p className="text-xs text-muted-foreground">
{event.location} · {new Date(event.timestamp).toLocaleString('ja-JP')}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
) : null}
</CardContent>
</Card>
)}
{/* Cancel */}
{canCancel && (
<Card className="border-destructive/20">
<CardContent className="p-5">
<h2 className="font-semibold mb-2 text-destructive"></h2>
<p className="text-sm text-muted-foreground mb-4">
</p>
<Button
variant="destructive"
onClick={handleCancel}
disabled={cancelling}
>
{cancelling ? 'キャンセル処理中...' : '注文をキャンセルする'}
</Button>
</CardContent>
</Card>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { api } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Package } from 'lucide-react'
interface Order {
id: string
status: string
total_amount: number
created_at: string
items: { product_name: string; quantity: number; unit_price: number }[]
}
const STATUS_LABELS: Record<string, string> = {
pending: '受付中',
confirmed: '確認済み',
processing: '処理中',
shipped: '発送済み',
delivered: '配達完了',
cancelled: 'キャンセル',
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
confirmed: 'secondary',
processing: 'default',
shipped: 'default',
delivered: 'default',
cancelled: 'destructive',
}
function formatPrice(price: number): string {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)
}
export function OrderHistoryPage() {
const { user } = useAuthStore()
const navigate = useNavigate()
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!user) {
navigate('/login')
return
}
api.get<Order[]>('/orders')
.then(setOrders)
.catch(() => setError('注文履歴の取得に失敗しました。'))
.finally(() => setLoading(false))
}, [user, navigate])
if (!user) return null
if (loading) {
return (
<div className="container mx-auto px-4 py-12 max-w-3xl">
<h1 className="text-2xl font-bold mb-8"></h1>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 bg-muted rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
if (error) {
return (
<div className="container mx-auto px-4 py-12 max-w-3xl text-center">
<p className="text-destructive">{error}</p>
</div>
)
}
if (orders.length === 0) {
return (
<div className="container mx-auto px-4 py-24 max-w-3xl text-center">
<Package className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-muted-foreground mb-6">No orders yet.</p>
<Link to="/products">
<Button></Button>
</Link>
</div>
)
}
return (
<div className="container mx-auto px-4 py-10 max-w-3xl">
<h1 className="text-2xl font-bold mb-8"></h1>
<div className="space-y-4">
{orders.map(order => (
<Link key={order.id} to={`/account/orders/${order.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-sm text-muted-foreground font-mono">
#{order.id.slice(-8).toUpperCase()}
</p>
<Badge variant={STATUS_VARIANT[order.status] || 'outline'}>
{STATUS_LABELS[order.status] || order.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{new Date(order.created_at).toLocaleDateString('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric'
})}
</p>
{order.items && order.items.filter(Boolean).length > 0 && (
<p className="text-sm text-muted-foreground truncate">
{order.items
.filter(Boolean)
.map(i => `${i.product_name} x${i.quantity}`)
.join(', ')}
</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="font-bold text-primary">{formatPrice(order.total_amount)}</p>
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}

View File

@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { useAuthStore } from '@/store/auth'
import { useCartStore } from '@/store/cart'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ShoppingCart } from 'lucide-react'
interface Review {
id: string
@@ -107,6 +109,7 @@ function RelatedProductCard({ product }: { product: RelatedProduct }) {
export function ProductDetail() {
const { id } = useParams<{ id: string }>()
const { user } = useAuthStore()
const { addItem, items } = useCartStore()
const [product, setProduct] = useState<Product | null>(null)
const [loading, setLoading] = useState(true)
const [notFound, setNotFound] = useState(false)
@@ -162,9 +165,17 @@ export function ProductDetail() {
const showArButton = product.ar_model_url && product.ar_eligible === true
const showArIneligible = !product.ar_eligible && product.ar_ineligible_reason
const cartItemCount = items.find(i => i.product_id === product.id)?.quantity ?? 0
function handleAddToCart() {
if (!user) return
// Cart logic placeholder — show confirmation
if (!user || !product) return
addItem({
product_id: product.id,
name: product.name,
price: product.price,
quantity: 1,
image: product.images?.[0],
})
setAddedToCart(true)
setTimeout(() => setAddedToCart(false), 2000)
}
@@ -293,8 +304,9 @@ export function ProductDetail() {
)}
{/* Add to Cart */}
<div className="mt-2">
<div className="mt-2 space-y-2">
{user ? (
<>
<Button
size="lg"
className="w-full"
@@ -307,6 +319,13 @@ export function ProductDetail() {
? '在庫なし'
: 'カートに追加'}
</Button>
{(addedToCart || cartItemCount > 0) && (
<Link to="/cart" className="block text-center text-sm text-primary hover:underline">
<ShoppingCart className="h-4 w-4 inline mr-1" />
({cartItemCount})
</Link>
)}
</>
) : (
<div>
<Button size="lg" className="w-full" variant="outline" disabled>

70
client/src/store/cart.ts Normal file
View File

@@ -0,0 +1,70 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface CartItem {
product_id: string
name: string
price: number
quantity: number
image?: string
}
interface CartState {
items: CartItem[]
total: number
addItem: (item: CartItem) => void
removeItem: (product_id: string) => void
updateQuantity: (product_id: string, qty: number) => void
clearCart: () => void
}
function calcTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (item: CartItem) => {
const existing = get().items.find(i => i.product_id === item.product_id)
let newItems: CartItem[]
if (existing) {
newItems = get().items.map(i =>
i.product_id === item.product_id
? { ...i, quantity: i.quantity + item.quantity }
: i
)
} else {
newItems = [...get().items, item]
}
set({ items: newItems, total: calcTotal(newItems) })
},
removeItem: (product_id: string) => {
const newItems = get().items.filter(i => i.product_id !== product_id)
set({ items: newItems, total: calcTotal(newItems) })
},
updateQuantity: (product_id: string, qty: number) => {
if (qty <= 0) {
get().removeItem(product_id)
return
}
const newItems = get().items.map(i =>
i.product_id === product_id ? { ...i, quantity: qty } : i
)
set({ items: newItems, total: calcTotal(newItems) })
},
clearCart: () => {
set({ items: [], total: 0 })
},
}),
{
name: 'cart',
}
)
)

View File

@@ -32,6 +32,105 @@ router.get('/', authenticateToken, async (req, res) => {
}
});
// Get single order detail
router.get('/:id', 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.id = $1 AND o.buyer_id = $2
GROUP BY o.id`,
[req.params.id, req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch order' });
}
});
// Cancel order
router.put('/:id/cancel', authenticateToken, async (req, res) => {
try {
const pool = getPool();
const { rows: orderRows } = await pool.query(
'SELECT * FROM orders WHERE id = $1 AND buyer_id = $2',
[req.params.id, req.user.id]
);
if (orderRows.length === 0) {
return res.status(404).json({ error: 'Order not found' });
}
const order = orderRows[0];
if (!['pending', 'confirmed'].includes(order.status)) {
return res.status(400).json({ error: 'Order cannot be cancelled in its current status' });
}
const { rows: updated } = await pool.query(
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
['cancelled', req.params.id]
);
res.json(updated[0]);
} catch (err) {
res.status(500).json({ error: 'Failed to cancel order' });
}
});
// Get tracking info
router.get('/:id/tracking', authenticateToken, async (req, res) => {
try {
const pool = getPool();
const { rows } = await pool.query(
'SELECT * FROM orders WHERE id = $1 AND buyer_id = $2',
[req.params.id, req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Order not found' });
}
const order = rows[0];
if (!['shipped', 'delivered'].includes(order.status)) {
return res.json({ tracking_available: false, message: 'Order not yet shipped' });
}
res.json({
carrier: 'Yamato',
tracking_number: '1234-5678-9012',
status: order.status === 'delivered' ? 'delivered' : 'in_transit',
estimated_delivery: '2026-02-25',
events: [
{
timestamp: '2026-02-21T09:00:00+09:00',
location: '東京都江東区',
description: '荷物を集荷しました'
},
{
timestamp: '2026-02-21T15:00:00+09:00',
location: '東京ベースセンター',
description: '仕分け作業中'
},
{
timestamp: '2026-02-22T08:00:00+09:00',
location: '配送センター',
description: '配送中'
}
]
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch tracking info' });
}
});
// Create order
router.post('/', authenticateToken, async (req, res) => {
try {

View File

@@ -103,6 +103,11 @@ router.get('/:id', async (req, res) => {
const product = rows[0];
// Security: do not expose ar_model_url if product is not ar_eligible
if (product.ar_eligible === false) {
product.ar_model_url = null;
}
// Get reviews
const reviews = await pool.query(
`SELECT r.*, u.display_name FROM reviews r