berrypod/assets/js/app.js

849 lines
25 KiB
JavaScript
Raw Normal View History

// 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/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()
}
}
}
}
// Mobile nav drawer - slides in from the left
const MobileNavDrawer = {
mounted() {
this.el.addEventListener("open-mobile-nav", () => this.open())
this.el.addEventListener("close-mobile-nav", () => this.close())
// Close on Escape key
this._keydown = (e) => {
if (e.key === "Escape" && this.isOpen()) {
e.preventDefault()
this.close()
}
}
document.addEventListener("keydown", this._keydown)
// Prevent hamburger button navigation on no-JS fallback
this._hamburger = document.querySelector('.header-hamburger')
if (this._hamburger) {
this._preventNav = (e) => e.preventDefault()
this._hamburger.addEventListener("click", this._preventNav)
}
},
destroyed() {
document.removeEventListener("keydown", this._keydown)
if (this._hamburger && this._preventNav) {
this._hamburger.removeEventListener("click", this._preventNav)
}
},
isOpen() {
return this.el.classList.contains("is-open")
},
open() {
this.el.classList.add("is-open")
document.body.style.overflow = "hidden"
// Focus the close button for accessibility
const closeBtn = this.el.querySelector(".mobile-nav-close")
if (closeBtn) closeBtn.focus()
},
close() {
this.el.classList.remove("is-open")
document.body.style.overflow = ""
// Return focus to hamburger button
if (this._hamburger) this._hamburger.focus()
}
}
// 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" })
}
}
}
}
// 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)
}
}
// EditorSheet: simple open/collapse sheet for page editing
// Positioning is handled by CSS - JS just handles click-outside and Escape
const EditorSheet = {
mounted() {
// Click outside to collapse (works in any mode for preview)
// Use mousedown instead of click to avoid race with LiveView re-renders
this._onDocMousedown = (e) => {
if (!this.el.contains(e.target) && this._getState() !== "collapsed") {
this._setState("collapsed")
}
}
document.addEventListener("mousedown", this._onDocMousedown)
// Escape key to collapse
this._onKeydown = (e) => {
if (e.key === "Escape" && this._getState() !== "collapsed") {
e.preventDefault()
this._setState("collapsed")
}
}
document.addEventListener("keydown", this._onKeydown)
},
destroyed() {
document.removeEventListener("mousedown", this._onDocMousedown)
document.removeEventListener("keydown", this._onKeydown)
},
_getState() {
return this.el.dataset.state || "collapsed"
},
_setState(state) {
this.el.dataset.state = state
this.el.setAttribute("aria-expanded", state !== "collapsed")
this.pushEvent("editor_set_sheet_state", { state })
this._announce(state === "collapsed" ? "Editor collapsed" : "Editor expanded")
},
_announce(message) {
const region = document.getElementById("editor-live-region")
if (region) {
region.textContent = message
}
}
}
// 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)
// Intercept LiveView navigation clicks when editor has unsaved changes.
// Uses capture phase to fire before LiveView's own click handler.
this._clickGuard = (e) => {
if (this.el.dataset.dirty !== "true") return
const link = e.target.closest("a[data-phx-link]")
if (!link) return
// Don't block clicks inside the editor itself (e.g. block controls)
if (this.el.contains(link)) return
if (!window.confirm("You have unsaved changes that will be lost. Leave anyway?")) {
e.preventDefault()
e.stopImmediatePropagation()
}
}
document.addEventListener("click", this._clickGuard, true)
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("click", this._clickGuard, true)
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, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard, EditorSheet},
})
// 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()
// Suppress connection error banners during intentional navigation (refresh, link click).
// beforeunload fires on navigation but NOT on genuine connection drops.
window.addEventListener("beforeunload", () => {
document.body.classList.add("navigating-away")
})
// 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
})
}