- 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
227 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|