← 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();
  }
}