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
This commit is contained in:
2026-02-21 22:06:25 +00:00
parent cc695c6f28
commit 325d2944fe
10 changed files with 1045 additions and 43 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

@@ -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

@@ -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,10 +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 { ARViewer } from '@/components/ARViewer'
import { ShoppingCart } from 'lucide-react'
interface Review {
id: string
@@ -46,7 +47,6 @@ interface Product {
ar_eligible?: boolean
ar_ineligible_reason?: string
stock_quantity?: number
dimensions?: { height: number; width: number; depth: number }
reviews?: Review[]
related_products?: RelatedProduct[]
}
@@ -109,11 +109,11 @@ 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)
const [addedToCart, setAddedToCart] = useState(false)
const [showAR, setShowAR] = useState(false)
useEffect(() => {
if (!id) return
@@ -165,17 +165,17 @@ export function ProductDetail() {
const showArButton = product.ar_model_url && product.ar_eligible === true
const showArIneligible = !product.ar_eligible && product.ar_ineligible_reason
// Determine if item is small (max dimension < 10cm)
const isSmallItem = product.dimensions
? Math.max(product.dimensions.height, product.dimensions.width, product.dimensions.depth) < 10
: false
// Determine placement mode from craft category
const placementMode = product.craft_category === 'textiles' ? 'wall' : 'floor'
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)
}
@@ -287,13 +287,15 @@ export function ProductDetail() {
{/* AR */}
{showArButton && (
<button
onClick={() => setShowAR(true)}
<a
href={product.ar_model_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white font-semibold rounded-md transition-colors w-fit text-sm"
>
<span>AR </span>
<span className="text-xs opacity-80">View in AR</span>
</button>
</a>
)}
{showArIneligible && (
<Badge variant="outline" className="w-fit text-muted-foreground border-muted-foreground/50 text-xs">
@@ -302,20 +304,28 @@ export function ProductDetail() {
)}
{/* Add to Cart */}
<div className="mt-2">
<div className="mt-2 space-y-2">
{user ? (
<Button
size="lg"
className="w-full"
onClick={handleAddToCart}
disabled={product.stock_quantity === 0 || addedToCart}
>
{addedToCart
? 'カートに追加しました'
: product.stock_quantity === 0
? '在庫なし'
: 'カートに追加'}
</Button>
<>
<Button
size="lg"
className="w-full"
onClick={handleAddToCart}
disabled={product.stock_quantity === 0 || addedToCart}
>
{addedToCart
? 'カートに追加しました'
: product.stock_quantity === 0
? '在庫なし'
: 'カートに追加'}
</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>
@@ -377,17 +387,6 @@ export function ProductDetail() {
</section>
)}
</div>
{/* AR Viewer Modal */}
{showAR && product.ar_model_url && (
<ARViewer
modelUrl={product.ar_model_url}
productName={product.name}
onClose={() => setShowAR(false)}
placementMode={placementMode}
isSmallItem={isSmallItem}
/>
)}
</div>
)
}

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 {