setting up front 1

This commit is contained in:
elvira
2026-07-31 17:41:55 +03:00
parent b88fde739c
commit bf8b9219fc
48 changed files with 2250 additions and 11 deletions
+10 -1
View File
@@ -1 +1,10 @@
import './bootstrap';
import "./bootstrap";
import "./utils/strip-accents";
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
const application = Application.start();
application.debug = false;
registerControllers(application);
@@ -0,0 +1,22 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
connect() {
this.scrollHandler = this.onScroll.bind(this)
window.addEventListener('scroll', this.scrollHandler, { passive: true })
this.onScroll()
}
disconnect() {
window.removeEventListener('scroll', this.scrollHandler)
}
onScroll() {
this.element.classList.toggle('is-visible', window.scrollY > 300)
}
scrollToTop(e) {
e.preventDefault()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
@@ -0,0 +1,17 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['menu']
open() {
this.menuTarget.classList.add('is-open')
}
close() {
this.menuTarget.classList.remove('is-open')
}
toggle() {
this.menuTarget.classList.toggle('is-open')
}
}
+20
View File
@@ -0,0 +1,20 @@
// Register all Stimulus controllers here.
// Example:
// import HelloController from './controllers/hello_controller';
// application.register('hello', HelloController);
import BackToTopController from './back-to-top-controller'
import ProductFormController from './product-form-controller'
import ProductGalleryController from './product-gallery-controller'
import QuantityController from './quantity-controller'
import StarRatingController from './star-rating-controller'
import TabsController from './tabs-controller'
export function registerControllers(application) {
application.register('back-to-top', BackToTopController)
application.register('product-form', ProductFormController)
application.register('product-gallery', ProductGalleryController)
application.register('quantity', QuantityController)
application.register('star-rating', StarRatingController)
application.register('tabs', TabsController)
}
@@ -0,0 +1,50 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['price', 'image', 'swatch', 'colorName']
static values = { variants: Array, selected: Number }
connect() {
const params = new URLSearchParams(window.location.search)
const urlId = parseInt(params.get('variant'))
const defaultId = this.variantsValue[0]?.id
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
? urlId
: defaultId
}
selectVariant(event) {
const id = parseInt(event.currentTarget.dataset.variantId)
this.selectedValue = id
const url = new URL(window.location)
url.searchParams.set('variant', id)
window.history.pushState({}, '', url)
}
selectedValueChanged(id) {
if (!id) return
const variant = this.variantsValue.find(v => v.id === id)
if (!variant) return
if (this.hasPriceTarget && variant.price !== null) {
this.priceTarget.textContent = '€' + parseFloat(variant.price).toFixed(2)
}
if (this.hasImageTarget && variant.image) {
this.imageTarget.src = variant.image
}
this.swatchTargets.forEach(swatch => {
const isSelected = parseInt(swatch.dataset.variantId) === id
swatch.classList.toggle('is-selected', isSelected)
swatch.setAttribute('aria-pressed', String(isSelected))
if (isSelected && this.hasColorNameTarget) {
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
}
})
}
}
@@ -0,0 +1,116 @@
import { Controller } from "@hotwired/stimulus";
const SCROLL_STEP = 1;
export default class extends Controller {
static targets = ["main", "thumb", "track", "lightbox", "lightboxImage", "lightboxCounter"];
connect() {
this.offset = 0;
this.lightboxIndex = 0;
this.#syncHeight();
this.mainTarget.addEventListener("load", () => this.#syncHeight());
this._onKeydown = this.#handleKeydown.bind(this);
document.addEventListener("keydown", this._onKeydown);
}
disconnect() {
document.removeEventListener("keydown", this._onKeydown);
}
select(event) {
const btn = event.currentTarget;
this.#setThumb(btn);
this.lightboxIndex = this.thumbTargets.indexOf(btn);
}
scrollUp() {
this.offset = Math.max(0, this.offset - SCROLL_STEP);
this.#applyScroll();
}
scrollDown() {
this.offset = Math.min(this.thumbTargets.length - 1, this.offset + SCROLL_STEP);
this.#applyScroll();
}
closeLightboxOnBackdrop(event) {
// Only close if clicking directly on the backdrop (the popover div itself),
// not on the image, arrows, close button, or counter
if (event.target === this.lightboxTarget) {
this.lightboxTarget.hidePopover();
}
}
noop(event) {
event.stopPropagation();
}
openLightbox() {
this.#updateLightbox(this.lightboxIndex);
this.lightboxTarget.showPopover();
}
prevImage() {
const next = (this.lightboxIndex - 1 + this.thumbTargets.length) % this.thumbTargets.length;
this.#updateLightbox(next);
}
nextImage() {
const next = (this.lightboxIndex + 1) % this.thumbTargets.length;
this.#updateLightbox(next);
}
// ── Private ────────────────────────────────────────────────────
#updateLightbox(index) {
this.lightboxIndex = index;
const thumb = this.thumbTargets[index];
if (!thumb) return;
this.lightboxImageTarget.src = thumb.dataset.src;
this.lightboxImageTarget.alt = thumb.dataset.alt;
this.lightboxCounterTarget.textContent =
`${index + 1} of ${this.thumbTargets.length}`;
}
#setThumb(btn) {
const src = btn.dataset.src;
const alt = btn.dataset.alt;
this.mainTarget.src = src;
this.mainTarget.alt = alt;
this.thumbTargets.forEach((t) => {
const active = t === btn;
t.classList.toggle("border-2", active);
t.classList.toggle("border-1", !active);
t.setAttribute("aria-pressed", String(active));
});
}
#applyScroll() {
if (!this.thumbTargets.length) return;
const thumbHeight = this.thumbTargets[0].offsetHeight;
const gap = 16; // gap-4 = 16px
this.trackTarget.scrollTop = this.offset * (thumbHeight + gap);
}
#syncHeight() {
const h = this.mainTarget.offsetHeight;
if (h > 0) {
this.trackTarget.style.maxHeight = h + "px";
}
}
#handleKeydown(e) {
if (!this.hasLightboxTarget) return;
if (!this.lightboxTarget.matches(":popover-open")) return;
if (e.key === "ArrowLeft") { e.preventDefault(); this.prevImage(); }
if (e.key === "ArrowRight") { e.preventDefault(); this.nextImage(); }
if (e.key === "Escape") { this.lightboxTarget.hidePopover(); }
}
}
@@ -0,0 +1,39 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['input', 'decrement']
static values = { min: { type: Number, default: 1 } }
connect() {
this.#updateDecrement()
}
increment() {
this.#setValue(this.#current + 1)
}
decrement() {
this.#setValue(this.#current - 1)
}
clamp() {
this.#setValue(this.#current)
}
get #current() {
return parseInt(this.inputTarget.value, 10) || this.minValue
}
#setValue(val) {
const clamped = Math.max(this.minValue, val)
this.inputTarget.value = clamped
this.#updateDecrement()
this.inputTarget.dispatchEvent(new Event('quantity:change', { bubbles: true }))
}
#updateDecrement() {
const atMin = this.#current <= this.minValue
this.decrementTarget.disabled = atMin
this.decrementTarget.setAttribute('aria-disabled', atMin)
}
}
@@ -0,0 +1,31 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['star', 'input']
static values = { rating: { type: Number, default: 0 } }
hover(event) {
this.#fill(parseInt(event.currentTarget.dataset.value))
}
leave() {
this.#fill(this.ratingValue)
}
select(event) {
const val = parseInt(event.currentTarget.dataset.value)
this.ratingValue = val
this.inputTarget.value = val
this.starTargets.forEach(star => {
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
})
}
#fill(upTo) {
this.starTargets.forEach(star => {
const filled = parseInt(star.dataset.value) <= upTo
star.querySelector('svg').setAttribute('fill', filled ? 'currentColor' : 'none')
})
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['button', 'panel']
show(event) {
this.#activate(event.currentTarget.dataset.panel)
}
#activate(panelId) {
this.buttonTargets.forEach(btn => {
const active = btn.dataset.panel === panelId
btn.classList.toggle('is-active', active)
btn.setAttribute('aria-selected', String(active))
})
this.panelTargets.forEach(panel => {
panel.hidden = panel.id !== `tab-panel-${panelId}`
})
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Strip Greek tonos (accent marks) from text inside elements that have the
* `uppercase` CSS class.
*
* In Greek typography, capital letters do not carry the tonos accent.
* CSS `text-transform: uppercase` uppercases the glyph but leaves the tonos
* in place, producing visually incorrect output (e.g. Ά instead of Α).
* This utility rewrites the text nodes directly so the rendered result is clean.
*
* The diaeresis (ϊ, ϋ) is preserved — it is retained in Greek uppercase.
*
* Runs once on DOMContentLoaded and then watches for dynamically added nodes
* via MutationObserver.
*/
const ACCENT_MAP = {
// Lowercase with tonos → without tonos
'ά': 'α', 'έ': 'ε', 'ή': 'η', 'ί': 'ι', 'ό': 'ο', 'ύ': 'υ', 'ώ': 'ω',
// Uppercase with tonos → without tonos (for already-uppercased text)
'Ά': 'Α', 'Έ': 'Ε', 'Ή': 'Η', 'Ί': 'Ι', 'Ό': 'Ο', 'Ύ': 'Υ', 'Ώ': 'Ω',
// Combined tonos + diaeresis → diaeresis only (preserve the diaeresis)
'ΐ': 'ϊ', 'ΰ': 'ϋ',
}
const ACCENT_RE = /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/g
function stripAccents(str) {
return str.replace(ACCENT_RE, c => ACCENT_MAP[c] ?? c)
}
function processElement(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
let node
while ((node = walker.nextNode())) {
const val = node.nodeValue
if (val && /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/.test(val)) {
node.nodeValue = stripAccents(val)
}
}
}
function applyToPage() {
document.querySelectorAll('.uppercase').forEach(processElement)
}
// ── Initial pass ──────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', applyToPage)
// ── Observe dynamic additions ─────────────────────────────────────────────────
const observer = new MutationObserver(mutations => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue
if (node.classList?.contains('uppercase')) processElement(node)
node.querySelectorAll?.('.uppercase').forEach(processElement)
}
}
})
observer.observe(document.body, { childList: true, subtree: true })