// 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/simpleshop_theme" 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}) }) }) } } // 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') 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) 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() { const container = this.el.parentElement const dots = container.querySelector('.product-image-dots') const spans = dots ? dots.querySelectorAll('.product-image-dot') : [] const lightbox = container.parentElement.querySelector('dialog') const thumbs = container.parentElement.querySelector('.pdp-gallery-thumbs') const thumbButtons = thumbs ? thumbs.querySelectorAll('.pdp-thumbnail') : [] const imageCount = this.el.children.length this.el.addEventListener('scroll', () => { const index = Math.round(this.el.scrollLeft / this.el.offsetWidth) spans.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 target = (current - 1 + imageCount) % imageCount 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 target = (current + 1) % imageCount this.el.scrollTo({left: target * this.el.offsetWidth, behavior: 'smooth'}) }) } } // 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) 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) }, updated() { this.selectedIndex = -1 this.updateHighlight() }, isOpen() { return this.el.style.display !== "none" && this.el.style.display !== "" }, open() { this.el.style.display = "flex" const input = this.el.querySelector("#search-input") if (input) { input.focus() input.select() } this.selectedIndex = -1 this.updateHighlight() }, close() { 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() } } } } const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal}, }) // 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 }) }