PageSourceSearch

https://ironhorse.sassnet.com/assets/controllers/product_lookup_controller-ed98baa1.js

js sassnet.com collected 2026-09-24 09:37:05 UTC 8,747 bytes, 212 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3export default class extends Controller {
4    static values = {
5        resultLimit: { type: Number, default: 25 },
6        typingDelay: { type: Number, default: 300 },
7        searchPath: { type: String, default: '/products/product_search' },
8        productIdFieldName: { type: String, default: 'line_items[][product_id]' },
9        retailProductIdFieldName: { type: String, default: 'line_items[][retail_product_id]' },
10        // When set, selecting a retail product loads its options into the
11        // "retail-options" frame so staff answer them from dropdowns.
12        retailOptionsPath: { type: String, default: '' }
13    }
14
15    connect() {
16        let typingTimer;
17
18        // Create a hidden lookup div
19        const resultsContainer = document.createElement('div');
20        resultsContainer.className = "hidden relative";
21        resultsContainer.id = `lookup-results-${this.element.id}`;
22        this.element.parentNode.appendChild(resultsContainer);
23        this.resultsContainer = resultsContainer;
24
25        this.element.addEventListener('input', (event) => {
26            clearTimeout(typingTimer);
27            typingTimer = setTimeout(() => {
28                this.sendRequest();
29            }, this.typingDelayValue);
30        });
31
32        this.element.addEventListener('keydown', (event) => {
33            if (!isNaN(parseInt(event.key))) {
34                const index = parseInt(event.key) - 1;
35                const resultElement = document.getElementById(this.resultsContainer.id + '-result-' + index);
36                if (resultElement) {
37                    const selectedId = resultElement.dataset.productId;
38                    // Need to find the product object from the last search results
39                    const product = this.lastSearchResults && this.lastSearchResults[index];
40                    this.selectItem(selectedId, resultElement.dataset.productName, product);
41                }
42            }
43            if (event.key === 'Tab') {
44                this.hideResults();
45            }
46        });
47
48        document.body.addEventListener('click', (event) => {
49            const resultsListElement = document.getElementById(this.resultsContainer.id + '-list');
50            const inResultsList = resultsListElement && resultsListElement.contains(event.target);
51            const inInput = this.element.contains(event.target);
52            if (!inResultsList && !inInput) {
53                this.hideResults();
54            }
55        });
56
57        this.element.addEventListener('focus', () => {
58            this.showResults();
59            if (this.element.value.length > 0) {
60                this.sendRequest();
61            }
62        });
63    }
64
65    sendRequest() {
66        // Get the CSRF token from the meta tag
67        const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
68
69        // If not in a form, you might want to fetch based on the element's value
70        const query = this.element.value;
71        if (query.length < 2) {
72            this.hideResults();
73            return;
74        }
75
76        fetch(`${this.searchPathValue}?query=${encodeURIComponent(query)}`, {
77            headers: {
78                'X-Requested-With': 'XMLHttpRequest',
79                'X-CSRF-Token': token
80            }
81        })
82            .then(response => response.json())
83            .then(products => {
84                // Store the results for keyboard navigation
85                this.lastSearchResults = products;
86                
87                // Clear the container first
88                this.resultsContainer.innerHTML = '';
89                this.showResults();
90
91                // Create the container element
92                const ul = document.createElement('ul');
93                ul.className = "z-10 absolute left-0 my-8 rounded-md border border-gray-300 bg-white p-4 shadow-lg w-[50vw]";
94                ul.id = this.resultsContainer.id + '-list';
95
96                // Add each list item
97                products.forEach((product, index) => {
98                    const li = document.createElement('li');
99                    li.className = "my-4 flex cursor-pointer items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-2 shadow-sm group first:mt-0 hover:border-gray-300 hover:shadow-md";
100                    li.id = this.resultsContainer.id + '-result-' + index;
101
102                    const label = product.label ?? product.description;
103                    const code = product.code ?? product.product_code;
104                    const displayName = `${label} (${code})`;
105                    const price = (product.price_cents / 100).toFixed(2);
106                    const optionsBadge = product.has_options
107                        ? '<span class="ml-4 rounded-md bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700">options</span>'
108                        : '';
109
110                    li.innerHTML = `
111                        <div>
112                            <span class="mr-8 text-sm text-gray-400">#${index + 1}</span>
113                            <span class="text-lg font-medium">${label}</span>
114                            <span class="ml-4 text-gray-600">(${code})</span>
115                            <span class="ml-4 text-gray-600">$${price}</span>
116                            ${optionsBadge}
117                        </div>
118                        <div>
119                            <span class="hidden group-hover:block">Select</span>
120                        </div>
121                    `;
122
123                    // Add click handler
124                    li.addEventListener('click', (event) => {
125                        // Stop bubble
126                        event.stopPropagation();
127                        this.selectItem(product.id, displayName, product);
128                    });
129                    li.dataset.productId = product.id;
130                    li.dataset.productName = displayName;
131                    li.dataset.specialOrder = product.special_order;
132
133                    ul.appendChild(li);
134                });
135
136                // Add the "Limited results" message
137                const limitSpan = document.createElement('span');
138                limitSpan.className = "pt-8 text-sm text-gray-600";
139                limitSpan.textContent = `Limited to ${this.resultLimitValue} results`;
140                ul.appendChild(limitSpan);
141
142                // Add the populated list to the results container
143                this.resultsContainer.appendChild(ul);
144            })
145            .catch(error => {
146                console.error("Error:", error);
147            });
148    }
149
150    selectItem(id, displayName, product = null) {
151        const isRetail = product?.sellable_type === 'retail_product';
152
153        // A line sells exactly one thing, so only the matching catalog's field
154        // carries an id — the other is cleared in case an earlier pick set it.
155        const legacyInput = this.hiddenInput('hidden-product-id', this.productIdFieldNameValue);
156        const retailInput = this.hiddenInput('hidden-retail-product-id', this.retailProductIdFieldNameValue);
157        legacyInput.value = isRetail ? '' : id;
158        retailInput.value = isRetail ? id : '';
159
160        this.loadRetailOptions(isRetail ? id : null);
161
162        // Set the displayed text to the product name
163        this.element.value = displayName;
164
165        // Emit a custom event for other controllers to listen to
166        if (product) {
167            const event = new CustomEvent('product-selected', {
168                detail: { product: product },
169                bubbles: true
170            });
171            this.element.dispatchEvent(event);
172        }
173        
174        this.hideResults();
175    }
176
177    hiddenInput(idPrefix, name) {
178        const id = `${idPrefix}-${this.element.id}`;
179        let input = document.getElementById(id);
180        if (!input) {
181            input = document.createElement('input');
182            input.type = 'hidden';
183            input.name = name;
184            input.id = id;
185            this.element.parentNode.appendChild(input);
186        }
187        return input;
188    }
189
190    loadRetailOptions(retailProductId) {
191        if (!this.retailOptionsPathValue) return;
192
193        const frame = document.getElementById('retail-options');
194        if (!frame) return;
195
196        if (retailProductId) {
197            frame.src = `${this.retailOptionsPathValue}?retail_product_id=${retailProductId}`;
198        } else {
199            frame.removeAttribute('src');
200            frame.innerHTML = '';
201        }
202    }
203
204    hideResults() {
205        this.resultsContainer.classList.add('hidden');
206        this.resultsContainer.innerHTML = '';
207    }
208
209    showResults() {
210        this.resultsContainer.classList.remove('hidden');
211    }
212}

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.