PageSourceSearch

https://ductdudes-platform.vercel.app/tech/js/messages.js?v=9

js ductdudes-platform.vercel.app collected 2026-10-03 06:48:50 UTC 19,762 bytes, 496 lines download raw bytes

1// ===========================
2// Two-Way SMS Messaging
3// Via Twilio (not tech's personal number)
4// Uses Supabase realtime for live updates
5// ===========================
6
7const Messages = {
8  activeConversation: null,
9  realtimeChannel: null,
10
11  templates: {
12    'onmyway':      'On my way! I\'ll be there in approximately 30 minutes.',
13    'running-late': 'Running a little behind schedule, but I\'m on my way. Thank you for your patience!',
14    'confirm':      'Hi! Just confirming your appointment with DuctDudes today. We\'ll see you soon!',
15    'access':       'We\'ll need access to your furnace room for the service. Could you please make sure it is accessible when we arrive?',
16    'complete':     'Your service is now complete. Thank you for choosing DuctDudes!',
17    'questions':    'Please do not hesitate to reach out if you have any questions about today\'s service.',
18  },
19
20  async init() {
21    await this.loadConversations();
22    this.renderConversations();
23  },
24
25  async loadConversations() {
26    // Try loading from database first
27    if (typeof supabase !== 'undefined' && supabase && AppState.tech.id) {
28      try {
29        const dbConversations = await DataService.messages.getConversationsForTech(AppState.tech.id);
30        if (dbConversations.length > 0) {
31          AppState.messages.conversations = dbConversations.map(c => ({
32            clientId: c.clientId,
33            clientName: c.clientName,
34            clientPhone: c.clientPhone,
35            lastMessage: c.lastMessage || '',
36            lastTime: c.lastTime ? new Date(c.lastTime).getTime() : null,
37            unread: c.unread,
38            unreadCount: c.unreadCount,
39            messages: [],
40          }));
41        }
42      } catch (err) {
43        console.error('Failed to load conversations:', err);
44      }
45    }
46
47    // Also add today's job clients who don't have conversations yet.
48    // Schedule.loadSchedule mixes jobs, assessments, and tasks into the same
49    // array — only real jobs have a `client` object. Tasks/assessments use a
50    // flat `client_name` string and would crash `job.client.id` below.
51    const allJobs = Object.values(AppState.schedule.jobs).flat()
52      .filter(j => j._type !== 'task' && j._type !== 'assessment' && j.client);
53    const existing = new Set(AppState.messages.conversations.map(c => c.clientId));
54
55    allJobs.forEach(job => {
56      if (existing.has(job.client.id)) return;
57      existing.add(job.client.id);
58
59      AppState.messages.conversations.push({
60        clientId: job.client.id,
61        clientName: job.client.name,
62        clientPhone: job.client.phone,
63        jobId: job.id,
64        lastMessage: '',
65        lastTime: null,
66        unread: false,
67        unreadCount: 0,
68        messages: [],
69      });
70    });
71  },
72
73  getHiddenConversations() {
74    try { return JSON.parse(localStorage.getItem('hiddenConvos') || '[]'); } catch { return []; }
75  },
76
77  hideConversation(clientId) {
78    const hidden = this.getHiddenConversations();
79    if (!hidden.includes(clientId)) hidden.push(clientId);
80    localStorage.setItem('hiddenConvos', JSON.stringify(hidden));
81    AppState.messages.conversations = AppState.messages.conversations.filter(c => c.clientId !== clientId);
82    this.renderConversations();
83  },
84
85  renderConversations() {
86    const container = document.getElementById('conversationList');
87    container.innerHTML = '';
88
89    // Filter out hidden conversations (unless they have unread — new message brings them back)
90    const hidden = this.getHiddenConversations();
91    const visible = AppState.messages.conversations.filter(c => !hidden.includes(c.clientId) || c.unread);
92
93    if (visible.length === 0) {
94      container.innerHTML = '<div class="no-jobs"><p>No conversations yet</p></div>';
95      // Update badge
96      const badge = document.getElementById('msgBadge');
97      if (badge) badge.style.display = 'none';
98      return;
99    }
100
101    // Sort: unread first, then by most recent
102    const sorted = [...visible].sort((a, b) => {
103      if (a.unread && !b.unread) return -1;
104      if (!a.unread && b.unread) return 1;
105      return (b.lastTime || 0) - (a.lastTime || 0);
106    });
107
108    // Group by date so we can add date separators
109    const today     = new Date(); today.setHours(0, 0, 0, 0);
110    const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
111    const dayLabel  = (ts) => {
112      if (!ts) return 'Earlier';
113      const d = new Date(ts); d.setHours(0, 0, 0, 0);
114      if (d.getTime() === today.getTime())     return 'Today';
115      if (d.getTime() === yesterday.getTime()) return 'Yesterday';
116      return d.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: d.getFullYear() !== today.getFullYear() ? 'numeric' : undefined });
117    };
118
119    let lastLabel = null;
120    sorted.forEach(convo => {
121      const label = dayLabel(convo.lastTime);
122      if (label !== lastLabel) {
123        lastLabel = label;
124        const sep = document.createElement('div');
125        sep.style.cssText = 'font-size:11px;font-weight:600;color:#aaa;text-transform:uppercase;letter-spacing:0.5px;padding:10px 16px 4px;';
126        sep.textContent = label;
127        container.appendChild(sep);
128      }
129
130      const timeStr = convo.lastTime ? this.formatConvoTime(convo.lastTime) : '';
131
132      const item = document.createElement('div');
133      item.className = 'conversation-item';
134      item.style.position = 'relative';
135
136      item.innerHTML = `
137        <div class="convo-info" style="flex:1;min-width:0;">
138          <div class="convo-name">${convo.clientName}</div>
139          <div class="convo-preview">${convo.lastMessage || 'No messages yet'}</div>
140        </div>
141        <div style="display:flex;flex-direction:column;align-items:flex-end;
141gap:4px;flex-shrink:0;margin-left:8px;">
142          ${timeStr ? `<span class="convo-time">${timeStr}</span>` : ''}
143          ${convo.unread ? '<div class="convo-unread"></div>' : ''}
144          <button class="convo-delete-btn" style="background:none;border:none;cursor:pointer;font-size:15px;color:#ccc;padding:0;line-height:1;" title="Hide conversation">&#10005;</button>
145        </div>
146      `;
147
148      item.querySelector('.convo-delete-btn').addEventListener('click', (e) => {
149        e.stopPropagation();
150        this.hideConversation(convo.clientId);
151      });
152
153      item.addEventListener('click', () => {
154        this.openChat(convo);
155      });
156
157      container.appendChild(item);
158    });
159
160    // Update badge
161    const unreadCount = AppState.messages.conversations.filter(c => c.unread).length;
162    const badge = document.getElementById('msgBadge');
163    if (badge) {
164      if (unreadCount > 0) {
165        badge.textContent = unreadCount;
166        badge.style.display = 'flex';
167      } else {
168        badge.style.display = 'none';
169      }
170    }
171  },
172
173  async openChat(convo) {
174    this.activeConversation = convo;
175    convo.unread = false;
176    convo.unreadCount = 0;
177
178    document.getElementById('chatClientName').textContent = convo.clientName;
179
180    convo.replyHistoryReady = false;
181    convo.replyAnchorId = null;
182    // Load full message history from database
183    if (typeof supabase !== 'undefined' && supabase && convo.clientId) {
184      try {
185        const dbMessages = await DataService.messages.getByClient(convo.clientId);
186        if (this.activeConversation !== convo) return;
187        convo.messages = dbMessages.map(m => ({
188          id: m.id,
189          text: m.message,
190          channel: m.channel,
191          phoneFrom: m.phone_from,
192          phoneTo: m.phone_to,
193          direction: m.direction === 'outbound' ? 'sent' : 'received',
194          timestamp: new Date(m.created_at).getTime(),
195        }));
196
197        convo.replyHistoryReady = true;
198        // Mark as read
199        await DataService.messages.markRead(convo.clientId);
200      } catch (err) {
201        console.error('Failed to load messages:', err);
202      }
203    }
204
205    if (this.activeConversation !== convo) return;
206    this.renderMessages();
207    this.bindChatEvents();
208
209    Screens.show('chat');
210
211    // Subscribe to realtime messages for this client
212    this.startRealtime(convo.clientId);
213  },
214
215  renderMessages() {
216    const renderedConversation = this.activeConversation;
217    const container = document.getElementById('chatMessages');
218    container.innerHTML = '';
219
220    if (this.activeConversation.messages.length === 0) {
221      container.innerHTML = `
222        <div style="text-align:center;padding:40px 20px;color:#888;">
223          <p>Send a message to ${this.activeConversation.clientName}</p>
224          <p style="font-size:12px;margin-top:8px;">Messages are sent from your DuctDudes business number, not your personal phone.</p>
225        </div>
226      `;
227      return;
228    }
229
230    // Day-boundary separator helper — same labels as the conversation list
231    const today     = new Date(); today.setHours(0, 0, 0, 0);
232    const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
233    const dayLabel  = (ts) => {
234      const d = new Date(ts); d.setHours(0, 0, 0, 0);
235      if (d.getTime() === today.getTime())     return 'Today';
236      if (d.getTime() === yesterday.getTime()) return 'Yesterday';
237      return d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric', year: d.getFullYear() !== today.getFullYear() ? 'numeric' : undefined });
238    };
239
240    let lastDayKey = null;
241    this.activeConversation.messages.forEach(msg => {
242      // Insert a date separator whenever the day rolls over between messages
243      if (msg.timestamp) {
244        const d = new Date(msg.timestamp); d.setHours(0, 0, 0, 0);
245        const key = d.getTime();
246        if (key !== lastDayKey) {
247          lastDayKey = key;
248          const sep = document.createElement('div');
249          sep.className = 'chat-day-separator';
250          sep.innerHTML = `<span>${dayLabel(msg.timestamp)}</span>`;
251          container.appendChild(sep);
252        }
253      }
254
255      const bubble = document.createElement('div');
256      bubble.className = `chat-bubble ${msg.direction === 'sent' ? 'sent' : 'received'}`;
257      bubble.innerHTML = `
258        <div>${msg.text}</div>
259        <div class="chat-time">${this.formatTime(msg.timestamp)}</div>
260      `;
261      if (msg.direction === 'received' && msg.channel === 'sms' && msg.id && msg.phoneTo && msg.phoneFrom) {
262        const replyButton = document.createElement('button');
263        replyButton.type = 'button';
264        replyButton.textContent = (this.activeConversation.replyAnchorId === msg.id ? 'Replying from ' : 'Reply from ') + msg.phoneTo;
265        replyButton.addEventListener('click', () => {
266          if (this.activeConversation !== renderedConversation) return;
267          this.activeConversation.replyAnchorId = msg.id;
268          this.renderMessages();
269          document.getElementById('chatInput').focus();
270        });
271        bubble.appendChild(replyButton);
272      }
273      container.appendChild(bubble);
274    });
275
276    container.scrollTop = container.scrollHeight;
277  },
278
279  bindChatEvents() {
280    const input = document.getElementById('chatInput');
281    const sendBtn = document.getElementById('btnSendMessage');
282
283    // Remove old listeners by cloning
284    const newSendBtn = sendBtn.cloneNode(true);
285    sendBtn.parentNode.replaceChild(newSendBtn, sendBtn);
286
287    const newInput = input.cloneNode(true);
288    input.parentNode.replaceChild(newInput, input);
289
290    newSendBtn.addEventListener('click', () => this.sendMessage());
291    newInput.addEventListener('keypress', (e) => {
292      if (e.key === 'Enter') this.sendMessage();
293    });
294
295    // Quick-reply templates toggle
296    const templatesBtn = document.getElementById('btnTemplates');
297    const templatesRow = document.getElementById('chatTemplatesRow');
298    const newTemplatesBtn = templatesBtn.cloneNode(true);
299    templatesBtn.parentNode.replaceChild(newTemplatesBtn, templatesBtn);
300
301    newTemplatesBtn.addEventListener('click', () => {
302      const isOpen = templatesRow.classList.toggle('open');
303      newTemplatesBtn.classList.toggle('active', isOpen);
304    });
305
306    templatesRow.querySelectorAll('.chat-template-chip').forEach(chip => {
307      chip.addEventListener('click', () => {
308        const text = Messages.templates[chip.dataset.template] || '';
309        document.getElementById('chatInput').value = text;
310        document.getElementById('chatInput').focus();
311        templatesRow.classList.remove('open');
312        newTemplatesBtn.classList.remove('active');
313      });
314    });
315  },
316
317  async sendMessage() {
318    const input = document.getElementById('chatInput');
319    const text = input.value.trim();
320    if (!text || !this.activeConversation) return;
321
322    // Existing inbound threads must use the verified receiving line, never the send default.
323    if (this.activeConversation.replyHistoryReady !== true) {
324      alert('Reload this conversation before sending so its receiving line can be verified.');
325      return;
326    }
327    if (this.activeConversation.messages.some(m => m.direction === 'received')) {
328      return this.sendAnchoredReply(text, input);
329    }
330
331    // Add to local messages immediately (optimistic UI)
332    const msg = {
333      text,
334      direction: 'sent',
335      timestamp: Date.now(),
336    };
337    this.activeConversation.messages.push(msg);
338    this.activeConversation.lastMessage = text;
339    this.activeConversation.lastTime = Date.now();
340
341    input.value = '';
342    this.renderMessages();
343
344    // Send via Twilio API (also saves to database on server side)
345    const clientPhone = this.activeConversation.clientPhone;
346    if (clientPhone) {
347      try {
348        const res = await DataService.staffFetch('/api/sms/send', {
349          method: 'POST',
350          headers: { 'Content-Type': 'application/json' },
351          body: JSON.stringify({
352            to: clientPhone,
353            body: text,
354            from: AppState.tech.twilioNumber || null,
355            clientId: this.activeConversation.clientId,
356            technicianId: AppState.tech.id,
357            jobId: this.activeConversation.jobId || null,
358          }),
359        });
360        const result = await res.json();
361        if (result.success) {
362          msg.sid = result.sid;
363          console.log('SMS sent via Twilio:', result.sid);
364        } else {
365          console.error('SMS send failed:', result.error);
366        }
367      } catch (err) {
368        console.error('SMS API error, saving to DB only:', err);
369        // Fallback: save to database without Twilio send
370        if (typeof supabase !== 'undefined' && supabase && AppState.tech.id) {
371          try {
372            await DataService.messages.send({
373              client_id: this.activeConversation.clientId,
374              job_id: this.activeConversation.jobId || null,
375              technician_id: AppState.tech.id,
376              sender_type: 'tech',
377              direction: 'outbound',
378              channel: 'sms',
379              phone_to: clientPhone,
380              message: text,
381            });
382          } catch (dbErr) {
383            console.error('DB fallback failed:', dbErr);
384          }
385        }
386      }
387    }
388  },
389
390  replyAnchor(convo) {
391    const incoming = convo.messages.filter(m => m.direction === 'received');
392    const eligible = incoming.filter(m => m.channel === 'sms' && m.id && /^\+1[0-9]{10}$/.test(m.phoneFrom || '') && /^\+1[0-9]{10}$/.test(m.phoneTo || ''));
393    if (convo.replyAnchorId) {
394      const selected = eligible.find(m => m.id === convo.replyAnchorId);
395      if (!selected) throw new Error('Selected incoming message is unavailable. Choose Reply again.');
396      return selected;
397    }
398    if (!eligible.length || eligible.length !== incoming.length) throw new Error('Choose Reply on a complete incoming SMS before sending.');
399    const pairs = new Set(eligible.map(m => m.phoneTo + '|' + m.phoneFrom));
400    if (pairs.size !== 1) throw new Error('This conversation contains multiple receiving lines or customer numbers. Choose Reply on the incoming message you want to answer.');
401    return eligible.reduce((latest,m) => (m.timestamp || 0) > (latest.timestamp || 0) ? m : latest);
402  },
403
404  async sendAnchoredReply(text, input) {
405    const convo = this.activeConversation;
406    if (convo.replyPending) return;
407    convo.replyPending = true;
408    let requestStarted = false;
409    let responseReceived = false;
410    try {
411      const anchor = this.replyAnchor(convo);
412      const { data, error } = await supabase.auth.getSession();
413      if (error || !data?.session?.access_token) throw new Error('Sign in again before replying.');
414      requestStarted = true;
415      const res = await fetch('/api/sms/reply', {
416        method: 'POST',
417        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + data.session.access_token },
418        body: JSON.stringify({ inboundMessageId: anchor.id, body: text }),
419      });
420      const result = await res.json();
421      responseReceived = true;
422      if (!res.ok || !result.success) throw new Error(result.error || 'Reply status is unconfirmed. Check message history before trying again.');
423      convo.messages.push({ text, direction: 'sent', timestamp: Date.now(), sid: result.sid, channel: 'sms', phoneFrom: result.from, phoneTo: result.to });
424      convo.lastMessage = text;
425      convo.lastTime = Date.now();
426      if (this.activeConversation === convo) { if (input.value.trim() === text) input.value = ''; this.renderMessages(); }
427    } catch (err) {
428      alert(requestStarted && !responseReceived ? 'Reply status is unconfirmed. Check message history before trying again.' : (err.message || 'Reply status is unconfirmed. Check message history before trying again.'));
429    } finally {
430      convo.replyPending = false;
431    }
432  },
433
434  // Realtime subscription for incoming messages
435  startRealtime(clientId) {
436    this.stopRealtime();
437
438    if (typeof supabase === 'undefined' || !supabase) return;
439
440    this.realtimeChannel = supabase
441      .channel('messages-' + clientId)
442      .on('postgres_changes', {
443        event: 'INSERT',
444        schema: 'public',
445        table: 'messages',
446        filter: 'client_id=eq.' + clientId,
447      }, (payload) => {
448        const newMsg = payload.new;
449        // Only add inbound messages (outbound ones we already added optimistically)
450        if (newMsg.direction === 'inbound' && this.activeConversation?.clientId === clientId) {
451          this.activeConversation.messages.push({
452            id: newMsg.id,
453            text: newMsg.message,
454            channel: newMsg.channel,
455            phoneFrom: newMsg.phone_from,
456            phoneTo: newMsg.phone_to,
457            direction: 'received',
458            timestamp: new Date(newMsg.created_at).getTime(),
459          });
460          this.activeConversation.lastMessage = newMsg.message;
461          this.activeConversation.lastTime = new Date(newMsg.created_at).getTime();
462          this.renderMessages();
463
464          // Mark as read since chat is open
465          DataService.messages.markRead(clientId).catch(() => {});
466        }
467      })
468      .subscribe();
469  },
470
471  stopRealtime() {
472    if (this.realtimeChannel) {
473      supabase.removeChannel(this.realtimeChannel);
474      this.realtimeChannel = null;
475    }
476  },
477
478  // Helpers
479  formatTime(timestamp) {
480    if (!timestamp) return '';
481    const date = new Date(timestamp);
482    return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
483  },
484
485  // For conversation list: show time if today, date if older
486  formatConvoTime(timestamp) {
487    if (!timestamp) return '';
488    const date  = new Date(timestamp);
489    const today = new Date(); today.setHours(0, 0, 0, 0);
490    const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0);
491    if (msgDay.getTime() === today.getTime()) {
492      return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
493    }
494    return date.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
495  },
496};

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.