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:
7
.saac/config.json
Normal file
7
.saac/config.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import { Products } from '@/pages/Products'
|
|||||||
import { ProductDetail } from '@/pages/ProductDetail'
|
import { ProductDetail } from '@/pages/ProductDetail'
|
||||||
import { CraftsmenList } from '@/pages/CraftsmenList'
|
import { CraftsmenList } from '@/pages/CraftsmenList'
|
||||||
import { CraftsmanProfile } from '@/pages/CraftsmanProfile'
|
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'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -28,6 +32,10 @@ function App() {
|
|||||||
<Route path="/products/:id" element={<ProductDetail />} />
|
<Route path="/products/:id" element={<ProductDetail />} />
|
||||||
<Route path="/craftsmen" element={<CraftsmenList />} />
|
<Route path="/craftsmen" element={<CraftsmenList />} />
|
||||||
<Route path="/craftsmen/:id" element={<CraftsmanProfile />} />
|
<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 />} />
|
<Route path="*" element={<Home />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useCartStore } from '@/store/cart'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ShoppingBag, User } from 'lucide-react'
|
import { ShoppingCart, User, ClipboardList } from 'lucide-react'
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { items } = useCartStore()
|
||||||
|
const cartCount = items.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b bg-white sticky top-0 z-50">
|
<nav className="border-b bg-white sticky top-0 z-50">
|
||||||
@@ -22,14 +25,35 @@ export function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<Link to="/orders">
|
<Link to="/account/orders">
|
||||||
<Button variant="ghost" size="icon"><ShoppingBag className="h-5 w-5" /></Button>
|
<Button variant="ghost" size="icon" title="注文履歴">
|
||||||
|
<ClipboardList className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/profile">
|
<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>
|
</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('/') }}>
|
<Button variant="outline" size="sm" onClick={() => { logout(); navigate('/') }}>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
114
client/src/pages/CartPage.tsx
Normal file
114
client/src/pages/CartPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
226
client/src/pages/CheckoutPage.tsx
Normal file
226
client/src/pages/CheckoutPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
317
client/src/pages/OrderDetailPage.tsx
Normal file
317
client/src/pages/OrderDetailPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
client/src/pages/OrderHistoryPage.tsx
Normal file
138
client/src/pages/OrderHistoryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useCartStore } from '@/store/cart'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ARViewer } from '@/components/ARViewer'
|
import { ShoppingCart } from 'lucide-react'
|
||||||
|
|
||||||
interface Review {
|
interface Review {
|
||||||
id: string
|
id: string
|
||||||
@@ -46,7 +47,6 @@ interface Product {
|
|||||||
ar_eligible?: boolean
|
ar_eligible?: boolean
|
||||||
ar_ineligible_reason?: string
|
ar_ineligible_reason?: string
|
||||||
stock_quantity?: number
|
stock_quantity?: number
|
||||||
dimensions?: { height: number; width: number; depth: number }
|
|
||||||
reviews?: Review[]
|
reviews?: Review[]
|
||||||
related_products?: RelatedProduct[]
|
related_products?: RelatedProduct[]
|
||||||
}
|
}
|
||||||
@@ -109,11 +109,11 @@ function RelatedProductCard({ product }: { product: RelatedProduct }) {
|
|||||||
export function ProductDetail() {
|
export function ProductDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
|
const { addItem, items } = useCartStore()
|
||||||
const [product, setProduct] = useState<Product | null>(null)
|
const [product, setProduct] = useState<Product | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [notFound, setNotFound] = useState(false)
|
const [notFound, setNotFound] = useState(false)
|
||||||
const [addedToCart, setAddedToCart] = useState(false)
|
const [addedToCart, setAddedToCart] = useState(false)
|
||||||
const [showAR, setShowAR] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -165,17 +165,17 @@ export function ProductDetail() {
|
|||||||
const showArButton = product.ar_model_url && product.ar_eligible === true
|
const showArButton = product.ar_model_url && product.ar_eligible === true
|
||||||
const showArIneligible = !product.ar_eligible && product.ar_ineligible_reason
|
const showArIneligible = !product.ar_eligible && product.ar_ineligible_reason
|
||||||
|
|
||||||
// Determine if item is small (max dimension < 10cm)
|
const cartItemCount = items.find(i => i.product_id === product.id)?.quantity ?? 0
|
||||||
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'
|
|
||||||
|
|
||||||
function handleAddToCart() {
|
function handleAddToCart() {
|
||||||
if (!user) return
|
if (!user || !product) return
|
||||||
// Cart logic placeholder — show confirmation
|
addItem({
|
||||||
|
product_id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
quantity: 1,
|
||||||
|
image: product.images?.[0],
|
||||||
|
})
|
||||||
setAddedToCart(true)
|
setAddedToCart(true)
|
||||||
setTimeout(() => setAddedToCart(false), 2000)
|
setTimeout(() => setAddedToCart(false), 2000)
|
||||||
}
|
}
|
||||||
@@ -287,13 +287,15 @@ export function ProductDetail() {
|
|||||||
|
|
||||||
{/* AR */}
|
{/* AR */}
|
||||||
{showArButton && (
|
{showArButton && (
|
||||||
<button
|
<a
|
||||||
onClick={() => setShowAR(true)}
|
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"
|
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>AR で確認</span>
|
||||||
<span className="text-xs opacity-80">View in AR</span>
|
<span className="text-xs opacity-80">View in AR</span>
|
||||||
</button>
|
</a>
|
||||||
)}
|
)}
|
||||||
{showArIneligible && (
|
{showArIneligible && (
|
||||||
<Badge variant="outline" className="w-fit text-muted-foreground border-muted-foreground/50 text-xs">
|
<Badge variant="outline" className="w-fit text-muted-foreground border-muted-foreground/50 text-xs">
|
||||||
@@ -302,8 +304,9 @@ export function ProductDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add to Cart */}
|
{/* Add to Cart */}
|
||||||
<div className="mt-2">
|
<div className="mt-2 space-y-2">
|
||||||
{user ? (
|
{user ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -316,6 +319,13 @@ export function ProductDetail() {
|
|||||||
? '在庫なし'
|
? '在庫なし'
|
||||||
: 'カートに追加'}
|
: 'カートに追加'}
|
||||||
</Button>
|
</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>
|
<div>
|
||||||
<Button size="lg" className="w-full" variant="outline" disabled>
|
<Button size="lg" className="w-full" variant="outline" disabled>
|
||||||
@@ -377,17 +387,6 @@ export function ProductDetail() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
70
client/src/store/cart.ts
Normal file
70
client/src/store/cart.ts
Normal 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',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
|
// Create order
|
||||||
router.post('/', authenticateToken, async (req, res) => {
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user