This is page 4 of 5. Use http://codebase.md/Xyborg/ChatGPT-Product-Info?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── assets
│ ├── chatgpt-product-info-reviews.png
│ └── chatgpt-product-info.png
├── chatgpt-product-info.js
├── chrome-extension
│ ├── assets
│ │ ├── flags
│ │ │ ├── all.svg
│ │ │ ├── ar.svg
│ │ │ ├── at.svg
│ │ │ ├── be.svg
│ │ │ ├── br.svg
│ │ │ ├── ch.svg
│ │ │ ├── de.svg
│ │ │ ├── es.svg
│ │ │ ├── fr.svg
│ │ │ ├── gb.svg
│ │ │ ├── it.svg
│ │ │ ├── mx.svg
│ │ │ ├── nl.svg
│ │ │ ├── pt.svg
│ │ │ ├── se.svg
│ │ │ └── us.svg
│ │ └── icons-ui
│ │ ├── analysis.svg
│ │ ├── check.svg
│ │ ├── database.svg
│ │ ├── down-arrow.svg
│ │ ├── edit.svg
│ │ ├── error.svg
│ │ ├── github.svg
│ │ ├── history.svg
│ │ ├── linkedin.svg
│ │ ├── negative.svg
│ │ ├── neutral.svg
│ │ ├── positive.svg
│ │ ├── project.svg
│ │ ├── search.svg
│ │ ├── settings.svg
│ │ ├── tag.svg
│ │ ├── up-arrow.svg
│ │ ├── warning.svg
│ │ └── x.svg
│ ├── content-script.js
│ ├── icons
│ │ ├── icon128.png
│ │ ├── icon16.png
│ │ ├── icon19.png
│ │ ├── icon32.png
│ │ ├── icon48.png
│ │ └── logobubble.svg
│ ├── manifest.json
│ ├── popup.html
│ ├── popup.js
│ ├── README.md
│ └── styles.css
├── LICENSE
└── README.md
```
# Files
--------------------------------------------------------------------------------
/chatgpt-product-info.js:
--------------------------------------------------------------------------------
```javascript
1 | // ChatGPT Product Info Search
2 | // Paste this entire script into ChatGPT's browser console and it will create a floating button
3 |
4 | (function() {
5 | // Remove existing modal and button if present
6 | const existingModal = document.getElementById('chatgpt-product-search-modal');
7 | const existingButton = document.getElementById('openProductSearchModalBtn');
8 | if (existingModal) {
9 | existingModal.remove();
10 | }
11 | if (existingButton) {
12 | existingButton.remove();
13 | }
14 |
15 | // Add floating button styles
16 | const floatingButtonCSS = `
17 | #openProductSearchModalBtn {
18 | position: fixed;
19 | right: 20px;
20 | top: 40%;
21 | transform: translateY(-50%);
22 | background-color: #007bff;
23 | color: white;
24 | border: none;
25 | border-radius: 50%;
26 | width: 60px;
27 | height: 60px;
28 | font-size: 24px;
29 | box-shadow: 0 3px 10px rgba(0,0,0,0.2);
30 | cursor: pointer;
31 | z-index: 9990;
32 | display: flex;
33 | align-items: center;
34 | justify-content: center;
35 | transition: all 0.3s ease;
36 | }
37 | #openProductSearchModalBtn:hover {
38 | background-color: #0056b3;
39 | box-shadow: 0 5px 15px rgba(0,0,0,0.3);
40 | }
41 | `;
42 |
43 | // Create modal HTML
44 | const modalHTML = `
45 | <div id="chatgpt-product-search-modal" style="
46 | position: fixed;
47 | top: 0;
48 | left: 0;
49 | width: 100%;
50 | height: 100%;
51 | background: rgb(230 237 248 / 80%);
52 | display: flex;
53 | align-items: center;
54 | justify-content: center;
55 | z-index: 10000;
56 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
57 | ">
58 | <div style="
59 | background: white;
60 | width: 90%;
61 | height:85%;
62 | border-radius: 8px;
63 | display: flex;
64 | flex-direction: column;
65 | overflow: hidden;
66 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
67 | ">
68 | <div style="
69 | background: #f8f9fa;
70 | padding: 12px 20px;
71 | display: flex;
72 | justify-content: space-between;
73 | align-items: center;
74 | border-bottom: 1px solid #e9ecef;
75 | ">
76 | <h1 style="
77 | font-size: 18px;
78 | font-weight: 600;
79 | margin: 0;
80 | color: #495057;
81 | ">🔍 ChatGPT Product Info Search</h1>
82 | <button id="close-modal-btn" style="
83 | background: none;
84 | border: none;
85 | color: #6c757d;
86 | font-size: 20px;
87 | width: 30px;
88 | height: 30px;
89 | border-radius: 4px;
90 | cursor: pointer;
91 | display: flex;
92 | align-items: center;
93 | justify-content: center;
94 | ">×</button>
95 | </div>
96 |
97 | <div style="
98 | flex: 1;
99 | display: flex;
100 | flex-direction: column;
101 | overflow: hidden;
102 | ">
103 | <div id="search-area" style="
104 | position: relative;
105 | padding: 20px;
106 | border-bottom: 1px solid #e9ecef;
107 | background: white;
108 | transition: all 0.3s ease;
109 | ">
110 | <!-- Collapse/Expand Button - positioned absolutely -->
111 | <div id="collapse-toggle" style="
112 | display: none;
113 | position: absolute;
114 | top: 8px;
115 | right: 20px;
116 | cursor: pointer;
117 | color: #007bff;
118 | font-size: 12px;
119 | font-weight: 500;
120 | transition: all 0.2s ease;
121 | border-radius: 4px;
122 | padding: 4px 8px;
123 | background: rgba(0, 123, 255, 0.1);
124 | border: 1px solid rgba(0, 123, 255, 0.2);
125 | z-index: 10;
126 | ">
127 | <span id="collapse-text">▲ Hide</span>
128 | </div>
129 |
130 | <div id="search-controls">
131 | <!-- Multi-product toggle -->
132 | <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px; padding: 8px 0;">
133 | <label style="
134 | display: flex;
135 | align-items: center;
136 | gap: 8px;
137 | font-size: 14px;
138 | color: #495057;
139 | font-weight: 500;
140 | cursor: pointer;
141 | ">
142 | <div style="
143 | position: relative;
144 | width: 44px;
145 | height: 24px;
146 | background: #dee2e6;
147 | border-radius: 12px;
148 | transition: background 0.3s ease;
149 | cursor: pointer;
150 | " id="toggle-background">
151 | <input type="checkbox" id="multi-product-toggle" style="
152 | position: absolute;
153 | opacity: 0;
154 | width: 100%;
155 | height: 100%;
156 | margin: 0;
157 | cursor: pointer;
158 | " />
159 | <div style="
160 | position: absolute;
161 | top: 2px;
162 | left: 2px;
163 | width: 20px;
164 | height: 20px;
165 | background: white;
166 | border-radius: 50%;
167 | transition: transform 0.3s ease;
168 | box-shadow: 0 2px 4px rgba(0,0,0,0.2);
169 | " id="toggle-slider"></div>
170 | </div>
171 | Multi-product search
172 | </label>
173 | <div style="
174 | font-size: 12px;
175 | color: #6c757d;
176 | font-style: italic;
177 | ">Search multiple products at once</div>
178 | </div>
179 |
180 | <!-- Single product input -->
181 | <div id="single-product-input" style="display: flex; gap: 12px; margin-bottom: 12px;">
182 | <input type="text" id="search-query" placeholder="Search query (e.g., iPhone 17, Nike shoes, Pets Deli Hundefutter)" style="
183 | flex: 1;
184 | padding: 8px 12px;
185 | border: 1px solid #dee2e6;
186 | border-radius: 4px;
187 | font-size: 14px;
188 | box-sizing: border-box;
189 | " />
190 | <button id="search-btn" style="
191 | background: #007bff;
192 | color: white;
193 | border: none;
194 | padding: 8px 16px;
195 | border-radius: 4px;
196 | font-size: 14px;
197 | font-weight: 500;
198 | cursor: pointer;
199 | white-space: nowrap;
200 | ">Search</button>
201 | </div>
202 |
203 | <!-- Multi product input -->
204 | <div id="multi-product-input" style="display: none; margin-bottom: 12px;">
205 | <textarea id="multi-search-query" placeholder="Enter product names, one per line: iPhone 17 Pro Samsung Galaxy S25 Google Pixel 9" style="
206 | width: 100%;
207 | min-height: 100px;
208 | padding: 8px 12px;
209 | border: 1px solid #dee2e6;
210 | border-radius: 4px;
211 | font-size: 14px;
212 | box-sizing: border-box;
213 | resize: vertical;
214 | font-family: inherit;
215 | margin-bottom: 8px;
216 | "></textarea>
217 | <div style="display: flex; gap: 12px;">
218 | <button id="multi-search-btn" style="
219 | background: #007bff;
220 | color: white;
221 | border: none;
222 | padding: 8px 16px;
223 | border-radius: 4px;
224 | font-size: 14px;
225 | font-weight: 500;
226 | cursor: pointer;
227 | white-space: nowrap;
228 | ">Search All Products</button>
229 | <div style="
230 | font-size: 12px;
231 | color: #6c757d;
232 | align-self: center;
233 | font-style: italic;
234 | ">Results will be shown in a table format</div>
235 | </div>
236 | </div>
237 | </div> <!-- End search-controls -->
238 |
239 | <!-- Hidden token field for status display -->
240 | <input type="password" id="auth-token" placeholder="Token will be fetched automatically" readonly style="
241 | display: none;
242 | padding: 8px 12px;
243 | border: 1px solid #dee2e6;
244 | border-radius: 4px;
245 | font-size: 14px;
246 | box-sizing: border-box;
247 | background-color: #f9f9f9;
248 | cursor: not-allowed;
249 | " />
250 | </div>
251 |
252 | <div id="results-container" style="
253 | flex: 1;
254 | overflow-y: auto;
255 | padding: 20px;
256 | ">
257 | <div id="welcome-state" style="
258 | text-align: center;
259 | padding: 60px 40px;
260 | color: #6c757d;
261 | display: flex;
262 | flex-direction: column;
263 | align-items: center;
264 | justify-content: center;
265 | height: 100%;
266 | min-height: 300px;
267 | ">
268 | <div style="
269 | font-size: 48px;
270 | margin-bottom: 20px;
271 | opacity: 0.7;
272 | ">🔍</div>
273 | <h3 style="
274 | margin: 0 0 12px 0;
275 | font-size: 20px;
276 | font-weight: 600;
277 | color: #495057;
278 | ">Product Search</h3>
279 | <p style="
280 | margin: 0 0 24px 0;
281 | font-size: 16px;
282 | line-height: 1.5;
283 | max-width: 400px;
284 | ">Search for product reviews, comparisons, and detailed information from across the web</p>
285 | <div style="
286 | padding: 16px 20px;
287 | border-left: 4px solid #007bff;
288 | max-width: 500px;
289 | text-align: left;
290 | ">
291 | <div style="font-weight: 600; margin-bottom: 8px; color: #495057;">Try searching for:</div>
292 | <div style="color: #6c757d; font-size: 14px; line-height: 1.4;">
293 | • "iPhone 17 Pro camera quality"<br>
294 | • "Nike Air Max running shoes"<br>
295 | • "MacBook Air M3 performance"<br>
296 | • "Tesla Model 3 reviews"
297 | </div>
298 | </div>
299 | <div id="auth-status" style="
300 | margin-top: 20px;
301 | padding: 8px 16px;
302 | border-radius: 20px;
303 | font-size: 13px;
304 | font-weight: 500;
305 | background: #e3f2fd;
306 | color: #1565c0;
307 | border: 1px solid #bbdefb;
308 | ">🔐 Checking authentication...</div>
309 | </div>
310 | </div>
311 | </div>
312 |
313 | <!-- Fixed Footer -->
314 | <div style="
315 | position: fixed;
316 | bottom: 0;
317 | left: 0;
318 | right: 0;
319 | background: #f8f9fa;
320 | border-top: 1px solid #e9ecef;
321 | padding: 8px 0;
322 | text-align: center;
323 | font-size: 14px;
324 | z-index: 10001;
325 | ">
326 | Created by <a href="https://www.martinaberastegue.com/" target="_blank" rel="noopener noreferrer">Martin Aberastegue (@Xyborg)</a>
327 | </div>
328 | </div>
329 | </div>
330 | `;
331 |
332 | // Function to create and show modal
333 | function createModal() {
334 | // Remove existing modal if present
335 | const existingModal = document.getElementById('chatgpt-product-search-modal');
336 | if (existingModal) {
337 | existingModal.remove();
338 | }
339 |
340 | // Inject modal into page
341 | document.body.insertAdjacentHTML('beforeend', modalHTML);
342 |
343 | // Get modal elements
344 | const modal = document.getElementById('chatgpt-product-search-modal');
345 | const closeBtn = document.getElementById('close-modal-btn');
346 | const searchBtn = document.getElementById('search-btn');
347 | const multiSearchBtn = document.getElementById('multi-search-btn');
348 | const searchQuery = document.getElementById('search-query');
349 | const multiSearchQuery = document.getElementById('multi-search-query');
350 | const authToken = document.getElementById('auth-token');
351 | const resultsContainer = document.getElementById('results-container');
352 | const multiProductToggle = document.getElementById('multi-product-toggle');
353 | const toggleBackground = document.getElementById('toggle-background');
354 | const toggleSlider = document.getElementById('toggle-slider');
355 | const singleProductInput = document.getElementById('single-product-input');
356 | const multiProductInput = document.getElementById('multi-product-input');
357 | const collapseToggle = document.getElementById('collapse-toggle');
358 | const collapseText = document.getElementById('collapse-text');
359 | const searchControls = document.getElementById('search-controls');
360 |
361 | // Close modal functionality
362 | closeBtn.addEventListener('click', () => {
363 | modal.remove();
364 | });
365 |
366 | // Close on outside click
367 | modal.addEventListener('click', (e) => {
368 | if (e.target === modal) {
369 | modal.remove();
370 | }
371 | });
372 |
373 | // Close on Escape key
374 | document.addEventListener('keydown', (e) => {
375 | if (e.key === 'Escape') {
376 | modal.remove();
377 | }
378 | });
379 |
380 | // Toggle functionality
381 | multiProductToggle.addEventListener('change', () => {
382 | const isMultiMode = multiProductToggle.checked;
383 |
384 | if (isMultiMode) {
385 | // Switch to multi-product mode
386 | singleProductInput.style.display = 'none';
387 | multiProductInput.style.display = 'block';
388 | toggleBackground.style.background = '#007bff';
389 | toggleSlider.style.transform = 'translateX(20px)';
390 | } else {
391 | // Switch to single-product mode
392 | singleProductInput.style.display = 'flex';
393 | multiProductInput.style.display = 'none';
394 | toggleBackground.style.background = '#dee2e6';
395 | toggleSlider.style.transform = 'translateX(0px)';
396 | }
397 | });
398 |
399 | // Collapse/Expand functionality
400 | collapseToggle.addEventListener('click', () => {
401 | const isCollapsed = searchControls.style.display === 'none';
402 |
403 | if (isCollapsed) {
404 | // Expand
405 | searchControls.style.display = 'block';
406 | collapseText.textContent = '▲ Hide';
407 | collapseToggle.style.background = 'rgba(0, 123, 255, 0.1)';
408 | collapseToggle.style.border = '1px solid rgba(0, 123, 255, 0.2)';
409 | collapseToggle.style.color = '#007bff';
410 | } else {
411 | // Collapse
412 | searchControls.style.display = 'none';
413 | collapseText.textContent = '▼ Show';
414 | collapseToggle.style.background = 'rgba(40, 167, 69, 0.1)';
415 | collapseToggle.style.border = '1px solid rgba(40, 167, 69, 0.2)';
416 | collapseToggle.style.color = '#28a745';
417 | }
418 | });
419 |
420 | // Add hover effects to collapse toggle
421 | collapseToggle.addEventListener('mouseenter', () => {
422 | const isCollapsed = searchControls.style.display === 'none';
423 | if (isCollapsed) {
424 | collapseToggle.style.background = 'rgba(40, 167, 69, 0.2)';
425 | collapseToggle.style.transform = 'scale(1.05)';
426 | } else {
427 | collapseToggle.style.background = 'rgba(0, 123, 255, 0.2)';
428 | collapseToggle.style.transform = 'scale(1.05)';
429 | }
430 | });
431 |
432 | collapseToggle.addEventListener('mouseleave', () => {
433 | const isCollapsed = searchControls.style.display === 'none';
434 | if (isCollapsed) {
435 | collapseToggle.style.background = 'rgba(40, 167, 69, 0.1)';
436 | } else {
437 | collapseToggle.style.background = 'rgba(0, 123, 255, 0.1)';
438 | }
439 | collapseToggle.style.transform = 'scale(1)';
440 | });
441 |
442 | // Search functionality
443 | searchBtn.addEventListener('click', performSearch);
444 | multiSearchBtn.addEventListener('click', performMultiSearch);
445 |
446 | // Enter key support
447 | searchQuery.addEventListener('keydown', (e) => {
448 | if (e.key === 'Enter') {
449 | performSearch();
450 | }
451 | });
452 |
453 | // Initialize token status
454 | initializeTokenStatus();
455 | }
456 |
457 | // Function to show the collapse toggle after results are displayed
458 | function showCollapseToggle() {
459 | const collapseToggle = document.getElementById('collapse-toggle');
460 | if (collapseToggle) {
461 | collapseToggle.style.display = 'block';
462 | }
463 | }
464 |
465 | async function performSearch() {
466 | const searchQuery = document.getElementById('search-query');
467 | const searchBtn = document.getElementById('search-btn');
468 | const resultsContainer = document.getElementById('results-container');
469 |
470 | if (!searchQuery || !searchBtn || !resultsContainer) {
471 | alert('Modal elements not found. Please try again.');
472 | return;
473 | }
474 |
475 | const query = searchQuery.value.trim();
476 |
477 | if (!query) {
478 | alert('Please enter a search query');
479 | return;
480 | }
481 |
482 | // Get token automatically
483 | let token;
484 | try {
485 | token = await getAutomaticToken();
486 | } catch (error) {
487 | alert('Failed to get authentication token. Please make sure you\'re logged in to ChatGPT.');
488 | return;
489 | }
490 |
491 | // Show loading state
492 | searchBtn.disabled = true;
493 | searchBtn.textContent = 'Searching...';
494 | resultsContainer.innerHTML = `
495 | <div style="text-align: center; padding: 40px; color: #666;">
496 | <div style="
497 | display: inline-block;
498 | width: 32px;
499 | height: 32px;
500 | border: 3px solid #f3f3f3;
501 | border-top: 3px solid #667eea;
502 | border-radius: 50%;
503 | animation: spin 1s linear infinite;
504 | margin-bottom: 10px;
505 | "></div>
506 | <p>Searching for "${query}"...</p>
507 | </div>
508 | <style>
509 | @keyframes spin {
510 | 0% { transform: rotate(0deg); }
511 | 100% { transform: rotate(360deg); }
512 | }
513 | </style>
514 | `;
515 |
516 | try {
517 | const result = await searchProduct(query, token);
518 | displayResults(result, query);
519 | } catch (error) {
520 | displayError(error.message);
521 | } finally {
522 | searchBtn.disabled = false;
523 | searchBtn.textContent = 'Search';
524 | // Show collapse toggle after results are displayed
525 | showCollapseToggle();
526 | }
527 | }
528 |
529 | async function performMultiSearch() {
530 | const multiSearchQuery = document.getElementById('multi-search-query');
531 | const multiSearchBtn = document.getElementById('multi-search-btn');
532 | const resultsContainer = document.getElementById('results-container');
533 |
534 | if (!multiSearchQuery || !multiSearchBtn || !resultsContainer) {
535 | alert('Modal elements not found. Please try again.');
536 | return;
537 | }
538 |
539 | const queries = multiSearchQuery.value.trim().split('\n').filter(q => q.trim());
540 |
541 | if (queries.length === 0) {
542 | alert('Please enter at least one product name');
543 | return;
544 | }
545 |
546 | if (queries.length > 10) {
547 | alert('Maximum 10 products allowed at once to avoid rate limiting');
548 | return;
549 | }
550 |
551 | // Get token automatically
552 | let token;
553 | try {
554 | token = await getAutomaticToken();
555 | } catch (error) {
556 | alert('Failed to get authentication token. Please make sure you\'re logged in to ChatGPT.');
557 | return;
558 | }
559 |
560 | // Show loading state
561 | multiSearchBtn.disabled = true;
562 | multiSearchBtn.textContent = 'Searching...';
563 | resultsContainer.innerHTML = `
564 | <div style="text-align: center; padding: 40px; color: #666;">
565 | <div style="
566 | display: inline-block;
567 | width: 32px;
568 | height: 32px;
569 | border: 3px solid #f3f3f3;
570 | border-top: 3px solid #667eea;
571 | border-radius: 50%;
572 | animation: spin 1s linear infinite;
573 | margin-bottom: 10px;
574 | "></div>
575 | <p>Searching ${queries.length} products...</p>
576 | <div id="progress-status" style="font-size: 14px; color: #999; margin-top: 10px;">
577 | Starting searches...
578 | </div>
579 | </div>
580 | <style>
581 | @keyframes spin {
582 | 0% { transform: rotate(0deg); }
583 | 100% { transform: rotate(360deg); }
584 | }
585 | </style>
586 | `;
587 |
588 | const results = [];
589 | const progressStatus = document.getElementById('progress-status');
590 |
591 | try {
592 | // Search products one by one
593 | for (let i = 0; i < queries.length; i++) {
594 | const query = queries[i].trim();
595 | if (progressStatus) {
596 | progressStatus.textContent = `Searching "${query}" (${i + 1}/${queries.length})...`;
597 | }
598 |
599 | try {
600 | const result = await searchProduct(query, token);
601 | results.push({
602 | query: query,
603 | success: true,
604 | data: result
605 | });
606 | } catch (error) {
607 | results.push({
608 | query: query,
609 | success: false,
610 | error: error.message
611 | });
612 | }
613 |
614 | // Add a small delay between requests to be respectful
615 | if (i < queries.length - 1) {
616 | await new Promise(resolve => setTimeout(resolve, 1000));
617 | }
618 | }
619 |
620 | displayMultiResults(results);
621 | } catch (error) {
622 | displayError(error.message);
623 | } finally {
624 | multiSearchBtn.disabled = false;
625 | multiSearchBtn.textContent = 'Search All Products';
626 | // Show collapse toggle after results are displayed
627 | showCollapseToggle();
628 | }
629 | }
630 |
631 | async function getAutomaticToken() {
632 | try {
633 | const response = await fetch("/api/auth/session");
634 | if (!response.ok) {
635 | throw new Error(`Session API responded with: ${response.status} ${response.statusText}`);
636 | }
637 | const sessionData = await response.json();
638 |
639 | if (!sessionData.accessToken) {
640 | throw new Error("No access token found in session. Please make sure you're logged in to ChatGPT.");
641 | }
642 |
643 | // Update the token input field to show it's been fetched
644 | const tokenInput = document.getElementById('auth-token');
645 | if (tokenInput) {
646 | tokenInput.placeholder = "✅ Token fetched from session";
647 | tokenInput.style.backgroundColor = "#f0f8ff";
648 | tokenInput.style.borderColor = "#007bff";
649 | }
650 |
651 | return sessionData.accessToken;
652 | } catch (error) {
653 | // Update the token input field to show error
654 | const tokenInput = document.getElementById('auth-token');
655 | if (tokenInput) {
656 | tokenInput.placeholder = "❌ Failed to fetch token automatically";
657 | tokenInput.style.backgroundColor = "#fff5f5";
658 | tokenInput.style.borderColor = "#ef4444";
659 | tokenInput.readOnly = false;
660 | tokenInput.style.cursor = "text";
661 | }
662 |
663 | throw error;
664 | }
665 | }
666 |
667 | async function searchProduct(query, token) {
668 | const requestBody = {
669 | "conversation_id": "",
670 | "is_client_thread": true,
671 | "message_id": "",
672 | "product_query": query,
673 | "supported_encodings": ["v1"],
674 | "product_lookup_key": {
675 | "data": JSON.stringify({
676 | "request_query": query,
677 | "all_ids": {"p2": [""]},
678 | "known_ids": {},
679 | "metadata_sources": ["p1", "p3"],
680 | "variant_sources": null,
681 | "last_variant_group_types": null,
682 | "merchant_hints": [],
683 | "provider_title": query
684 | }),
685 | "version": "1",
686 | "variant_options_query": null
687 | }
688 | };
689 |
690 | const response = await fetch("https://chatgpt.com/backend-api/search/product_info", {
691 | "headers": {
692 | "accept": "text/event-stream",
693 | "accept-language": "en-GB,en-US;q=0.9,en;q=0.8,es-AR;q=0.7,es;q=0.6,de;q=0.5,it;q=0.4,zh-CN;q=0.3,zh;q=0.2,id;q=0.1,pt-BR;q=0.1,pt;q=0.1,fr;q=0.1,tr;q=0.1,pl;q=0.1,sv;q=0.1,ru;q=0.1,ar;q=0.1,el;q=0.1",
694 | "authorization": "Bearer " + token,
695 | "content-type": "application/json",
696 | "oai-client-version": "prod-43c98f917bf2c3e3a36183e9548cd048e4e40615",
697 | "oai-device-id": generateDeviceId(),
698 | "oai-language": "en-US",
699 | "priority": "u=1, i",
700 | "sec-ch-ua": '"Opera";v="120", "Not-A.Brand";v="8", "Chromium";v="135"',
701 | "sec-ch-ua-arch": '"arm"',
702 | "sec-ch-ua-bitness": '"64"',
703 | "sec-ch-ua-full-version": '"120.0.5543.161"',
704 | "sec-ch-ua-full-version-list": '"Opera";v="120.0.5543.161", "Not-A.Brand";v="8.0.0.0", "Chromium";v="135.0.7049.115"',
705 | "sec-ch-ua-mobile": "?0",
706 | "sec-ch-ua-model": '""',
707 | "sec-ch-ua-platform": '"macOS"',
708 | "sec-ch-ua-platform-version": '"15.5.0"',
709 | "sec-fetch-dest": "empty",
710 | "sec-fetch-mode": "cors",
711 | "sec-fetch-site": "same-origin"
712 | },
713 | "referrer": window.location.href,
714 | "referrerPolicy": "strict-origin-when-cross-origin",
715 | "body": JSON.stringify(requestBody),
716 | "method": "POST",
717 | "mode": "cors",
718 | "credentials": "include"
719 | });
720 |
721 | if (!response.ok) {
722 | throw new Error(`HTTP error! status: ${response.status}`);
723 | }
724 |
725 | const responseText = await response.text();
726 | return parseProductInfo(responseText);
727 | }
728 |
729 | function parseProductInfo(content) {
730 | const products = [];
731 | const reviews = [];
732 | const productLinks = []; // Store product links from grouped_citation
733 | const rationaleObj = { text: '' }; // Track the current rationale being built (using object for reference)
734 | const citations = new Map(); // Store citations by cite key
735 | const summaryObj = { text: '' }; // Track the current summary being built (using object for reference)
736 |
737 | const lines = content.split('\n');
738 | let currentEvent = null;
739 | let currentData = [];
740 | let eventCount = 0;
741 |
742 | for (let i = 0; i < lines.length; i++) {
743 | const line = lines[i];
744 |
745 | if (line.startsWith('event: ')) {
746 | if (currentEvent && currentData.length > 0) {
747 | processEvent(currentEvent, currentData.join('\n'), products, reviews, productLinks, rationaleObj, eventCount, citations, summaryObj);
748 | eventCount++;
749 | }
750 |
751 | currentEvent = line.replace('event: ', '').trim();
752 | currentData = [];
753 | } else if (line.startsWith('data: ')) {
754 | currentData.push(line.replace('data: ', ''));
755 | } else if (line.trim() === '') {
756 | continue;
757 | } else {
758 | currentData.push(line);
759 | }
760 | }
761 |
762 | if (currentEvent && currentData.length > 0) {
763 | processEvent(currentEvent, currentData.join('\n'), products, reviews, productLinks, rationaleObj, eventCount, citations, summaryObj);
764 | eventCount++;
765 | }
766 |
767 | // Map citations to reviews
768 | for (const review of reviews) {
769 | if (review.cite && citations.has(review.cite)) {
770 | review.url = citations.get(review.cite).url;
771 | }
772 | }
773 |
774 | const uniqueMerchants = new Set();
775 | for (const product of products) {
776 | for (const offer of product.offers || []) {
777 | if (offer.merchant_name) {
778 | uniqueMerchants.add(offer.merchant_name);
779 | }
780 | }
781 | }
782 |
783 | const reviewThemes = [...new Set(reviews.map(review => review.theme))];
784 |
785 | return {
786 | products: products,
787 | productLinks: productLinks, // Add detected product links
788 | reviews: reviews,
789 | rationale: rationaleObj.text || null,
790 | reviewSummary: summaryObj.text || null, // Add the built summary
791 | summary: {
792 | total_products: products.length,
793 | total_product_links: productLinks.length,
794 | total_reviews: reviews.length,
795 | unique_merchants: uniqueMerchants.size,
796 | review_themes: reviewThemes
797 | }
798 | };
799 | }
800 |
801 | function processEvent(eventType, dataStr, products, reviews, productLinks, rationaleObj, eventIndex, citations, summaryObj) {
802 | if (eventType !== 'delta' || !dataStr || dataStr === '""') {
803 | return;
804 | }
805 |
806 | try {
807 | const data = JSON.parse(dataStr);
808 |
809 | if (typeof data !== 'object' || data === null) {
810 | return;
811 | }
812 |
813 | // Track processed patches to avoid duplicates
814 | const processedPatches = new Set();
815 |
816 | // Handle direct patch objects (like your examples)
817 | if (data.p === '/grouped_citation' && data.o === 'replace' && data.v && data.v.url) {
818 | eventIndex++;
819 | const citeKey = `turn0search${eventIndex}`;
820 | citations.set(citeKey, {
821 | url: data.v.url,
822 | title: data.v.title || ''
823 | });
824 |
825 | // Remove any existing product link with the same URL to avoid duplicates
826 | const existingLinkIndex = productLinks.findIndex(link => link.url === data.v.url);
827 |
828 | // Capture ALL grouped_citation objects as product links
829 | const productLink = {
830 | title: data.v.title || '',
831 | url: data.v.url,
832 | snippet: data.v.snippet || '',
833 | source: extractDomainFromUrl(data.v.url)
834 | };
835 |
836 | if (existingLinkIndex >= 0) {
837 | // Update existing link with potentially better title
838 | productLinks[existingLinkIndex] = productLink;
839 | } else {
840 | // Add new link
841 | productLinks.push(productLink);
842 | }
843 | }
844 |
845 | // Handle direct rationale patches
846 | if (data.p === '/rationale' && data.o === 'append' && data.v) {
847 | rationaleObj.text += data.v;
848 | }
849 |
850 | // Handle direct summary patches
851 | if (data.p === '/summary' && data.o === 'append' && data.v) {
852 | summaryObj.text += data.v;
853 | }
854 |
855 | // Capture citations from cite_map and rationale patches
856 | if (data.v && Array.isArray(data.v)) {
857 | for (const patch of data.v) {
858 | if (patch.p && patch.p.startsWith('/cite_map/') && patch.o === 'add' && patch.v && patch.v.url) {
859 | const citeKey = patch.p.replace('/cite_map/', '');
860 | citations.set(citeKey, {
861 | url: patch.v.url,
862 | title: patch.v.title || ''
863 | });
864 | }
865 |
866 | // Capture grouped_citation from data.v array (like your example!)
867 | if (patch.p === '/grouped_citation' && patch.o === 'replace' && patch.v && patch.v.url) {
868 | eventIndex++;
869 | const citeKey = `turn0search${eventIndex}`;
870 | citations.set(citeKey, {
871 | url: patch.v.url,
872 | title: patch.v.title || ''
873 | });
874 |
875 | // Remove any existing product link with the same URL to avoid duplicates
876 | const existingLinkIndex = productLinks.findIndex(link => link.url === patch.v.url);
877 |
878 | // Capture ALL grouped_citation objects as product links
879 | const productLink = {
880 | title: patch.v.title || '',
881 | url: patch.v.url,
882 | snippet: patch.v.snippet || '',
883 | source: extractDomainFromUrl(patch.v.url)
884 | };
885 |
886 | if (existingLinkIndex >= 0) {
887 | // Update existing link with potentially better title
888 | productLinks[existingLinkIndex] = productLink;
889 | } else {
890 | // Add new link
891 | productLinks.push(productLink);
892 | }
893 | }
894 |
895 | // Handle individual grouped_citation property updates (like URL and title changes)
896 | if (patch.p && patch.p.startsWith('/grouped_citation/') && patch.o === 'replace' && patch.v) {
897 |
898 | // Find or create the latest citation entry
899 | let latestCiteKey = null;
900 | let maxCiteNum = -1;
901 |
902 | // Find the highest numbered citation
903 | for (const [key, value] of citations.entries()) {
904 | if (key.startsWith('turn0search')) {
905 | const num = parseInt(key.replace('turn0search', ''));
906 | if (num > maxCiteNum) {
907 | maxCiteNum = num;
908 | latestCiteKey = key;
909 | }
910 | }
911 | }
912 |
913 | // If no citation exists yet, create one
914 | if (!latestCiteKey) {
915 | latestCiteKey = `turn0search${eventIndex}`;
916 | citations.set(latestCiteKey, {});
917 | }
918 |
919 | // Update the citation with the new property
920 | const existingCitation = citations.get(latestCiteKey) || {};
921 |
922 | if (patch.p === '/grouped_citation/url') {
923 | citations.set(latestCiteKey, {
924 | ...existingCitation,
925 | url: patch.v
926 | });
927 | } else if (patch.p === '/grouped_citation/title') {
928 | citations.set(latestCiteKey, {
929 | ...existingCitation,
930 | title: patch.v
931 | });
932 | }
933 |
934 | // Update or add product link if we have both URL and title
935 | const updatedCitation = citations.get(latestCiteKey);
936 | if (updatedCitation.url) {
937 | // Remove any existing product link with the same URL to avoid duplicates
938 | const existingLinkIndex = productLinks.findIndex(link => link.url === updatedCitation.url);
939 |
940 | const productLink = {
941 | title: updatedCitation.title || 'Product Link',
942 | url: updatedCitation.url,
943 | snippet: '',
944 | source: extractDomainFromUrl(updatedCitation.url)
945 | };
946 |
947 | if (existingLinkIndex >= 0) {
948 | // Update existing link
949 | productLinks[existingLinkIndex] = productLink;
950 | } else {
951 | // Add new link
952 | productLinks.push(productLink);
953 | }
954 | }
955 | }
956 |
957 | // Capture rationale patches from data.v array
958 | if (patch.p === '/rationale' && patch.o === 'append' && patch.v) {
959 | const patchKey = `/rationale-${patch.v}`;
960 | if (!processedPatches.has(patchKey)) {
961 | processedPatches.add(patchKey);
962 | rationaleObj.text += patch.v;
963 | }
964 | }
965 |
966 | // Capture summary patches from data.v array
967 | if (patch.p === '/summary' && patch.o === 'append' && patch.v) {
968 | summaryObj.text += patch.v;
969 | }
970 | }
971 | }
972 |
973 | // Also check patch operations for citations and rationale updates
974 | if (data.o === 'patch' && data.v && Array.isArray(data.v)) {
975 | for (const patch of data.v) {
976 | if (patch.p && patch.p.startsWith('/cite_map/') && patch.o === 'add' && patch.v && patch.v.url) {
977 | const citeKey = patch.p.replace('/cite_map/', '');
978 | citations.set(citeKey, {
979 | url: patch.v.url,
980 | title: patch.v.title || ''
981 | });
982 | }
983 |
984 | if (patch.p === '/grouped_citation' && patch.o === 'replace' && patch.v && patch.v.url) {
985 | // This is a citation being added/updated
986 | citations.set(`turn0search${eventIndex}`, {
987 | url: patch.v.url,
988 | title: patch.v.title || ''
989 | });
990 |
991 | // Remove any existing product link with the same URL to avoid duplicates
992 | const existingLinkIndex = productLinks.findIndex(link => link.url === patch.v.url);
993 |
994 | // Capture ALL grouped_citation objects as product links
995 | const productLink = {
996 | title: patch.v.title || '',
997 | url: patch.v.url,
998 | snippet: patch.v.snippet || '',
999 | source: extractDomainFromUrl(patch.v.url)
1000 | };
1001 |
1002 | if (existingLinkIndex >= 0) {
1003 | // Update existing link with potentially better title
1004 | productLinks[existingLinkIndex] = productLink;
1005 | } else {
1006 | // Add new link
1007 | productLinks.push(productLink);
1008 | }
1009 | }
1010 |
1011 | // Capture rationale patches
1012 | if (patch.p === '/rationale' && patch.o === 'append' && patch.v) {
1013 | const patchKey = `/rationale-${patch.v}`;
1014 | if (!processedPatches.has(patchKey)) {
1015 | processedPatches.add(patchKey);
1016 | rationaleObj.text += patch.v;
1017 | }
1018 | }
1019 |
1020 | // Capture summary patches
1021 | if (patch.p === '/summary' && patch.o === 'append' && patch.v) {
1022 | summaryObj.text += patch.v;
1023 | }
1024 | }
1025 | }
1026 |
1027 | if (data.v && typeof data.v === 'object' && !Array.isArray(data.v)) {
1028 | const vData = data.v;
1029 |
1030 | if (vData.type === 'product_entity' && vData.product) {
1031 | const product = vData.product;
1032 |
1033 | const productInfo = {
1034 | title: product.title || '',
1035 | price: product.price || '',
1036 | url: product.url || '',
1037 | merchants: product.merchants || '',
1038 | description: product.description || null,
1039 | rating: product.rating || null,
1040 | num_reviews: product.num_reviews || null,
1041 | featured_tag: product.featured_tag || null,
1042 | image_urls: product.image_urls || [],
1043 | offers: []
1044 | };
1045 |
1046 | if (product.offers) {
1047 | for (const offerData of product.offers) {
1048 | const offer = {
1049 | merchant_name: offerData.merchant_name || '',
1050 | product_name: offerData.product_name || '',
1051 | url: offerData.url || '',
1052 | price: offerData.price || '',
1053 | details: offerData.details || null,
1054 | available: offerData.available !== false,
1055 | tag: offerData.tag?.text || null
1056 | };
1057 | productInfo.offers.push(offer);
1058 | }
1059 | }
1060 |
1061 | productInfo.variants = [];
1062 | if (product.variants) {
1063 | for (const variant of product.variants) {
1064 | const selectedOption = variant.options?.find(opt => opt.selected)?.label || null;
1065 | productInfo.variants.push({
1066 | type: variant.label || '',
1067 | selected: selectedOption
1068 | });
1069 | }
1070 | }
1071 |
1072 | products.push(productInfo);
1073 | }
1074 |
1075 | else if (vData.type === 'product_reviews') {
1076 | // Initialize the current summary from the product_reviews object
1077 | if (vData.summary) {
1078 | summaryObj.text = vData.summary;
1079 | }
1080 |
1081 | const reviewList = vData.reviews || [];
1082 | for (const reviewData of reviewList) {
1083 | const review = {
1084 | source: reviewData.source || '',
1085 | theme: reviewData.theme || '',
1086 | summary: reviewData.summary || '',
1087 | sentiment: reviewData.sentiment || '',
1088 | rating: reviewData.rating || null,
1089 | num_reviews: reviewData.num_reviews || null,
1090 | cite: reviewData.cite || `turn0search${eventIndex}`,
1091 | url: null // Will be populated later from citations
1092 | };
1093 | reviews.push(review);
1094 | }
1095 | }
1096 |
1097 | else if (vData.type === 'product_rationale') {
1098 | const rationale = vData.rationale || '';
1099 | if (rationale) {
1100 | // Initialize rationale - set the initial text
1101 | rationaleObj.text = rationale;
1102 | }
1103 | }
1104 | }
1105 |
1106 | else if (data.o === 'add' && data.v) {
1107 | const vData = data.v;
1108 | if (typeof vData === 'object' && vData.type === 'product_reviews') {
1109 | const reviewList = vData.reviews || [];
1110 | for (const reviewData of reviewList) {
1111 | if (typeof reviewData === 'object') {
1112 | const review = {
1113 | source: reviewData.source || '',
1114 | theme: reviewData.theme || '',
1115 | summary: reviewData.summary || '',
1116 | sentiment: reviewData.sentiment || '',
1117 | rating: reviewData.rating || null,
1118 | num_reviews: reviewData.num_reviews || null,
1119 | cite: reviewData.cite || `turn0search${eventIndex}`,
1120 | url: null // Will be populated later from citations
1121 | };
1122 | reviews.push(review);
1123 | }
1124 | }
1125 | }
1126 | }
1127 |
1128 | else if (data.o === 'patch' && data.v) {
1129 | const vData = data.v;
1130 | if (Array.isArray(vData)) {
1131 | for (const item of vData) {
1132 | if (typeof item === 'object' && item.p === '/reviews' && item.o === 'append') {
1133 | const reviewList = item.v || [];
1134 | if (Array.isArray(reviewList)) {
1135 | for (const reviewData of reviewList) {
1136 | if (typeof reviewData === 'object') {
1137 | const review = {
1138 | source: reviewData.source || '',
1139 | theme: reviewData.theme || '',
1140 | summary: reviewData.summary || '',
1141 | sentiment: reviewData.sentiment || '',
1142 | rating: reviewData.rating || null,
1143 | num_reviews: reviewData.num_reviews || null,
1144 | cite: reviewData.cite || `turn0search${eventIndex}`,
1145 | url: null // Will be populated later from citations
1146 | };
1147 | reviews.push(review);
1148 | }
1149 | }
1150 | }
1151 | }
1152 | }
1153 | }
1154 | }
1155 |
1156 | if (data.v && Array.isArray(data.v)) {
1157 | for (const item of data.v) {
1158 | if (typeof item === 'object' && item.p === '/reviews' && item.o === 'append') {
1159 | const reviewList = item.v || [];
1160 | if (Array.isArray(reviewList)) {
1161 | for (const reviewData of reviewList) {
1162 | if (typeof reviewData === 'object') {
1163 | const review = {
1164 | source: reviewData.source || '',
1165 | theme: reviewData.theme || '',
1166 | summary: reviewData.summary || '',
1167 | sentiment: reviewData.sentiment || '',
1168 | rating: reviewData.rating || null,
1169 | num_reviews: reviewData.num_reviews || null,
1170 | cite: reviewData.cite || `turn0search${eventIndex}`,
1171 | url: null // Will be populated later from citations
1172 | };
1173 | reviews.push(review);
1174 | }
1175 | }
1176 | }
1177 | }
1178 | }
1179 | }
1180 |
1181 | } catch (jsonError) {
1182 | return;
1183 | }
1184 | }
1185 |
1186 | function generateDeviceId() {
1187 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
1188 | const r = Math.random() * 16 | 0;
1189 | const v = c == 'x' ? r : (r & 0x3 | 0x8);
1190 | return v.toString(16);
1191 | });
1192 | }
1193 |
1194 | function extractDomainFromUrl(url) {
1195 | try {
1196 | const urlObj = new URL(url);
1197 | return urlObj.hostname.replace('www.', '');
1198 | } catch (e) {
1199 | return url.split('/')[2] || url;
1200 | }
1201 | }
1202 |
1203 | function displayResults(data, query) {
1204 | const resultsContainer = document.getElementById('results-container');
1205 | if (!resultsContainer) {
1206 | console.error('Results container not found');
1207 | return;
1208 | }
1209 |
1210 | if (!data || (!data.reviews.length && !data.products.length && !data.productLinks.length)) {
1211 | resultsContainer.innerHTML = `
1212 | <div style="
1213 | background: #fef2f2;
1214 | color: #991b1b;
1215 | padding: 15px;
1216 | border-radius: 8px;
1217 | border-left: 4px solid #ef4444;
1218 | margin: 20px 0;
1219 | ">
1220 | <h3>No results found</h3>
1221 | <p>No products or reviews were found for "${query}". Try a different search term.</p>
1222 | </div>
1223 | `;
1224 | return;
1225 | }
1226 |
1227 | let html = `
1228 | <div style="
1229 | padding: 12px;
1230 | margin-bottom: 16px;
1231 | border-bottom: 1px solid #e9ecef;
1232 | ">
1233 | <div style="
1234 | font-size: 16px;
1235 | font-weight: 600;
1236 | color: #495057;
1237 | margin-bottom: 8px;
1238 | ">Results for "${query}"</div>
1239 | <div style="display: flex; gap: 16px; font-size: 14px; color: #6c757d;">
1240 | <span>${data.summary.total_reviews} reviews</span>
1241 | <span>${data.summary.total_products} products</span>
1242 | <span>${data.summary.total_product_links} citation links</span>
1243 | <span>${data.summary.review_themes.length} themes</span>
1244 | </div>
1245 | </div>
1246 | `;
1247 |
1248 | // Show message when we have links but no reviews or products
1249 | if (data.productLinks.length > 0 && data.reviews.length === 0 && data.products.length === 0) {
1250 | html += `
1251 | <div style="margin-bottom: 20px;">
1252 | <div style="
1253 | background: #fff3cd;
1254 | border: 1px solid #ffeaa7;
1255 | border-radius: 6px;
1256 | padding: 12px;
1257 | margin: 0 12px;
1258 | color: #856404;
1259 | font-size: 13px;
1260 | ">
1261 | <div style="font-weight: 600; margin-bottom: 4px;">⚠️ Limited Information Available</div>
1262 | <div>ChatGPT found ${data.productLinks.length} relevant link(s) for this product, but no detailed reviews or product information were retrieved. You can check the citation links below for more information.</div>
1263 | </div>
1264 | </div>
1265 | `;
1266 | }
1267 |
1268 | if (data.rationale && data.rationale.trim()) {
1269 | html += `
1270 | <div style="margin-bottom: 20px;">
1271 | <div style="
1272 | font-size: 14px;
1273 | font-weight: 600;
1274 | color: #495057;
1275 | margin-bottom: 8px;
1276 | padding: 0 12px;
1277 | ">Product Overview</div>
1278 | <div style="
1279 | background: #e2f1ff;
1280 | padding: 12px;
1281 | margin: 0 12px;
1282 | color: #000;
1283 | line-height: 1.4;
1284 | font-size: 13px;
1285 | border-left: 3px solid #3a6797;
1286 | ">${data.rationale}</div>
1287 | </div>
1288 | `;
1289 | }
1290 |
1291 | if (data.reviewSummary && data.reviewSummary.trim()) {
1292 | html += `
1293 | <div style="margin-bottom: 20px;">
1294 | <div style="
1295 | font-size: 14px;
1296 | font-weight: 600;
1297 | color: #495057;
1298 | margin-bottom: 8px;
1299 | padding: 0 12px;
1300 | ">Review Summary</div>
1301 | <div style="
1302 | background: #f5fee8;
1303 | padding: 12px;
1304 | margin: 0 12px;
1305 | color: #000;
1306 | line-height: 1.4;
1307 | font-size: 13px;
1308 | border-left: 3px solid #93ac71;
1309 | ">${data.reviewSummary}</div>
1310 | </div>
1311 | `;
1312 | }
1313 |
1314 | if (data.productLinks && data.productLinks.length > 0) {
1315 | html += `
1316 | <div style="margin-bottom: 20px;">
1317 | <div style="
1318 | font-size: 14px;
1319 | font-weight: 600;
1320 | color: #495057;
1321 | margin-bottom: 8px;
1322 | padding: 0 12px;
1323 | ">Citation Links</div>
1324 | ${data.productLinks.map(link => `
1325 | <div style="
1326 | border: 1px solid #e9ecef;
1327 | border-radius: 6px;
1328 | padding: 12px;
1329 | margin: 0 12px 8px 12px;
1330 | background: #f8f9fa;
1331 | ">
1332 | <div style="
1333 | display: flex;
1334 | align-items: center;
1335 | margin-bottom: 6px;
1336 | gap: 8px;
1337 | ">
1338 | <img src="https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(link.url)}&size=128" alt="${link.source}" style="
1339 | width: 16px;
1340 | height: 16px;
1341 | border-radius: 2px;
1342 | flex-shrink: 0;
1343 | " onerror="this.style.display='none'">
1344 | <span style="
1345 | font-weight: 600;
1346 | color: #007bff;
1347 | font-size: 14px;
1348 | ">${link.title}</span>
1349 | <a href="${link.url}" target="_blank" style="
1350 | color: #28a745;
1351 | text-decoration: none;
1352 | font-size: 14px;
1353 | margin-left: auto;
1354 | background: #d4edda;
1355 | padding: 4px 8px;
1356 | border-radius: 4px;
1357 | border: 1px solid #c3e6cb;
1358 | " title="Visit citation page">↗</a>
1359 | </div>
1360 | ${link.snippet ? `<div style="
1361 | color: #6c757d;
1362 | font-size: 12px;
1363 | line-height: 1.3;
1364 | margin-top: 4px;
1365 | ">${link.snippet}</div>` : ''}
1366 | </div>
1367 | `).join('')}
1368 | </div>
1369 | `;
1370 | }
1371 |
1372 | if (data.reviews.length > 0) {
1373 | // Calculate sentiment distribution
1374 | const sentimentCounts = data.reviews.reduce((acc, review) => {
1375 | acc[review.sentiment] = (acc[review.sentiment] || 0) + 1;
1376 | return acc;
1377 | }, {});
1378 |
1379 | const totalReviews = data.reviews.length;
1380 | const positiveCount = sentimentCounts.positive || 0;
1381 | const neutralCount = sentimentCounts.neutral || 0;
1382 | const negativeCount = sentimentCounts.negative || 0;
1383 |
1384 | const positivePercent = Math.round((positiveCount / totalReviews) * 100);
1385 | const neutralPercent = Math.round((neutralCount / totalReviews) * 100);
1386 | const negativePercent = Math.round((negativeCount / totalReviews) * 100);
1387 |
1388 | html += `
1389 | <div>
1390 | <div style="
1391 | display: flex;
1392 | align-items: center;
1393 | justify-content: space-between;
1394 | margin-bottom: 12px;
1395 | padding: 0 12px;
1396 | ">
1397 | <div style="
1398 | font-size: 14px;
1399 | font-weight: 600;
1400 | color: #495057;
1401 | ">Reviews</div>
1402 | <div style="display: flex; align-items: center; gap: 12px;">
1403 | <div style="
1404 | display: flex;
1405 | background: #f8f9fa;
1406 | border-radius: 8px;
1407 | overflow: hidden;
1408 | width: 100px;
1409 | height: 6px;
1410 | border: 1px solid #e9ecef;
1411 | ">
1412 | ${positiveCount > 0 ? `<div style="
1413 | background: #28a745;
1414 | width: ${positivePercent}%;
1415 | height: 100%;
1416 | " title="${positiveCount} positive (${positivePercent}%)"></div>` : ''}
1417 | ${neutralCount > 0 ? `<div style="
1418 | background: #ffc107;
1419 | width: ${neutralPercent}%;
1420 | height: 100%;
1421 | " title="${neutralCount} neutral (${neutralPercent}%)"></div>` : ''}
1422 | ${negativeCount > 0 ? `<div style="
1423 | background: #dc3545;
1424 | width: ${negativePercent}%;
1425 | height: 100%;
1426 | " title="${negativeCount} negative (${negativePercent}%)"></div>` : ''}
1427 | </div>
1428 | <div style="
1429 | font-size: 12px;
1430 | color: #6c757d;
1431 | white-space: nowrap;
1432 | ">
1433 | <span style="color: #28a745;">●</span> ${positivePercent}%
1434 | ${neutralCount > 0 ? `<span style="color: #ffc107; margin-left: 6px;">●</span> ${neutralPercent}%` : ''}
1435 | ${negativeCount > 0 ? `<span style="color: #dc3545; margin-left: 6px;">●</span> ${negativePercent}%` : ''}
1436 | </div>
1437 | </div>
1438 | </div>
1439 | ${data.reviews.map(review => {
1440 | const sentimentColor = review.sentiment === 'positive' ? '#28a745' :
1441 | review.sentiment === 'negative' ? '#dc3545' : '#ffc107';
1442 |
1443 |
1444 | return `
1445 | <div style="
1446 | border-bottom: 1px solid #f8f9fa;
1447 | padding: 12px;
1448 | margin-bottom: 1px;
1449 | ">
1450 | <div style="
1451 | display: flex;
1452 | align-items: center;
1453 | margin-bottom: 8px;
1454 | gap: 8px;
1455 | ">
1456 | <img src="https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(review.url || `https://${review.source.toLowerCase().replace(/\s+/g, '')}.com`)}&size=128" alt="${review.source}" style="
1457 | width: 16px;
1458 | height: 16px;
1459 | border-radius: 2px;
1460 | flex-shrink: 0;
1461 | " onerror="this.style.display='none'">
1462 | <span style="font-weight: 500; color: #495057; font-size: 14px;">${review.source}</span>
1463 | ${review.url ? `<a href="${review.url}" target="_blank" style="
1464 | color: #6c757d;
1465 | text-decoration: none;
1466 | font-size: 12px;
1467 | margin-left: auto;
1468 | " title="Open source">↗</a>` : ''}
1469 | <span style="
1470 | width: 8px;
1471 | height: 8px;
1472 | border-radius: 50%;
1473 | background: ${sentimentColor};
1474 | display: inline-block;
1475 | margin-left: ${review.url ? '4px' : 'auto'};
1476 | "></span>
1477 | </div>
1478 | <div style="
1479 | font-size: 13px;
1480 | font-weight: 600;
1481 | color: #007bff;
1482 | margin-bottom: 6px;
1483 | ">${review.theme}</div>
1484 | <div style="color: #6c757d; line-height: 1.4; font-size: 13px;">${review.summary}</div>
1485 | </div>
1486 | `;
1487 | }).join('')}
1488 | </div>
1489 | `;
1490 | }
1491 |
1492 | if (data.summary.review_themes.length > 0) {
1493 | html += `
1494 | <div style="margin-bottom: 20px;">
1495 | <div style="
1496 | font-size: 14px;
1497 | font-weight: 600;
1498 | color: #495057;
1499 | margin-bottom: 8px;
1500 | padding: 0 12px;
1501 | ">Themes</div>
1502 | <div style="display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px;">
1503 | ${data.summary.review_themes.map(theme =>
1504 | `<span style="
1505 | background: #e9ecef;
1506 | color: #495057;
1507 | padding: 4px 8px;
1508 | border-radius: 12px;
1509 | font-size: 12px;
1510 | ">${theme}</span>`
1511 | ).join('')}
1512 | </div>
1513 | </div>
1514 | `;
1515 | }
1516 |
1517 | resultsContainer.innerHTML = html;
1518 | }
1519 |
1520 | function displayMultiResults(results) {
1521 | const resultsContainer = document.getElementById('results-container');
1522 | if (!resultsContainer) {
1523 | console.error('Results container not found');
1524 | return;
1525 | }
1526 |
1527 | const successfulResults = results.filter(r => r.success);
1528 | const failedResults = results.filter(r => !r.success);
1529 |
1530 | let html = `
1531 | <div style="
1532 | padding: 12px;
1533 | margin-bottom: 16px;
1534 | border-bottom: 1px solid #e9ecef;
1535 | ">
1536 | <div style="
1537 | font-size: 16px;
1538 | font-weight: 600;
1539 | color: #495057;
1540 | margin-bottom: 8px;
1541 | ">Multi-Product Search Results</div>
1542 | <div style="display: flex; gap: 16px; font-size: 14px; color: #6c757d;">
1543 | <span>${successfulResults.length}/${results.length} products found</span>
1544 | ${failedResults.length > 0 ? `<span style="color: #dc3545;">${failedResults.length} failed</span>` : ''}
1545 | </div>
1546 | </div>
1547 | `;
1548 |
1549 | if (failedResults.length > 0) {
1550 | html += `
1551 | <div style="margin-bottom: 20px;">
1552 | <div style="
1553 | font-size: 14px;
1554 | font-weight: 600;
1555 | color: #dc3545;
1556 | margin-bottom: 8px;
1557 | padding: 0 12px;
1558 | ">Search Errors</div>
1559 | <div style="
1560 | background: #f8d7da;
1561 | border: 1px solid #f5c6cb;
1562 | border-radius: 4px;
1563 | padding: 12px;
1564 | margin: 0 12px;
1565 | color: #721c24;
1566 | font-size: 13px;
1567 | ">
1568 | ${failedResults.map(r => `<div><strong>${r.query}:</strong> ${r.error}</div>`).join('<br>')}
1569 | </div>
1570 | </div>
1571 | `;
1572 | }
1573 |
1574 | if (successfulResults.length === 0) {
1575 | html += `
1576 | <div style="
1577 | text-align: center;
1578 | padding: 60px 40px;
1579 | color: #6c757d;
1580 | ">
1581 | <div style="font-size: 48px; margin-bottom: 20px; opacity: 0.7;">❌</div>
1582 | <h3 style="margin: 0 0 12px 0; font-size: 20px; font-weight: 600; color: #495057;">No Results Found</h3>
1583 | <p style="margin: 0; font-size: 16px; line-height: 1.5;">None of the products could be found. Please try different search terms.</p>
1584 | </div>
1585 | `;
1586 | resultsContainer.innerHTML = html;
1587 | return;
1588 | }
1589 |
1590 | // Create comparison table
1591 | html += `
1592 | <div style="margin-bottom: 20px;">
1593 | <div style="
1594 | font-size: 14px;
1595 | font-weight: 600;
1596 | color: #495057;
1597 | margin-bottom: 12px;
1598 | padding: 0 12px;
1599 | ">Product Comparison Table</div>
1600 |
1601 | <div style="overflow-x: auto; margin: 0 12px;">
1602 | <table style="
1603 | width: 100%;
1604 | border-collapse: collapse;
1605 | background: white;
1606 | border: 1px solid #e9ecef;
1607 | border-radius: 6px;
1608 | overflow: hidden;
1609 | font-size: 13px;
1610 | ">
1611 | <thead>
1612 | <tr style="background: #f8f9fa; border-bottom: 2px solid #e9ecef;">
1613 | <th style="
1614 | padding: 12px 8px;
1615 | text-align: left;
1616 | font-weight: 600;
1617 | color: #495057;
1618 | border-right: 1px solid #e9ecef;
1619 | min-width: 150px;
1620 | ">Product</th>
1621 | <th style="
1622 | padding: 12px 8px;
1623 | text-align: center;
1624 | font-weight: 600;
1625 | color: #495057;
1626 | border-right: 1px solid #e9ecef;
1627 | min-width: 80px;
1628 | ">Reviews</th>
1629 | <th style="
1630 | padding: 12px 8px;
1631 | text-align: center;
1632 | font-weight: 600;
1633 | color: #495057;
1634 | border-right: 1px solid #e9ecef;
1635 | min-width: 120px;
1636 | ">Sentiment</th>
1637 | <th style="
1638 | padding: 12px 8px;
1639 | text-align: left;
1640 | font-weight: 600;
1641 | color: #495057;
1642 | border-right: 1px solid #e9ecef;
1643 | min-width: 200px;
1644 | ">Top Themes</th>
1645 | <th style="
1646 | padding: 12px 8px;
1647 | text-align: center;
1648 | font-weight: 600;
1649 | color: #495057;
1650 | min-width: 80px;
1651 | ">Links</th>
1652 | </tr>
1653 | </thead>
1654 | <tbody>
1655 | `;
1656 |
1657 | successfulResults.forEach((result, index) => {
1658 | const data = result.data;
1659 |
1660 | // Calculate sentiment distribution
1661 | const sentimentCounts = data.reviews.reduce((acc, review) => {
1662 | acc[review.sentiment] = (acc[review.sentiment] || 0) + 1;
1663 | return acc;
1664 | }, {});
1665 |
1666 | const totalReviews = data.reviews.length;
1667 | const positiveCount = sentimentCounts.positive || 0;
1668 | const neutralCount = sentimentCounts.neutral || 0;
1669 | const negativeCount = sentimentCounts.negative || 0;
1670 |
1671 | const positivePercent = totalReviews > 0 ? Math.round((positiveCount / totalReviews) * 100) : 0;
1672 | const neutralPercent = totalReviews > 0 ? Math.round((neutralCount / totalReviews) * 100) : 0;
1673 | const negativePercent = totalReviews > 0 ? Math.round((negativeCount / totalReviews) * 100) : 0;
1674 |
1675 | // Get top 3 themes
1676 | const topThemes = data.summary.review_themes.slice(0, 3);
1677 |
1678 | html += `
1679 | <tr style="
1680 | border-bottom: 1px solid #f8f9fa;
1681 | ${index % 2 === 0 ? 'background: #fdfdfd;' : 'background: white;'}
1682 | ">
1683 | <td style="
1684 | padding: 12px 8px;
1685 | border-right: 1px solid #e9ecef;
1686 | vertical-align: top;
1687 | ">
1688 | <div style="
1689 | font-weight: 600;
1690 | color: #007bff;
1691 | margin-bottom: 4px;
1692 | cursor: pointer;
1693 | " data-product-index="${index}" class="product-name-link">${result.query}</div>
1694 | ${data.rationale ? `<div style="
1695 | color: #6c757d;
1696 | font-size: 11px;
1697 | line-height: 1.3;
1698 | max-height: 60px;
1699 | overflow: hidden;
1700 | ">${data.rationale.substring(0, 120)}${data.rationale.length > 120 ? '...' : ''}</div>` : ''}
1701 | </td>
1702 | <td style="
1703 | padding: 12px 8px;
1704 | text-align: center;
1705 | border-right: 1px solid #e9ecef;
1706 | vertical-align: top;
1707 | ">
1708 | <div style="font-weight: 600; color: #495057;">${totalReviews}</div>
1709 | <div style="font-size: 11px; color: #6c757d;">reviews</div>
1710 | </td>
1711 | <td style="
1712 | padding: 12px 8px;
1713 | text-align: center;
1714 | border-right: 1px solid #e9ecef;
1715 | vertical-align: top;
1716 | ">
1717 | ${totalReviews > 0 ? `
1718 | <div style="
1719 | display: flex;
1720 | background: #f8f9fa;
1721 | border-radius: 4px;
1722 | overflow: hidden;
1723 | width: 60px;
1724 | height: 6px;
1725 | margin: 0 auto 4px auto;
1726 | border: 1px solid #e9ecef;
1727 | ">
1728 | ${positiveCount > 0 ? `<div style="
1729 | background: #28a745;
1730 | width: ${positivePercent}%;
1731 | height: 100%;
1732 | " title="${positiveCount} positive"></div>` : ''}
1733 | ${neutralCount > 0 ? `<div style="
1734 | background: #ffc107;
1735 | width: ${neutralPercent}%;
1736 | height: 100%;
1737 | " title="${neutralCount} neutral"></div>` : ''}
1738 | ${negativeCount > 0 ? `<div style="
1739 | background: #dc3545;
1740 | width: ${negativePercent}%;
1741 | height: 100%;
1742 | " title="${negativeCount} negative"></div>` : ''}
1743 | </div>
1744 | <div style="
1745 | font-size: 10px;
1746 | color: #6c757d;
1747 | line-height: 1.2;
1748 | ">
1749 | <span style="color: #28a745;">●</span>${positivePercent}%
1750 | ${neutralCount > 0 ? `<br><span style="color: #ffc107;">●</span>${neutralPercent}%` : ''}
1751 | ${negativeCount > 0 ? `<br><span style="color: #dc3545;">●</span>${negativePercent}%` : ''}
1752 | </div>
1753 | ` : '<span style="color: #6c757d; font-size: 11px;">No reviews</span>'}
1754 | </td>
1755 | <td style="
1756 | padding: 12px 8px;
1757 | border-right: 1px solid #e9ecef;
1758 | vertical-align: top;
1759 | ">
1760 | ${topThemes.length > 0 ? topThemes.map(theme => `
1761 | <span style="
1762 | display: inline-block;
1763 | background: #e9ecef;
1764 | color: #495057;
1765 | padding: 2px 6px;
1766 | border-radius: 8px;
1767 | font-size: 10px;
1768 | margin: 1px 2px 1px 0;
1769 | ">${theme}</span>
1770 | `).join('') : '<span style="color: #6c757d; font-size: 11px;">No themes</span>'}
1771 | ${data.summary.review_themes.length > 3 ? `<span style="color: #6c757d; font-size: 10px;">+${data.summary.review_themes.length - 3} more</span>` : ''}
1772 | </td>
1773 | <td style="
1774 | padding: 12px 8px;
1775 | text-align: center;
1776 | vertical-align: top;
1777 | ">
1778 | <div style="font-weight: 600; color: #495057;">${data.productLinks.length}</div>
1779 | <div style="font-size: 11px; color: #6c757d;">links</div>
1780 | ${(data.reviews.length > 0 || data.products.length > 0 || data.rationale || data.reviewSummary) ? `
1781 | <button data-product-index="${index}" class="view-details-btn" style="
1782 | background: #007bff;
1783 | color: white;
1784 | border: none;
1785 | padding: 2px 6px;
1786 | border-radius: 3px;
1787 | font-size: 10px;
1788 | cursor: pointer;
1789 | margin-top: 4px;
1790 | ">View</button>
1791 | ` : data.productLinks.length > 0 ? `
1792 | <button data-product-index="${index}" class="view-details-btn" style="
1793 | background: #ffc107;
1794 | color: #212529;
1795 | border: none;
1796 | padding: 2px 6px;
1797 | border-radius: 3px;
1798 | font-size: 10px;
1799 | cursor: pointer;
1800 | margin-top: 4px;
1801 | ">Links</button>
1802 | ` : ''}
1803 | </td>
1804 | </tr>
1805 | `;
1806 | });
1807 |
1808 | html += `
1809 | </tbody>
1810 | </table>
1811 | </div>
1812 | </div>
1813 | `;
1814 |
1815 | // Add detailed results section (initially hidden)
1816 | html += `
1817 | <div id="detailed-results" style="display: none;">
1818 | <div style="
1819 | font-size: 14px;
1820 | font-weight: 600;
1821 | color: #495057;
1822 | margin-bottom: 12px;
1823 | padding: 0 12px;
1824 | ">Detailed Product Information</div>
1825 | <div id="detailed-content"></div>
1826 | </div>
1827 | `;
1828 |
1829 | resultsContainer.innerHTML = html;
1830 |
1831 | // Store results for detailed view
1832 | window.multiSearchResults = successfulResults;
1833 |
1834 | // Add event listeners for product details links
1835 | const productNameLinks = document.querySelectorAll('.product-name-link');
1836 | productNameLinks.forEach(link => {
1837 | link.addEventListener('click', function() {
1838 | const index = parseInt(this.getAttribute('data-product-index'));
1839 | showProductDetails(index);
1840 | });
1841 | });
1842 |
1843 | const viewDetailsBtns = document.querySelectorAll('.view-details-btn');
1844 | viewDetailsBtns.forEach(btn => {
1845 | btn.addEventListener('click', function() {
1846 | const index = parseInt(this.getAttribute('data-product-index'));
1847 | showProductDetails(index);
1848 | });
1849 | });
1850 | }
1851 |
1852 | // Function to show detailed product information
1853 | window.showProductDetails = function(index) {
1854 | const detailedResults = document.getElementById('detailed-results');
1855 | const detailedContent = document.getElementById('detailed-content');
1856 |
1857 | if (!window.multiSearchResults || !detailedResults || !detailedContent) {
1858 | return;
1859 | }
1860 |
1861 | const result = window.multiSearchResults[index];
1862 | if (!result) {
1863 | return;
1864 | }
1865 |
1866 | const productName = result.query;
1867 |
1868 | // Display the single product result using existing function
1869 | const tempContainer = document.createElement('div');
1870 | tempContainer.innerHTML = '';
1871 |
1872 | // Use the existing displayResults function but capture its output
1873 | const originalContainer = document.getElementById('results-container');
1874 | const tempId = 'temp-results-container';
1875 | tempContainer.id = tempId;
1876 | document.body.appendChild(tempContainer);
1877 |
1878 | // Temporarily replace the results container
1879 | const originalGetElementById = document.getElementById;
1880 | document.getElementById = function(id) {
1881 | if (id === 'results-container') {
1882 | return tempContainer;
1883 | }
1884 | return originalGetElementById.call(document, id);
1885 | };
1886 |
1887 | displayResults(result.data, result.query);
1888 |
1889 | // Restore original function
1890 | document.getElementById = originalGetElementById;
1891 |
1892 | // Move the content to detailed view
1893 | detailedContent.innerHTML = `
1894 | <div style="
1895 | background: #f8f9fa;
1896 | padding: 12px;
1897 | margin-bottom: 16px;
1898 | border-radius: 6px;
1899 | border-left: 4px solid #007bff;
1900 | ">
1901 | <div style="
1902 | display: flex;
1903 | justify-content: space-between;
1904 | align-items: center;
1905 | ">
1906 | <h3 style="margin: 0; color: #495057;">Detailed view: ${productName}</h3>
1907 | <button class="close-details-btn" style="
1908 | background: #6c757d;
1909 | color: white;
1910 | border: none;
1911 | padding: 4px 8px;
1912 | border-radius: 3px;
1913 | font-size: 12px;
1914 | cursor: pointer;
1915 | ">Close</button>
1916 | </div>
1917 | </div>
1918 | ${tempContainer.innerHTML}
1919 | `;
1920 |
1921 | // Clean up
1922 | document.body.removeChild(tempContainer);
1923 |
1924 | // Add event listener for close button
1925 | const closeBtn = detailedContent.querySelector('.close-details-btn');
1926 | if (closeBtn) {
1927 | closeBtn.addEventListener('click', function() {
1928 | detailedResults.style.display = 'none';
1929 | });
1930 | }
1931 |
1932 | // Show detailed results
1933 | detailedResults.style.display = 'block';
1934 | detailedResults.scrollIntoView({ behavior: 'smooth' });
1935 | };
1936 |
1937 | function displayError(message) {
1938 | const resultsContainer = document.getElementById('results-container');
1939 | if (!resultsContainer) {
1940 | console.error('Results container not found');
1941 | alert(`Search Error: ${message}`);
1942 | return;
1943 | }
1944 |
1945 | resultsContainer.innerHTML = `
1946 | <div style="
1947 | background: #fef2f2;
1948 | color: #991b1b;
1949 | padding: 15px;
1950 | border-radius: 8px;
1951 | border-left: 4px solid #ef4444;
1952 | margin: 20px 0;
1953 | ">
1954 | <h3>Search Error</h3>
1955 | <p>${message}</p>
1956 | <p>Please check your token and try again.</p>
1957 | </div>
1958 | `;
1959 | }
1960 |
1961 | // Initialize token status check
1962 | async function initializeTokenStatus() {
1963 | const tokenInput = document.getElementById('auth-token');
1964 | const authStatus = document.getElementById('auth-status');
1965 |
1966 | if (!tokenInput || !authStatus) {
1967 | console.error('Token status elements not found');
1968 | return;
1969 | }
1970 |
1971 | try {
1972 | const response = await fetch("/api/auth/session");
1973 | if (response.ok) {
1974 | const sessionData = await response.json();
1975 | if (sessionData.accessToken) {
1976 | // Update hidden token field
1977 | if (tokenInput) {
1978 | tokenInput.placeholder = "✅ Token ready - session active";
1979 | tokenInput.style.backgroundColor = "#f0f8ff";
1980 | tokenInput.style.borderColor = "#007bff";
1981 | }
1982 |
1983 | // Update visible auth status
1984 | if (authStatus) {
1985 | authStatus.innerHTML = "✅ Ready to search";
1986 | authStatus.style.background = "#d4edda";
1987 | authStatus.style.color = "#155724";
1988 | authStatus.style.borderColor = "#c3e6cb";
1989 | }
1990 |
1991 | } else {
1992 | throw new Error("No access token in session");
1993 | }
1994 | } else {
1995 | throw new Error(`Session check failed: ${response.status}`);
1996 | }
1997 | } catch (error) {
1998 | // Update hidden token field
1999 | if (tokenInput) {
2000 | tokenInput.placeholder = "❌ Please log in to ChatGPT first";
2001 | tokenInput.style.backgroundColor = "#fff5f5";
2002 | tokenInput.style.borderColor = "#ef4444";
2003 | }
2004 |
2005 | // Update visible auth status
2006 | if (authStatus) {
2007 | authStatus.innerHTML = "❌ Please log in to ChatGPT first";
2008 | authStatus.style.background = "#f8d7da";
2009 | authStatus.style.color = "#721c24";
2010 | authStatus.style.borderColor = "#f5c6cb";
2011 | }
2012 |
2013 | }
2014 | }
2015 |
2016 | // Function to create floating button
2017 | function createFloatingButton() {
2018 | const button = document.createElement('button');
2019 | button.id = 'openProductSearchModalBtn';
2020 | button.innerHTML = '🛍️';
2021 | button.title = 'Open ChatGPT Product Info Search';
2022 | document.body.appendChild(button);
2023 | return button;
2024 | }
2025 |
2026 | // Add styles to the page
2027 | const styleSheet = document.createElement("style");
2028 | styleSheet.type = "text/css";
2029 | styleSheet.innerText = floatingButtonCSS;
2030 | document.head.appendChild(styleSheet);
2031 |
2032 | // Create floating button and add click handler
2033 | const floatingButton = createFloatingButton();
2034 | floatingButton.addEventListener('click', createModal);
2035 |
2036 | })();
```