+ {/* Cart icon — always visible */}
+
+
+ {cartCount > 0 && (
+
+ {cartCount > 99 ? '99+' : cartCount}
+
+ )}
+
+
{user ? (
<>
-
-
+
+
-
+
+
+
+ 注文履歴
+
+
diff --git a/client/src/pages/CartPage.tsx b/client/src/pages/CartPage.tsx
new file mode 100644
index 0000000..9169c6f
--- /dev/null
+++ b/client/src/pages/CartPage.tsx
@@ -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 (
+
+
+
カートは空です
+
Your cart is empty.
+
+
+
+
+ )
+ }
+
+ return (
+
+
ショッピングカート
+
+
+ {items.map(item => (
+
+
+ {/* Image */}
+
+ {item.image ? (
+

+ ) : (
+
🏺
+ )}
+
+
+ {/* Info */}
+
+
{item.name}
+
{formatPrice(item.price)}
+
+
+ {/* Quantity controls */}
+
+
+
{item.quantity}
+
+
+
+ {/* Item total */}
+
+ {formatPrice(item.price * item.quantity)}
+
+
+ {/* Remove */}
+
+
+
+ ))}
+
+
+ {/* Summary */}
+
+
+
+ 合計
+ {formatPrice(total)}
+
+
+
+
+ 買い物を続ける
+
+
+
+
+
+ )
+}
diff --git a/client/src/pages/CheckoutPage.tsx b/client/src/pages/CheckoutPage.tsx
new file mode 100644
index 0000000..ed70269
--- /dev/null
+++ b/client/src/pages/CheckoutPage.tsx
@@ -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
(EMPTY_ADDRESS)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login')
+ }
+ }, [user, navigate])
+
+ if (!user) return null
+
+ if (items.length === 0) {
+ return (
+
+
カートが空です
+
+
+
+
+ )
+ }
+
+ 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 (
+
+
チェックアウト (Checkout)
+
+
+ {/* Shipping Address Form */}
+
+
+ {/* Order Summary */}
+
+
注文内容確認
+
+
+ {items.map(item => (
+
+
+
{item.name}
+
+ {formatPrice(item.price)} x {item.quantity}
+
+
+
+ {formatPrice(item.price * item.quantity)}
+
+
+ ))}
+
+ 合計
+ {formatPrice(total)}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ 注文確定後のキャンセルは注文詳細ページから可能です。
+
+
+
+
+ )
+}
diff --git a/client/src/pages/OrderDetailPage.tsx b/client/src/pages/OrderDetailPage.tsx
new file mode 100644
index 0000000..9055ca8
--- /dev/null
+++ b/client/src/pages/OrderDetailPage.tsx
@@ -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 = {
+ pending: '受付中',
+ confirmed: '確認済み',
+ processing: '処理中',
+ shipped: '発送済み',
+ delivered: '配達完了',
+ cancelled: 'キャンセル',
+}
+
+const STATUS_VARIANT: Record = {
+ 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(null)
+ const [tracking, setTracking] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [cancelling, setCancelling] = useState(false)
+ const [error, setError] = useState(null)
+
+ const fetchOrder = useCallback(async () => {
+ if (!id) return
+ try {
+ const data = await api.get(`/orders/${id}`)
+ setOrder(data)
+ if (['shipped', 'delivered'].includes(data.status)) {
+ const track = await api.get(`/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 (
+
+
+
+ {[1, 2, 3].map(i =>
)}
+
+
+ )
+ }
+
+ if (error && !order) {
+ return (
+
+ )
+ }
+
+ if (!order) return null
+
+ const address = parseAddress(order.shipping_address)
+ const canCancel = ['pending', 'confirmed'].includes(order.status)
+ const validItems = (order.items || []).filter(Boolean)
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ 注文 #{order.id.slice(-8).toUpperCase()}
+
+
+ {new Date(order.created_at).toLocaleDateString('ja-JP', {
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
+ })}
+
+
+
+
+ {STATUS_LABELS[order.status] || order.status}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Order Items */}
+
+
+ 注文商品
+
+ {validItems.map(item => (
+
+
+
{item.product_name}
+
+ {formatPrice(item.unit_price)} x {item.quantity}
+
+
+
+ {formatPrice(item.total_price)}
+
+
+ ))}
+
+ 合計
+ {formatPrice(order.total_amount)}
+
+
+
+
+
+ {/* Shipping Address */}
+
+
+ 配送先
+
+ {address.recipient_name &&
{address.recipient_name}
}
+ {address.phone &&
{address.phone}
}
+ {address.postal_code &&
〒{address.postal_code}
}
+
+ {[address.prefecture, address.city, address.address_line1, address.address_line2]
+ .filter(Boolean)
+ .join(' ')}
+
+
+
+
+
+ {/* Tracking */}
+ {(tracking || ['shipped', 'delivered'].includes(order.status)) && (
+
+
+
+
+
配送追跡
+
+ {tracking && tracking.tracking_available === false ? (
+ {tracking.message}
+ ) : tracking ? (
+
+
+
+
配送会社
+
{tracking.carrier}
+
+
+
追跡番号
+
{tracking.tracking_number}
+
+
+
配達予定日
+
{tracking.estimated_delivery}
+
+
+
ステータス
+
+ {tracking.status === 'in_transit' ? '配送中' : tracking.status === 'delivered' ? '配達完了' : tracking.status}
+
+
+
+ {tracking.events && tracking.events.length > 0 && (
+
+
配送履歴
+
+ {tracking.events.map((event, idx) => (
+
+
+
+
{event.description}
+
+ {event.location} · {new Date(event.timestamp).toLocaleString('ja-JP')}
+
+
+
+ ))}
+
+
+ )}
+
+ ) : null}
+
+
+ )}
+
+ {/* Cancel */}
+ {canCancel && (
+
+
+ 注文キャンセル
+
+ キャンセルは注文状態が「受付中」または「確認済み」の場合のみ可能です。
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/client/src/pages/OrderHistoryPage.tsx b/client/src/pages/OrderHistoryPage.tsx
new file mode 100644
index 0000000..c407000
--- /dev/null
+++ b/client/src/pages/OrderHistoryPage.tsx
@@ -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 = {
+ pending: '受付中',
+ confirmed: '確認済み',
+ processing: '処理中',
+ shipped: '発送済み',
+ delivered: '配達完了',
+ cancelled: 'キャンセル',
+}
+
+const STATUS_VARIANT: Record = {
+ 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([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login')
+ return
+ }
+ api.get('/orders')
+ .then(setOrders)
+ .catch(() => setError('注文履歴の取得に失敗しました。'))
+ .finally(() => setLoading(false))
+ }, [user, navigate])
+
+ if (!user) return null
+
+ if (loading) {
+ return (
+
+
注文履歴
+
+ {[1, 2, 3].map(i => (
+
+ ))}
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (orders.length === 0) {
+ return (
+
+
+
注文履歴がありません
+
No orders yet.
+
+
+
+
+ )
+ }
+
+ return (
+
+
注文履歴
+
+ {orders.map(order => (
+
+
+
+
+
+
+
+ #{order.id.slice(-8).toUpperCase()}
+
+
+ {STATUS_LABELS[order.status] || order.status}
+
+
+
+ {new Date(order.created_at).toLocaleDateString('ja-JP', {
+ year: 'numeric', month: 'long', day: 'numeric'
+ })}
+
+ {order.items && order.items.filter(Boolean).length > 0 && (
+
+ {order.items
+ .filter(Boolean)
+ .map(i => `${i.product_name} x${i.quantity}`)
+ .join(', ')}
+
+ )}
+
+
+
{formatPrice(order.total_amount)}
+
詳細を見る →
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/pages/ProductDetail.tsx b/client/src/pages/ProductDetail.tsx
index 8c18acd..3907ef6 100644
--- a/client/src/pages/ProductDetail.tsx
+++ b/client/src/pages/ProductDetail.tsx
@@ -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(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 && (
-
+
)}
{showArIneligible && (
@@ -302,20 +304,28 @@ export function ProductDetail() {
)}
{/* Add to Cart */}
-
+
{user ? (
-
+ <>
+
+ {(addedToCart || cartItemCount > 0) && (
+
+
+ カートを見る ({cartItemCount}点)
+
+ )}
+ >
) : (
-
- {/* AR Viewer Modal */}
- {showAR && product.ar_model_url && (
-
setShowAR(false)}
- placementMode={placementMode}
- isSmallItem={isSmallItem}
- />
- )}
)
}
diff --git a/client/src/store/cart.ts b/client/src/store/cart.ts
new file mode 100644
index 0000000..727bae0
--- /dev/null
+++ b/client/src/store/cart.ts
@@ -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
()(
+ 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',
+ }
+ )
+)
diff --git a/src/routes/orders.js b/src/routes/orders.js
index c1076c4..3d2e6aa 100644
--- a/src/routes/orders.js
+++ b/src/routes/orders.js
@@ -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 {