feat: initialize product-site app with Next.js, i18n routing, and custom brand configurations

This commit is contained in:
Firman Ramdhani
2026-07-29 15:29:35 +07:00
parent fcea8f221b
commit eb63a48047
32 changed files with 5829 additions and 196 deletions
@@ -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>
);
}