import { gsap } from 'gsap';
import { Flip } from 'gsap/Flip';
gsap.registerPlugin(Flip);
export function mount(root: HTMLElement) {
const tiles = [...root.querySelectorAll<HTMLButtonElement>('[data-pt-tile]')];
const grid = root.querySelector<HTMLElement>('.pt__grid')!;
const detail = root.querySelector<HTMLElement>('[data-pt-detail]')!;
const slot = root.querySelector<HTMLElement>('[data-pt-slot]')!;
const back = root.querySelector<HTMLButtonElement>('[data-pt-back]')!;
const title = root.querySelector<HTMLElement>('[data-pt-title]')!;
const price = root.querySelector<HTMLElement>('[data-pt-price]')!;
const add = root.querySelector<HTMLButtonElement>('.pt__buy');
const fades = detail.querySelectorAll<HTMLElement>('[data-pt-fade]');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let open: HTMLButtonElement | null = null;
let closing = false;
let home: Element | null = null;
let addTimer = 0;
// Flip records where the photo is, moves it in the page, then animates the difference.
const move = (art: HTMLElement, into: Element, done?: () => void) => {
const state = Flip.getState(art);
into.append(art);
Flip.from(state, {
duration: 0.7,
ease: 'power3.inOut',
absolute: true,
zIndex: 5,
onComplete: () => {
gsap.set(art, { clearProps: 'all' });
done?.();
},
});
};
const resetAdd = () => {
clearTimeout(addTimer);
if (!add?.dataset.label) return;
add.textContent = add.dataset.label;
add.style.minWidth = '';
};
tiles.forEach((tile) =>
tile.addEventListener('click', () => {
if (open || closing) return;
open = tile;
const art = tile.querySelector<HTMLElement>('[data-pt-art]')!;
home = art.parentElement;
title.textContent = tile.dataset.name!;
price.textContent = tile.dataset.price!;
resetAdd();
detail.hidden = false;
// The shop's tiles are hidden behind the product page, so keyboard focus must not wander onto them.
grid.inert = true;
if (reduced) {
// Reduced motion: the product page simply fades in over the shop.
slot.append(art);
gsap.fromTo(detail, { opacity: 0 }, { opacity: 1, duration: 0.3 });
} else {
move(art, slot);
gsap.fromTo(fades, { y: 16, opacity: 0 }, { y: 0, opacity: 1, stagger: 0.06, delay: 0.35, duration: 0.4 });
gsap.to(tiles, { opacity: 0, duration: 0.25 });
}
back.focus({ preventScroll: true });
}),
);
back.addEventListener('click', () => {
if (!open || !home || closing) return;
const art = slot.querySelector<HTMLElement>('[data-pt-art]')!;
const tile = open;
closing = true;
resetAdd();
const finish = () => {
detail.hidden = true;
gsap.set(detail, { clearProps: 'opacity,backgroundColor,pointerEvents' });
open = null;
closing = false;
};
grid.inert = false;
tile.focus({ preventScroll: true });
if (reduced) {
home.append(art);
gsap.to(detail, { opacity: 0, duration: 0.3, onComplete: finish });
return;
}
// Let the shop show through while the photo glides back into its own tile.
gsap.set(detail, { backgroundColor: 'transparent', pointerEvents: 'none' });
// Back can come while the opening fades are still running; stop them so none finishes later.
gsap.killTweensOf(fades, 'y,opacity');
gsap.to(fades, { opacity: 0, duration: 0.2 });
gsap.set(tile, { opacity: 1, overwrite: true });
gsap.to(tiles.filter((t) => t !== tile), { opacity: 1, duration: 0.35, delay: 0.15, overwrite: true });
move(art, home, finish);
});
// A small confirmation, like a real shop gives.
add?.addEventListener('click', () => {
add.dataset.label ??= add.textContent ?? '';
// Hold the button's width so the shorter label doesn't make it shrink.
add.style.minWidth = `${add.offsetWidth}px`;
add.textContent = 'Added ✓';
if (!reduced) gsap.fromTo(add, { scale: 0.92 }, { scale: 1, duration: 0.5, ease: 'back.out(3)' });
clearTimeout(addTimer);
addTimer = window.setTimeout(resetAdd, 1500);
});
}