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" />
|
<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>
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user