Replace PreviewData indirection in all shop LiveViews with direct Products context queries. Home, collection, product detail and error pages now query the database. Categories loaded once in ThemeHook. Cart hydration no longer falls back to mock data. PreviewData kept only for the theme editor. Search modal gains keyboard navigation (arrow keys, Enter, Escape), Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate links, and 150ms debounce. SearchModal JS hook manages selection state and highlight. search.ex gets transaction safety on reindex and a public remove_product/1. 10 new integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
503 lines
14 KiB
JavaScript
503 lines
14 KiB
JavaScript
// 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 `<link>` 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
|
|
})
|
|
}
|
|
|