berrypod/assets/js/app.js
jamey 6eda1de1bc
All checks were successful
deploy / deploy (push) Successful in 1m21s
add period comparison deltas to analytics stat cards
Each stat card now shows the percentage change vs the equivalent
previous period (e.g. 30d compares last 30 days vs 30 days before).
Handles zero-baseline with "new" label and caps extreme deltas at
>999%. Seed data extended to 2 years for meaningful 12m comparisons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:01:25 +00:00

655 lines
19 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/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')
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() {
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)
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)
},
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: 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"
}
}
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, CollectionFilters, CardRadioScroll, AnalyticsInit, ChartTooltip},
})
// 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
})
}