// Smaller reusable components. Loaded as Babel script. // Globals expected: React, COPY. const { useState, useEffect, useRef, useMemo } = React; // ── Icon glyphs (small inline SVGs) ───────────────────────────────────────── function Icon({ name, size = 16, stroke = 'currentColor' }) { const s = { width: size, height: size, fill: 'none', stroke, strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }; switch (name) { case 'arrow-right': return ; case 'arrow-down': return ; case 'chevron': return ; case 'close': return ; case 'whatsapp': return ( ); case 'phone': return ; case 'mail': return ; case 'pin': return ; case 'check': return ; case 'plus': return ; case 'globe': return ; default: return null; } } // ── Hairline + numeric label ──────────────────────────────────────────────── function SectionHeader({ number, eyebrow, heading, className = '' }) { return (
{number} {eyebrow}

{heading}

); } // ── Animated count-up ─────────────────────────────────────────────────────── function CountUp({ target, duration = 1400 }) { const ref = useRef(null); const [val, setVal] = useState(target); const [started, setStarted] = useState(false); // Parse leading number portion (e.g. "24.7M" -> 24.7, "M"). Strings without // a leading number are passed through. const m = String(target).match(/^([\d.,]+)(.*)$/); const numeric = m ? parseFloat(m[1].replace(/,/g, '')) : null; const suffix = m ? m[2] : ''; useEffect(() => { if (numeric === null) return; const el = ref.current; if (!el) return; const obs = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !started) setStarted(true); }); }, { threshold: 0.4 }); obs.observe(el); return () => obs.disconnect(); }, [numeric, started]); useEffect(() => { if (!started || numeric === null) return; const t0 = performance.now(); let raf; const tick = (t) => { const k = Math.min(1, (t - t0) / duration); const eased = 1 - Math.pow(1 - k, 3); const cur = numeric * eased; const formatted = cur >= 100 ? Math.round(cur).toLocaleString() : cur.toFixed(1); setVal(formatted + suffix); if (k < 1) raf = requestAnimationFrame(tick); else setVal(target); // ensure final exact text }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [started, numeric, duration, target, suffix]); return {numeric === null ? target : val}; } // ── Nav (sticky, transparent → solid on scroll) ───────────────────────────── function Nav({ locale, setLocale, onRegister, dir }) { const t = COPY[locale] || COPY.en; const [scrolled, setScrolled] = useState(false); const [open, setOpen] = useState(false); const [langOpen, setLangOpen] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 40); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, []); const items = [ ['overview', t.nav.overview], ['residences', t.nav.residences], ['amenities', t.nav.amenities], ['gallery', t.nav.gallery], ['location', t.nav.location], ['provenance', t.nav.provenance], ]; return ( ); } // ── Phone with international code prefix ──────────────────────────────────── function PhoneField({ value, onChange, label, dir }) { const [code, setCode] = useState('+971'); const [num, setNum] = useState(value || ''); const codes = ['+971', '+44', '+1', '+7', '+86', '+33', '+49', '+966', '+974', '+965']; useEffect(() => { onChange?.(`${code} ${num}`); }, [code, num]); return ( ); } // ── Register Interest form ────────────────────────────────────────────────── function RegisterForm({ locale, presetResidence, onSent }) { const t = COPY[locale] || COPY.en; const f = (t.convert && t.convert.fields) || COPY.en.convert.fields; const sent = (t.convert && t.convert.sent) || COPY.en.convert.sent; const submit = f.submit; const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [phone, setPhone] = useState(''); const [country, setCountry] = useState(''); const [residence, setResidence] = useState(presetResidence || (f.residenceOpt && f.residenceOpt[0]) || ''); const [timeline, setTimeline] = useState((f.timelineOpt && f.timelineOpt[0]) || ''); const [message, setMessage] = useState(''); const [consent, setConsent] = useState(false); const [submitted, setSubmitted] = useState(false); const [errors, setErrors] = useState({}); const validate = () => { const e = {}; if (!name || name.trim().length < 2) e.name = true; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = true; if (!phone || phone.replace(/\D/g, '').length < 6) e.phone = true; if (!consent) e.consent = true; setErrors(e); return Object.keys(e).length === 0; }; const onSubmit = (e) => { e.preventDefault(); if (!validate()) return; setSubmitted(true); onSent?.({ kind: 'register', name, email, phone, country, residence, timeline, message, locale }); }; if (submitted) { return (

{sent}

Ref · SSM-{Date.now().toString().slice(-6)}

); } return (