// If you want to use Phoenix channels, run `mix help phx.gen.channel` // to get started and then uncomment the line below. // import "./user_socket.js" // You can include dependencies in two ways. // // The simplest option is to put them in assets/vendor and // import them using relative paths: // // import "../vendor/some-package.js" // // Alternatively, you can `npm install some-package --prefix assets` and import // them using a path starting with the package name: // // import "some-package" // // If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. // To load it, simply add a second `` to your `root.html.heex` file. // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/berrypod" import topbar from "../vendor/topbar" // Hook to sync color picker and text input const ColorSync = { mounted() { const picker = this.el.querySelector('input[type="color"]') const text = this.el.querySelector('input[type="text"]') if (picker && text) { picker.addEventListener('input', (e) => { text.value = e.target.value }) text.addEventListener('input', (e) => { picker.value = e.target.value }) } } } // Hook to persist cart to session via API const CartPersist = { mounted() { this.handleEvent("persist_cart", ({items}) => { const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") fetch("/api/cart", { method: "POST", headers: { "Content-Type": "application/json", "x-csrf-token": csrfToken }, body: JSON.stringify({items}) }) }) this.handleEvent("persist_country", ({code}) => { document.cookie = `shipping_country=${code};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax` }) } } // Hook for accessible cart drawer (WAI-ARIA dialog pattern) const CartDrawer = { mounted() { this.triggerElement = null this.boundKeydown = this.handleKeydown.bind(this) this.boundTouchmove = this.handleTouchmove.bind(this) this.scrollY = 0 this.isOpen = this.el.classList.contains('open') // Prevent the cart link from navigating when the drawer is available this._cartTrigger = document.querySelector('a[aria-label="Cart"]') if (this._cartTrigger) { this._preventNav = (e) => e.preventDefault() this._cartTrigger.addEventListener("click", this._preventNav) } if (this.isOpen) { this.lockScroll() document.addEventListener('keydown', this.boundKeydown) } }, updated() { const nowOpen = this.el.classList.contains('open') if (nowOpen && !this.isOpen) { this.onOpen() } else if (!nowOpen && this.isOpen) { this.onClose() } this.isOpen = nowOpen }, lockScroll() { // Store current scroll position this.scrollY = window.scrollY // Lock body scroll (works on mobile too) document.body.style.position = 'fixed' document.body.style.top = `-${this.scrollY}px` document.body.style.left = '0' document.body.style.right = '0' document.body.style.overflow = 'hidden' // Prevent touchmove on document (extra safety for iOS) document.addEventListener('touchmove', this.boundTouchmove, { passive: false }) }, unlockScroll() { // Restore body scroll document.body.style.position = '' document.body.style.top = '' document.body.style.left = '' document.body.style.right = '' document.body.style.overflow = '' // Restore scroll position window.scrollTo(0, this.scrollY) // Remove touchmove listener document.removeEventListener('touchmove', this.boundTouchmove) }, handleTouchmove(e) { // Allow scrolling inside the drawer itself if (this.el.contains(e.target)) { return } e.preventDefault() }, onOpen() { // Store trigger for focus return this.triggerElement = document.activeElement // Lock scroll this.lockScroll() // Focus first focusable element (close button) const firstFocusable = this.el.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) if (firstFocusable) { setTimeout(() => firstFocusable.focus(), 50) } // Enable focus trap and Escape handling document.addEventListener('keydown', this.boundKeydown) }, onClose() { // Unlock scroll this.unlockScroll() // Remove keyboard listener document.removeEventListener('keydown', this.boundKeydown) // Return focus to trigger element if (this.triggerElement) { this.triggerElement.focus() } }, handleKeydown(e) { // Close on Escape - let server handle the state change if (e.key === 'Escape') { this.pushEvent("close_cart_drawer") return } // Focus trap on Tab if (e.key === 'Tab') { const focusable = this.el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) const first = focusable[0] const last = focusable[focusable.length - 1] if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } }, destroyed() { document.removeEventListener('keydown', this.boundKeydown) document.removeEventListener('touchmove', this.boundTouchmove) if (this._cartTrigger && this._preventNav) { this._cartTrigger.removeEventListener("click", this._preventNav) } document.body.style.position = '' document.body.style.top = '' document.body.style.left = '' document.body.style.right = '' document.body.style.overflow = '' } } // Hook for PDP image lightbox const Lightbox = { mounted() { const dialog = this.el const lightboxImage = dialog.querySelector('#lightbox-image') const lightboxCounter = dialog.querySelector('#lightbox-counter') // Get images from data attribute const getImages = () => { try { return JSON.parse(dialog.dataset.images || '[]') } catch { return [] } } const getCurrentIndex = () => parseInt(dialog.dataset.currentIndex || '0', 10) const setCurrentIndex = (idx) => { dialog.dataset.currentIndex = idx.toString() } const updateImage = () => { const images = getImages() const idx = getCurrentIndex() if (images.length > 0 && lightboxImage) { lightboxImage.src = images[idx] if (lightboxCounter) { lightboxCounter.textContent = `${idx + 1} / ${images.length}` } } } const nextImage = () => { const images = getImages() const newIdx = (getCurrentIndex() + 1) % images.length setCurrentIndex(newIdx) updateImage() } const prevImage = () => { const images = getImages() const newIdx = (getCurrentIndex() - 1 + images.length) % images.length setCurrentIndex(newIdx) updateImage() } const openLightbox = () => { updateImage() dialog.showModal() } const closeLightbox = () => { dialog.close() } // Event listeners for custom events dispatched from LiveView.JS dialog.addEventListener('pdp:open-lightbox', openLightbox) dialog.addEventListener('pdp:close-lightbox', closeLightbox) dialog.addEventListener('pdp:next-image', nextImage) dialog.addEventListener('pdp:prev-image', prevImage) // Close on clicking backdrop dialog.addEventListener('click', (e) => { if (e.target === dialog) { closeLightbox() } }) // Keyboard navigation dialog.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') { nextImage() } else if (e.key === 'ArrowLeft') { prevImage() } else if (e.key === 'Escape') { closeLightbox() } }) // Store cleanup function this.cleanup = () => { dialog.removeEventListener('pdp:open-lightbox', openLightbox) dialog.removeEventListener('pdp:close-lightbox', closeLightbox) dialog.removeEventListener('pdp:next-image', nextImage) dialog.removeEventListener('pdp:prev-image', prevImage) } }, destroyed() { if (this.cleanup) { this.cleanup() } } } const ProductImageScroll = { mounted() { this.el.addEventListener('scroll', () => { const {dots, thumbButtons, lightbox} = this._queryDom() const index = Math.round(this.el.scrollLeft / this.el.offsetWidth) dots.forEach((dot, i) => { dot.classList.toggle('product-image-dot-active', i === index) }) thumbButtons.forEach((btn, i) => { btn.classList.toggle('pdp-thumbnail-active', i === index) }) if (lightbox) lightbox.dataset.currentIndex = index.toString() }, {passive: true}) this.el.addEventListener('pdp:scroll-to', (e) => { const index = e.detail.index this.el.scrollTo({left: index * this.el.offsetWidth, behavior: 'smooth'}) }) this.el.addEventListener('pdp:scroll-prev', () => { const current = Math.round(this.el.scrollLeft / this.el.offsetWidth) const count = this.el.children.length const target = (current - 1 + count) % count this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'}) }) this.el.addEventListener('pdp:scroll-next', () => { const current = Math.round(this.el.scrollLeft / this.el.offsetWidth) const count = this.el.children.length const target = (current + 1) % count this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'}) }) }, updated() { this.el.scrollTo({left: 0, behavior: 'instant'}) }, _queryDom() { const container = this.el.parentElement const dotsEl = container.querySelector('.product-image-dots') return { dots: dotsEl ? dotsEl.querySelectorAll('.product-image-dot') : [], thumbButtons: container.parentElement.querySelector('.pdp-gallery-thumbs') ?.querySelectorAll('.pdp-thumbnail') || [], lightbox: container.parentElement.querySelector('dialog') } } } // Hook for search modal keyboard navigation and shortcuts const SearchModal = { mounted() { this.selectedIndex = -1 this._globalKeydown = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault() if (this.isOpen()) { this.close() } else { this.open() } } } document.addEventListener("keydown", this._globalKeydown) // Prevent the search trigger link from navigating when the modal is available this._searchTrigger = document.querySelector('a[aria-label="Search"]') if (this._searchTrigger) { this._preventNav = (e) => e.preventDefault() this._searchTrigger.addEventListener("click", this._preventNav) } this.el.addEventListener("open-search", () => this.open()) this.el.addEventListener("close-search", () => this.close()) this.el.addEventListener("keydown", (e) => { if (!this.isOpen()) return switch (e.key) { case "Escape": e.preventDefault() this.close() break case "ArrowDown": e.preventDefault() this.moveSelection(1) break case "ArrowUp": e.preventDefault() this.moveSelection(-1) break case "Enter": e.preventDefault() this.navigateToSelected() break } }) }, destroyed() { document.removeEventListener("keydown", this._globalKeydown) if (this._searchTrigger && this._preventNav) { this._searchTrigger.removeEventListener("click", this._preventNav) } }, updated() { if (this._closing) this.el.style.display = "none" this.selectedIndex = -1 this.updateHighlight() }, isOpen() { return this.el.style.display !== "none" && this.el.style.display !== "" }, open() { this._closing = false this.el.style.display = "flex" this.pushEvent("open_search", {}) const input = this.el.querySelector("#search-input") if (input) { input.focus() input.select() } this.selectedIndex = -1 this.updateHighlight() }, close() { this._closing = true this.el.style.display = "none" this.pushEvent("clear_search", {}) this.selectedIndex = -1 this.updateHighlight() }, getResults() { return this.el.querySelectorAll('[role="option"]') }, moveSelection(delta) { const results = this.getResults() if (results.length === 0) return this.selectedIndex += delta if (this.selectedIndex < -1) this.selectedIndex = results.length - 1 if (this.selectedIndex >= results.length) this.selectedIndex = 0 this.updateHighlight() }, updateHighlight() { const results = this.getResults() results.forEach((r, i) => { const isSelected = i === this.selectedIndex r.style.background = isSelected ? "var(--t-surface-sunken)" : "" r.setAttribute("aria-selected", isSelected) }) if (this.selectedIndex >= 0 && results[this.selectedIndex]) { results[this.selectedIndex].scrollIntoView({ block: "nearest" }) } }, navigateToSelected() { const results = this.getResults() const index = this.selectedIndex >= 0 ? this.selectedIndex : 0 if (results[index]) { const link = results[index].querySelector("a") if (link) { this.el.style.display = "none" link.click() } } } } // Flex-wrap base → horizontal scroll enhancement for collection category pills. // If the pills wrap past 2 rows on mobile, switches to single-row scroll // and scrolls the active pill into view. const CollectionFilters = { mounted() { this._enhance() }, updated() { this._enhance() }, _enhance() { const nav = this.el const ul = nav.querySelector("ul") if (!ul) return // Reset to measure natural wrap height nav.classList.remove("is-scrollable") const firstItem = ul.querySelector("li") if (!firstItem) return const rowHeight = firstItem.offsetHeight const wrapsToMany = ul.scrollHeight > rowHeight * 2.5 if (wrapsToMany && window.innerWidth < 640) { nav.classList.add("is-scrollable") const active = nav.querySelector("[aria-current]") if (active) { active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" }) } } } } const CardRadioScroll = { mounted() { this.el.addEventListener("change", (e) => { if (!e.target.matches('input[type="radio"]')) return const key = e.target.value const form = this.el.closest("form") if (!form) return form.querySelectorAll("[data-adapter]").forEach((section) => { const match = section.dataset.adapter === key section.hidden = !match section.querySelectorAll("input, textarea, select, button[type='submit']").forEach((input) => { input.disabled = !match }) }) const target = document.getElementById(`adapter-config-${key}`) if (target) target.scrollIntoView({ behavior: "smooth", block: "nearest" }) }) } } // Analytics export: reads the current period and filters from the DOM at click time // so the download URL is always correct, even if clicked before the LiveView re-render. const AnalyticsExport = { mounted() { this.el.addEventListener("click", (e) => { e.preventDefault() const params = new URLSearchParams() params.set("period", this.el.getAttribute("data-period") || "30d") document.querySelectorAll("[data-filter-dimension]").forEach((span) => { const dim = span.getAttribute("data-filter-dimension") const val = span.getAttribute("data-filter-value") if (dim && val) params.set(`filter[${dim}]`, val) }) // Use a temporary anchor click so the download doesn't navigate away and // kill the LiveView WebSocket. const a = document.createElement("a") a.href = `/admin/analytics/export?${params}` a.download = "" document.body.appendChild(a) a.click() document.body.removeChild(a) }) } } // Analytics: send screen width for device classification (Layer 3) const AnalyticsInit = { mounted() { this.pushEvent("analytics:screen", { width: window.innerWidth }) } } // Analytics: chart tooltip on hover/tap const ChartTooltip = { mounted() { this._setup() }, updated() { this._setup() }, _setup() { // Re-query after LiveView patches this.tooltip = this.el.querySelector("[data-tooltip]") this.bars = this.el.querySelector("[data-bars]") if (!this.tooltip || !this.bars) return // Clean up previous listeners if re-setting up if (this._cleanup) this._cleanup() const onMove = (e) => { const clientX = e.touches ? e.touches[0].clientX : e.clientX const bar = this._barAt(clientX) if (bar) this._show(bar, clientX) } const onLeave = () => this._hide() const onDocTap = (e) => { if (!this.el.contains(e.target)) this._hide() } this.bars.addEventListener("mousemove", onMove) this.bars.addEventListener("mouseleave", onLeave) this.bars.addEventListener("touchstart", onMove, { passive: true }) document.addEventListener("touchstart", onDocTap, { passive: true }) this._cleanup = () => { this.bars.removeEventListener("mousemove", onMove) this.bars.removeEventListener("mouseleave", onLeave) this.bars.removeEventListener("touchstart", onMove) document.removeEventListener("touchstart", onDocTap) } }, destroyed() { if (this._cleanup) this._cleanup() }, _barAt(clientX) { const children = this.bars.children for (let i = 0; i < children.length; i++) { const rect = children[i].getBoundingClientRect() if (clientX >= rect.left && clientX <= rect.right) return children[i] } return null }, _show(bar, clientX) { const label = bar.dataset.label const visitors = bar.dataset.visitors if (!label) return this.tooltip.textContent = `${label}: ${visitors} visitor${visitors === "1" ? "" : "s"}` this.tooltip.style.display = "block" // Position: centered on cursor, clamped to chart bounds const chartRect = this.el.getBoundingClientRect() const tipWidth = this.tooltip.offsetWidth let left = clientX - chartRect.left - tipWidth / 2 left = Math.max(0, Math.min(left, chartRect.width - tipWidth)) this.tooltip.style.left = left + "px" }, _hide() { if (this.tooltip) this.tooltip.style.display = "none" } } // Warns before navigating away from pages with unsaved changes const DirtyGuard = { mounted() { this._beforeUnload = (e) => { if (this.el.dataset.dirty === "true") { e.preventDefault() e.returnValue = "" } } window.addEventListener("beforeunload", this._beforeUnload) }, destroyed() { window.removeEventListener("beforeunload", this._beforeUnload) } } // DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors const EditorKeyboard = { mounted() { this._beforeUnload = (e) => { if (this.el.dataset.dirty === "true") { e.preventDefault() e.returnValue = "" } } window.addEventListener("beforeunload", this._beforeUnload) const prefix = this.el.dataset.eventPrefix || "" this._keydown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault() if (e.shiftKey) { this.pushEvent(prefix + "redo") } else { this.pushEvent(prefix + "undo") } } } document.addEventListener("keydown", this._keydown) }, destroyed() { window.removeEventListener("beforeunload", this._beforeUnload) document.removeEventListener("keydown", this._keydown) } } const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard}, }) // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) // Scroll preview frame to top when changing pages window.addEventListener("phx:scroll-preview-top", (e) => { const previewFrame = document.querySelector('.preview-frame') if (previewFrame) { previewFrame.scrollTop = 0 } }) // connect if there are any LiveViews on the page liveSocket.connect() // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket // The lines below enable quality of life phoenix_live_reload // development features: // // 1. stream server logs to the browser console // 2. click on elements to jump to their definitions in your code editor // if (process.env.NODE_ENV === "development") { window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { // Enable server log streaming to client. // Disable with reloader.disableServerLogs() reloader.enableServerLogs() // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component // // * click with "c" key pressed to open at caller location // * click with "d" key pressed to open at function component definition location let keyDown window.addEventListener("keydown", e => keyDown = e.key) window.addEventListener("keyup", e => keyDown = null) window.addEventListener("click", e => { if(keyDown === "c"){ e.preventDefault() e.stopImmediatePropagation() reloader.openEditorAtCaller(e.target) } else if(keyDown === "d"){ e.preventDefault() e.stopImmediatePropagation() reloader.openEditorAtDef(e.target) } }, true) window.liveReloader = reloader }) }