feat: initialize product-site app with Next.js, i18n routing, and custom brand configurations
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function DestinationsCarousel({
|
||||
header,
|
||||
children
|
||||
}: {
|
||||
header: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const dotsRef = useRef<HTMLDivElement>(null);
|
||||
const prevBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const nextBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const track = trackRef.current;
|
||||
const dotsWrap = dotsRef.current;
|
||||
if (!track || !viewport || !dotsWrap) return;
|
||||
|
||||
const realCards = Array.from(track.children) as HTMLElement[];
|
||||
const n = realCards.length;
|
||||
if (n === 0) return;
|
||||
|
||||
const CARD_WIDTH = 200;
|
||||
const ACTIVE_WIDTH = 320;
|
||||
const GAP = 16;
|
||||
let isAnimating = false;
|
||||
|
||||
function realIndexOf(pos: number) {
|
||||
return ((pos % n) + n) % n;
|
||||
}
|
||||
|
||||
function widthOfSlot(i: number, activeReal: number) {
|
||||
return realIndexOf(i) === activeReal ? ACTIVE_WIDTH : CARD_WIDTH;
|
||||
}
|
||||
|
||||
dotsWrap.innerHTML = "";
|
||||
const dots: HTMLButtonElement[] = [];
|
||||
realCards.forEach((c, i) => {
|
||||
const d = document.createElement("button");
|
||||
d.setAttribute("aria-label", "Ke slide " + (i + 1));
|
||||
d.addEventListener("click", () => {
|
||||
goTo(i);
|
||||
resetAutoplay();
|
||||
});
|
||||
dotsWrap.appendChild(d);
|
||||
dots.push(d);
|
||||
});
|
||||
|
||||
const beforeClones = realCards.map((c) => {
|
||||
const cl = c.cloneNode(true) as HTMLElement;
|
||||
cl.setAttribute("aria-hidden", "true");
|
||||
return cl;
|
||||
});
|
||||
const afterClones = realCards.map((c) => {
|
||||
const cl = c.cloneNode(true) as HTMLElement;
|
||||
cl.setAttribute("aria-hidden", "true");
|
||||
return cl;
|
||||
});
|
||||
|
||||
track.innerHTML = "";
|
||||
beforeClones.concat(realCards, afterClones).forEach((el) => {
|
||||
track.appendChild(el);
|
||||
});
|
||||
|
||||
const slots = Array.from(track.children) as HTMLElement[];
|
||||
let position = n + 2;
|
||||
|
||||
function render(instant: boolean) {
|
||||
if (instant) track!.classList.add("no-anim");
|
||||
else track!.classList.remove("no-anim");
|
||||
|
||||
const activeReal = realIndexOf(position);
|
||||
|
||||
let offset = 0;
|
||||
for (let i = 0; i < position; i++) {
|
||||
offset += widthOfSlot(i, activeReal) + GAP;
|
||||
}
|
||||
offset += widthOfSlot(position, activeReal) / 2;
|
||||
|
||||
const viewportRect = viewport!.getBoundingClientRect();
|
||||
const translate = viewportRect.width / 2 - offset;
|
||||
track!.style.transform = "translateX(" + translate + "px)";
|
||||
|
||||
slots.forEach((s, i) => {
|
||||
const isActive = realIndexOf(i) === activeReal;
|
||||
s.classList.toggle("active", isActive);
|
||||
});
|
||||
dots.forEach((d, i) => {
|
||||
d.classList.toggle("on", i === activeReal);
|
||||
});
|
||||
|
||||
if (instant) {
|
||||
void track!.offsetWidth;
|
||||
track!.classList.remove("no-anim");
|
||||
}
|
||||
}
|
||||
|
||||
function goToPosition(pos: number) {
|
||||
position = pos;
|
||||
isAnimating = true;
|
||||
render(false);
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (isAnimating) return;
|
||||
goToPosition(position + 1);
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (isAnimating) return;
|
||||
goToPosition(position - 1);
|
||||
}
|
||||
|
||||
function goTo(realIndex: number) {
|
||||
if (isAnimating) return;
|
||||
goToPosition(n + realIndex);
|
||||
}
|
||||
|
||||
const onTransitionEnd = (e: TransitionEvent) => {
|
||||
if (e.target !== track || e.propertyName !== "transform") return;
|
||||
if (position >= 2 * n) {
|
||||
position -= n;
|
||||
render(true);
|
||||
} else if (position < n) {
|
||||
position += n;
|
||||
render(true);
|
||||
}
|
||||
isAnimating = false;
|
||||
};
|
||||
track.addEventListener("transitionend", onTransitionEnd);
|
||||
|
||||
const onPrevClick = () => { prev(); resetAutoplay(); };
|
||||
const onNextClick = () => { next(); resetAutoplay(); };
|
||||
|
||||
const prevBtn = prevBtnRef.current;
|
||||
const nextBtn = nextBtnRef.current;
|
||||
if (prevBtn) prevBtn.addEventListener("click", onPrevClick);
|
||||
if (nextBtn) nextBtn.addEventListener("click", onNextClick);
|
||||
|
||||
const onTrackClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const card = target.closest('.dest-card') as HTMLElement;
|
||||
if (!card || isAnimating) return;
|
||||
|
||||
const slotsArray = Array.from(track.children) as HTMLElement[];
|
||||
const slotIndex = slotsArray.indexOf(card);
|
||||
|
||||
if (slotIndex !== -1) {
|
||||
goToPosition(slotIndex);
|
||||
resetAutoplay();
|
||||
}
|
||||
};
|
||||
track.addEventListener("click", onTrackClick);
|
||||
|
||||
let autoplayTimer: ReturnType<typeof setInterval>;
|
||||
function startAutoplay() {
|
||||
autoplayTimer = setInterval(next, 5000);
|
||||
}
|
||||
function stopAutoplay() {
|
||||
clearInterval(autoplayTimer);
|
||||
}
|
||||
function resetAutoplay() {
|
||||
stopAutoplay();
|
||||
startAutoplay();
|
||||
}
|
||||
viewport.addEventListener("mouseenter", stopAutoplay);
|
||||
viewport.addEventListener("mouseleave", startAutoplay);
|
||||
|
||||
const onResize = () => render(true);
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
render(true);
|
||||
startAutoplay();
|
||||
|
||||
return () => {
|
||||
stopAutoplay();
|
||||
window.removeEventListener("resize", onResize);
|
||||
track.removeEventListener("transitionend", onTransitionEnd);
|
||||
track.removeEventListener("click", onTrackClick);
|
||||
viewport.removeEventListener("mouseenter", stopAutoplay);
|
||||
viewport.removeEventListener("mouseleave", startAutoplay);
|
||||
if (prevBtn) prevBtn.removeEventListener("click", onPrevClick);
|
||||
if (nextBtn) nextBtn.removeEventListener("click", onNextClick);
|
||||
|
||||
track.innerHTML = "";
|
||||
realCards.forEach((c) => track.appendChild(c));
|
||||
dotsWrap.innerHTML = "";
|
||||
};
|
||||
}, [header, children]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="destinations-head-row">
|
||||
{header}
|
||||
<div className="dest-controls">
|
||||
<button
|
||||
className="dest-btn"
|
||||
ref={prevBtnRef}
|
||||
aria-label="Slide sebelumnya"
|
||||
>
|
||||
<i className="uil uil-angle-left"></i>
|
||||
</button>
|
||||
<button
|
||||
className="dest-btn"
|
||||
ref={nextBtnRef}
|
||||
aria-label="Slide berikutnya"
|
||||
>
|
||||
<i className="uil uil-angle-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dest-viewport" ref={viewportRef}>
|
||||
<div className="dest-track" ref={trackRef}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dest-dots" ref={dotsRef}></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from '@/i18n/routing';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { ChangeEvent } from 'react';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
function onSelectChange(event: ChangeEvent<HTMLSelectElement>) {
|
||||
const nextLocale = event.target.value;
|
||||
router.replace(pathname, { locale: nextLocale });
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
defaultValue={locale}
|
||||
onChange={onSelectChange}
|
||||
className="bg-transparent border border-white/20 rounded-md text-white px-3 py-2.5 text-sm focus:outline-none focus:border-brand-2-500 appearance-none cursor-pointer hover:bg-white/5 transition-colors"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'right 10px center',
|
||||
paddingRight: '32px',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<option value="id" className="text-black font-semibold">
|
||||
ID
|
||||
</option>
|
||||
<option value="en" className="text-black font-semibold">
|
||||
EN
|
||||
</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user