← back to Dw Photo Capture

public/kid-simple/v3-text-buddy.html

552 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>v3 — Text Buddy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
  :root{
    --paper:#EFEAE3;
    --gold:#C9A24B;
    --sage:#DCE8DA;
    --text:#2B2B2B;
    --muted:#6B6255;
  }
  *{box-sizing:border-box;}
  html,body{margin:0;padding:0;background:#0a0a0a;font-family:'Inter',sans-serif;}
  .stage{
    width:390px;height:780px;margin:24px auto;position:relative;
    background:var(--paper);border-radius:36px;overflow:hidden;
    box-shadow:0 20px 60px rgba(0,0,0,.5);border:8px solid #000;color:var(--text);
    display:flex;flex-direction:column;
  }
  header{
    display:flex;flex-direction:column;gap:8px;
    padding:14px 16px 10px;border-bottom:1px solid #ded6c8;background:var(--paper);flex-shrink:0;
  }
  .header-top{display:flex;align-items:center;justify-content:space-between;}
  header .wordmark{font-family:'Fraunces',serif;font-weight:700;font-size:18px;}
  header .sam-label{font-size:14px;color:var(--muted);margin-top:2px;}
  .header-actions{display:flex;gap:10px;}
  .nav-btn{
    flex:1;min-height:56px;background:#fff;border:2px solid #ded6c8;border-radius:14px;padding:0 10px;
    font-size:17px;font-weight:600;cursor:pointer;color:var(--text);font-family:'Inter',sans-serif;
  }
  .nav-btn:disabled{opacity:.4;cursor:not-allowed;}
  .nav-btn:focus-visible, .chip:focus-visible, .thumb-card:focus-visible, .result-card:focus-visible, input:focus-visible{outline:3px solid var(--gold);outline-offset:2px;}
  .nav-btn:active, .chip:active, .result-card:active, .cam-bubble:active{transform:scale(.97);}
  .demo-toggle-inline{font-size:14px;color:var(--muted);display:flex;align-items:center;gap:8px;}
  .demo-toggle-inline input{width:18px;height:18px;}
  #thread{
    flex:1;overflow-y:auto;padding:18px 16px 24px;display:flex;flex-direction:column;gap:12px;
  }
  .bubble{
    max-width:78%;padding:14px 16px;border-radius:16px;font-size:17px;line-height:1.4;
  }
  .sam{
    align-self:flex-start;background:#fff;border-left:5px solid var(--gold);
    border-radius:4px 16px 16px 16px;
  }
  .user{
    align-self:flex-end;background:var(--sage);border-radius:16px 4px 16px 16px;
  }
  .step-note{align-self:flex-start;font-size:14px;color:var(--muted);padding:0 4px;}
  .chips{display:flex;flex-wrap:wrap;gap:10px;align-self:flex-start;max-width:92%;}
  .chip{
    min-height:56px;padding:0 18px;border-radius:20px;background:#fff;border:2px solid var(--gold);
    color:var(--text);font-size:17px;font-weight:600;cursor:pointer;
  }
  .cam-bubble{
    align-self:flex-start;background:#fff;border:2px dashed var(--gold);border-radius:16px;
    padding:18px;display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;
    min-width:180px;min-height:56px;
  }
  .cam-bubble .cam-icon{font-size:40px;}
  .cam-bubble .cam-label{font-size:17px;color:var(--muted);}
  .swatch-bubble{
    align-self:flex-end;width:180px;height:130px;border-radius:14px;
    background:repeating-linear-gradient(45deg,#8FAE8B 0 20px,#B9CBB2 20px 40px),linear-gradient(135deg,#8FAE8B,#6f8d6c);
    background-blend-mode:multiply;
  }
  .result-card{
    align-self:flex-start;display:flex;gap:10px;background:#fff;border-radius:14px;padding:10px;
    border:2px solid #ded6c8;cursor:pointer;max-width:88%;align-items:center;min-height:56px;
  }
  .result-card .thumb{width:56px;height:56px;border-radius:10px;flex-shrink:0;
    background:repeating-linear-gradient(45deg,#C97B5B 0 12px,#e0a184 12px 24px);}
  .result-card .rc-title{font-size:16px;font-weight:700;}
  .result-card .rc-sub{font-size:17px;color:var(--muted);}
  .full-item{
    align-self:flex-start;background:#fff;border-radius:16px;padding:14px;border:2px solid #ded6c8;max-width:88%;
  }
  .full-item .swatch{width:100%;height:150px;border-radius:12px;margin-bottom:10px;
    background:repeating-linear-gradient(45deg,#8FAE8B 0 20px,#B9CBB2 20px 40px),linear-gradient(135deg,#8FAE8B,#6f8d6c);
    background-blend-mode:multiply;}
  .full-item .swatch.blue{background:repeating-linear-gradient(45deg,#7C93B0 0 20px,#B3C3D6 20px 40px),linear-gradient(135deg,#7C93B0,#5f7692);background-blend-mode:multiply;}
  .full-item .rc-title{font-size:18px;font-weight:700;}
  .full-item .rc-sub{font-size:17px;color:var(--muted);}
  .search-mock{
    align-self:flex-start;background:#fff;border-radius:14px;padding:14px 16px;font-size:17px;
    color:var(--muted);border:2px solid #ded6c8;min-width:220px;
  }
  .text-input{
    width:100%;border:none;outline:none;font-size:17px;font-family:'Inter',sans-serif;
    background:transparent;color:var(--text);min-height:24px;
  }
  .spinner-inline{display:inline-block;width:16px;height:16px;border-radius:50%;
    border:3px solid rgba(0,0,0,.12);border-top-color:var(--gold);animation:spin 1s linear infinite;margin-right:8px;vertical-align:-3px;}
  @keyframes spin{to{transform:rotate(360deg);}}
  .toast{
    position:absolute;left:16px;right:16px;bottom:16px;background:#2B2B2B;color:#fff;
    border-radius:14px;padding:14px 18px;display:none;align-items:center;justify-content:space-between;
    gap:12px;box-shadow:0 8px 24px rgba(0,0,0,.35);z-index:20;font-size:17px;border:2px solid var(--gold);
  }
  .toast.show{display:flex;}
  .toast button{background:none;border:none;color:#F4D48A;font-weight:700;font-size:17px;cursor:pointer;min-height:56px;min-width:56px;padding:0 6px;}
  @media (prefers-reduced-motion: reduce){
    .spinner-inline{animation:none;border-top-color:var(--gold);}
    *{transition:none !important;}
  }
</style>
</head>
<body>
<div class="stage">
  <header>
    <div class="header-top">
      <div>
        <div class="wordmark">DW</div>
        <div class="sam-label">chatting with Sam</div>
      </div>
    </div>
    <div class="header-actions">
      <button class="nav-btn" id="undoBtn" onclick="undoLast()" aria-label="Undo last">‹ Undo last</button>
      <button class="nav-btn" onclick="resetChat()" aria-label="New chat">⌂ New chat</button>
    </div>
    <label class="demo-toggle-inline"><input type="checkbox" id="demoToggle" onchange="demoNoMatch=this.checked"> Demo: show "no match"</label>
  </header>
  <div id="thread" aria-live="polite"></div>
</div>

<!-- TOAST (undo) -->
<div class="toast" id="toast" role="status" aria-live="polite">
  <span id="toastText">Saved</span>
  <button onclick="toastUndo()">Undo</button>
</div>

<script>
  const thread = document.getElementById('thread');
  let demoNoMatch = false;
  let stepStack = [];
  let toastTimer = null;

  function scrollBottom(){ thread.scrollTop = thread.scrollHeight; }

  function setUndoEnabled(){
    document.getElementById('undoBtn').disabled = stepStack.length === 0;
  }

  // Anything "awaiting a tap" (chips, camera prompt, brand picker, result list,
  // type-a-code box) is tagged .pending-ui. pushStep always clears it first, so
  // both a real click AND an undo-replay land on a clean, non-duplicated screen.
  function clearPendingUI(){
    document.querySelectorAll('#thread .pending-ui').forEach(el => el.remove());
  }

  function pushStep(fn){
    clearPendingUI();
    fn();
    stepStack.push(fn);
    setUndoEnabled();
  }

  function undoLast(){
    if(stepStack.length === 0) return;
    hideToast();
    stepStack.pop();
    const keep = stepStack;
    stepStack = [];
    thread.innerHTML = '';
    startHome();
    keep.forEach(pushStep);
    setUndoEnabled();
  }

  function toastUndo(){ undoLast(); }

  function resetChat(){
    hideToast();
    stepStack = [];
    thread.innerHTML = '';
    startHome();
    setUndoEnabled();
  }

  function addSam(text){
    const el = document.createElement('div');
    el.className = 'bubble sam';
    el.innerHTML = text;
    thread.appendChild(el);
    scrollBottom();
    return el;
  }
  function addUser(text){
    const el = document.createElement('div');
    el.className = 'bubble user';
    el.innerHTML = text;
    thread.appendChild(el);
    scrollBottom();
    return el;
  }
  function addStepNote(text){
    const el = document.createElement('div');
    el.className = 'step-note';
    el.textContent = text;
    thread.appendChild(el);
    scrollBottom();
  }
  function addChips(options){
    const wrap = document.createElement('div');
    wrap.className = 'chips pending-ui';
    options.forEach(opt => {
      const b = document.createElement('button');
      b.className = 'chip';
      b.textContent = opt.label;
      b.onclick = () => pushStep(opt.action);
      wrap.appendChild(b);
    });
    thread.appendChild(wrap);
    scrollBottom();
    return wrap;
  }
  function addCameraBubble(onShot){
    function showCameraStep(){
      const el = document.createElement('div');
      el.className = 'cam-bubble pending-ui';
      el.setAttribute('role','button');
      el.tabIndex = 0;
      el.innerHTML = '<div class="cam-icon">📸</div><div class="cam-label">Tap to shoot</div>';
      // shoot is a recorded step (pushStep, not a bare onclick) so tapping the
      // shutter has its own undo point. Its "Looks good"/"Retake" actions never
      // hold onto a closed-over DOM node — they re-query the live tree by the
      // '.shot-pending' marker at click time, so a replay (which rebuilds the
      // #thread DOM from scratch) never touches a node from a destroyed tree.
      const shoot = () => {
        const photo = addUser('<div class="swatch-bubble"></div>');
        photo.classList.add('shot-pending');
        const looking = addSam('<span class="spinner-inline"></span>Looking…');
        setTimeout(() => {
          looking.remove();
          addSam("Here's what I got — good?");
          addChips([
            {label:'Looks good', action: () => {
              const p = thread.querySelector('.shot-pending');
              if(p) p.classList.remove('shot-pending');
              onShot();
            }},
            {label:'Retake', action: () => {
              const p = thread.querySelector('.shot-pending');
              if(p) p.remove();
              showCameraStep();
            }},
          ]);
        }, 900);
      };
      el.onclick = () => pushStep(shoot);
      el.onkeydown = (e) => { if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); pushStep(shoot); } };
      thread.appendChild(el);
      scrollBottom();
    }
    showCameraStep();
  }
  function addDoneCard(opts){
    const el = document.createElement('div');
    el.className = 'full-item';
    el.innerHTML = '<div class="swatch'+(opts.blue ? ' blue' : '')+'"></div>'+
      '<div class="rc-title">'+opts.name+'</div>'+
      '<div class="rc-sub">code '+opts.code+' · '+opts.brand+'</div>'+
      '<div class="rc-sub" style="margin-top:6px;">'+opts.action+'</div>';
    thread.appendChild(el);
    scrollBottom();
  }
  function randomCode(){ return 'DW-' + Math.floor(10000 + Math.random() * 89999); }

  function showToast(text){
    document.getElementById('toastText').textContent = text;
    const t = document.getElementById('toast');
    t.classList.add('show');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(hideToast, 5000);
  }
  function hideToast(){
    document.getElementById('toast').classList.remove('show');
    clearTimeout(toastTimer);
  }

  function startHome(){
    thread.innerHTML = '';
    addSam('Hey! What are we doing today?');
    addChips([
      {label:'Add New Item', action: flowAddStart},
      {label:'Update an Item', action: flowUpdateStart},
      {label:'Find an Item', action: flowFindStart},
      {label:'A Sample Came In', action: flowSampleStart},
    ]);
  }

  // ---------- ADD NEW ITEM ----------
  function flowAddStart(){
    addUser('Add New Item');
    addSam('Cool, snap a photo of it 📸');
    addCameraBubble(() => {
      addSam('Which brand?');
      addBrandPicker((brand) => addAddSaved(brand));
    });
  }
  function addBrandPicker(onPick){
    const recent = ['York', 'Kravet', 'Thibaut'];
    const all = ['Brewster', 'Cole & Son', 'Mulberry', 'Other / Unsure'];
    const wrap = document.createElement('div');
    wrap.className = 'pending-ui';
    wrap.style.cssText = 'align-self:flex-start;width:100%;display:flex;flex-direction:column;gap:10px;';
    wrap.innerHTML =
      '<div class="search-mock" style="width:100%;"><input type="text" class="text-input" id="brandSearchInput" placeholder="Search brands…" aria-label="Search brands"></div>'+
      '<div class="step-note">Recent</div>'+
      '<div class="chips" id="recentChipsRow"></div>'+
      '<div class="step-note">All Brands</div>'+
      '<div class="chips" id="allChipsRow"></div>';
    thread.appendChild(wrap);
    const recentRow = wrap.querySelector('#recentChipsRow');
    const allRow = wrap.querySelector('#allChipsRow');
    function addBrandChip(row, name){
      const b = document.createElement('button');
      b.className = 'chip';
      b.textContent = name;
      b.onclick = () => pushStep(() => { addUser(name); onPick(name); });
      row.appendChild(b);
    }
    recent.forEach(n => addBrandChip(recentRow, n));
    all.forEach(n => addBrandChip(allRow, n));
    scrollBottom();
    wrap.querySelector('#brandSearchInput').addEventListener('input', function(){
      const q = this.value.trim().toLowerCase();
      Array.from(allRow.children).forEach(b => {
        b.style.display = (q.length === 0 || b.textContent.toLowerCase().includes(q)) ? '' : 'none';
      });
    });
  }
  function addAddSaved(brand){
    showToast('Saved · Undo');
    addSam('Saved it as a draft! 🎉');
    addDoneCard({name:'New Item', code: randomCode(), brand: brand, action:'Saved as a draft'});
    addChips([
      {label:'Do Another', action: () => { addUser('Do Another'); flowAddStart(); }},
      {label:'Home', action: () => { addUser('Home'); resetChat(); }},
    ]);
  }

  // ---------- UPDATE AN ITEM ----------
  function flowUpdateStart(){
    addUser('Update an Item');
    addStepNote('Step 1 of 2');
    addSam('Show me the label or pattern');
    addCameraBubble(() => {
      if(demoNoMatch){
        showNoMatch(
          () => { addUser('Try again'); flowUpdateStart(); },
          () => { addUser('Type the code instead'); updateTypeSearch(); }
        );
        return;
      }
      updateConfirm();
    });
  }
  function updateConfirm(){
    addSam("Found it — Palm Damask — Sage, code DW-10423. That the one?");
    addChips([
      {label:"Yep, that's it", action: () => {
        addUser("Yep, that's it");
        updateNewPhoto();
      }},
      {label:'Nope, let me search', action: () => {
        addUser('Nope, let me search');
        updateTypeSearch();
      }},
    ]);
  }
  function updateTypeSearch(){
    addSam('No problem — type the name or code:');
    const el = document.createElement('div');
    el.className = 'search-mock pending-ui';
    el.innerHTML = '<input type="text" class="text-input" enterkeyhint="search" placeholder="Palm Damask…" aria-label="Type the item name or code">';
    thread.appendChild(el);
    scrollBottom();
    const searchInput = el.querySelector('input');
    const sendIt = () => {
      const val = searchInput.value.trim() || 'Palm Damask';
      pushStep(() => {
        addUser(val);
        addSam("Great — Palm Damask — Sage, code DW-10423. That the one?");
        addChips([{label:"Yep, that's it", action: () => { addUser("Yep, that's it"); updateNewPhoto(); }}]);
      });
    };
    searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); sendIt(); } });
    addChips([{label:'Send', action: sendIt}]);
  }
  function updateNewPhoto(){
    addStepNote('Step 2 of 2');
    addSam('Send me the new photo');
    addCameraBubble(() => {
      showToast('Saved · Undo');
      addSam('Updated! ✅');
      addDoneCard({name:'Palm Damask — Sage', code:'DW-10423', brand:'York', action:'Updated the photo', blue:true});
      addChips([
        {label:'Do Another', action: () => { addUser('Do Another'); flowUpdateStart(); }},
        {label:'Home', action: () => { addUser('Home'); resetChat(); }},
      ]);
    });
  }

  // ---------- FIND AN ITEM ----------
  function flowFindStart(){
    addUser('Find an Item');
    findAskMethod();
  }
  function findAskMethod(){
    addSam('How do you want to search? 📷 label 🎤 voice ⌨️ type');
    addChips([
      {label:'📷 label', action: () => { addUser('📷 label'); findShowResults(); }},
      {label:'🎤 voice', action: () => { addUser('🎤 voice'); findShowResults(); }},
      {label:'⌨️ type', action: () => { addUser('⌨️ type'); findShowResults(); }},
    ]);
  }
  function findShowResults(){
    if(demoNoMatch){
      showNoMatch(
        () => { addUser('Try again'); findAskMethod(); },
        () => { addUser('Type the code instead'); findTypeSearch(); }
      );
      return;
    }
    addSam("Here's what I found:");
    const items = [
      {name:'Palm Damask — Sage', code:'DW-10423', brand:'York'},
      {name:'Meadow Stripe — Gold', code:'DW-20115', brand:'Kravet'},
      {name:'Coastal Weave — Blue', code:'DW-30871', brand:'Thibaut'},
    ];
    const listWrap = document.createElement('div');
    listWrap.className = 'pending-ui';
    listWrap.style.cssText = 'display:flex;flex-direction:column;gap:10px;align-self:flex-start;width:100%;';
    items.forEach(it => {
      const card = document.createElement('div');
      card.className = 'result-card';
      card.tabIndex = 0;
      card.setAttribute('role','button');
      card.innerHTML = '<div class="thumb"></div><div><div class="rc-title">'+it.name+'</div><div class="rc-sub">code '+it.code+'</div></div>';
      const pick = () => pushStep(() => { addUser(it.name); findShowDetail(it); });
      card.onclick = pick;
      card.onkeydown = (e) => { if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); pick(); } };
      listWrap.appendChild(card);
    });
    thread.appendChild(listWrap);
    scrollBottom();
  }
  function findTypeSearch(){
    addSam('Type the item name or code:');
    const el = document.createElement('div');
    el.className = 'search-mock pending-ui';
    el.innerHTML = '<input type="text" class="text-input" enterkeyhint="search" placeholder="DW-10423" aria-label="Type the item code">';
    thread.appendChild(el);
    scrollBottom();
    const searchInput = el.querySelector('input');
    const sendIt = () => {
      const val = searchInput.value.trim() || 'DW-10423';
      pushStep(() => {
        addUser(val);
        findShowDetail({name:'Palm Damask — Sage', code:'DW-10423', brand:'York'});
      });
    };
    searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); sendIt(); } });
    addChips([{label:'Send', action: sendIt}]);
  }
  function findShowDetail(it){
    addSam('Found it —');
    addDoneCard({name:it.name, code:it.code, brand:it.brand, action:'Found it'});
    addChips([
      {label:'Update This', action: () => {
        addUser('Update This');
        updateNewPhoto();
      }},
      {label:'Search Again', action: () => { addUser('Search Again'); findAskMethod(); }},
    ]);
  }

  // ---------- SAMPLE IN ----------
  function flowSampleStart(){
    addUser('A Sample Came In');
    addSam('Scan the box label');
    addCameraBubble(() => {
      if(demoNoMatch){
        showNoMatch(
          () => { addUser('Try again'); flowSampleStart(); },
          () => { addUser('Type the code instead'); sampleTypeSearch(); }
        );
        return;
      }
      sampleConfirm();
    });
  }
  function sampleConfirm(){
    addSam('Found it — mark it received?');
    addChips([
      {label:'Yes', action: () => { addUser('Yes'); sampleFinish(); }},
    ]);
  }
  function sampleTypeSearch(){
    addSam('Type the item name or code:');
    const el = document.createElement('div');
    el.className = 'search-mock pending-ui';
    el.innerHTML = '<input type="text" class="text-input" enterkeyhint="search" placeholder="DW-10423" aria-label="Type the item code">';
    thread.appendChild(el);
    scrollBottom();
    const searchInput = el.querySelector('input');
    const sendIt = () => {
      const val = searchInput.value.trim() || 'DW-10423';
      pushStep(() => {
        addUser(val);
        sampleConfirm();
      });
    };
    searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter'){ e.preventDefault(); sendIt(); } });
    addChips([{label:'Send', action: sendIt}]);
  }
  function sampleFinish(){
    const printing = addSam('<span class="spinner-inline"></span>Printing your sticker now 🖨️');
    setTimeout(() => {
      printing.remove();
      showToast('Saved · Undo');
      addSam('All set! ✅');
      addDoneCard({name:'Palm Damask — Sage', code:'DW-10423', brand:'York', action:'Marked as received'});
      addChips([
        {label:'Do Another', action: () => { addUser('Do Another'); flowSampleStart(); }},
        {label:'Home', action: () => { addUser('Home'); resetChat(); }},
      ]);
    }, 1200);
  }

  // ---------- SHARED NO-MATCH ----------
  function showNoMatch(tryAgainFn, typeItFn){
    addSam("Hmm, I couldn't find a match.");
    addChips([
      {label:'Try again', action: tryAgainFn},
      {label:'Type the code instead', action: typeItFn},
    ]);
  }

  startHome();
  setUndoEnabled();
</script>
</body>
</html>