berrypod/assets/js/app.js
jamey 5c2f70ce44 add shipping costs with live exchange rates and country detection
Shipping rates fetched from Printify during product sync, converted to
GBP at sync time using frankfurter.app ECB exchange rates with 5%
buffer. Cached in shipping_rates table per blueprint/provider/country.

Cart page shows shipping estimate with country selector (detected from
Accept-Language header, persisted in cookie). Stripe Checkout includes
shipping_options for UK domestic and international delivery. Order
shipping_cost extracted from Stripe on payment.

ScheduledSyncWorker runs every 6 hours via Oban cron to keep rates
and exchange rates fresh. REST_OF_THE_WORLD fallback covers unlisted
countries. 780 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:48:00 +00:00

514 lines
15 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})
})
})
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() {
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("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()
}
}
}
}
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
})
}