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:
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>職人マルシェ - Japanese Craft Marketplace</title>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
144
client/src/components/ARViewer.tsx
Normal file
144
client/src/components/ARViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<a
|
||||
href={product.ar_model_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span>AR で確認</span>
|
||||
<span className="text-xs opacity-80">View in AR</span>
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
{showArIneligible && (
|
||||
<Badge variant="outline" className="w-fit text-muted-foreground border-muted-foreground/50 text-xs">
|
||||
@@ -368,6 +377,17 @@ export function ProductDetail() {
|
||||
</section>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user