const { useState, useEffect, useRef, useMemo, createContext, useContext } = React; // Currency config, overwritten from products.json on load. Prices in the // catalog are stored in RON (lei); euro is derived via euroConversion. let CURRENCY = { base: "RON", euroConversion: 5.26 }; const fmtLei = (n) => `${Math.round(n).toLocaleString("ro-RO")} lei`; const fmtEur = (n) => `€${Math.round(n / (CURRENCY.euroConversion || 1))}`; // ─────────── i18n ─────────── const I18N = { ro: { // announcement annc1: "Livrare gratuită în toată țara la orice comandă de peste 200 lei", // nav nav_home: "Acasă", nav_shop: "Magazin", nav_contact: "Contactează-ne", // shop page shop_h2_a: "Toate ", shop_h2_em: "creațiile noastre.", shop_sub: "Fiecare piesă, realizată manual în atelierul nostru.\nAlege și personalizează.", // shop filters filter_all: "Toate", filter_label_cat: "Categorie", filter_label_show: "Afișează", filter_sort: "Sortează", sort_featured: "Recomandate", sort_price_asc: "Preț crescător", sort_price_desc: "Preț descrescător", filter_empty: "Nicio creație nu corespunde filtrelor alese.", filter_clear: "Resetează filtrele", // header / search search_placeholder: "Caută un cadou, o idee, un sentiment…", aria_search: "Căutare", aria_account: "Cont", aria_wishlist: "Favorite", aria_close: "Închide", // hero hero_eyebrow: "Cadouri personalizate · Făcute în Iași", hero_h1_1: "Dacă o poți visa,", hero_h1_2: "noi o putem crea.", hero_lede: "La Artelier, transformăm ideile tale în amintiri palpabile. Combinăm tehnologia printării 3D de înaltă precizie cu magia lucrului făcut manual.\n\n\nFiecare piesă premium este finisată cu atenție și pictată manual, iar pentru un efect cu adevărat spectaculos, îi putem adăuga lumini LED integrate. \n\n\nOferă un cadou unic! Lasă-ne un mesaj cu ideea ta și o aducem la viață.", hero_cta_primary: "Începe să creezi", hero_cta_secondary: "Cum funcționează", hero_meta1_lbl: "obiecte ajunse acasă", hero_meta2_lbl: "recenzie medie", hero_meta3_lbl: "timp până la primul proiect", // floating cards float_pick: "Alege finisajul", float_lbl: "Cană personalizată", // categories cat_h2_a: "Explorează ", cat_h2_em: "categoriile noastre →", cat_sub: "Cadouri create din pasiune — pentru cei dragi.", cat_product: "produs", cat_products: "produse", // products prod_h2_a: "Piesele iubite de ", prod_h2_em: "clienții noștri.", prod_sub: "Fiecare cadou pictat manual este unic.", tab_best: "Cele mai vândute", tab_new: "Noutăți", tab_weddings: "Nunți", prod_inquire: "Comandă privat", wa_msg: "Bună! Aș dori să comand: ", from: "de la", // how it works how_eyebrow: "Procesul Artelier", how_h2_a: "Trei pași simpli. ", how_h2_em: "Un cadou unicat.", // testimonials testi_h2_a: "Note de la ", testi_h2_em: "destinatarii noștri.", testi_sub: "4,9 medie din peste 3.200 de recenzii · cumpărători verificați", // cta cta_h2_a: "Un cadou pentru tine ", cta_h2_em: "din partea noastră.", cta_p: "La orice comandă de minim 100 lei, vei primi un cadou din partea echipei\u00A0Artelier.", cta_placeholder: "adresa@email.ro", cta_btn: "Mă abonez →", cta_invalid: "Te rugăm să introduci un email valid.", cta_thanks: "Mulțumim — plasează o comandă și vei primi cadoul tău alături la livrare. ✦", // footer ftr_brand_p: "La Artelier aducem la realitate ideile tale. Dacă dorești un cadou personalizat, un obiect care nu se mai găsește sau este mai puțin costisitoare printarea acestuia, ai ajuns în locul potrivit. Scrie-ne pe WhatsApp ce îți dorești iar noi ne vom ocupa de restul.", ftr_addr: "© 2026 Artelier · Iași, România", ftr_menu: "Meniu", m_home: "Acasă", m_contact: "Contactează-ne", ftr_cats: "Categorii", c_flowers: "Buchete de flori", c_magnets: "Magneți personalizați", c_handpainted: "Obiecte pictate manual", c_print3d: "Obiecte printate 3D", c_design3d: "Proiectare 3D", ftr_social: "Rețele de socializare", // search suggestions sug: ["Cutii gravate", "Cadouri de nuntă", "Imprimeuri foto", "Pentru ea", "Sub 250 lei", "Noutăți"], // currency fmt: (n) => fmtLei(n), // categories cat_engraved: "Obiecte gravate", cat_photo: "Imprimeuri foto", cat_mugs: "Pahare & căni", cat_jewel: "Bijuterii", cat_home: "Casă & living", // category placeholders (English fine, they're maker notes) // product detail page pd_back: "Înapoi la magazin", pd_back_home: "Înapoi la pagina principală", pd_gallery: "Galerie", pd_dimensions: "Dimensiuni", pd_delivery: "Timp de livrare", pd_delivery_unit: "zile", pd_details: "Detalii", pd_price_on_request: "Preț la cerere", pd_inquire: "Comandă pe WhatsApp", pd_related: "Alte produse care ți-ar putea plăcea", pd_not_found: "Produsul nu a fost găsit.", // steps step1_t: "Concept", step1_b: "Împărtășește o idee, o fotografie, un nume, o frază. Cu cât detaliul este mai mic, cu atât contează mai mult.", step2_t: "Proiectare", step2_b: "Colegii noștri îți trimit machete în 48 de ore pentru rafinarea tipografiei, detaliilor, finisajului, până e exact cum îți dorești.", step3_t: "Realizare", step3_b: "Realizat în atelierul nostru din Iași și livrat în cel mult 14 zile, după complexitate.", // testimonials t1_q: "Cutia din nuc a sosit împachetată ca o bijuterie. Mama a plâns când a deschis-o.", t1_n: "Camelia R.", t1_l: "Cluj-Napoca, RO", t1_i: "CR", t2_q: "Le-am trimis o schiță făcută pe un șervețel. Au transformat-o în cel mai grijuliu cadou de aniversare.", t2_n: "Mihai S.", t2_l: "Timișoara, RO", t2_i: "MS", t3_q: "Trei runde de machete, zero presiune. Echipa chiar a ținut să iasă perfect spațierea literelor.", t3_n: "Ana T.", t3_l: "Iași, RO", t3_i: "AT" }, en: { annc1: "Free nationwide delivery on any order over 200 lei", nav_home: "Home", nav_shop: "Shop", nav_contact: "Contact us", // shop page shop_h2_a: "All ", shop_h2_em: "our creations.", shop_sub: "Every piece handmade in our atelier.\nBrowse and personalize.", // shop filters filter_all: "All", filter_label_cat: "Category", filter_label_show: "Show", filter_sort: "Sort", sort_featured: "Featured", sort_price_asc: "Price: low to high", sort_price_desc: "Price: high to low", filter_empty: "No creations match the selected filters.", filter_clear: "Clear filters", search_placeholder: "Search for a gift, an idea, a feeling…", aria_search: "Search", aria_account: "Account", aria_wishlist: "Wishlist", aria_close: "Close", hero_eyebrow: "Custom gifts · Made in Iași", hero_h1_1: "If you can dream it,", hero_h1_2: "we can make it.", hero_lede: "At Artelier, we turn your ideas into tangible memories. We blend high-precision 3D printing with the magic of handcrafting.\n\n\nEvery premium piece is carefully finished and hand-painted — and for a truly spectacular effect, we can add integrated LED lights.\n\n\nGive a one-of-a-kind gift! Send us a message with your idea and we'll bring it to life.", hero_cta_primary: "Start designing", hero_cta_secondary: "How it works", hero_meta1_lbl: "pieces in homes", hero_meta2_lbl: "average review", hero_meta3_lbl: "proof turnaround", float_pick: "Pick your finish", float_lbl: "Custom mug", cat_h2_a: "Explore ", cat_h2_em: "our categories →", cat_sub: "Gifts created with passion — for the ones you love.", cat_product: "product", cat_products: "products", prod_h2_a: "Pieces our ", prod_h2_em: "customers love.", prod_sub: "Every hand-painted gift is one of a kind.", tab_best: "Bestsellers", tab_new: "New arrivals", tab_weddings: "Weddings", prod_inquire: "Order privately", wa_msg: "Hello! I'd like to order: ", from: "from", how_eyebrow: "The Artelier process", how_h2_a: "Three simple steps. ", how_h2_em: "One unique gift.", testi_h2_a: "Notes from ", testi_h2_em: "our recipients.", testi_sub: "4.9 average from 3,200+ reviews · verified buyers", cta_h2_a: "A gift for you, ", cta_h2_em: "from us.", cta_p: "On any order of at least 20 euro, you'll receive a gift from the Artelier\u00A0team.", cta_placeholder: "your@email.com", cta_btn: "Subscribe →", cta_invalid: "Please enter a valid email.", cta_thanks: "Thank you — place an order and you'll receive your gift along with your delivery. ✦", ftr_brand_p: "At Artelier we bring your ideas to life. Whether you want a personalized gift, an item that's no longer available, or a cheaper way to 3D-print one, you've come to the right place. Tell us what you'd like on WhatsApp and we'll take care of the rest.", ftr_addr: "© 2026 Artelier · Iași, România", ftr_menu: "Menu", m_home: "Home", m_contact: "Contact us", ftr_cats: "Categories", c_flowers: "Flower bouquets", c_magnets: "Personalized magnets", c_handpainted: "Hand-painted items", c_print3d: "3D printed items", c_design3d: "3D design", ftr_social: "Social media", sug: ["Engraved boxes", "Wedding gifts", "Photo prints", "For her", "Under €50", "New arrivals"], fmt: (n) => fmtEur(n), cat_engraved: "Engraved Keepsakes", cat_photo: "Photo Prints", cat_mugs: "Drinkware", cat_jewel: "Jewelry", cat_home: "Home & Living", // product detail page pd_back: "Back to shop", pd_back_home: "Back to home", pd_gallery: "Gallery", pd_dimensions: "Dimensions", pd_delivery: "Delivery time", pd_delivery_unit: "days", pd_details: "Details", pd_price_on_request: "Price on request", pd_inquire: "Order on WhatsApp", pd_related: "Other products you might like", pd_not_found: "Product not found.", step1_t: "Concept", step1_b: "Share an idea, a photo, a name, a phrase. The smaller the detail, the more it matters.", step2_t: "Design", step2_b: "Our team sends you proofs within 48 hours to refine the type, the details, the finish, until it's exactly how you want it.", step3_t: "Crafted", step3_b: "Crafted in our Iași atelier and delivered within 14 days at most, depending on complexity.", t1_q: "The walnut box arrived wrapped like a piece of jewelry. My mother cried opening it.", t1_n: "Camille R.", t1_l: "Paris, FR", t1_i: "CR", t2_q: "I sent a napkin sketch from a notebook. They turned it into the most thoughtful anniversary gift.", t2_n: "Mateo S.", t2_l: "Buenos Aires, AR", t2_i: "MS", t3_q: "Three rounds of proofs, zero pressure. The team genuinely cared about getting the kerning right.", t3_n: "Hana T.", t3_l: "Tokyo, JP", t3_i: "HT" } }; const LangCtx = createContext({ lang: "ro", t: I18N.ro, setLang: () => {} }); const useT = () => useContext(LangCtx); // ─────────── Data ─────────── // ─────────── Product catalog (loaded from products.json) ─────────── // Pick the right language out of a { ro, en } field (or pass through a plain string). const L = (field, lang) => { if (field == null) return ""; if (typeof field === "string") return field; return field[lang] || field.en || field.ro || ""; }; // Build a price string honoring priceVisible / fromPrice flags. function priceLabel(p, t) { if (p.priceVisible === false) return t.pd_price_on_request; const value = t.fmt(p.price); return p.fromPrice ? { from: t.from, value } : { value }; } const TESTIMONIALS_KEYS = [ { qK: "t1_q", nK: "t1_n", lK: "t1_l", iK: "t1_i" }, { qK: "t2_q", nK: "t2_n", lK: "t2_l", iK: "t2_i" }, { qK: "t3_q", nK: "t3_n", lK: "t3_l", iK: "t3_i" }]; // ─────────── Routing helpers ─────────── // Hash routes: "#/" (home), "#/shop" (all products), or "#/product/". const parseRoute = () => { const h = (window.location.hash || "").replace(/^#/, ""); const m = h.match(/^\/product\/([^?]+)(?:\?from=(\w+))?$/); if (m) return { page: "product", slug: decodeURIComponent(m[1]), from: m[2] || null }; const s = h.match(/^\/shop\/?(?:\?cat=([^&]+))?$/); if (s) return { page: "shop", cat: s[1] ? decodeURIComponent(s[1]) : null }; return { page: "home" }; }; const goHome = () => {window.location.hash = "#/";}; const goShop = (cat) => {window.location.hash = "#/shop" + (cat ? "?cat=" + encodeURIComponent(cat) : "");}; const goProduct = (slug, from) => {window.location.hash = "#/product/" + encodeURIComponent(slug) + (from ? "?from=" + from : "");}; // ─────────── Helpers ─────────── const ImgPh = ({ label, style }) =>
{label}
; const Stars = ({ n = 5 }) =>
{Array.from({ length: n }, (_, i) => )}
; // ─────────── Components ─────────── function LangToggle({ lang, setLang }) { return (
·
); } function Header({ onNav, active, lang, setLang }) { const { t } = useT(); const [searchOpen, setSearchOpen] = useState(false); const [socialsOpen, setSocialsOpen] = useState(false); const [scrolled, setScrolled] = useState(false); const [anim, setAnim] = useState(false); const [q, setQ] = useState(""); const inputRef = useRef(null); const headerRef = useRef(null); const mounted = useRef(false); // Enable the logo pop animation only after the first real scroll toggle, // so the marks don't pop on initial page load. useEffect(() => { if (mounted.current) setAnim(true);else mounted.current = true; }, [scrolled]); useEffect(() => { if (searchOpen) setTimeout(() => inputRef.current?.focus(), 60); }, [searchOpen]); useEffect(() => { let ticking = false; const update = () => { ticking = false; const y = window.scrollY; // Continuous thinning: 0 at top -> 1 at 50px scrolled. const p = Math.min(1, Math.max(0, y / 50)); if (headerRef.current) headerRef.current.style.setProperty("--p", p.toFixed(4)); // Hysteresis: condense past 50px, expand only below 35px — avoids // flip-flopping (and the resulting shake) when hovering near the threshold. setScrolled((prev) => { if (!prev && y > 50) return true; if (prev && y < 35) return false; return prev; }); }; const onScroll = () => { if (!ticking) {ticking = true;requestAnimationFrame(update);} }; update(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); const navItems = [ ["Home", t.nav_home], ["Shop", t.nav_shop]]; // Navigate then close the socials popover. const navTo = (id) => {setSocialsOpen(false);onNav(id);}; // Social icons — rendered both in the desktop header and the mobile menu. const socialLinks = <> ; return ( <>
{[0, 1].map((g) =>
{[0, 1, 2].map((i) => {t.annc1} )}
)}
{e.preventDefault();setSocialsOpen(false);onNav("Home");window.scrollTo(0, 0);}}> Artelier Artelier
{socialLinks}
{searchOpen &&
setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && setSearchOpen(false)} />
{t.sug.map((s) => )}
}
{/* Mobile bottom tab bar — app-style navigation (hidden on desktop) */} {socialsOpen &&
setSocialsOpen(false)}>
} ); } function Hero({ onShop, products }) { const { t, lang } = useT(); // Products with a real photo to rotate through in the hero showcase. const showcase = (products || []).filter((p) => !p.draft && p.mainPhoto); const [idx, setIdx] = useState(0); // Advance every 3 seconds. useEffect(() => { if (showcase.length < 2) return; const id = setInterval(() => setIdx((i) => (i + 1) % showcase.length), 3000); return () => clearInterval(id); }, [showcase.length]); const active = showcase.length ? showcase[Math.min(idx, showcase.length - 1)] : null; const activeName = active ? L(active.name, lang) : t.float_lbl; const activeBadge = active && active.badge ? L(active.badge, lang) : ""; const priceStr = active ? priceLabel(active, t) : null; return (
Artelier
{(() => { const parts = t.hero_eyebrow.split(" · "); return parts.length > 1 ? {parts[0]} · {parts.slice(1).join(" · ")} : {t.hero_eyebrow}; })()}

If you can dream it,
we can make it.

{t.hero_lede}

active && goProduct(active.slug, "home")} style={{ cursor: active ? "pointer" : "default" }}> {showcase.length ? showcase.map((p, i) => {L(p.name, ) : } {activeBadge && {activeBadge} }
{activeName} {priceStr && {typeof priceStr === "string" ? priceStr : (priceStr.from ? priceStr.from + " " : "") + priceStr.value} }
); } function Categories({ categories = [] }) { const { t, lang } = useT(); if (!categories.length) return null; return (

{t.cat_h2_a}{t.cat_h2_em}

{t.cat_sub}
{categories.map((c) => )}
); } // One category tile. If the category has products with photos, it cross-fades // through their main photos (one per second). One photo → static. None → // keeps the labelled placeholder. function CategoryCard({ c }) { const { t, lang } = useT(); const label = L(c.label, lang); const photos = c.photos || []; const [idx, setIdx] = useState(0); useEffect(() => { setIdx(0); if (photos.length < 2) return; const id = setInterval( () => setIdx((i) => (i + 1) % photos.length), 3000 ); return () => clearInterval(id); }, [photos.length]); return (
goShop(c.id)}>
{photos.length ? photos.map((src, i) => {label} ) : }
{c.count} {c.count === 1 ? t.cat_product : t.cat_products}
{label}
); } // Renders a product image, or a labelled placeholder when no src is set yet. const ProductImage = ({ src, label, alt }) => src ? {alt : ; // Price display honoring priceVisible / fromPrice. function ProductPrice({ p, t, className }) { const lbl = priceLabel(p, t); if (typeof lbl === "string") { return
{lbl}
; } return (
{lbl.from && {lbl.from}}{lbl.value}
); } function inquireOnWhatsApp(t, name) { const url = "https://api.whatsapp.com/send/?phone=40770446732&type=phone_number&app_absent=0&text=" + encodeURIComponent(t.wa_msg + name); window.open(url, "_blank", "noopener"); } function ProductCard({ p, from }) { const { t, lang } = useT(); const name = L(p.name, lang); const badge = p.badge ? L(p.badge, lang) : ""; const mainLabel = p.gallery && p.gallery[0] ? p.gallery[0].label : name; return (
goProduct(p.slug, from)}>
{badge && {badge} }
{(p.categories || []).map((c) => L(c, lang)).join(" · ")}
{name}
{p.short &&
{L(p.short, lang)}
}
); } function Products({ products }) { const { t } = useT(); const [tab, setTab] = useState("bestsellers"); const tabs = [ ["bestsellers", t.tab_best], ["new", t.tab_new]]; const list = products.filter( (p) => !p.draft && (p.collections || []).includes(tab) ); return (

{t.prod_h2_a}{t.prod_h2_em}

{t.prod_sub}
{tabs.map(([id, label]) => )}
{list.map((p) => )}
); } // ─────────── Shop page (all products) ─────────── function ShopPage({ products, categories, initialCat }) { const { t, lang } = useT(); const [cat, setCat] = useState(initialCat || "all"); // category id or "all" const [show, setShow] = useState("all"); // "all" | "bestsellers" | "new" const [sort, setSort] = useState("featured"); // "featured" | "price-asc" | "price-desc" // Sync the active category when arriving via a deep link (e.g. clicking a // category tile on the home page) while the shop page is already mounted. useEffect(() => { setCat(initialCat || "all"); }, [initialCat]); const all = (products || []).filter((p) => !p.draft); // Only show category chips for categories that actually contain products. const catChips = (categories || []).filter((c) => (c.count || 0) > 0); const showChips = [ ["bestsellers", t.tab_best], ["new", t.tab_new]]; let list = all.filter((p) => { if (cat !== "all" && !(p.categoryIds || []).includes(cat)) return false; if (show !== "all" && !(p.collections || []).includes(show)) return false; return true; }); if (sort !== "featured") { // Products without a visible price sort to the end regardless of direction. const val = (p) => p.priceVisible === false || typeof p.price !== "number" ? null : p.price; list = [...list].sort((a, b) => { const va = val(a),bv = val(b); if (va == null && bv == null) return 0; if (va == null) return 1; if (bv == null) return -1; return sort === "price-asc" ? va - bv : bv - va; }); } const dirty = cat !== "all" || show !== "all" || sort !== "featured"; const reset = () => {setCat("all");setShow("all");setSort("featured");}; return (

