// Shared, page-agnostic section components for The Vivaan Hotel & Resorts.
// Built on top of window.VivaanDesignSystem_b1c8ba (Hero, Button, NavigationBar,
// BookingPanel, FormField). These are page-specific compositions, not formal
// DS components — same pattern as DiscoverSection/Footer in the DS's own
// ui_kit reference.
const { Button } = window.VivaanDesignSystem_b1c8ba;
/* ---------------------------------------------------------------------- */
/* Primitives */
/* ---------------------------------------------------------------------- */
// Tasteful abstract placeholder standing in for estate photography — the DS
// itself falls back to flat tone fields rather than fabricated photos, so we
// follow that convention throughout instead of pulling in stock imagery.
function PhotoBlock({ tone = "taupe", caption, ratio, style }) {
// Flat single-token fields only — matches Hero's own no-photo fallback
// (var(--vivaan-taupe), no gradient) and the DS's "no decorative
// gradients" rule. A photo would replace this entirely; this is a stand-in.
const tones = {
taupe: "var(--vivaan-taupe)",
cream: "var(--vivaan-cream)",
ink: "var(--vivaan-black)",
gold: "var(--vivaan-gold)",
};
const { backgroundImage, ...restStyle } = style || {};
const wrap = {
position: "relative",
aspectRatio: ratio || "4 / 5",
backgroundColor: tones[tone],
overflow: "hidden",
...restStyle,
};
const cap = {
position: "absolute",
left: 20,
bottom: 18,
right: 20,
color: tone === "cream" ? "var(--color-text-primary)" : "var(--color-text-on-dark)",
fontFamily: "var(--font-serif)",
fontSize: 11,
letterSpacing: "0.18em",
textTransform: "uppercase",
opacity: 0.9,
};
return (
{caption ?
{caption}
: null}
);
}
// Hover-swap: shows images[0] by default, crossfades to images[1] on
// hover/focus (and back on leave). Used for room cards with a "before/after"
// or two-angle photo pair. Falls back to a flat PhotoBlock if no images.
function HoverImageSwap({ images, tone, ratio, caption, style, fadeMs = 500 }) {
const [hovered, setHovered] = React.useState(false);
if (!images || images.length < 2) {
return ;
}
const wrap2 = {
position: "relative",
aspectRatio: ratio || "4 / 5",
overflow: "hidden",
backgroundColor: "var(--vivaan-taupe)",
...style,
};
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
tabIndex={0}
>
{images.map((src, i) => (
))}
{caption ? (
{caption}
) : null}
);
}
// Slow, editorial crossfade between a set of photographs — reusable anywhere
// a single PhotoBlock previously stood in for real imagery. Matches the
// brand's "luxury pace": long hold, slow fade, no motion on the images.
function PhotoSlideshow({ images, ratio, caption, style, holdMs = 4500, fadeMs = 1500 }) {
const [active, setActive] = React.useState(0);
React.useEffect(() => {
if (!images || images.length < 2) return;
const id = setInterval(() => {
setActive((v) => (v + 1) % images.length);
}, holdMs);
return () => clearInterval(id);
}, [images, holdMs]);
const wrap = {
position: "relative",
aspectRatio: ratio || "4 / 5",
overflow: "hidden",
backgroundColor: "var(--vivaan-taupe)",
...style,
};
const cap = {
position: "absolute",
left: 20,
bottom: 18,
right: 20,
zIndex: 2,
color: "var(--color-text-on-dark)",
fontFamily: "var(--font-serif)",
fontSize: 11,
letterSpacing: "0.18em",
textTransform: "uppercase",
opacity: 0.9,
};
return (
{(images || []).map((src, i) => (
))}
{caption ?
{caption}
: null}
);
}
function SectionLabel({ children, dark, style }) {
return (
{children}
);
}
// parts: [{ text, italic }]
function EditorialHeading({ parts, size = 48, dark, style }) {
// Fluid type: scales between ~58% and 100% of the given size across the
// viewport, so every headline built on this component (home + inner pages)
// is responsive without per-callsite breakpoint work.
const fluidSize = typeof size === "number" ? `clamp(${Math.round(size * 0.58)}px, 6vw, ${size}px)` : size;
return (
{parts.map((p, i) => (
{p.text}
))}
);
}
function BodyCopy({ children, dark, style }) {
return (
{children}
);
}
/* ---------------------------------------------------------------------- */
/* Navigation + Footer */
/* ---------------------------------------------------------------------- */
const NAV_LINKS = [
{ label: "Stay", href: "rooms.html" },
{ label: "Dining", href: "dining.html" },
{ label: "Wedding & Events", href: "wedding.html" },
{ label: "Meetings & Conferences", href: "corporate.html" },
];
const VIVAAN_LOGO_SRC = (typeof window !== "undefined" && window.__resources && window.__resources.logoImg) ? window.__resources.logoImg : "assets/vivaan-logo-white.png";
function SiteNav() {
const { NavigationBar } = window.VivaanDesignSystem_b1c8ba;
const [scrolled, setScrolled] = React.useState(false);
const [menuOpen, setMenuOpen] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 60);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const navBg = scrolled ? "var(--vivaan-overlay-dark)" : "var(--color-surface-overlay-nav)";
const logo = (
);
return (
{/* Desktop / laptop / landscape-tablet nav (>=900px) — the DS NavigationBar as-is */}
}
links={NAV_LINKS}
cta={Book Now }
style={{
backgroundColor: navBg,
padding: "12px 40px",
gap: "24px",
transition: "background-color 0.3s ease",
}}
/>
{/* Mobile / portrait-tablet nav (<900px) — compact bar + slide-down menu */}
{logo}
setMenuOpen((v) => !v)}
className="nav-mobile-toggle"
>
{menuOpen ? (
) : (
)}
{menuOpen ? (
{NAV_LINKS.map((l) => (
setMenuOpen(false)}>{l.label}
))}
setMenuOpen(false)} style={{ marginTop: 16, textAlign: "center", justifyContent: "center" }}>
Book Now
) : null}
);
}
function SocialGlyph({ label, href, background, children }) {
return (
{children}
);
}
function InstagramGlyph() {
return (
);
}
function FacebookGlyph() {
return (
);
}
function YouTubeGlyph() {
return (
);
}
function LinkedInGlyph() {
return (
);
}
function SiteFooter() {
const wrap = {
position: "relative",
backgroundColor: "var(--vivaan-black)",
color: "var(--color-text-on-dark)",
overflow: "hidden",
fontFamily: "var(--font-serif)",
};
const inner = {
position: "relative",
zIndex: 1,
padding: "var(--space-2xl) var(--space-xl) var(--space-lg)",
display: "flex",
flexDirection: "column",
gap: "var(--space-xl)",
};
const watermark = {
position: "absolute",
right: "-40px",
bottom: "-120px",
fontSize: "560px",
lineHeight: 1,
fontWeight: 400,
color: "rgba(255,255,255,0.04)",
userSelect: "none",
pointerEvents: "none",
};
const top = { display: "flex", justifyContent: "space-between", gap: "var(--space-xl)", flexWrap: "wrap" };
const colTitle = { fontSize: 11, letterSpacing: "0.18em", textTransform: "uppercase", color: "rgba(255,255,255,0.5)", marginBottom: 16 };
const linkStyle = {
display: "block",
fontSize: 14,
color: "var(--color-text-on-dark)",
textDecoration: "none",
opacity: 0.85,
marginBottom: 10,
};
const rule = {
borderTop: "1px solid rgba(255,255,255,0.15)",
paddingTop: "var(--space-sm)",
display: "flex",
justifyContent: "space-between",
fontSize: 11,
opacity: 0.55,
letterSpacing: "0.05em",
flexWrap: "wrap",
gap: 12,
};
return (
V
The Vivaan Hotel & Resorts, NH-1, Karnal, Haryana, India.
© {new Date().getFullYear()} The Vivaan Hotel & Resorts. All rights reserved.
Privacy Policy
Terms
);
}
/* ---------------------------------------------------------------------- */
/* Page-header (for inner pages — a shorter hero band, not the full 100vh) */
/* ---------------------------------------------------------------------- */
function PageHeader({ eyebrow, parts, body, tone = "ink", images, video, vimeoId }) {
return (
{vimeoId ? (
) : video ? (
) : images && images.length > 1 ? (
) : (
)}
);
}
/* ---------------------------------------------------------------------- */
/* Floating booking widget (persistent across pages) */
/* ---------------------------------------------------------------------- */
function FloatingBookButton() {
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setVisible(window.scrollY > window.innerHeight * 0.9);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
if (!visible) return null;
return (
);
}
// Minimal line glyphs standing in for the brand's bespoke icon set (none
// supplied) — thin-stroke, single-color, matching the DS's icon convention.
function LayoutIcon({ name }) {
const common = { width: 40, height: 40, viewBox: "0 0 40 40", fill: "none", stroke: "var(--vivaan-gold)", strokeWidth: 1.4 };
switch (name) {
case "Theatre":
return (
{[10, 16, 22, 28].map((y) => )}
);
case "U Shape":
return (
);
case "Box Setup":
return (
);
case "Cluster":
return (
);
case "Round Table":
default:
return (
);
}
}
// Auto-scrolling "coverflow" filmstrip: images drift past continuously,
// the one nearest center scales up, and the whole thing can be grabbed
// and dragged to scrub back and forth (pauses auto-scroll while dragging).
// Positions are computed cumulatively each frame (not fixed flex gaps) so
// the visual gap between card edges stays constant even as cards scale.
function EstateFilmstrip({ tones }) {
const containerRef = React.useRef(null);
const itemRefs = React.useRef([]);
const stateRef = React.useRef({ scrollX: 0, dragging: false, startX: 0, startScrollX: 0, lastTime: null, resumeAt: 0 });
const ITEM_WIDTH = 220;
const GAP = 20;
const STEP = ITEM_WIDTH + GAP;
const SPEED = 36; // px per second
const RANGE = 3; // falloff range, in card-widths
const items = React.useMemo(() => [...tones, ...tones, ...tones], [tones]);
const singleSetWidth = tones.length * STEP;
const n = items.length;
React.useEffect(() => {
stateRef.current.scrollX = singleSetWidth;
let raf;
function frame(time) {
const st = stateRef.current;
if (st.lastTime == null) st.lastTime = time;
const dt = Math.min((time - st.lastTime) / 1000, 0.05);
st.lastTime = time;
if (!st.dragging && time > st.resumeAt) {
st.scrollX += SPEED * dt;
}
if (st.scrollX >= singleSetWidth * 2) st.scrollX -= singleSetWidth;
if (st.scrollX < 0) st.scrollX += singleSetWidth;
const centerIndex = st.scrollX / STEP;
// Pass 1: scale/ease for every card, from its index-distance to center.
const scale = new Array(n);
const eased = new Array(n);
for (let i = 0; i < n; i++) {
const norm = Math.min(Math.abs(i - centerIndex) / RANGE, 1);
const e = 0.5 - 0.5 * Math.cos(norm * Math.PI);
eased[i] = e;
scale[i] = 1 - e * 0.5;
}
// Pass 2: cumulative centers, keeping a constant GAP between edges
// regardless of each neighbour's scale.
const centers = new Array(n);
centers[0] = 0;
for (let i = 1; i < n; i++) {
centers[i] = centers[i - 1] + (ITEM_WIDTH * scale[i - 1]) / 2 + GAP + (ITEM_WIDTH * scale[i]) / 2;
}
const lo = Math.max(0, Math.min(n - 2, Math.floor(centerIndex)));
const frac = centerIndex - lo;
const virtualCenter = centers[lo] + (centers[lo + 1] - centers[lo]) * frac;
const containerWidth = containerRef.current ? containerRef.current.offsetWidth : 0;
const shift = containerWidth / 2 - virtualCenter;
itemRefs.current.forEach((el, i) => {
if (!el) return;
const finalX = centers[i] + shift - ITEM_WIDTH / 2;
const lift = -(1 - eased[i]) * 16;
el.style.transform = `translate(${finalX}px, -50%) translateY(${lift}px) scale(${scale[i]})`;
el.style.zIndex = String(1000 - Math.round(Math.abs(i - centerIndex) * 10));
el.style.opacity = String(1 - eased[i] * 0.25);
});
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [singleSetWidth, n]);
function handlePointerDown(e) {
const st = stateRef.current;
st.dragging = true;
st.startX = e.clientX;
st.startScrollX = st.scrollX;
if (containerRef.current) containerRef.current.style.cursor = "grabbing";
e.currentTarget.setPointerCapture(e.pointerId);
}
function handlePointerMove(e) {
const st = stateRef.current;
if (!st.dragging) return;
const dx = e.clientX - st.startX;
st.scrollX = st.startScrollX - dx;
}
function handlePointerUp() {
const st = stateRef.current;
st.dragging = false;
st.lastTime = null;
st.resumeAt = performance.now() + 900;
if (containerRef.current) containerRef.current.style.cursor = "grab";
}
return (
{items.map((tone, i) => (
(itemRefs.current[i] = el)}
style={{
position: "absolute",
left: 0,
top: "50%",
width: ITEM_WIDTH,
pointerEvents: "none",
willChange: "transform",
}}
>
))}
);
}
function EnquiryForm({ title = "Send an Enquiry", showScheduleOptions = false }) {
const { FormField } = window.VivaanDesignSystem_b1c8ba;
const [sent, setSent] = React.useState(false);
const [schedule, setSchedule] = React.useState(null);
const [name, setName] = React.useState("");
const [phone, setPhone] = React.useState("");
const [email, setEmail] = React.useState("");
const [guestCount, setGuestCount] = React.useState(100);
const [errors, setErrors] = React.useState({});
const wrap = { backgroundColor: "var(--vivaan-cream)", padding: "var(--space-xl)", display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%", maxWidth: 480, boxSizing: "border-box" };
const scheduleRow = { display: "flex", gap: 12, flexWrap: "wrap" };
const scheduleBtn = (name) => ({
flex: 1,
padding: "12px 16px",
fontFamily: "var(--font-serif)",
fontSize: 12,
letterSpacing: "0.1em",
textTransform: "uppercase",
textAlign: "center",
border: schedule === name ? "1px solid var(--vivaan-gold)" : "1px solid var(--color-border-hairline)",
color: schedule === name ? "var(--vivaan-gold)" : "var(--color-text-primary)",
background: "transparent",
cursor: "pointer",
});
const errorNote = { fontFamily: "var(--font-serif)", fontSize: 11, letterSpacing: "0.06em", color: "var(--vivaan-gold)", marginTop: -4 };
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.(com|in|co)$/i;
function handleNameChange(v) {
setName(v.replace(/[^A-Za-z\s]/g, ""));
}
function handlePhoneChange(v) {
setPhone(v.replace(/[^0-9+\-]/g, ""));
}
function handleSubmit() {
const nextErrors = {};
if (!name.trim()) nextErrors.name = "Required";
if (!phone.trim()) nextErrors.phone = "Required";
else if (!/^[0-9+\-]+$/.test(phone)) nextErrors.phone = "Numbers, + and - only";
if (email.trim() && !EMAIL_PATTERN.test(email.trim())) nextErrors.email = "Enter a valid email (.com, .in or .co)";
if (!guestCount || guestCount < 1) nextErrors.guestCount = "Required";
setErrors(nextErrors);
if (Object.keys(nextErrors).length === 0) setSent(true);
}
if (sent) {
return (
Thank You
Your enquiry has been received. Our events team will be in touch within 24 hours.
);
}
return (
{title}
{errors.name ?
{errors.name}
: null}
{errors.phone ?
{errors.phone}
: null}
{errors.email ?
{errors.email}
: null}
{errors.guestCount ?
{errors.guestCount}
: null}
{showScheduleOptions ? (
Prefer to connect first?
setSchedule("visit")}>Site Visit
setSchedule("meet")}>Google Meet
) : null}
Submit Enquiry
);
}
Object.assign(window, {
PhotoBlock,
PhotoSlideshow,
HoverImageSwap,
LayoutIcon,
EnquiryForm,
EstateFilmstrip,
SectionLabel,
EditorialHeading,
BodyCopy,
SiteNav,
SiteFooter,
PageHeader,
FloatingBookButton,
NAV_LINKS,
});