// 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 (
);
}
// ── Modal (private viewing / brochure gate) ─────────────────────────────────
function Modal({ open, onClose, kind, locale }) {
const t = COPY[locale] || COPY.en;
const m = (t.modal || COPY.en.modal);
const f = (t.convert && t.convert.fields) || COPY.en.convert.fields;
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [date, setDate] = useState('');
const [time, setTime] = useState('10:00');
const [where, setWhere] = useState(m.locOpt[0]);
const [sent, setSent] = useState(false);
useEffect(() => {
if (open) { setSent(false); document.body.style.overflow = 'hidden'; }
else document.body.style.overflow = '';
return () => { document.body.style.overflow = ''; };
}, [open]);
if (!open) return null;
const isViewing = kind === 'viewing';
const isBroker = kind === 'broker';
const title = isViewing ? m.titleViewing : isBroker ? m.titleBroker : m.titleBrochure;
const submit = isViewing ? m.submit : m.submitBrochure;
const sentMsg = isViewing ? ((t.convert && t.convert.sentViewing) || COPY.en.convert.sentViewing) : ((t.convert && t.convert.sentBrochure) || COPY.en.convert.sentBrochure);
const doSubmit = (e) => {
e.preventDefault();
setSent(true);
};
return (
e.stopPropagation()}>
Six Senses Residences · Dubai Marina
{title}
{(t.convert && t.convert.body) || COPY.en.convert.body}
Tel+971 4 368 3355
WhatsApp+971 52 459 6183
HQPeninsula, Business Bay
{sent ? (
) : (
)}
);
}
// ── WhatsApp floating button ────────────────────────────────────────────────
function WhatsAppFab({ dir }) {
const [shown, setShown] = useState(false);
useEffect(() => { const t = setTimeout(() => setShown(true), 1200); return () => clearTimeout(t); }, []);
return (
+971 52 459 6183
);
}
// ── Mobile sticky CTA ───────────────────────────────────────────────────────
function MobileBar({ locale, onRegister, onViewing }) {
const t = COPY[locale] || COPY.en;
return (
);
}
// ── Footer ──────────────────────────────────────────────────────────────────
function Footer({ locale }) {
const t = COPY[locale] || COPY.en;
const f = t.footer || COPY.en.footer;
return (
);
}
// Export to window for cross-script use
Object.assign(window, {
Icon, SectionHeader, CountUp, Nav,
RegisterForm, Modal, WhatsAppFab, MobileBar, Footer, PhoneField,
});