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:
2026-06-05 12:17:08 -05:00
parent afcaf991b5
commit bf8b2aa631
7 changed files with 53 additions and 9 deletions
+2 -1
View File
@@ -159,7 +159,8 @@
"eventOverview": "Veranstaltungsübersicht",
"projectChronicle": "Projektchronik",
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
"mediaGallery": "Mediengalerie"
"mediaGallery": "Mediengalerie",
"viewFullCase": "Vollständige Fallstudie ansehen"
},
"Footer": {
"madeInItaly": "Hergestellt in Italien",
+2 -1
View File
@@ -159,7 +159,8 @@
"eventOverview": "Event Overview",
"projectChronicle": "Project Chronicle",
"pendingData": "[ Chronicle data pending for this node ]",
"mediaGallery": "Media Gallery"
"mediaGallery": "Media Gallery",
"viewFullCase": "View full case study"
},
"Footer": {
"madeInItaly": "Made in Italy",
+2 -1
View File
@@ -159,7 +159,8 @@
"eventOverview": "Resumen del Evento",
"projectChronicle": "Crónica del Proyecto",
"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": {
"madeInItaly": "Hecho en Italia",
+2 -1
View File
@@ -159,7 +159,8 @@
"eventOverview": "Panoramica Evento",
"projectChronicle": "Cronaca del Progetto",
"pendingData": "[ Dati cronaca in attesa per questo nodo ]",
"mediaGallery": "Galleria Media"
"mediaGallery": "Galleria Media",
"viewFullCase": "Vedi il caso completo"
},
"Footer": {
"madeInItaly": "Made in Italy",
+2 -1
View File
@@ -159,7 +159,8 @@
"eventOverview": "Detaji de l'evento",
"projectChronicle": "Storia del projeto",
"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": {
"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[] }) {
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 [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
@@ -1147,7 +1162,7 @@ export default function ApplicationClient({ data, realCases, images, breadcrumbs
{realCases.map((node) => {
const isExpanded = expandedCase === node.id;
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 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">
+25 -1
View File
@@ -1,10 +1,12 @@
"use client";
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 { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import { trackEvent } from "@/lib/analytics/gtag";
export interface CaseStudyData {
id: string;
@@ -305,6 +307,28 @@ export default function CaseStudyModal({ isOpen, onClose, data }: ModalProps) {
</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 ? (
<div className="max-w-none mb-12">
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">