{t.shop_h2_a}{t.shop_h2_em}

{t.shop_sub}
{t.filter_label_cat}
{catChips.map((c) => )}
{t.filter_label_show}
{showChips.map(([id, label]) => )}
{t.filter_sort}
{dirty && }
{list.length > 0 ?
{list.map((p) => )}
:

{t.filter_empty}

}
); } // ─────────── Related products carousel ─────────── // An infinite, auto-advancing carousel of other products. Shows 4 cards at a // time (2 on mobile), prioritising pieces from the same category, then filling // with the rest. Clones at both ends make the loop seamless in either // direction; auto-play advances one card every 4s and pauses on hover. const VISIBLE = 4; function RelatedProducts({ product, products, from }) { const { t } = useT(); const pool = useMemo(() => { const others = (products || []).filter((p) => !p.draft && p.id !== product.id); const catIds = product.categoryIds || []; const same = [], rest = []; others.forEach((p) => ((p.categoryIds || []).some((id) => catIds.includes(id)) ? same : rest).push(p) ); const shuffle = (arr) => { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; return [...shuffle(same), ...shuffle(rest)]; }, [product.id, products]); const [start, setStart] = useState(0); const [anim, setAnim] = useState(true); const [paused, setPaused] = useState(false); // Reset when the displayed product changes (navigating between products). useEffect(() => {setStart(0);setAnim(true);}, [product.id]); const looping = pool.length > VISIBLE; // Auto-play (paused on hover). useEffect(() => { if (!looping || paused) return; const id = setInterval(() => setStart((s) => s + 1), 4000); return () => clearInterval(id); }, [looping, paused, pool.length]); // Re-enable the transition on the frame after a no-anim wrap reset. useEffect(() => { if (!anim) { const r = requestAnimationFrame(() => requestAnimationFrame(() => setAnim(true))); return () => cancelAnimationFrame(r); } }, [anim]); // Safety net: if clicks/auto-play drift `start` clear past the cloned buffer // — e.g. mashing the arrow faster than each 720ms slide, which interrupts the // transitions so onTransitionEnd never fires to wrap — snap back into range. // The correction is a whole-loop multiple, so it shows identical cards and // never leaves a blank gap. useEffect(() => { if (pool.length > VISIBLE && (start >= 2 * pool.length - VISIBLE || start <= VISIBLE - pool.length)) { setAnim(false); setStart((s) => ((s % pool.length) + pool.length) % pool.length); } }, [start, pool.length]); if (!pool.length) return null; // Not enough to loop — just show a static row. if (!looping) { return (

