feat(map): connect Global Map cases to their full application case pages
Closes the disconnect where a case study opened from the 3D globe had no
path to its full write-up — you had to leave the globe, open the right
application, and hunt for it.
- CaseStudyModal: new "View full case study" CTA for real installations
(not events / HQ). It deep-links via the locale-aware next-intl Link to
/applications/{application}#case-{nodeId}, closes the modal, and fires a
case_study_viewed GA event.
- ApplicationClient: on mount it reads a "#case-<id>" hash, auto-expands the
matching case in the "Proven Solutions" wall, and smooth-scrolls to it.
Each case row now carries id="case-<id>" + scroll-mt for correct offset.
- viewFullCase string added to the CaseStudyModal namespace in all 5 locales.
The GlobalNode.application field (already equal to the Application slug) is
the join key — no schema change needed.
Verified: production build compiles, TypeScript clean, 5 message files valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -159,7 +159,8 @@
|
|||||||
"eventOverview": "Veranstaltungsübersicht",
|
"eventOverview": "Veranstaltungsübersicht",
|
||||||
"projectChronicle": "Projektchronik",
|
"projectChronicle": "Projektchronik",
|
||||||
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
|
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
|
||||||
"mediaGallery": "Mediengalerie"
|
"mediaGallery": "Mediengalerie",
|
||||||
|
"viewFullCase": "Vollständige Fallstudie ansehen"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Hergestellt in Italien",
|
"madeInItaly": "Hergestellt in Italien",
|
||||||
|
|||||||
+2
-1
@@ -159,7 +159,8 @@
|
|||||||
"eventOverview": "Event Overview",
|
"eventOverview": "Event Overview",
|
||||||
"projectChronicle": "Project Chronicle",
|
"projectChronicle": "Project Chronicle",
|
||||||
"pendingData": "[ Chronicle data pending for this node ]",
|
"pendingData": "[ Chronicle data pending for this node ]",
|
||||||
"mediaGallery": "Media Gallery"
|
"mediaGallery": "Media Gallery",
|
||||||
|
"viewFullCase": "View full case study"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Made in Italy",
|
"madeInItaly": "Made in Italy",
|
||||||
|
|||||||
+2
-1
@@ -159,7 +159,8 @@
|
|||||||
"eventOverview": "Resumen del Evento",
|
"eventOverview": "Resumen del Evento",
|
||||||
"projectChronicle": "Crónica del Proyecto",
|
"projectChronicle": "Crónica del Proyecto",
|
||||||
"pendingData": "[ Datos de crónica pendientes para este nodo ]",
|
"pendingData": "[ Datos de crónica pendientes para este nodo ]",
|
||||||
"mediaGallery": "Galería de Medios"
|
"mediaGallery": "Galería de Medios",
|
||||||
|
"viewFullCase": "Ver el caso completo"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Hecho en Italia",
|
"madeInItaly": "Hecho en Italia",
|
||||||
|
|||||||
+2
-1
@@ -159,7 +159,8 @@
|
|||||||
"eventOverview": "Panoramica Evento",
|
"eventOverview": "Panoramica Evento",
|
||||||
"projectChronicle": "Cronaca del Progetto",
|
"projectChronicle": "Cronaca del Progetto",
|
||||||
"pendingData": "[ Dati cronaca in attesa per questo nodo ]",
|
"pendingData": "[ Dati cronaca in attesa per questo nodo ]",
|
||||||
"mediaGallery": "Galleria Media"
|
"mediaGallery": "Galleria Media",
|
||||||
|
"viewFullCase": "Vedi il caso completo"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Made in Italy",
|
"madeInItaly": "Made in Italy",
|
||||||
|
|||||||
+2
-1
@@ -159,7 +159,8 @@
|
|||||||
"eventOverview": "Detaji de l'evento",
|
"eventOverview": "Detaji de l'evento",
|
||||||
"projectChronicle": "Storia del projeto",
|
"projectChronicle": "Storia del projeto",
|
||||||
"pendingData": "[ Dati de la storia drio rivar par sto nodo ]",
|
"pendingData": "[ Dati de la storia drio rivar par sto nodo ]",
|
||||||
"mediaGallery": "Gałeria de foto"
|
"mediaGallery": "Gałeria de foto",
|
||||||
|
"viewFullCase": "Varda el caso completo"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Fato in Itaia",
|
"madeInItaly": "Fato in Itaia",
|
||||||
|
|||||||
@@ -1004,6 +1004,21 @@ function ExpandedCaseStudy({ node }: { node: any }) {
|
|||||||
export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
|
export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
|
||||||
const [expandedCase, setExpandedCase] = useState<string | null>(null);
|
const [expandedCase, setExpandedCase] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Deep-link from the Global Map: a "#case-<id>" hash opens the matching
|
||||||
|
// case study, expands it, and scrolls to it. This is the bridge that
|
||||||
|
// connects a node's modal on the 3D globe to its full write-up here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash.startsWith("#case-")) return;
|
||||||
|
const id = decodeURIComponent(hash.slice("#case-".length));
|
||||||
|
setExpandedCase(id);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.getElementById(`case-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
||||||
const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
|
const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
|
||||||
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
|
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
|
||||||
@@ -1147,7 +1162,7 @@ export default function ApplicationClient({ data, realCases, images, breadcrumbs
|
|||||||
{realCases.map((node) => {
|
{realCases.map((node) => {
|
||||||
const isExpanded = expandedCase === node.id;
|
const isExpanded = expandedCase === node.id;
|
||||||
return (
|
return (
|
||||||
<div key={node.id} className="bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl">
|
<div key={node.id} id={`case-${node.id}`} className="scroll-mt-28 bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl target:ring-2 target:ring-[#0066CC] dark:target:ring-[#00F0FF]">
|
||||||
<div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors">
|
<div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors">
|
||||||
<div className="flex items-center gap-5 flex-1">
|
<div className="flex items-center gap-5 flex-1">
|
||||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
|
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon } from "lucide-react";
|
import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon, ArrowRight } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
export interface CaseStudyData {
|
export interface CaseStudyData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -298,13 +300,35 @@ export default function CaseStudyModal({ isOpen, onClose, data }: ModalProps) {
|
|||||||
<div className="col-span-2 md:col-span-1 bg-[#F5F5F7] dark:bg-[#1D1D1F] p-5 rounded-2xl border border-transparent dark:border-white/5 flex flex-col justify-center">
|
<div className="col-span-2 md:col-span-1 bg-[#F5F5F7] dark:bg-[#1D1D1F] p-5 rounded-2xl border border-transparent dark:border-white/5 flex flex-col justify-center">
|
||||||
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">{t("systemStatus")}</span>
|
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">{t("systemStatus")}</span>
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
<span className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
{/* 🔥 AQUÍ ESTABA EL ERROR: Simplificamos la lógica 🔥 */}
|
{/* 🔥 AQUÍ ESTABA EL ERROR: Simplificamos la lógica 🔥 */}
|
||||||
{isEvent ? (isUpcoming ? t("scheduled") : t("concluded")) : t("operational")}
|
{isEvent ? (isUpcoming ? t("scheduled") : t("concluded")) : t("operational")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bridge to the full case study inside its application page.
|
||||||
|
Only for real installations whose `application` maps to an
|
||||||
|
Application slug (not events or the HQ node). */}
|
||||||
|
{!isEvent && !isHQ && data.application && data.application !== "hq" && data.application !== "event" && (
|
||||||
|
<Link
|
||||||
|
href={`/applications/${data.application}#case-${data.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent({ name: "case_study_viewed", params: { nodeId: data.id, application: data.application } });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="group mb-10 flex items-center justify-between gap-4 w-full bg-[#0066CC] dark:bg-[#00F0FF] text-white dark:text-black px-6 py-4 rounded-2xl font-medium hover:bg-[#0052a3] dark:hover:bg-[#00F0FF]/80 transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col text-left">
|
||||||
|
<span className="text-[10px] uppercase tracking-widest opacity-70">
|
||||||
|
{data.application.replace(/-/g, " ")}
|
||||||
|
</span>
|
||||||
|
<span className="text-base">{t("viewFullCase")}</span>
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={20} className="group-hover:translate-x-1 transition-transform shrink-0" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.projectOverview ? (
|
{data.projectOverview ? (
|
||||||
<div className="max-w-none mb-12">
|
<div className="max-w-none mb-12">
|
||||||
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">
|
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">
|
||||||
|
|||||||
Reference in New Issue
Block a user