Files
shokuninmarche/client/src/pages/CheckoutPage.tsx
tester 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

227 lines
7.2 KiB
TypeScript

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