1import { Controller } from "@hotwired/stimulus" 2 3// "Add products" â a no-code product picker. The store searches its catalogue, 4// ticks products, and hits Insert; we ask the server to render an email-safe 5// product grid and dispatch it to the campaign editor, which splices it into the 6// body between the <!--lb:products-block--> markers (replacing any existing grid). 7// 8// data-controller="ecommerce--marketing--product-picker" 9// data-ecommerce--marketing--product-picker-search-url-value="/admin/marketing/campaigns/search_products.json" 10// data-ecommerce--marketing--product-picker-block-url-value="/admin/marketing/campaigns/product_block.json" 11// 12// Dispatches: ecommerce--marketing--product-picker:insert { detail: { html, productIds } } 13export default class extends Controller { 14 static targets = [ 15 "toggleButton", "panel", 16 "columns", "buttonLabel", "showPrice", "showDescription", 17 "query", "selected", "results", "insertButton", "count", "status" 18 ] 19 static values = { searchUrl: String, blockUrl: String } 20 21 connect() { 22 this.selected = new Map() // id -> { id, title, image_url } 23 this.lastResults = new Map() // id -> product json from the last search 24 this.searchTimer = null 25 this.renderSelected() 26 } 27 28 disconnect() { 29 if (this.searchTimer) clearTimeout(this.searchTimer) 30 } 31 32 // -------- show / hide -------- 33 toggle() { 34 const hidden = this.panelTarget.classList.toggle("hidden") 35 if (!hidden) { 36 this.hydrateFromBody() 37 this.runSearch() 38 if (this.hasQueryTarget) this.queryTarget.focus() 39 } 40 } 41 42 // Pre-select whatever products are already in the body's product block, so the 43 // store can reopen the picker, untick one, and Insert again to drop it. 44 hydrateFromBody() { 45 const ta = this.bodyTextarea() 46 if (!ta) return 47 const block = ta.value.match(/<!--lb:products-block-->([\s\S]*?)<!--\/lb:products-block-->/) 48 if (!block) return 49 const ids = [...new Set([...block[1].matchAll(/data-lb-product-id="(\d+)"/g)].map((m) => Number(m[1])))] 50 const unknown = ids.filter((id) => !this.selected.has(id)) 51 if (unknown.length === 0) return 52 fetch(`${this.searchUrlValue}?ids=${unknown.join(",")}`, { headers: { Accept: "application/json" } }) 53 .then((r) => (r.ok ? r.json() : [])) 54 .then((list) => { list.forEach((p) => this.selected.set(p.id, { id: p.id, title: p.title, image_url: p.image_url })) }) 55 .catch(() => {}) 56 .finally(() => this.renderSelected()) 57 } 58 59 // -------- search -------- 60 search() { 61 if (this.searchTimer) clearTimeout(this.searchTimer) 62 this.searchTimer = setTimeout(() => this.runSearch(), 250) 63 } 64 65 runSearch() { 66 const q = this.hasQueryTarget ? this.queryTarget.value.trim() : "" 67 const url = q ? `${this.searchUrlValue}?q=${encodeURIComponent(q)}` : this.searchUrlValue 68 this.resultsTarget.innerHTML = `<p class="px-1 py-3 text-sm text-gray-400">Searchingâ¦</p>` 69 fetch(url, { headers: { Accept: "application/json" } }) 70 .then((r) => (r.ok ? r.json() : [])) 71 .then((list) => { 72 this.lastResults = new Map(list.map((p) => [p.id, p])) 73 this.renderResults(list) 74 }) 75 .catch(() => { this.resultsTarget.innerHTML = `<p class="px-1 py-3 text-sm text-red-500">Couldn't load products.</p>` }) 76 } 77 78 renderResults(list) { 79 if (list.length === 0) { 80 this.resultsTarget.innerHTML = `<p class="px-1 py-3 text-sm text-gray-400">No products found.</p>` 81 return 82 } 83 this.resultsTarget.innerHTML = list.map((p) => this.resultRow(p)).join("") 84 this.markResultRows() 85 } 86 87 resultRow(p) { 88 const img = p.image_url 89 ? `<img src="${this.esc(p.image_url)}" alt="" class="h-10 w-10 rounded object-cover bg-gray-100 dark:bg-gray-700 shrink-0">` 90 : `<div class="h-10 w-10 rounded bg-gray-100 dark:bg-gray-700 shrink-0"></div>` 91 const meta = [p.vendor, p.price_label].filter(Boolean).map((s) => this.esc(s)).join(" · ") 92 const stock = p.in_stock ? "" : `<span class="ml-1 text-amber-500 text-xs">out of stock</span>` 93 return `<button type="button" data-product-id="${p.id}" data-action="ecommerce--marketing--product-picker#toggleProduct" class="w-full flex items-center gap-3 px-1 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800/60 rounded">${img}<span class="min-w-0 flex-1"><span class="block text-sm text-gray-900 dark:text-gray-100 truncate">${this.esc(p.title)}</span><span class="block text-xs text-gray-400 truncate">${meta}${stock}</span></span><span data-role="state" class="text-xs font-medium text-indigo-600 dark:text-indigo-400 shrink-0">Add</span></button>` 94 } 95 96 toggleProduct(event) { 97 const id = Number(event.currentTarget.dataset.productId) 98 if (this.selected.has(id)) { 99 this.selected.delete(id) 100 } else { 101 const p = this.lastResults.get(id) || this.selected.get(id) 102 if (p) this.selected.set(id, { id: p.id, title: p.title, image_url: p.image_url }) 103 } 104 this.renderSelected() 105 this.markResultRows() 106 } 107 108 markResultRows() { 109 this.resultsTarget.querySelectorAll("[data-product-id]").forEach((row) => { 110 const on = this.selected.has(Number(row.dataset.productId)) 111 const state = row.querySelector("[data-role='state']") 112 if (state) { 113 state.textContent = on ? "â Added" : "Add" 114 state.classList.toggle("text-green-600", on) 115 state.classList.toggle("dark:text-green-400", on) 116 state.classList.toggle("text-indigo-600", !on) 117 state.classList.toggle("dark:text-indigo-400", !on) 118 } 119 row.classList.toggle("bg-indigo-50/60", on) 120 row.classList.toggle("dark:bg-indigo-900/20", on) 121 }) 122 } 123 124 // -------- selected chips -------- 125 renderSelected() { 126 const items = [...this.selected.values()] 127 this.selectedTarget.innerHTML = items.map((p) => `
128 <span class="inline-flex items-center gap-1.5 rounded-full bg-gray-100 dark:bg-gray-700 pl-1.5 pr-1 py-0.5 text-xs text-gray-700 dark:text-gray-200"> 129 ${p.image_url ? `<img src="${this.esc(p.image_url)}" alt="" class="h-4 w-4 rounded-full object-cover">` : ""} 130 <span class="max-w-[10rem] truncate">${this.esc(p.title)}</span> 131 <button type="button" data-product-id="${p.id}" data-action="ecommerce--marketing--product-picker#removeSelected" class="ml-0.5 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-100" aria-label="Remove">×</button> 132 </span>`).join("") 133 if (this.hasCountTarget) this.countTarget.textContent = String(items.length) 134 if (this.hasInsertButtonTarget) { 135 this.insertButtonTarget.disabled = items.length === 0 136 this.insertButtonTarget.classList.toggle("opacity-50", items.length === 0) 137 this.insertButtonTarget.classList.toggle("cursor-not-allowed", items.length === 0) 138 } 139 } 140 141 removeSelected(event) { 142 this.selected.delete(Number(event.currentTarget.dataset.productId)) 143 this.renderSelected() 144 this.markResultRows() 145 } 146 147 clear() { 148 this.selected.clear() 149 this.renderSelected() 150 this.markResultRows() 151 } 152 153 // -------- insert -------- 154 insert() { 155 const ids = [...this.selected.keys()] 156 if (ids.length === 0) return 157 158 const body = new URLSearchParams() 159 ids.forEach((id) => body.append("product_ids[]", id)) 160 body.append("columns", this.hasColumnsTarget ? this.columnsTarget.value : "2") 161 body.append("show_price", this.hasShowPriceTarget && !this.showPriceTarget.checked ? "false" : "true") 162 body.append("show_description", this.hasShowDescriptionTarget && this.showDescriptionTarget.checked ? "true" : "false") 163 if (this.hasButtonLabelTarget && this.buttonLabelTarget.value.trim()) body.append("button_label", this.buttonLabelTarget.value.trim()) 164 165 const csrf = document.querySelector("meta[name='csrf-token']") 166 if (this.hasInsertButtonTarget) this.insertButtonTarget.disabled = true 167 fetch(this.blockUrlValue, { 168 method: "POST", 169 headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRF-Token": csrf ? csrf.content : "", Accept: "application/json" }, 170 body: body.toString() 171 }) 172 .then((r) => (r.ok ? r.json() : Promise.reject(r))) 173 .then((data) => { 174 this.dispatch("insert", { detail: { html: data.html, productIds: data.product_ids || ids } }) 175 const n = (data.product_ids || ids).length 176 this.setStatus(`Inserted ${n} ${n === 1 ? "product" : "products"} into your email.`) 177 if (!this.panelTarget.classList.contains("hidden")) this.toggle() 178 }) 179 .catch(() => this.setStatus("Couldn't build the product grid â please try again.", true)) 180 .finally(() => { if (this.hasInsertButtonTarget) this.insertButtonTarget.disabled = this.selected.size === 0 }) 181 } 182 183 setStatus(message, isError = false) { 184 if (!this.hasStatusTarget) return 185 this.statusTarget.textContent = message 186 this.statusTarget.classList.remove("hidden") 187 this.statusTarget.classList.toggle("text-red-600", isError) 188 this.statusTarget.classList.toggle("dark:text-red-400", isError) 189 } 190 191 // -------- helpers -------- 192 bodyTextarea() { 193 const form = this.element.closest("form") 194 return form ? form.querySelector("[data-ecommerce--marketing--campaign-editor-target='htmlSource']") : null 195 } 196 197 esc(value) { 198 const d = document.createElement("div") 199 d.textContent = value == null ? "" : String(value) 200 return d.innerHTML 201 } 202}
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.