← back to Dw War Room
src/showroom/SearchOverlay.ts
733 lines
// SearchOverlay.ts — Live Shopify product search overlay for the 3D showroom
// Debounced search with glassmorphism dropdown matching the DW design system
// Uses a server-side proxy at /api/shopify/search to keep credentials safe
import * as THREE from 'three';
import type { Showroom } from './Showroom';
import { cropTexture } from './TextureCrop';
interface ShopifyProduct {
id: number;
title: string;
vendor: string;
product_type: string;
handle: string;
images: { src: string }[];
variants: { price: string; sku: string }[];
tags: string;
}
interface SearchResult {
id: number;
title: string;
vendor: string;
productType: string;
handle: string;
imageUrl: string;
price: string;
sku: string;
}
export class SearchOverlay {
private container: HTMLElement;
private inputEl!: HTMLInputElement;
private resultsEl!: HTMLElement;
private wrapperEl!: HTMLElement;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private isOpen = false;
private results: SearchResult[] = [];
private selectedIndex = -1;
private showroom: Showroom | null = null;
private loadingEl!: HTMLElement;
private emptyEl!: HTMLElement;
constructor(parent: HTMLElement) {
this.container = parent;
this.buildDom();
this.applyStyles();
this.bindEvents();
}
/** Optionally wire to a Showroom instance for wall display */
setShowroom(showroom: Showroom): void {
this.showroom = showroom;
}
private buildDom(): void {
// Wrapper — top-center overlay
this.wrapperEl = document.createElement('div');
this.wrapperEl.id = 'search-overlay';
this.wrapperEl.className = 'search-overlay';
// Search input group
const inputGroup = document.createElement('div');
inputGroup.className = 'search-input-group';
// Search icon
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
iconSvg.setAttribute('width', '16');
iconSvg.setAttribute('height', '16');
iconSvg.setAttribute('viewBox', '0 0 24 24');
iconSvg.setAttribute('fill', 'none');
iconSvg.setAttribute('stroke', 'currentColor');
iconSvg.setAttribute('stroke-width', '2');
iconSvg.setAttribute('class', 'search-icon');
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', '11');
circle.setAttribute('cy', '11');
circle.setAttribute('r', '8');
iconSvg.appendChild(circle);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', '21');
line.setAttribute('y1', '21');
line.setAttribute('x2', '16.65');
line.setAttribute('y2', '16.65');
iconSvg.appendChild(line);
inputGroup.appendChild(iconSvg);
// Input
this.inputEl = document.createElement('input');
this.inputEl.type = 'text';
this.inputEl.className = 'search-input';
this.inputEl.placeholder = 'Search products...';
this.inputEl.autocomplete = 'off';
this.inputEl.spellcheck = false;
inputGroup.appendChild(this.inputEl);
// Keyboard shortcut hint
const shortcut = document.createElement('span');
shortcut.className = 'search-shortcut';
shortcut.textContent = '/';
inputGroup.appendChild(shortcut);
this.wrapperEl.appendChild(inputGroup);
// Results dropdown
this.resultsEl = document.createElement('div');
this.resultsEl.className = 'search-results';
this.resultsEl.style.display = 'none';
// Loading indicator
this.loadingEl = document.createElement('div');
this.loadingEl.className = 'search-loading';
this.loadingEl.textContent = 'Searching...';
this.loadingEl.style.display = 'none';
this.resultsEl.appendChild(this.loadingEl);
// Empty state
this.emptyEl = document.createElement('div');
this.emptyEl.className = 'search-empty';
this.emptyEl.textContent = 'No products found';
this.emptyEl.style.display = 'none';
this.resultsEl.appendChild(this.emptyEl);
this.wrapperEl.appendChild(this.resultsEl);
this.container.appendChild(this.wrapperEl);
}
private bindEvents(): void {
// Input events
this.inputEl.addEventListener('input', () => this.onInputChange());
this.inputEl.addEventListener('focus', () => this.onFocus());
this.inputEl.addEventListener('keydown', (e) => this.onKeyDown(e));
// Close on outside click
document.addEventListener('click', (e) => {
if (!this.wrapperEl.contains(e.target as Node)) {
this.closeResults();
}
});
// Keyboard shortcut: "/" to focus search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== this.inputEl) {
e.preventDefault();
this.inputEl.focus();
}
if (e.key === 'Escape' && document.activeElement === this.inputEl) {
this.inputEl.blur();
this.closeResults();
}
});
}
private onInputChange(): void {
const query = this.inputEl.value.trim();
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (query.length < 2) {
this.closeResults();
return;
}
this.showLoading();
this.debounceTimer = setTimeout(() => {
this.searchShopify(query);
}, 300);
}
private onFocus(): void {
if (this.results.length > 0) {
this.openResults();
}
}
private onKeyDown(e: KeyboardEvent): void {
if (!this.isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
this.highlightResult();
break;
case 'ArrowUp':
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
this.highlightResult();
break;
case 'Enter':
e.preventDefault();
if (this.selectedIndex >= 0 && this.selectedIndex < this.results.length) {
this.selectResult(this.results[this.selectedIndex]);
}
break;
case 'Escape':
this.closeResults();
break;
}
}
private async searchShopify(query: string): Promise<void> {
try {
const res = await fetch(`/api/shopify/search?q=${encodeURIComponent(query)}&limit=10`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
const products: ShopifyProduct[] = data.products || [];
this.results = products.map(p => ({
id: p.id,
title: p.title,
vendor: p.vendor,
productType: p.product_type,
handle: p.handle,
imageUrl: p.images?.[0]?.src || '',
price: p.variants?.[0]?.price || '0.00',
sku: p.variants?.[0]?.sku || '',
}));
this.renderResults();
} catch (err) {
console.error('[SearchOverlay] Search failed:', err);
this.results = [];
this.showEmpty('Search unavailable');
}
}
private renderResults(): void {
// Clear previous result items (keep loading/empty elements)
const items = this.resultsEl.querySelectorAll('.search-result-item');
items.forEach(item => item.remove());
this.loadingEl.style.display = 'none';
if (this.results.length === 0) {
this.showEmpty('No products found');
return;
}
this.emptyEl.style.display = 'none';
this.selectedIndex = -1;
this.results.forEach((result, index) => {
const item = document.createElement('div');
item.className = 'search-result-item';
item.dataset.index = String(index);
// Product image
const imgWrap = document.createElement('div');
imgWrap.className = 'search-result-image';
if (result.imageUrl) {
const img = document.createElement('img');
// Use Shopify CDN resize for small thumbnails
img.src = result.imageUrl.replace(/\.([^.]+)$/, '_80x80.$1');
img.alt = result.title;
img.loading = 'lazy';
img.onerror = () => {
img.style.display = 'none';
imgWrap.classList.add('no-image');
};
imgWrap.appendChild(img);
} else {
imgWrap.classList.add('no-image');
}
item.appendChild(imgWrap);
// Product details
const details = document.createElement('div');
details.className = 'search-result-details';
const titleEl = document.createElement('div');
titleEl.className = 'search-result-title';
titleEl.textContent = result.title;
details.appendChild(titleEl);
const metaEl = document.createElement('div');
metaEl.className = 'search-result-meta';
const vendorSpan = document.createElement('span');
vendorSpan.className = 'search-result-vendor';
vendorSpan.textContent = result.vendor;
metaEl.appendChild(vendorSpan);
if (result.productType) {
const dot = document.createElement('span');
dot.className = 'search-result-dot';
dot.textContent = ' \u00b7 ';
metaEl.appendChild(dot);
const typeSpan = document.createElement('span');
typeSpan.textContent = result.productType;
metaEl.appendChild(typeSpan);
}
details.appendChild(metaEl);
item.appendChild(details);
// Price
const priceEl = document.createElement('div');
priceEl.className = 'search-result-price';
const priceVal = parseFloat(result.price);
priceEl.textContent = priceVal > 0 ? `$${priceVal.toFixed(2)}` : '';
item.appendChild(priceEl);
// Events
item.addEventListener('click', () => this.selectResult(result));
item.addEventListener('mouseenter', () => {
this.selectedIndex = index;
this.highlightResult();
});
this.resultsEl.appendChild(item);
});
this.openResults();
}
private highlightResult(): void {
const items = this.resultsEl.querySelectorAll('.search-result-item');
items.forEach((item, i) => {
(item as HTMLElement).classList.toggle('highlighted', i === this.selectedIndex);
});
// Scroll highlighted item into view
if (this.selectedIndex >= 0) {
const highlighted = items[this.selectedIndex] as HTMLElement;
if (highlighted) {
highlighted.scrollIntoView({ block: 'nearest' });
}
}
}
private selectResult(result: SearchResult): void {
console.log('[SearchOverlay] Selected product:', result.title, result.handle);
this.closeResults();
this.inputEl.value = '';
// Try to apply texture to a wall in the showroom
if (result.imageUrl && this.showroom) {
this.applyToShowroomWall(result);
} else {
// Fallback: open product on Shopify store
const url = `https://designer-laboratory-sandbox.myshopify.com/products/${result.handle}`;
window.open(url, '_blank');
}
}
private applyToShowroomWall(result: SearchResult): void {
if (!this.showroom) return;
this.showToast(`Loading "${result.title}" onto wall...`);
// Use higher resolution source, then crop to 512x768 (no stretching)
const textureUrl = result.imageUrl.replace(/\.([^.]+)$/, '_600x600.$1');
cropTexture(textureUrl)
.then((texture) => {
const scene = this.showroom!.scene;
let applied = false;
scene.traverse((child) => {
if (applied) return;
if (child instanceof THREE.Mesh && child.name === 'search-preview-wall') {
const mat = child.material as THREE.MeshStandardMaterial;
if (mat.map) mat.map.dispose();
mat.map = texture;
mat.needsUpdate = true;
applied = true;
}
});
if (!applied) {
const camera = this.showroom!.camera;
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const wallPos = camera.position.clone().add(dir.multiplyScalar(5));
wallPos.y = 2.5;
// PlaneGeometry matches the 2:3 crop aspect (3 wide x 4.5 tall)
const geo = new THREE.PlaneGeometry(3, 4.5);
const mat = new THREE.MeshStandardMaterial({
map: texture,
roughness: 0.4,
metalness: 0.0,
envMapIntensity: 0.5,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.name = 'search-preview-wall';
mesh.position.copy(wallPos);
mesh.lookAt(camera.position);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
// Frame matches the 2:3 plane
const frameGeo = new THREE.BoxGeometry(3.15, 4.65, 0.05);
const frameMat = new THREE.MeshStandardMaterial({
color: 0x333333,
roughness: 0.3,
metalness: 0.4,
});
const frame = new THREE.Mesh(frameGeo, frameMat);
frame.name = 'search-preview-frame';
frame.position.copy(wallPos);
frame.position.z -= 0.03;
frame.lookAt(camera.position);
scene.add(frame);
// Label below
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 512;
labelCanvas.height = 64;
const ctx = labelCanvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, 512, 64);
ctx.fillStyle = '#f0ebe4';
ctx.font = '24px Inter, -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(result.title.substring(0, 40), 256, 28);
ctx.font = '14px Inter, -apple-system, sans-serif';
ctx.fillStyle = '#888888';
ctx.fillText(result.vendor, 256, 52);
}
const labelTex = new THREE.CanvasTexture(labelCanvas);
const labelGeo = new THREE.PlaneGeometry(2.5, 0.3);
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex });
const label = new THREE.Mesh(labelGeo, labelMat);
label.name = 'search-preview-label';
label.position.copy(wallPos);
label.position.y -= 2.55;
label.lookAt(camera.position);
scene.add(label);
}
this.showToast(`"${result.title}" displayed on wall`);
})
.catch((err) => {
console.error('[SearchOverlay] Failed to crop texture:', err);
const url = `https://designer-laboratory-sandbox.myshopify.com/products/${result.handle}`;
window.open(url, '_blank');
});
}
private showToast(message: string): void {
// Remove existing toast
const existing = document.getElementById('search-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'search-toast';
toast.className = 'search-toast';
toast.textContent = message;
this.container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
private showLoading(): void {
this.resultsEl.style.display = 'block';
this.isOpen = true;
this.loadingEl.style.display = 'block';
this.emptyEl.style.display = 'none';
// Remove existing result items
const items = this.resultsEl.querySelectorAll('.search-result-item');
items.forEach(item => item.remove());
}
private showEmpty(message: string): void {
this.resultsEl.style.display = 'block';
this.isOpen = true;
this.loadingEl.style.display = 'none';
this.emptyEl.style.display = 'block';
this.emptyEl.textContent = message;
}
private openResults(): void {
this.resultsEl.style.display = 'block';
this.isOpen = true;
}
private closeResults(): void {
this.resultsEl.style.display = 'none';
this.isOpen = false;
this.selectedIndex = -1;
}
private applyStyles(): void {
const style = document.createElement('style');
style.textContent = `
/* ===== Search Overlay ===== */
.search-overlay {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
width: 420px;
max-width: calc(100vw - 32px);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ===== Input Group ===== */
.search-input-group {
position: relative;
display: flex;
align-items: center;
background: rgba(8, 11, 22, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 10px;
padding: 0 14px;
transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.search-input-group:focus-within {
border-color: rgba(99, 102, 241, 0.4);
box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 20px rgba(99, 102, 241, 0.1);
}
.search-icon {
color: #64748b;
flex-shrink: 0;
margin-right: 10px;
transition: color 150ms;
}
.search-input-group:focus-within .search-icon {
color: #8b5cf6;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #f1f5f9;
font-size: 14px;
font-family: inherit;
font-weight: 500;
padding: 12px 0;
letter-spacing: 0.2px;
}
.search-input::placeholder {
color: #64748b;
font-weight: 400;
}
.search-shortcut {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(148, 163, 184, 0.12);
color: #64748b;
font-size: 11px;
font-weight: 600;
font-family: monospace;
margin-left: 8px;
}
.search-input-group:focus-within .search-shortcut {
display: none;
}
/* ===== Results Dropdown ===== */
.search-results {
margin-top: 6px;
background: rgba(8, 11, 22, 0.95);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
max-height: 420px;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.02) inset;
}
.search-results::-webkit-scrollbar { width: 4px; }
.search-results::-webkit-scrollbar-track { background: transparent; }
.search-results::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.15); border-radius: 2px; }
/* ===== Result Item ===== */
.search-result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
transition: background 100ms;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover,
.search-result-item.highlighted {
background: rgba(99, 102, 241, 0.06);
}
/* ===== Product Image ===== */
.search-result-image {
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(148, 163, 184, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.search-result-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.search-result-image.no-image::after {
content: '';
width: 20px;
height: 20px;
background: rgba(148, 163, 184, 0.15);
border-radius: 3px;
}
/* ===== Product Details ===== */
.search-result-details {
flex: 1;
min-width: 0;
}
.search-result-title {
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.search-result-meta {
font-size: 11px;
color: #64748b;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-result-vendor {
color: #8b5cf6;
font-weight: 500;
}
.search-result-dot {
color: #475569;
}
/* ===== Price ===== */
.search-result-price {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
color: #06b6d4;
font-variant-numeric: tabular-nums;
}
/* ===== Loading / Empty ===== */
.search-loading,
.search-empty {
padding: 20px 14px;
text-align: center;
color: #64748b;
font-size: 13px;
}
.search-loading::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(148, 163, 184, 0.15);
border-top-color: #8b5cf6;
border-radius: 50%;
animation: searchSpin 0.6s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes searchSpin {
to { transform: rotate(360deg); }
}
/* ===== Toast Notification ===== */
.search-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
background: rgba(8, 11, 22, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 8px;
padding: 10px 20px;
color: #f1f5f9;
font-size: 13px;
font-family: 'Inter', -apple-system, sans-serif;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
animation: toastIn 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
.search-toast.fade-out {
opacity: 0;
transform: translateX(-50%) translateY(10px);
transition: opacity 300ms, transform 300ms;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`;
document.head.appendChild(style);
}
dispose(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.wrapperEl.remove();
}
}