Next.js 14 website with standalone output configured for Docker deployment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
298 lines
9.8 KiB
TypeScript
298 lines
9.8 KiB
TypeScript
"use client";
|
||
|
||
import { motion, useScroll, useTransform, useInView } from "framer-motion";
|
||
import { useRef, useState } from "react";
|
||
import Image from "next/image";
|
||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||
|
||
/*
|
||
* Coordinates are percentages (0–100) relative to the map image (2720×1568).
|
||
* Each point was placed by identifying the geographic feature on the
|
||
* istanbul_map.png silhouette:
|
||
*
|
||
* Karaköy — European shore just south of the Golden Horn mouth
|
||
* Moda İskelesi — tip of the Kadıköy/Moda peninsula (Asian side)
|
||
* Moda (coffee) — slightly inland on the Moda neighbourhood streets
|
||
* Fenerbahçe — the prominent cape/park jutting south into the Marmara
|
||
* Caddebostan — along the Asian Marmara coast, east of Fenerbahçe
|
||
* Bağdat Caddesi— runs parallel to the coast, slightly inland
|
||
* Suadiye — further east along the Marmara coast
|
||
*/
|
||
const waypoints = [
|
||
{
|
||
id: "karakoy",
|
||
name: "Karaköy Ferry Terminal",
|
||
description:
|
||
"Your journey begins here. Board the ferry with Turkish tea in hand.",
|
||
icon: "🚢",
|
||
x: 27,
|
||
y: 34,
|
||
side: "europe",
|
||
},
|
||
{
|
||
id: "moda",
|
||
name: "Moda İskelesi",
|
||
description:
|
||
"Step onto a 1917 pier that now houses a library and café over the sea.",
|
||
icon: "📚",
|
||
x: 42,
|
||
y: 56,
|
||
side: "asia",
|
||
},
|
||
{
|
||
id: "coffee",
|
||
name: "Moda Coffee District",
|
||
description:
|
||
"Third-wave coffee tasting in Istanbul's coolest neighbourhood.",
|
||
icon: "☕",
|
||
x: 48,
|
||
y: 53,
|
||
side: "asia",
|
||
},
|
||
{
|
||
id: "fenerbahce",
|
||
name: "Fenerbahçe Park",
|
||
description:
|
||
"Mediterranean-style marina with yachts, pine trees, and sea air.",
|
||
icon: "🌳",
|
||
x: 44,
|
||
y: 67,
|
||
side: "asia",
|
||
},
|
||
{
|
||
id: "caddebostan",
|
||
name: "Caddebostan Seafront",
|
||
description:
|
||
"The promenade where Istanbulites gather at sunset. Street food heaven.",
|
||
icon: "🌅",
|
||
x: 53,
|
||
y: 78,
|
||
side: "asia",
|
||
},
|
||
{
|
||
id: "bagdat",
|
||
name: "Bağdat Caddesi",
|
||
description:
|
||
"Istanbul's Champs-Élysées. 6km of designer boutiques and patisseries.",
|
||
icon: "🛍️",
|
||
x: 64,
|
||
y: 60,
|
||
side: "asia",
|
||
},
|
||
{
|
||
id: "suadiye",
|
||
name: "Suadiye",
|
||
description:
|
||
"Sunset dinner with the European skyline painted in gold. The grand finale.",
|
||
icon: "🍷",
|
||
x: 63,
|
||
y: 86,
|
||
side: "asia",
|
||
},
|
||
];
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* SVG path that connects the waypoints via a smooth Bézier curve. */
|
||
/* The path crosses the Bosphorus water from Karaköy to Moda, then */
|
||
/* follows the Asian coastline south-east to Suadiye. */
|
||
/* ------------------------------------------------------------------ */
|
||
const ROUTE_PATH = [
|
||
"M 27 34", // Karaköy
|
||
"C 32 42, 38 52, 42 56", // ferry crossing → Moda İskelesi
|
||
"C 44 55, 46 54, 48 53", // walk to Moda coffee district
|
||
"C 47 57, 45 63, 44 67", // south to Fenerbahçe
|
||
"C 46 72, 50 76, 53 78", // east to Caddebostan
|
||
"C 57 76, 62 66, 64 60", // inland to Bağdat Caddesi
|
||
"C 64 68, 63 78, 63 86", // back to coast at Suadiye
|
||
].join(" ");
|
||
|
||
function WaypointPin({
|
||
waypoint,
|
||
index,
|
||
isActive,
|
||
onClick,
|
||
}: {
|
||
waypoint: (typeof waypoints)[0];
|
||
index: number;
|
||
isActive: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
const ref = useRef(null);
|
||
const isInView = useInView(ref, { once: true });
|
||
|
||
return (
|
||
<motion.button
|
||
ref={ref}
|
||
className="absolute z-10 -translate-x-1/2 -translate-y-1/2"
|
||
style={{ left: `${waypoint.x}%`, top: `${waypoint.y}%` }}
|
||
initial={{ opacity: 0, scale: 0 }}
|
||
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
||
transition={{
|
||
delay: 0.4 + index * 0.18,
|
||
type: "spring" as const,
|
||
stiffness: 300,
|
||
}}
|
||
onClick={onClick}
|
||
aria-label={waypoint.name}
|
||
>
|
||
{/* Pulse ring behind pin */}
|
||
<span
|
||
className={`absolute inset-0 rounded-full ${
|
||
isActive ? "bg-coral-spritz/30 animate-ping" : ""
|
||
}`}
|
||
/>
|
||
|
||
{/* Pin circle */}
|
||
<motion.div
|
||
className={`relative w-9 h-9 md:w-11 md:h-11 rounded-full flex items-center justify-center text-sm md:text-lg shadow-lg cursor-pointer border-2 transition-colors ${
|
||
isActive
|
||
? "bg-coral-spritz text-white border-white scale-110"
|
||
: "bg-white text-deep-nazar border-white/80 hover:bg-bosphorus hover:text-white hover:border-bosphorus"
|
||
}`}
|
||
whileHover={{ scale: 1.2 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
>
|
||
{waypoint.icon}
|
||
</motion.div>
|
||
|
||
{/* Step number label */}
|
||
<span className="absolute -top-1 -right-1 w-4 h-4 md:w-5 md:h-5 rounded-full bg-deep-nazar text-white text-[9px] md:text-[10px] font-bold flex items-center justify-center border border-white">
|
||
{index + 1}
|
||
</span>
|
||
|
||
</motion.button>
|
||
);
|
||
}
|
||
|
||
export function InteractiveMap() {
|
||
const sectionRef = useRef(null);
|
||
const [activeWaypoint, setActiveWaypoint] = useState<string | null>(null);
|
||
|
||
const { scrollYProgress } = useScroll({
|
||
target: sectionRef,
|
||
offset: ["start end", "end start"],
|
||
});
|
||
|
||
const pathLength = useTransform(scrollYProgress, [0.15, 0.55], [0, 1]);
|
||
|
||
return (
|
||
<section
|
||
id="the-route"
|
||
className="py-20 md:py-32 section-padding bg-warm-sand"
|
||
ref={sectionRef}
|
||
>
|
||
<div className="max-w-7xl mx-auto">
|
||
<SectionHeader
|
||
eyebrow="The Route"
|
||
title="Follow the Coastline"
|
||
subtitle="From a European pier to an Asian sunset. Here's every stop on our signature experience."
|
||
/>
|
||
|
||
{/* ---- Map Container ---- */}
|
||
<div
|
||
className="relative rounded-3xl overflow-hidden shadow-lg border border-sun-yolk/20"
|
||
onClick={() => setActiveWaypoint(null)}
|
||
>
|
||
{/* Map background image — the container inherits the image's
|
||
intrinsic aspect ratio so pins stay aligned at every size. */}
|
||
<Image
|
||
src="/images/istanbul_map.png"
|
||
alt="Illustrated map of Istanbul showing the Bosphorus strait, European side, and Asian side coastline"
|
||
width={2720}
|
||
height={1568}
|
||
className="block w-full h-auto"
|
||
priority
|
||
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 90vw, 1280px"
|
||
/>
|
||
|
||
{/* Animated route path overlay */}
|
||
<svg
|
||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||
viewBox="0 0 100 100"
|
||
preserveAspectRatio="none"
|
||
fill="none"
|
||
>
|
||
{/* Shadow/glow underneath */}
|
||
<motion.path
|
||
d={ROUTE_PATH}
|
||
stroke="rgba(30,58,138,0.15)"
|
||
strokeWidth="0.8"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ pathLength }}
|
||
fill="none"
|
||
/>
|
||
{/* Main dashed route line */}
|
||
<motion.path
|
||
d={ROUTE_PATH}
|
||
stroke="#1E3A8A"
|
||
strokeWidth="0.35"
|
||
strokeDasharray="0.8 0.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
style={{ pathLength }}
|
||
fill="none"
|
||
/>
|
||
</svg>
|
||
|
||
{/* Waypoint pins */}
|
||
{waypoints.map((wp, i) => (
|
||
<WaypointPin
|
||
key={wp.id}
|
||
waypoint={wp}
|
||
index={i}
|
||
isActive={activeWaypoint === wp.id}
|
||
onClick={() => {
|
||
setActiveWaypoint(activeWaypoint === wp.id ? null : wp.id);
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
{/* Side labels */}
|
||
<div className="absolute left-[12%] top-[10%] md:top-[12%] text-deep-nazar/25 font-display font-bold text-[10px] md:text-base uppercase tracking-[0.25em] select-none pointer-events-none">
|
||
Europe
|
||
</div>
|
||
<div className="absolute right-[10%] top-[10%] md:top-[12%] text-deep-nazar/25 font-display font-bold text-[10px] md:text-base uppercase tracking-[0.25em] select-none pointer-events-none">
|
||
Asia
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile: scrollable stop list below the map */}
|
||
<div className="mt-6 md:mt-8">
|
||
<div className="flex gap-3 overflow-x-auto pb-4 snap-x snap-mandatory scrollbar-hide md:grid md:grid-cols-4 lg:grid-cols-7 md:overflow-visible md:pb-0">
|
||
{waypoints.map((wp, i) => (
|
||
<motion.button
|
||
key={wp.id}
|
||
className={`flex-shrink-0 snap-start w-44 md:w-auto bg-white rounded-2xl p-4 shadow-sm text-left transition-all ${
|
||
activeWaypoint === wp.id
|
||
? "ring-2 ring-bosphorus shadow-md"
|
||
: "hover:shadow-md"
|
||
}`}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
whileInView={{ opacity: 1, y: 0 }}
|
||
viewport={{ once: true }}
|
||
transition={{ delay: i * 0.08 }}
|
||
onClick={() =>
|
||
setActiveWaypoint(activeWaypoint === wp.id ? null : wp.id)
|
||
}
|
||
>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="w-6 h-6 rounded-full bg-deep-nazar text-white text-[10px] font-bold flex items-center justify-center">
|
||
{i + 1}
|
||
</span>
|
||
<span className="text-lg">{wp.icon}</span>
|
||
</div>
|
||
<p className="font-display font-bold text-sm text-deep-nazar leading-tight">
|
||
{wp.name}
|
||
</p>
|
||
<p className="text-[11px] text-deep-nazar/50 mt-1 leading-relaxed line-clamp-2">
|
||
{wp.description}
|
||
</p>
|
||
</motion.button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|