declare var QRCodeStyling: any; document.addEventListener('DOMContentLoaded', () => { // --- DATA --- const sampleProducts = [ { id: "shisa_demo", name: "【ARDemo】チェア", price: "¥10,000", description: "ARDemo用のチェア。", modelSrc: "models/Demo0001.glb", altText: "チェアの3Dモデル", environmentImage: "environments/brown_photostudio_01_1k.jpg", exposure: "1.0", hotspots: [ { slot: "hotspot-shisa-depth", position: "0.2275 0 -0.2325", normal: "0 0 1", text: "奥行: 45.5cm" }, { slot: "hotspot-shisa-width", position: "0.2275 0 0.2325", normal: "1 0 0", text: "横幅: 46.5cm" }, { slot: "hotspot-shisa-height", position: "0 0.83 -0.2325", normal: "0 1 0", text: "高さ:83cm" } ] }, { id: "tina_audio", name: "TINA AUDIO 1.0", price: "¥385,000", description: "TINA AUDIO 1.0(直径10cm高さ1m)", modelSrc: "models/tina_audio_1.glb", altText: "TINA AUDIO 1.0の3Dモデル", environmentImage: "environments/brown_photostudio_02_1k.hdr", exposure: "1.0", hotspots: [ { slot: "hotspot-tina-height", position: "0 0.984 0.057", normal: "0 0 1", text: "高さ(Height): 984mm" }, { slot: "hotspot-tina-diameter", position: "0.0575 0.5 0", normal: "1 0 0", text: "直径(Ø): 115mm" }, { slot: "hotspot-tina-basediameter", position: "0 0.01 0.11", normal: "0 0 1", text: "底板直径(Bottom Ø): 220mm" }, { slot: "hotspot-tina-topdiameter", position: "0 0.984 -0.057", normal: "0 0 -1", text: "天板直径(Top Ø): 114mm" } ] }, { id: "tina_audio_1_5", name: "TINA AUDIO 1.5", price: "¥517,000", description: "TINA AUDIO 1.5(直径10cm高さ1.5m)", modelSrc: "models/tina_audio_1_5.glb", altText: "TINA AUDIO 1.5の3Dモデル", environmentImage: "environments/brown_photostudio_02_1k.hdr", exposure: "1.0", hotspots: [ { slot: "hotspot-tina1.5-height", position: "0 1.457 0.057", normal: "0 0 1", text: "高さ(Height): 1457mm" }, { slot: "hotspot-tina1.5-diameter", position: "0.0575 0.75 0", normal: "1 0 0", text: "直径(Ø): 115mm" }, { slot: "hotspot-tina1.5-basediameter", position: "0 0.01 0.12", normal: "0 0 1", text: "底板直径(Bottom Ø): 240mm" }, { slot: "hotspot-tina1.5-topdiameter", position: "0 1.457 -0.057", normal: "0 0 -1", text: "天板直径(Top Ø): 114mm" } ] }, { id: "tina_audio_2", name: "TINA AUDIO 2.0", price: "¥715,000", description: "TINA AUDIO 2.0(直径10cm高さ2m)", modelSrc: "models/tina_audio_2.glb", altText: "TINA AUDIO 2.0の3Dモデル", environmentImage: "environments/brown_photostudio_02_1k.hdr", exposure: "1.0", hotspots: [ { slot: "hotspot-tina2.0-height", position: "0 1.945 0.115", normal: "0 0 1", text: "高さ(Height): 1945mm" }, { slot: "hotspot-tina2.0-diameter", position: "0.0575 1 0", normal: "1 0 0", text: "直径(Ø): 115mm" }, { slot: "hotspot-tina2.0-basediameter", position: "0 0.01 0.14", normal: "0 0 1", text: "底板直径(Bottom Ø): 280mm" }, { slot: "hotspot-tina2.0-topdiameter", position: "0 1.945 -0.115", normal: "0 0 -1", text: "天板直径(Top Ø): 230mm" } ] }, { id: "tina_audio_2_high_class", name: "TINA AUDIO 2.0 HIGH CLASS", price: "¥1,430,000", description: "TINA AUDIO 2.0(直径20cm高さ2m)", modelSrc: "models/tina_audio_2_HC.glb", altText: "TINA AUDIO 2.0 HIGH CLASSの3Dモデル", environmentImage: "environments/brown_photostudio_02_1k.hdr", exposure: "1.0", hotspots: [ { slot: "hotspot-tina2-height", position: "0 2.03 0.21", normal: "0 0 1", text: "高さ(Height): 2030mm" }, { slot: "hotspot-tina2-diameter", position: "0.1075 1 0", normal: "1 0 0", text: "直径(Ø): 215mm" }, { slot: "hotspot-tina2hc-basediameter", position: "0 0.01 0.175", normal: "0 0 1", text: "底板直径(Bottom Ø): 350mm" }, { slot: "hotspot-tina2hc-topdiameter", position: "0 2.03 -0.21", normal: "0 0 -1", text: "天板直径(Top Ø): 420mm" } ] }, { id: "kerberos_2025", name: "ケルベロス2025", price: "¥77,000", description: "[サイズ]約 幅12cm×高さ約10cm×奥行12㎝  重量約170g [素 材] 陶器", modelSrc: "models/ikutouen_0001_Kerberos.glb", altText: "ケルベロス2025の3Dモデル", environmentImage: "environments/brown_photostudio_01_1k.jpg", exposure: "1.0", variants: [ { name: "デフォルト", modelSrc: "models/ikutouen_0001_Kerberos.glb", icon: "models/ikutouen/ikutouen_0001_Kerberos.jpg" }, { name: "ピンク", modelSrc: "models/ikutouen_0001_Kerberos_pink.glb", icon: "models/ikutouen/ikutouen_0001_Kerberos_pink.jpg" }, { name: "黄色", modelSrc: "models/ikutouen_0001_Kerberos_yellow.glb", icon: "models/ikutouen/ikutouen_0001_Kerberos_yellow.jpg" }, ], hotspots: [ { slot: "hotspot-kerberos-width", position: "0.06 0.07 0.02", normal: "1 0 0", text: "幅12cm" }, { slot: "hotspot-kerberos-height", position: "0 0.09 0.04", normal: "0 1 0", text: "高さ約10cm" }, { slot: "hotspot-kerberos-depth", position: "0 0 -0.06", normal: "0 0 -1", text: "奥行12㎝" }, { slot: "hotspot-kerberos-weight", position: "0 0 0.06", normal: "0 0 1", text: "重量約170g" } ] }, { id: "large_flower_vase", name: "花器大", price: "¥33,000", description: "[バリエーション] 2色 白唐草線彫//黒釉唐草線彫 [サイズ] 口径約10.5cm× 高さ約25cm 重量約1300g [素材]陶器", modelSrc: "models/ikutouen_0002_Kaki.glb", altText: "花器大の3Dモデル", environmentImage: "environments/brown_photostudio_01_1k.jpg", exposure: "1.0", hotspots: [ { slot: "hotspot-vase-diameter", position: "0 0.24 0.0525", normal: "0 0 1", text: "口径約10.5cm" }, { slot: "hotspot-vase-height", position: "0 0.24 0", normal: "0 1 0", text: "高さ約25cm" }, { slot: "hotspot-vase-weight", position: "0 0 0.0425", normal: "0 0 1", text: "重量約1300g" } ] }, { id: "shisa_17cm_milky_white", name: "横向17cm 透かし彫り獅子 乳白", price: "¥264,000", description: "[サイズ] 幅約15cm×高さ約17cm×奥行19㎝ 重量約1.2kg (1匹あたり) [素材] 陶器", modelSrc: "models/ikutouen_0003_ShisaLR.glb", altText: "横向17cm透かし彫り獅子乳白の3Dモデル", environmentImage: "environments/brown_photostudio_01_1k.jpg", exposure: "1.0" } ]; // --- DOM ELEMENTS --- const appContainer = document.getElementById('app-container'); const viewerContainer = document.getElementById('viewer-container'); const contentContainer = document.getElementById('content-container'); const footer = document.getElementById('footer'); const modelViewer = document.getElementById('model-viewer') as any; const arButton = document.getElementById('ar-button'); const productSelector = document.getElementById('product-selector') as HTMLSelectElement; const productNameEl = document.getElementById('product-name'); const productPriceEl = document.getElementById('product-price'); const productDescriptionEl = document.getElementById('product-description'); const dimensionToggle = document.getElementById('dimension-toggle'); const dimensionToggleText = document.getElementById('dimension-toggle-text'); const dimensionToggleContainer = document.getElementById('dimension-toggle-container'); const variantControls = document.getElementById('variant-controls'); const qrCodeContainer = document.getElementById('qrcode-container'); const qrCodeProductName = document.getElementById('qrcode-product-name'); const qrCodeDownloadLink = document.getElementById('qrcode-download-link'); const qrCodeColorPicker = document.getElementById('qrcode-color-picker') as HTMLInputElement; // New QR Elements const qrTabs = document.querySelectorAll('.qr-tab'); const qrPanels = document.querySelectorAll('.qr-panel'); const qrTextInput = document.getElementById('qr-text') as HTMLInputElement; const qrTextApplyBtn = document.getElementById('qr-text-apply'); const qrTextColorHexInput = document.getElementById('qr-text-color-hex') as HTMLInputElement; const qrTextColorPicker = document.getElementById('qr-text-color') as HTMLInputElement; const qrImageUpload = document.getElementById('qr-image-upload') as HTMLInputElement; const qrImagePreviewContainer = document.getElementById('qr-image-preview-container'); const qrImagePreview = document.getElementById('qr-image-preview') as HTMLImageElement; const qrImageRemoveBtn = document.getElementById('qr-image-remove'); // --- STATE --- let showDimensions = true; let isModelOnlyView = false; let currentDisplayUrl = ''; let currentProductName = ''; let qrCodeInstance = null; let qrOverlay = { type: 'text', text: '', textColor: '#000000', image: null, }; // --- FUNCTIONS --- const escapeXml = (unsafe) => { return unsafe.replace(/[<>&'"]/g, c => { switch (c) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case '\'': return '''; case '"': return '"'; default: return c; } }); }; const getQrTextFontSize = (length) => { switch (length) { case 1: return 48; case 2: return 36; case 3: return 28; case 4: return 22; case 5: return 22; default: return 22; } }; const updateQRCode = () => { qrCodeContainer.innerHTML = ''; if (!currentDisplayUrl) return; const color = qrCodeColorPicker.value; const dotShape = (document.querySelector('input[name="dot-shape"]:checked') as HTMLInputElement).value; const dotsOptions: any = { color: color, type: dotShape }; const cornersSquareOptions: any = { color: color }; const cornersDotOptions: any = { color: color }; switch (dotShape) { case 'rounded': cornersSquareOptions.type = 'extra-rounded'; cornersDotOptions.type = 'dot'; break; case 'dots': cornersSquareOptions.type = 'dot'; cornersDotOptions.type = 'dot'; break; default: cornersSquareOptions.type = 'square'; cornersDotOptions.type = 'square'; break; } const options: any = { width: 280, height: 280, data: currentDisplayUrl, dotsOptions: dotsOptions, cornersSquareOptions: cornersSquareOptions, cornersDotOptions: cornersDotOptions, backgroundOptions: { color: "#ffffff" }, qrOptions: { errorCorrectionLevel: 'H' }, imageOptions: { imageSize: 0.4, margin: 4 } }; if (qrOverlay.type === 'image' && qrOverlay.image) { options.image = qrOverlay.image; options.imageOptions.hideBackgroundDots = true; } else if (qrOverlay.type === 'text' && qrOverlay.text) { const sanitizedText = escapeXml(qrOverlay.text); const fontSize = getQrTextFontSize(qrOverlay.text.length); const svg = `${sanitizedText}`; // Correctly encode SVG with 2-byte characters for btoa options.image = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); options.imageOptions.hideBackgroundDots = false; } try { qrCodeInstance = new QRCodeStyling(options); qrCodeInstance.append(qrCodeContainer); qrCodeProductName.textContent = currentProductName; } catch (e) { console.error("QR Code generation failed:", e); } }; const loadProduct = (productId, modelSrc?) => { const product = sampleProducts.find(p => p.id === productId); if (!product) return; // Update viewer attributes const srcToLoad = modelSrc || product.modelSrc; if (modelViewer.src !== srcToLoad) { modelViewer.src = srcToLoad; } modelViewer.alt = product.altText; modelViewer.environmentImage = product.environmentImage || ''; modelViewer.exposure = product.exposure || '1.0'; modelViewer.setAttribute('aria-label', `Interactive 3D model of ${product.name}`); productNameEl.textContent = product.name; productPriceEl.textContent = product.price; productDescriptionEl.textContent = product.description; // Update URL without reloading page const url = new URL(window.location.href); url.searchParams.set('product', productId); history.pushState({}, '', url); // Update hotspots const existingHotspots = modelViewer.querySelectorAll('[slot^="hotspot-"]'); existingHotspots.forEach(h => h.remove()); if (product.hotspots) { product.hotspots.forEach(hotspot => { const button = document.createElement('button'); button.slot = hotspot.slot; button.setAttribute('data-position', hotspot.position); button.setAttribute('data-normal', hotspot.normal); button.innerHTML = `
${hotspot.text}
`; modelViewer.appendChild(button); }); } const hasDimensions = Array.isArray(product.hotspots) && product.hotspots.length > 0; dimensionToggleContainer.style.display = hasDimensions ? 'block' : 'none'; if (hasDimensions && showDimensions) { modelViewer.classList.add('show-dimensions'); } else { modelViewer.classList.remove('show-dimensions'); } // Update Variants variantControls.innerHTML = ''; if (product.variants && product.variants.length > 1 && !isModelOnlyView) { product.variants.forEach(variant => { const button = document.createElement('button'); button.dataset.modelSrc = variant.modelSrc; const isActive = srcToLoad === variant.modelSrc; if (variant.icon) { // Image-based button button.innerHTML = `${variant.name}`; button.className = `w-12 h-12 p-1 rounded-full transition-all duration-200 shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-transparent ${isActive ? 'ring-blue-500' : 'ring-transparent hover:ring-blue-300'}`; button.setAttribute('aria-label', variant.name); } else { // Text-based button button.textContent = variant.name; button.className = `px-4 py-2 text-sm font-medium rounded-full transition-colors duration-200 shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-transparent focus:ring-blue-500 ${isActive ? 'bg-blue-600 text-white' : 'bg-white/80 backdrop-blur-sm text-gray-800 hover:bg-white'}`; } button.setAttribute('aria-pressed', String(isActive)); button.addEventListener('click', () => { loadProduct(productId, variant.modelSrc); }); variantControls.appendChild(button); }); } updateShareOptions(product); }; const updateShareOptions = (product) => { const basePageUrl = window.location.href; let cleanBaseUrl = ''; try { const urlObject = new URL(basePageUrl); cleanBaseUrl = urlObject.origin + urlObject.pathname; } catch (e) { const parts = basePageUrl.split(/[?#]/); cleanBaseUrl = parts[0]; } // URL for Display/QR const qrParams = new URLSearchParams(); qrParams.set('product', product.id); qrParams.set('view', 'model-only'); qrParams.set('utm_source', 'store_qr'); qrParams.set('utm_medium', 'offline_flyer'); const displayUrl = `${cleanBaseUrl}?${qrParams.toString()}`; // URL for Embed const embedParams = new URLSearchParams(); embedParams.set('product', product.id); embedParams.set('view', 'model-only'); embedParams.set('utm_source', 'ec_site'); embedParams.set('utm_medium', 'embed_button'); const embedUrl = `${cleanBaseUrl}?${embedParams.toString()}`; const embedCode = ``; (document.getElementById('displayUrl') as HTMLInputElement).value = displayUrl; (document.getElementById('embedCode') as HTMLTextAreaElement).value = embedCode; currentDisplayUrl = displayUrl; currentProductName = product.name; updateQRCode(); }; const handleCopy = async (button) => { const targetId = button.dataset.copyTarget; const textToCopy = (document.getElementById(targetId) as HTMLInputElement).value; const originalText = button.textContent; try { await navigator.clipboard.writeText(textToCopy); button.textContent = 'コピーしました!'; } catch (err) { button.textContent = 'コピー失敗'; } setTimeout(() => { button.textContent = originalText; }, 2000); }; const setupModelOnlyView = () => { isModelOnlyView = true; viewerContainer.className = 'w-full relative flex-shrink-0 h-full'; modelViewer.removeAttribute('auto-rotate'); contentContainer.style.display = 'none'; footer.style.display = 'none'; }; // --- INITIALIZATION --- const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('view') === 'model-only') { setupModelOnlyView(); } // Populate product selector sampleProducts.forEach(product => { const option = document.createElement('option'); option.value = product.id; option.textContent = product.name; productSelector.appendChild(option); }); // Set initial product const productIdFromUrl = urlParams.get('product'); const initialProductId = productIdFromUrl && sampleProducts.some(p => p.id === productIdFromUrl) ? productIdFromUrl : sampleProducts[0].id; productSelector.value = initialProductId; loadProduct(initialProductId); // --- EVENT LISTENERS --- productSelector.addEventListener('change', (e) => { loadProduct((e.target as HTMLSelectElement).value); }); modelViewer.addEventListener('load', () => { arButton.style.display = modelViewer.canActivateAR ? 'flex' : 'none'; }); arButton.addEventListener('click', () => { modelViewer.activateAR(); }); dimensionToggle.addEventListener('click', () => { showDimensions = !showDimensions; modelViewer.classList.toggle('show-dimensions', showDimensions); dimensionToggleText.textContent = showDimensions ? '寸法を隠す' : '寸法を表示'; }); document.querySelectorAll('.copy-button').forEach(button => { button.addEventListener('click', () => handleCopy(button as HTMLElement)); }); qrCodeColorPicker.addEventListener('input', updateQRCode); document.querySelectorAll('input[name="dot-shape"]').forEach(radio => { radio.addEventListener('change', updateQRCode); }); qrCodeDownloadLink.addEventListener('click', (e) => { e.preventDefault(); if (qrCodeInstance) { qrCodeInstance.download({ name: `qr_code_${currentProductName.replace(/\s+/g, '_')}`, extension: "png" }); } }); // --- NEW QR OVERLAY LISTENERS --- const setActiveTab = (tabId) => { if (!tabId) { qrOverlay.type = 'none'; } else { qrOverlay.type = tabId; } qrTabs.forEach((tab: HTMLElement) => { const isSelected = tab.dataset.tab === tabId; tab.classList.toggle('border-yellow-400', isSelected); tab.classList.toggle('border-transparent', !isSelected); if (isSelected && !(tab as HTMLButtonElement).disabled) { tab.style.backgroundColor = '#3d7370'; } else { tab.style.backgroundColor = ''; } }); qrPanels.forEach((panel: HTMLElement) => { panel.classList.toggle('hidden', panel.dataset.panel !== tabId); }); updateQRCode(); }; qrTabs.forEach((tab: HTMLElement) => { tab.addEventListener('click', () => { if (!(tab as HTMLButtonElement).disabled) { setActiveTab(tab.dataset.tab); } }); }); qrTextApplyBtn.addEventListener('click', () => { qrOverlay.text = qrTextInput.value; updateQRCode(); }); qrTextInput.addEventListener('change', () => { qrOverlay.text = qrTextInput.value; updateQRCode(); }); qrTextColorPicker.addEventListener('input', (e) => { qrOverlay.textColor = (e.target as HTMLInputElement).value; qrTextColorHexInput.value = (e.target as HTMLInputElement).value; if (qrOverlay.text) updateQRCode(); }); qrTextColorHexInput.addEventListener('change', (e) => { const newColor = (e.target as HTMLInputElement).value; if (/^#([0-9A-F]{3}){1,2}$/i.test(newColor)) { qrOverlay.textColor = newColor; qrTextColorPicker.value = newColor; if (qrOverlay.text) updateQRCode(); } }); qrImageUpload.addEventListener('change', (e) => { const file = (e.target as HTMLInputElement).files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { qrOverlay.image = event.target.result; qrImagePreview.src = event.target.result as string; qrImagePreviewContainer.classList.remove('hidden'); updateQRCode(); }; reader.readAsDataURL(file); } }); qrImageRemoveBtn.addEventListener('click', () => { qrOverlay.image = null; qrImageUpload.value = ''; qrImagePreviewContainer.classList.add('hidden'); qrImagePreview.src = ''; updateQRCode(); }); // Initial QR Overlay Setup setActiveTab('text'); });