From cc695c6f287d264020fa61d3800e7027bf736181 Mon Sep 17 00:00:00 2001 From: tester Date: Sat, 21 Feb 2026 22:04:44 +0000 Subject: [PATCH] feat: AR Viewer - WebAR with model-viewer, 5 environment presets, placement modes - Add Google model-viewer 3.5.0 CDN script to client/index.html for WebXR/QuickLook AR - Create ARViewer.tsx modal component with 5 Japanese environment presets (tatami, modern-japanese, wooden-table, stone-surface, neutral), floor/shelf/wall placement mode selector, WebXR support detection, and small-item scale reference badge - Replace external AR link in ProductDetail.tsx with in-browser ARViewer modal; auto-detect placement mode from craft_category (textiles=wall, others=floor) and isSmallItem from product dimensions - Patch GET /api/products/:id to null out ar_model_url when ar_eligible is false, preventing URL exposure for ineligible products Co-Authored-By: Claude Sonnet 4.6 --- client/index.html | 1 + client/src/components/ARViewer.tsx | 144 +++++++++++++++++++++++++++++ client/src/pages/ProductDetail.tsx | 30 +++++- src/routes/products.js | 5 + 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 client/src/components/ARViewer.tsx diff --git a/client/index.html b/client/index.html index 5de6db7..450c232 100644 --- a/client/index.html +++ b/client/index.html @@ -6,6 +6,7 @@ 職人マルシェ - Japanese Craft Marketplace +
diff --git a/client/src/components/ARViewer.tsx b/client/src/components/ARViewer.tsx new file mode 100644 index 0000000..d7fba9c --- /dev/null +++ b/client/src/components/ARViewer.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' + +declare global { + namespace JSX { + interface IntrinsicElements { + 'model-viewer': React.DetailedHTMLProps & { + src?: string + alt?: string + ar?: string + 'ar-modes'?: string + 'camera-controls'?: string + 'auto-rotate'?: string + 'shadow-intensity'?: string + exposure?: string + style?: React.CSSProperties + 'environment-image'?: string + scale?: string + class?: string + }, HTMLElement> + } + } +} + +interface ARViewerProps { + modelUrl: string + productName: string + onClose: () => void + placementMode?: 'floor' | 'shelf' | 'wall' + isSmallItem?: boolean +} + +const ENVIRONMENT_PRESETS = [ + { id: 'tatami', label: '畳の上', labelEn: 'Tatami', hdr: 'neutral' }, + { id: 'modern-japanese', label: 'モダン和室', labelEn: 'Modern Japanese', hdr: 'neutral' }, + { id: 'wooden-table', label: '木製テーブル', labelEn: 'Wooden Table', hdr: 'neutral' }, + { id: 'stone-surface', label: '石面', labelEn: 'Stone Surface', hdr: 'neutral' }, + { id: 'neutral', label: 'ニュートラル', labelEn: 'Neutral', hdr: 'neutral' }, +] as const + +export function ARViewer({ modelUrl, productName, onClose, placementMode = 'floor', isSmallItem = false }: ARViewerProps) { + const [selectedEnv, setSelectedEnv] = useState(0) + const [selectedPlacement, setSelectedPlacement] = useState(placementMode) + const [arSupported, setArSupported] = useState(null) + + useEffect(() => { + // Check WebXR AR support + if ('xr' in navigator) { + (navigator as any).xr?.isSessionSupported('immersive-ar') + .then((supported: boolean) => setArSupported(supported)) + .catch(() => setArSupported(false)) + } else { + setArSupported(false) + } + }, []) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

AR で確認 / View in AR

+

{productName}

+
+ +
+ + {/* model-viewer */} +
+ + {isSmallItem && ( +
+ スケール参考: ≈ 500円玉 +
+ )} + {arSupported === false && ( +
+
+ このデバイスはARに非対応です。3Dビューワーをご利用ください。 +
(AR not supported on this device — 3D viewer available) +
+
+ )} +
+ + {/* Controls */} +
+ {/* Placement mode */} +
+

配置モード / Placement

+
+ {([['floor', '🔲 床 (Floor)'], ['shelf', '📚 棚 (Shelf)'], ['wall', '🖼️ 壁 (Wall)']] as const).map(([mode, label]) => ( + + ))} +
+
+ + {/* Environment presets */} +
+

環境 / Environment

+
+ {ENVIRONMENT_PRESETS.map((env, i) => ( + + ))} +
+
+
+
+
+ ) +} diff --git a/client/src/pages/ProductDetail.tsx b/client/src/pages/ProductDetail.tsx index 43ef298..8c18acd 100644 --- a/client/src/pages/ProductDetail.tsx +++ b/client/src/pages/ProductDetail.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from '@/store/auth' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { ARViewer } from '@/components/ARViewer' interface Review { id: string @@ -45,6 +46,7 @@ interface Product { ar_eligible?: boolean ar_ineligible_reason?: string stock_quantity?: number + dimensions?: { height: number; width: number; depth: number } reviews?: Review[] related_products?: RelatedProduct[] } @@ -111,6 +113,7 @@ export function ProductDetail() { const [loading, setLoading] = useState(true) const [notFound, setNotFound] = useState(false) const [addedToCart, setAddedToCart] = useState(false) + const [showAR, setShowAR] = useState(false) useEffect(() => { if (!id) return @@ -162,6 +165,14 @@ 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' + function handleAddToCart() { if (!user) return // Cart logic placeholder — show confirmation @@ -276,15 +287,13 @@ export function ProductDetail() { {/* AR */} {showArButton && ( - setShowAR(true)} 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" > AR で確認 View in AR - + )} {showArIneligible && ( @@ -368,6 +377,17 @@ export function ProductDetail() { )} + + {/* AR Viewer Modal */} + {showAR && product.ar_model_url && ( + setShowAR(false)} + placementMode={placementMode} + isSmallItem={isSmallItem} + /> + )} ) } diff --git a/src/routes/products.js b/src/routes/products.js index ad5b377..7aa925a 100644 --- a/src/routes/products.js +++ b/src/routes/products.js @@ -103,6 +103,11 @@ router.get('/:id', async (req, res) => { const product = rows[0]; + // Security: do not expose ar_model_url if product is not ar_eligible + if (product.ar_eligible === false) { + product.ar_model_url = null; + } + // Get reviews const reviews = await pool.query( `SELECT r.*, u.display_name FROM reviews r