{t.pd_related}

{pool.map((p) => )}
); } // A full copy of the pool on each side makes the loop seamless in both // directions and leaves a generous buffer for fast clicking. const items = [...pool, ...pool, ...pool]; const offset = pool.length + start; // Once a slide settles outside the middle copy, rebase into [0, pool.length) // with no animation. Modulo handles an overshoot of any size in one step. const onEnd = () => { if (start < 0 || start >= pool.length) { setAnim(false); setStart((s) => ((s % pool.length) + pool.length) % pool.length); } }; return (
setPaused(true)} onMouseLeave={() => setPaused(false)} >

{t.pd_related}

{items.map((p, i) => )}
); } // ─────────── Product detail page ─────────── function ProductDetail({ product, from, products }) { const { t, lang } = useT(); const [active, setActive] = useState(0); // Return to wherever the product was opened from: the shop if it came from // there, otherwise the home page (also the default for deep links). const goBack = () => from === "shop" ? goShop() : goHome(); const backLabel = from === "shop" ? t.pd_back : t.pd_back_home; useEffect(() => {setActive(0);window.scrollTo(0, 0);}, [product && product.id]); if (!product) { return (

{t.pd_not_found}

); } const name = L(product.name, lang); const gallery = product.gallery && product.gallery.length ? product.gallery : [{ src: product.mainPhoto, label: name }]; const current = gallery[Math.min(active, gallery.length - 1)]; const d = product.delivery; return (
{product.badge && {L(product.badge, lang)} }
{gallery.length > 1 &&
{gallery.map((g, i) => )}
}
{(product.categories || []).map((c) => L(c, lang)).join(" · ")}

{name}

{product.short &&

{L(product.short, lang)}

}
{product.dimensions &&
{t.pd_dimensions}
{L(product.dimensions, lang)}
} {d &&
{t.pd_delivery}
{d.min}–{d.max} {t.pd_delivery_unit}
}
{product.description &&

{L(product.description, lang)}

}
); } function HowItWorks() { const { t } = useT(); const steps = [ { num: "1", title: t.step1_t, body: t.step1_b }, { num: "2", title: t.step2_t, body: t.step2_b }, { num: "3", title: t.step3_t, body: t.step3_b }]; return (
{t.how_eyebrow}

{t.how_h2_a}{t.how_h2_em}

{steps.map((s) =>
{s.num}

{s.title}

{s.body}

)}
); } function Testimonials() { const { t } = useT(); return (

{t.testi_h2_a}{t.testi_h2_em}

{t.testi_sub}
{TESTIMONIALS_KEYS.map((tk) =>

„{t[tk.qK]}"

{t[tk.iK]}
{t[tk.nK]}{t[tk.lK]}
)}
); } function CTA() { const { t } = useT(); return (

{t.cta_h2_a}{t.cta_h2_em}

{t.cta_p}

); } function Footer({ categories = [] }) { const { t, lang } = useT(); return ( ); } // ─────────── Tweaks ─────────── const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "palette": ["#B46161", "#000022", "#F7F3FC"], "displayFont": "Instrument Serif", "showAnnouncement": true, "ctaStyle": "accent" } /*EDITMODE-END*/; const FONT_STACKS = { "Instrument Serif": '"Instrument Serif", "Times New Roman", serif', "DM Serif Display": '"DM Serif Display", "Times New Roman", serif', "Cormorant": '"Cormorant Garamond", "Times New Roman", serif' }; function applyTweaks(t) { const r = document.documentElement.style; r.setProperty("--accent", t.palette[0]); r.setProperty("--ink", t.palette[1]); r.setProperty("--bg", t.palette[2]); r.setProperty("--serif", FONT_STACKS[t.displayFont] || FONT_STACKS["Instrument Serif"]); const hex = t.palette[1].replace("#", ""); const bigint = parseInt(hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex, 16); const rr = bigint >> 16 & 255,gg = bigint >> 8 & 255,bb = bigint & 255; r.setProperty("--ink-70", `rgba(${rr},${gg},${bb},0.7)`); r.setProperty("--ink-50", `rgba(${rr},${gg},${bb},0.5)`); r.setProperty("--ink-30", `rgba(${rr},${gg},${bb},0.3)`); r.setProperty("--ink-15", `rgba(${rr},${gg},${bb},0.15)`); r.setProperty("--ink-08", `rgba(${rr},${gg},${bb},0.08)`); r.setProperty("--ink-04", `rgba(${rr},${gg},${bb},0.04)`); const ax = t.palette[0].replace("#", ""); const aint = parseInt(ax.length === 3 ? ax.split("").map((c) => c + c).join("") : ax, 16); const ar = aint >> 16 & 255,ag = aint >> 8 & 255,ab = aint & 255; r.setProperty("--accent-soft", `rgba(${ar},${ag},${ab},0.1)`); } const FONT_LINK_ID = "__extra-fonts"; function ensureFontLoaded(name) { if (name === "Instrument Serif") return; let link = document.getElementById(FONT_LINK_ID); if (!link) { link = document.createElement("link"); link.rel = "stylesheet"; link.id = FONT_LINK_ID; document.head.appendChild(link); } const family = name.replace(/ /g, "+"); link.href = `https://fonts.googleapis.com/css2?family=${family}:ital,wght@0,400;0,500;1,400&display=swap`; } // ─────────── App ─────────── function App() { const [tw, setTweak] = useTweaks(TWEAK_DEFAULTS); const [lang, setLang] = useState("ro"); const [products, setProducts] = useState(null); const [categories, setCategories] = useState([]); const [route, setRoute] = useState(parseRoute); // Load the catalog ("database") from products.json. useEffect(() => { fetch("products.json", { cache: "no-store" }). then((r) => r.json()). then((data) => { const list = Array.isArray(data) ? data : data.products || []; // Pick up currency config (base + euro conversion rate) from the catalog. if (data.currency) CURRENCY = { ...CURRENCY, ...data.currency }; // Resolve each product's category id against the top-level categories list. const catMap = {}; (data.categories || []).forEach((c) => {catMap[c.id] = c.label;}); // Map each collection id to its badge label. A product's badge is derived // from the collections it belongs to (no per-product badge in the catalog). const collBadge = {}; (data.collections || []).forEach((c) => {if (c.badge) collBadge[c.id] = c.badge;}); // Priority: a product in "bestsellers" shows the Bestseller badge even if // it is also "new"; a product only in "new" shows the New badge. const badgeFor = (p) => { const ids = p.collections || []; if (ids.includes("bestsellers")) return { badge: collBadge["bestsellers"], badgeAccent: true }; if (ids.includes("new")) return { badge: collBadge["new"], badgeAccent: false }; return { badge: null, badgeAccent: false }; }; // Count how many (non-draft) products belong to each category id. const counts = {}; // Collect each non-draft product's main photo per category id, so a // category tile can preview the actual pieces inside it. const photosByCat = {}; list.forEach((p) => { if (p.draft) return; (p.categories || []).forEach((id) => { counts[id] = (counts[id] || 0) + 1; if (p.mainPhoto) (photosByCat[id] = photosByCat[id] || []).push(p.mainPhoto); }); }); setCategories( (data.categories || []).map((c) => ({ ...c, count: counts[c.id] || 0, photos: photosByCat[c.id] || [] })) ); setProducts( list.map((p) => { const withBadge = { ...p, ...badgeFor(p) }; return Array.isArray(p.categories) ? { ...withBadge, categoryIds: p.categories, categories: p.categories.map((id) => catMap[id] || id) } : withBadge; }) ); }). catch((err) => {console.error("Could not load products.json", err);setProducts([]);}); }, []); // Keep the route in sync with the URL hash (back/forward + deep links). useEffect(() => { const onHash = () => setRoute(parseRoute()); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); // Land at the top of the page instantly whenever the route changes (home, // shop, category, or product) — no smooth scroll back up. useEffect(() => {window.scrollTo(0, 0);}, [route.page, route.cat]); useEffect(() => {applyTweaks(tw);}, [tw]); useEffect(() => {ensureFontLoaded(tw.displayFont);}, [tw.displayFont]); useEffect(() => {document.documentElement.lang = lang;}, [lang]); useEffect(() => { const el = document.querySelector(".annc"); if (el) el.style.display = tw.showAnnouncement ? "" : "none"; }, [tw.showAnnouncement]); useEffect(() => { const el = document.querySelector(".cta-inner"); if (!el) return; if (tw.ctaStyle === "accent") { el.style.setProperty("--cta-bg", "var(--accent)"); el.style.color = "#fff"; el.style.border = "0"; } else if (tw.ctaStyle === "ink") { el.style.setProperty("--cta-bg", "var(--ink)"); el.style.color = "var(--bg)"; el.style.border = "0"; } else { el.style.setProperty("--cta-bg", "#fff"); el.style.color = "var(--ink)"; el.style.border = "1px solid var(--ink-08)"; } }, [tw.ctaStyle, tw.palette]); const t = I18N[lang]; const onProductPage = route.page === "product"; const onShopPage = route.page === "shop"; const activeProduct = onProductPage && products ? products.find((p) => p.slug === route.slug && !p.draft) : null; // Which top-nav item is highlighted (product detail keeps Shop lit). const activeNav = onShopPage || onProductPage ? "Shop" : "Home"; // Handle top-nav clicks: route + scroll. const onNav = (n) => { if (n === "Shop") { goShop(); window.scrollTo(0, 0); } else if (n === "Contact") { goHome(); setTimeout(() => { const f = document.querySelector(".footer") || document.querySelector("footer"); if (f) window.scrollTo({ top: f.getBoundingClientRect().top + window.scrollY - 20, behavior: "smooth" }); }, 60); } else { goHome(); window.scrollTo(0, 0); } }; return (
{onProductPage ? products === null ?
: : onShopPage ? products === null ?
: : {window.scrollTo({ top: window.innerHeight, behavior: "smooth" });}} /> {products && products.length > 0 && } }