PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/…/product_picker_controller-73c3dec5.js

js littlebirdelectronics.com.au collected 2026-09-25 14:10:49 UTC 9,390 bytes, 202 lines download raw bytes

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">&times;</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.