PageSourceSearch

https://destin-concierge-new.vercel.app/destiny-v4.js

js destin-concierge-new.vercel.app collected 2026-10-03 06:42:03 UTC 16,259 bytes, 188 lines download raw bytes

1// Destiny v4 for the website bubble (loaded by destiny-head.js only when
2// /api/destiny-engine says so). The conversation lives on the server; this file sends
3// the guest's words, runs the turn, and draws answers with the bubble's own functions.
4// It also shows Ozan's live-chat messages and swaps contact placeholders back to what
5// the guest typed (kept only in this tab).
6(function(){
7  const SEED='destiny_v4_seed',SENT='destiny_v4_sent',SEEN='destiny_v4_owner_seen';
8  const EMAIL=/[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}/g,PHONE=/(?<![\w@.])(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}(?![\w@])/g,TOKEN=/\[(email|phone) (\d{1,2})\]/g;
9  const hex=n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
10  const uuid=()=>crypto.randomUUID?crypto.randomUUID():hex(16);
11  // Rules 4 (testing): this tab was opened with ?rules=4 or ?voice=4.
12  const rules4=()=>{try{return sessionStorage.getItem('db_rules')==='4';}catch(e){return false;}};
13  const quoteMode=()=>{try{const q=new URLSearchParams(location.search).get('quote');if(q==='1')sessionStorage.setItem('db_quote','1');if(q==='0')sessionStorage.removeItem('db_quote');return sessionStorage.getItem('db_quote')==='1';}catch{return false;}};
14  const quickMode=()=>{try{const q=new URLSearchParams(location.search).get('quick');if(q==='2')sessionStorage.setItem('db_quick','2');if(q==='0')sessionStorage.removeItem('db_quick');return sessionStorage.getItem('db_quick')==='2';}catch{return false;}};
15  let ownerScript=null;
16  function loadOwnerForm(){if(window.DestinyOwnerRequest)return Promise.resolve();return ownerScript??=new Promise((ok,no)=>{const el=document.createElement('script');el.src='/destiny-owner-request.js';el.onload=ok;el.onerror=()=>{ownerScript=null;no(Error('owner_request_unavailable'));};document.head.appendChild(el);});}
17  let quoteScript=null;
18  function loadQuoteForm(){if(window.DestinyQuoteForm)return Promise.resolve();return quoteScript??=new Promise((ok,no)=>{const el=document.createElement('script');el.src='/destiny-quote-form.js';el.onload=ok;el.onerror=()=>{quoteScript=null;no(Error('quote_unavailable'));};document.head.appendChild(el);});}
19  const sleep=ms=>new Promise(r=>setTimeout(r,ms));
20  const store={get(k,d){try{return JSON.parse(sessionStorage.getItem(k))??d;}catch{return d;}},set(k,v){try{sessionStorage.setItem(k,JSON.stringify(v));}catch{}}};
21  const e164=raw=>{const d=String(raw).replace(/\D/g,'');return d.length===10?'+1'+d:d.length===11&&d[0]==='1'?'+'+d:null;};
22  // Same numbering as the server (contacts.js), rebuilt from what this guest typed.
23  function ownContacts(){
24    const c={emails:[],phones:[]};
25    for(const text of store.get(SENT,[])){
26      String(text).replace(EMAIL,m=>{m=m.toLowerCase();if(!c.emails.includes(m))c.emails.push(m);return m;});
27      String(text).replace(EMAIL,'').replace(PHONE,m=>{const p=e164(m);if(p&&!c.phones.includes(p))c.phones.push(p);return m;});
28    }
29    return c;
30  }
31  const guestView=text=>{const c=ownContacts();return String(text||'').replace(TOKEN,(m,k,n)=>(k==='email'?c.emails:c.phones)[Number(n)-1]||(k==='email'?'your email 💌':'your number 📱'));};
32  const pageContext=()=>{
33    const source=sessionStorage.getItem('db_source');
34    if(source==='ticker'){try{const t=JSON.parse(sessionStorage.getItem('db_ticker')||'{}');return {source,unit:t.unit,arrival:t.arrival,departure:t.departure};}catch{return {source};}}
35    return source?{source}:null;
36  };
37  // Destiny's text often already contains its links; add only the ones it left out.
38  const compose=a=>a.catalogVersion===2?a.reply:[a.reply,...(a.links||[]).filter(u=>!String(a.reply).includes(u))].join('\n\n');
39  const friendly={private_memory_sensitive_input:'For your safety, please do not share c
39ard numbers or reservation codes in chat. Your email or phone number is fine 😊'};
40
41  // opts.page: the page context when the page knows it (the concierge page's pageSource).
42  window.DestinyV4={start(config,ui,opts={}){
43    quoteMode();quickMode(); // Remember the explicit test switch even before the first typed turn.
44    const path=config.chatPath,voicePath=config.voicePath||'/api/destiny-private-voice';let clientId=uuid(),sequence=0,after=0,booted=null,ownerStatus='none',watching=null,fresh=false;
45    async function post(body,to=path){
46      const r=await fetch(to,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),credentials:'same-origin'});
47      const d=await r.json().catch(()=>({}));if(!r.ok){const e=Error(d.error||'request_failed');e.status=r.status;throw e;}return d;
48    }
49    // A streamed request: partial answer lines as they come, the last line is the result.
50    async function postStream(body,onLine,to=path){
51      const r=await fetch(to,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),credentials:'same-origin'});
52      if(!r.ok||!r.body?.getReader){const d=await r.json().catch(()=>({}));if(!r.ok){const e=Error(d.error||'request_failed');e.status=r.status;throw e;}return d;}
53      const reader=r.body.getReader(),dec=new TextDecoder();let buf='',last=null;
54      for(;;){const {value,done}=await reader.read();if(value)buf+=dec.decode(value,{stream:!done});
55        let at;while((at=buf.indexOf('\n'))>=0){const line=buf.slice(0,at).trim();buf=buf.slice(at+1);if(!line)continue;let o;try{o=JSON.parse(line);}catch{continue;}if(o.partial)onLine(o.partial);else last=o;}
56        if(done)break;}
57      if(buf.trim()){try{last=JSON.parse(buf.trim());}catch{}}
58      if(!last)throw Error('request_failed');if(last.error)throw Error(last.error);return last;
59    }
60    function seed(){let s=localStorage.getItem(SEED);if(!/^[a-f0-9]{64}$/.test(s||'')){s=hex(32);localStorage.setItem(SEED,s);}return s;}
61    function boot(){const page=opts.page||pageContext();return booted??=post({op:'bootstrap',bootstrapId:seed(),...(fresh?{fresh:true}:{}),...(page?{page}:{})}).then(s=>{fresh=false;owner(s.owner);return s;}).catch(e=>{booted=null;throw e;});}
62    // Ozan's live chat: notes and his messages, each shown once.
63    function owner(o){
64      if(!o)return;ownerStatus=o.status;
65      const seen=store.get(SEEN,0),msgs=o.messages||[];
66      msgs.slice(seen).forEach(m=>m.from==='ozan'?ui.addOzan(m.text):ui.addNote(m.text));
67      store.set(SEEN,msgs.length);
68      if(['invited','active'].includes(ownerStatus))watch();
69    }
70    function watch(){
71      if(watching)return;
72      watching=setInterval(async()=>{try{const s=await post({op:'status',after});owner(s.owner);if(!['invited','active'].includes(ownerStatus)){clearInterval(watching);watching=null;}}catch{}},3000);
73    }
74    // One guest message. A retry reuses the same turnId and sequence, so the server treats
75    // it as the same message (never sent twice).
76    async function turn(text,turnId,seq,retry,voice,onPartial=null,gate=null){
77      let stage='bootstrap';
78      try{
79        await boot();
80        stage='accept';
81        if(!retry)store.set(SENT,[...store.get(SENT,[]),text]);
82        // Spoken words go in as voice turns (same conversation, same brain; HQ marks them 🎤).
83        if(gate?.before)await gate.before.catch(()=>{}); // Voice 6: acceptances stay in order, processing does not wait
84        try{await post({op:'accept',text,turnId,clientId,clientSequence:seq,synthetic:true,...(rules4()||voice?.v>=4?{rules:4}:{}),...(voice?{voice}:quoteMode()?{quote:1}:{}),...(!voice&&config.quickVersion===2&&quickMode()?{quick:2,rules:4}:{})},voice?voicePath:path);}finally{gate?.accepted?.(turnId);}
85        // Fast path: run the turn right away and get its answer back in the same request.
86        stage='process';
87        {let so_far='';const r=onPartial?await postStream({op:'process',after,stream:true},p=>{if(p.turnId!==turnId)return;if(p.done){try{onPartial(so_far,true);}catch{}}else if(typeof p.text==='string'){so_far+=p.text;
87try{onPartial(so_far);}catch{}}else if(p.stage){try{onPartial.onStage?.(p.stage);}catch{}}else if(p.timing){try{onPartial.onTiming?.(p.timing);}catch{}}}):await post({op:'process',after});owner(r.owner);let mine=null;
88          for(const a of r.answers||[]){after=Math.max(after,a.sequence);if(a.turnId===turnId)mine=a;}
89          if(mine)return mine;if(r.blocked)throw Error('paused');}
90        for(let i=0;i<400;i++){
91          stage='status';
92          const s=await post({op:'status',after});owner(s.owner);
93          let mine=null;for(const a of s.answers||[]){after=Math.max(after,a.sequence);if(a.turnId===turnId)mine=a;}
94          if(mine)return mine;
95          if(s.blocked)throw Error('paused');
96          if(s.pending){stage='process';const r=await post({op:'process'});if(r.busy)await sleep(500);}else await sleep(400);
97        }
98        throw Error('timeout');
99      }catch(e){e.stage??=stage;throw e;}
100    }
101    // Connection problems are counted in HQ (a code and the failing step only).
102    function report(kind,e){
103      try{const page=(opts.page||pageContext())?.source||null;
104        fetch('/api/destiny-client-event',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},credentials:'same-origin',
105          body:JSON.stringify({kind,code:String(e?.message||'unknown'),stage:e?.stage||'unknown',page:typeof page==='string'?page.toLowerCase():null})}).catch(()=>{});
106      }catch{}
107    }
108    const NO_RETRY=['paused','timeout','private_memory_sensitive_input','conversation_expired'];
109    async function showQuote(offer,turnId,host,onPrepared,isCurrent=()=>true,options={}){
110      if(!host){host=document.createElement('div');host.className='db-msg bot';document.getElementById('db-messages')?.appendChild(host);}
111      const loading=document.createElement('p');loading.textContent='Loading quote form (test mode)…';host.appendChild(loading);
112      try{await loadQuoteForm();}catch(e){loading.textContent='The quote form could not load. Please try again.';throw e;}
113      loading.remove();if(!isCurrent()||host.isConnected===false)return;
114      let prefillEmail=options.prefillEmail||'';
115      if(!prefillEmail)try{await boot();prefillEmail=(await post({op:'quote_contact',turnId})).email||'';}catch{}
116      if(!isCurrent()||host.isConnected===false)return;
117      return window.DestinyQuoteForm.mount(host,{offer,turnId,onPrepared,prefillEmail,onFailed:options.onFailed,onDismiss:options.onDismiss,submit:async b=>{await boot();return post(b,'/api/destiny-quote');}});
118    }
119    async function showOwnerRequest(request,turnId,host,onPrepared,isCurrent=()=>true,options={}){
120      if(!host){host=document.createElement('div');host.className='db-msg bot';document.getElementById('db-messages')?.append(host);}
121      host.textContent='Loading request form…';await loadOwnerForm();let prefillEmail=options.prefillEmail||'';
122      if(!prefillEmail)try{await boot();prefillEmail=(await post({op:'owner_request_contact',turnId})).email||'';}catch{}
123      if(!isCurrent()||host.isConnected===false)return;
124      return window.DestinyOwnerRequest.mount(host,{request,turnId,prefillEmail,isCurrent,onPrepared,onFailed:options.onFailed,onDismiss:options.onDismiss,submit:b=>post(b)});
125    }
126    function showActions(a){if(a.catalogVersion!==2||!a.actions?.length)return;if(ui.addActions){ui.addActions(a.actions);return;}const host=document.createElement('div');host.className='db-msg bot';host.style.cssText='display:flex;gap:8px;flex-wrap:wrap';for(const item of a.actions.slice(0,3)){const link=document.createElement('a');link.href=item.url;link.textContent=item.label;link.rel='noopener noreferrer';if(item.url.startsWith('https:'))link.target='_blank';link.style.cssText='display:inline-block;padding:12px 16px;border-radius:24px;background:#076f73;color:white;text-decoration:none';host.append(link);}document.getElementById('db-messages')?.append(host);}
127    return {
128      showQuote,showOwnerRequest,
129      ownerActive:()=>ownerStatus==='active',
130      // Voice 5: load the brain's prompt cache when a call starts (no answer, nothing saved).
131      async warm(){await boot();return post({op:'warm'},voicePath);},
132      // Voice 6: a sentence she already answered herself is closed with her words (the brain moves on).
133      // Voice 8: quick names the approved entry she spoke, so the server records its canonical copy.
134      close(turnId,reply,quick){return post({op:'close',turnId,reply:String(reply).slice(0,400),...(typeof quick==='string'?{quick}:{})},voicePath);},
135      // Voice 8: a complete booking request from the call's fast helper (the server checks it).
136      fastBook(turnId,book){return post({op:'fast_book',turnId,book},voicePath);},
137      // Voice 5: this browser's conversation already has turns (a returning caller).
138      async returning(){const s=await boot();return (s?.accepted||0)>0;},
139      greeting:source=>config.greetings?.[source]||null,
140      // The bubble's first line (HQ Appearance).
141      opener:()=>config.opener||null,
142      // The chips on the welcome message (HQ Appearance).
143      welcomeChips:()=>Array.isArray(config.welcomeChips)?config.welcomeChips.slice(0,4).map(c=>({label:String(c.label),prompt:String(c.prompt)})):[],
144      // Past messages after a reload (the guest's own words when this tab has them).
145      async restore(){
146        try{
147          const s=await boot(),st=await post({op:'status',after:0}),sent=store.get(SENT,[]);let i=0;
148          for(const a of st.answers||[]){after=Math.max(after,a.sequence);ui.addU(a.question.includes('[')&&sent[i]?sent[i]:guestView(a.question));i++;if(a.reply)ui.addB(guestView(compose(a)));showActions(a);}
149          store.set(SEEN,0);owner(st.owner);return (st.answers||[]).length>0;
150        }catch{return false;}
151      },
152      // opts.voice: how the spoken words were heard (the mic). Returns the answer (voice reads it).
153      async send(text,retried=false,opts={}){
154        if(text==='__popup_open__'){ui.addB(config.greetings?.popup||"Who do I have the pleasure of welcoming to Destin today? 🌊");return;}
155        ui.showTyping('');
156        const turnId=uuid(),seq=++sequence,typedAt=Date.now();let first=null;
157        try{
158          let a;
159          try{a=await turn(text,turnId,seq,false,opts.voice,opts.onPartial||null,opts.gate||null);}
160          catch(e){
161            // One quiet retry for a network blip or a mixed-up conversation ticket.
162            if(NO_RETRY.includes(e.message))throw e;
163            first=e;booted=null;await sleep(800);
164            a=await turn(text,turnId,seq,true,opts.voice);
165            report('recovered',first);
166          }
167          const wait=500-(Date.now()-typedAt);if(wait>0)await sleep(wait);
168          ui.rmTyping();
169          if(a.reply)ui.addB(guestView(compose(a)),{type:true});showActions(a);
170          if(!opts.voice&&a.catalogVersion===2&&a.ownerRequest){if(ui.addOwnerRequest)ui.addOwnerRequest(a);else await showOwnerRequest(a.ownerRequest,a.turnId).catch(()=>ui.addNote?.('The request form could not load. Please contact Ozan directly.'));}
171          if(!opts.voice&&quoteMode()&&a.offerQuote){if(a.quoteInvite)ui.addB(a.quoteInvite);if(ui.addQuote)ui.addQuote(a);else await showQuote(a.offerQuote,a.turnId).catch(()=>{});}
172          // Loop breaker: a tap sends what Destiny understood as the guest's own words.
173          if(a.confirm?.text&&ui.addConfirm)ui.addConfirm({label:String(a.confirm.label||a.confirm.text),text:String(a.confirm.text)});
174          // "Guests also asked" (never next to the confirm button).
175          else if(Array.isArray(a.chips)&&a.chips.length&&ui.addChips)ui.addChips(a.chips.slice(0,3).map(c=>({label:String(c.label),prompt:String(c.prompt)})));
176          return a;
177        }catch(e){
178          ui.rmTyping();
179          if(e.message==='conversation_expired'&&!retried){this.reset();return this.send(text,true,opts);}
180          if(e.message!=='private_memory_sensitive_input')report('connect_failed',e);
181          ui.addB(friendly[e.message]||"Sorry, I couldn't connect. Please try again or reach Ozan at (972) 357-4262.");
182        }
183      },
184      // A new conversation (the ticker starts one for its dates).
185      reset(){fresh=true;localStorage.removeItem(SEED);store.set(SENT,[]);store.set(SEEN,0);booted=null;after=0;clientId=uuid();sequence=0;ownerStatus='none';if(watching){clearInterval(watching);watching=null;}},
186    };
187  }};
188})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.