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 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 22:04:44 +00:00
parent 4859999340
commit cc695c6f28
4 changed files with 175 additions and 5 deletions

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>職人マルシェ - Japanese Craft Marketplace</title> <title>職人マルシェ - Japanese Craft Marketplace</title>
<meta name="description" content="Discover authentic Japanese crafts directly from master craftsmen" /> <meta name="description" content="Discover authentic Japanese crafts directly from master craftsmen" />
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -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<React.HTMLAttributes<HTMLElement> & {
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<boolean | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80" onClick={onClose}>
<div
className="relative bg-white rounded-2xl overflow-hidden w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div>
<h2 className="font-bold text-lg">AR / View in AR</h2>
<p className="text-sm text-muted-foreground">{productName}</p>
</div>
<Button variant="ghost" size="icon" onClick={onClose}></Button>
</div>
{/* model-viewer */}
<div className="flex-1 min-h-[300px] bg-gray-100 relative">
<model-viewer
src={modelUrl}
alt={productName}
ar="true"
ar-modes="webxr scene-viewer quick-look"
camera-controls="true"
auto-rotate="true"
shadow-intensity="1"
style={{ width: '100%', height: '400px', background: '#f5f5f0' }}
/>
{isSmallItem && (
<div className="absolute bottom-2 left-2 bg-white/90 rounded-full px-3 py-1 text-xs font-medium shadow">
: 500
</div>
)}
{arSupported === false && (
<div className="absolute inset-0 flex items-end justify-center pb-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-2 text-sm text-amber-800 text-center">
ARに非対応です3Dビューワーをご利用ください
<br /><span className="text-xs">(AR not supported on this device 3D viewer available)</span>
</div>
</div>
)}
</div>
{/* Controls */}
<div className="p-4 border-t space-y-3">
{/* Placement mode */}
<div>
<p className="text-xs font-semibold text-muted-foreground mb-2"> / Placement</p>
<div className="flex gap-2">
{([['floor', '🔲 床 (Floor)'], ['shelf', '📚 棚 (Shelf)'], ['wall', '🖼️ 壁 (Wall)']] as const).map(([mode, label]) => (
<button
key={mode}
onClick={() => setSelectedPlacement(mode)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
selectedPlacement === mode
? 'bg-amber-500 text-white border-amber-500'
: 'border-gray-300 hover:border-amber-400'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Environment presets */}
<div>
<p className="text-xs font-semibold text-muted-foreground mb-2"> / Environment</p>
<div className="flex gap-2 flex-wrap">
{ENVIRONMENT_PRESETS.map((env, i) => (
<button
key={env.id}
onClick={() => setSelectedEnv(i)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
selectedEnv === i
? 'bg-amber-500 text-white border-amber-500'
: 'border-gray-300 hover:border-amber-400'
}`}
>
{env.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useAuthStore } from '@/store/auth'
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'
interface Review { interface Review {
id: string id: string
@@ -45,6 +46,7 @@ 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[]
} }
@@ -111,6 +113,7 @@ export function ProductDetail() {
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
@@ -162,6 +165,14 @@ 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 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) return
// Cart logic placeholder — show confirmation // Cart logic placeholder — show confirmation
@@ -276,15 +287,13 @@ export function ProductDetail() {
{/* AR */} {/* AR */}
{showArButton && ( {showArButton && (
<a <button
href={product.ar_model_url} onClick={() => setShowAR(true)}
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>
</a> </button>
)} )}
{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">
@@ -368,6 +377,17 @@ 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>
) )
} }

View File

@@ -103,6 +103,11 @@ router.get('/:id', async (req, res) => {
const product = rows[0]; 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 // Get reviews
const reviews = await pool.query( const reviews = await pool.query(
`SELECT r.*, u.display_name FROM reviews r `SELECT r.*, u.display_name FROM reviews r