pages/ValuePartnership/GlobePage/GlobeDetailCard.jsxjavascript
import { Fragment, useState, useMemo, useRef, useEffect } from "react";
import PropTypes from "prop-types";
import { UIMCButton } from "@ui-marcom/react/button";
import { UIMCControlButton } from "@ui-marcom/react/control-button";
import { UIMCTabGroup } from "@ui-marcom/react/tab-group";
import { UIMCTab } from "@ui-marcom/react/tab";
import { UIMCTabPanel } from "@ui-marcom/react/tab-panel";
import { IMPACT_CATEGORIES, TAG_META } from "../../../services/config/globeDefaults";
import { resolveAssetPath } from "../../../utils/resolveAssetPath";
import "./GlobeDetailCard.scss";
const placeLine = (point) =>
point.customer ? `${point.customer}, ${point.country}` : point.country;
// content-authored text fields (figure/keyfacts/partnership/outcomes/impacts)
// carry inline markup - footnote refs and ™ as <sup>, chemical formulas as
// <sub> - so they need dangerouslySetInnerHTML rather than plain JSX text,
// which would render the tags literally instead of interpreting them
const richText = (html) => ({ __html: html });
// keyfacts.challenges[]/value_adds[] items carry either an `icon` (a name
// like "Clinical-workflow", resolved against the shared icon set ported
// from value-globe.js) or a `number` ("1st", "83%") - never both. Shared
// between the two keyfacts columns below (renderOverview) so both render
// the same way; a plain function rather than a component since it's called
// inline inside a .map(), not used as JSX with its own props.
const renderFactIcon = (item) =>
(item.icon || item.number) && (
<div className="globe-detail-card__fact-icon-container">
{item.icon && (
<img
className="globe-detail-card__fact-icon"
src={resolveAssetPath(`/assets/icons/challenges/${item.icon}.svg`)}
alt=""
/>
)}
{item.number && <p className="globe-detail-card__fact-number">{item.number}</p>}
</div>
);
/**
* Detail overlay for a clicked globe marker, ported from value-globe.js's
* card__big/card__details. Two views:
* - "preview" (default): compact popup with a short blurb and a "Show
* details" button, shown right after clicking a marker.
* - "details": the full card - a single "Overview" tab (headline/image/
* keyfacts/partnership/quote) for outcome points; one tab per impact
* category for impact points instead, no separate "Overview" tab there -
* each category tab renders its own outcomes/impacts, key facts & team
* setup, and customer key-challenges/goals sections, see
* value-globe.js:900-983 for the ported-from markup.
*
* @param {Object} props
* @param {Object} props.point - The clicked location's full content entry.
* @param {Object} props.labels - `content.valuePartnership.labels`.
* @param {function(): void} props.onClose
*/
export const GlobeDetailCard = ({ point, labels, onClose }) => {
const [view, setView] = useState("preview");
const categoryTabs = useMemo(() => IMPACT_CATEGORIES.filter(([key]) => point[key]), [point]);
// outcome points get a single "Overview" tab (headline/image/keyfacts/
// partnership/quote); impact points get one tab per available category
// instead, with no separate "Overview" tab of their own
const tabs = point.type === "impact" ? categoryTabs : [["overview", "Overview", "checkmark"]];
const [activeTabIndex, setActiveTabIndex] = useState(0);
const activeCategory = tabs[activeTabIndex]?.[0];
const activeData = activeCategory && activeCategory !== "overview" ? point[activeCategory] : null;
// listen for the documented "uimc-change" event directly on the element
// (rather than trusting the `onChange` prop to reach the Lit component
// through @lit/react's property-vs-event heuristic). UIMCTabGroup only
// renders once `view` is "details", so the effect must depend on `view`
// too - an empty dep array would run once while the ref is still null
// (during the "preview" view, before the tab group exists) and never
// re-run once the ref is actually populated.
const tabGroupRef = useRef(null);
useEffect(() => {
const el = tabGroupRef.current;
if (!el) return undefined;
const handleChange = (e) => setActiveTabIndex(e.detail.index);
el.addEventListener("uimc-change", handleChange);
return () => el.removeEventListener("uimc-change", handleChange);
}, [view]);
const tagMeta = TAG_META[point.type];
// preview shows the short figure (e.g. "83%", "1st") ported from
// value-globe.js's card__big - the long descriptive headline (point.headline
// for outcome points, or the active category's headline for impact points,
// see renderCategory below) is reserved for card__details/"details" view
const previewHeadline = point.figure?.headline || "";
const previewText = point.figure?.text || "";
// a handful of points only have the preview teaser filled in (figure/
// drivers) with the rest of the content still pending editorially - for
// those, "details" view would render as an almost-empty overlay (no
// image, no keyfacts, no partnership text), so skip offering the button
// rather than leading to a dead end. Impact points have no dedicated
// "overview" fields of their own - their content lives entirely in the
// per-category tabs, so completeness there means at least one category tab.
const hasDetailContent =
point.type === "impact"
? categoryTabs.length > 0
: Boolean(
point.headline || point.image || point.keyfacts || point.partnership || point.quote?.text
);
if (view === "preview") {
return (
<div className="globe-detail-card globe-detail-card--preview">
<div className="globe-detail-card-head-container">
<div className="globe-detail-card globe-detail-card__head">
{tagMeta && (
<div className={`globe-detail-card__tag globe-detail-card__tag--${tagMeta.modifier}`}>
{tagMeta.label}
</div>
)}
<UIMCControlButton
className="globe-detail-card__close"
icon="close"
title="Schließen"
onClick={onClose}
/>
</div>
<h6 className="globe-detail-card__place globe-detail-card__place--preview">
<ui-marcom-icon icon="hospital" className="globe-detail-card__place-icon" />
{placeLine(point)}
</h6>
</div>
{previewHeadline && (
<div className="globe-detail-card__details-container">
<h1 className="globe-detail-card__headline globe-detail-card__headline--preview">
{previewHeadline}
</h1>
{previewText && (
<p
className="globe-detail-card__preview-text"
dangerouslySetInnerHTML={richText(previewText)}
/>
)}
</div>
)}
<div className="globe-detail-card__footer">
{hasDetailContent && (
<UIMCButton
className="globe-detail-card__show-details"
variant="primary-cta"
title={labels.showDetails}
onClick={() => setView("details")}>
<ui-marcom-icon slot="prefix" icon="arrow-right-medium"></ui-marcom-icon>
{labels.showDetails}
</UIMCButton>
)}
</div>
</div>
);
}
const renderOverview = () => (
<>
<div className="globe-detail-card__header">
<div className="globe-detail-card__header-content">
<h1
className={`globe-detail-card__headline globe-detail-card__headline--${tagMeta?.modifier}`}>
{point.headline}
</h1>
<h6 className="globe-detail-card__place">{placeLine(point)}</h6>
</div>
{point.image && <img className="globe-detail-card__image" src={point.image} alt="" />}
</div>
{point.keyfacts && (
<div className="globe-detail-card__keyfacts">
<h3 className="globe-detail-card__keyfacts-headline">{labels.keyFacts}</h3>
{/* a single grid (not two independent flex columns) so each
challenge/value-add pair lands in the same row and stretches to
match height - the two arrays are paired 1:1 by index for
nearly every point, but a few have one more entry on one side
(see GlobeDetailCard.jsx history), hence Math.max + the
per-side null checks below rather than a plain zip */}
<div className="globe-detail-card__keyfacts-grid">
<h4 className="globe-detail-card__keyfacts-heading">{labels.keyChallenges}</h4>
<h4 className="globe-detail-card__keyfacts-heading globe-detail-card__keyfacts-heading--orange">
{labels.keyValueAdds}
</h4>
{Array.from({
length: Math.max(point.keyfacts.challenges.length, point.keyfacts.value_adds.length),
}).map((_, index) => {
const challenge = point.keyfacts.challenges[index];
const valueAdd = point.keyfacts.value_adds[index];
return (
<Fragment key={index}>
{challenge ? (
<div className="globe-detail-card__fact-box globe-detail-card__fact-box--grey">
{renderFactIcon(challenge)}
<p dangerouslySetInnerHTML={richText(challenge.text)} />
</div>
) : (
<div />
)}
{valueAdd ? (
<div className="globe-detail-card__fact-box globe-detail-card__fact-box--orange">
{renderFactIcon(valueAdd)}
<p dangerouslySetInnerHTML={richText(valueAdd.text)} />
</div>
) : (
<div />
)}
</Fragment>
);
})}
</div>
</div>
)}
{point.partnership && (
<div className="globe-detail-card__partnership">
<h3>{point.partnership.headline}</h3>
<ul>
{point.partnership.content.map((item) => (
<li key={item} dangerouslySetInnerHTML={richText(item)} />
))}
</ul>
</div>
)}
{point.quote?.text && (
<div className="globe-detail-card__quote">
<h3>{labels.quote}</h3>
<p>“{point.quote.text}”</p>
<div className="globe-detail-card__quote-author-info">
<span className="globe-detail-card__quote-author">{point.quote.author}</span>
<span className="globe-detail-card__quote-info">
{point.quote.info ? `${point.quote.info}` : ""}
</span>
</div>
</div>
)}
</>
);
const renderCategory = () =>
activeData && (
<>
<h1 className="globe-detail-card__headline globe-detail-card__headline--petrol">
{activeData.headline}
</h1>
{/* paired grid, same reasoning/pattern as renderOverview's
challenges/value-adds keyfacts-grid: two independent flex columns
let each outcome/impact box size off only its own text, so
corresponding rows didn't line up in height */}
<div className="globe-detail-card__keyfacts-grid">
<h4 className="globe-detail-card__keyfacts-heading">{labels.outcomes}</h4>
<h4 className="globe-detail-card__keyfacts-heading globe-detail-card__keyfacts-heading--petrol">
{labels.potentialImpacts}
</h4>
{Array.from({
length: Math.max(activeData.outcomes?.length ?? 0, activeData.impacts?.length ?? 0),
}).map((_, index) => {
const outcome = activeData.outcomes?.[index];
const impact = activeData.impacts?.[index];
return (
<Fragment key={index}>
{outcome ? (
<div className="globe-detail-card__fact-box globe-detail-card__fact-box--grey">
{outcome.highlight && (
<>
<strong className="globe-detail-card__fact-number globe-detail-card__fact-number--petrol">
{outcome.highlight.headline}
</strong>
<span
className="globe-detail-card__fact-sub"
dangerouslySetInnerHTML={richText(outcome.highlight.subline)}
/>
</>
)}
<p dangerouslySetInnerHTML={richText(outcome.text)} />
</div>
) : (
<div />
)}
{impact ? (
<div className="globe-detail-card__fact-box globe-detail-card__fact-box--petrol">
<p dangerouslySetInnerHTML={richText(impact.text)} />
{impact.badges?.map((badge) => (
<span key={badge} className="globe-detail-card__badge">
{badge.toUpperCase()}
</span>
))}
</div>
) : (
<div />
)}
</Fragment>
);
})}
</div>
{activeData.keyfacts && (
<div className="globe-detail-card__keyfacts">
{/* one grey box for region/size/team - ported from value-globe.js's
card__setup, which the old .keyfacts-col/.fact-box split (grey
region+size boxes, separate petrol team box) didn't match */}
<div className="globe-detail-card__setup">
<h3 className="globe-detail-card__keyfacts-headline">{labels.keyFactsTeamSetup}</h3>
<div className="globe-detail-card__setup-row">
{activeData.keyfacts.region && (
<div className="globe-detail-card__setup-item">
<img
className="globe-detail-card__setup-icon"
src={resolveAssetPath("/assets/icons/icon-location.svg")}
alt=""
/>
<div>
<p className="globe-detail-card__setup-label">{labels.region}</p>
<p className="globe-detail-card__setup-value globe-detail-card__setup-value--black-70">
{activeData.keyfacts.region}
</p>
</div>
</div>
)}
{activeData.keyfacts.size && (
<div className="globe-detail-card__setup-item">
<img
className="globe-detail-card__setup-icon"
src={resolveAssetPath("/assets/icons/hospital_grey-1.svg")}
alt=""
/>
<div>
<p className="globe-detail-card__setup-value globe-detail-card__setup-value--black-70">
{activeData.keyfacts.size.headline}
</p>
<p className="globe-detail-card__setup-label">
{activeData.keyfacts.size.subline}
</p>
</div>
</div>
)}
</div>
{activeData.keyfacts.team?.length > 0 && (
<div className="globe-detail-card__setup-item">
<img
className="globe-detail-card__setup-icon"
src={resolveAssetPath("/assets/icons/icon-team.svg")}
alt=""
/>
<div>
<p className="globe-detail-card__setup-value globe-detail-card__setup-value--petrol">
{labels.esTeam}
</p>
<div className="globe-detail-card__setup-team">
{activeData.keyfacts.team.map((member) => (
<div key={member.name}>
<p className="globe-detail-card__setup-label globe-detail-card__setup-value--petrol">
{member.name}
</p>
<p className="globe-detail-card__setup-label">{member.role}</p>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
{activeData.customer && (
// reuses the same grey box as "Key facts & team setup" above,
// same as value-globe.js's card__setup which both sections share
<div className="globe-detail-card__setup">
<h3>{activeData.customer.headline}</h3>
{activeData.customer.description && (
<p className="globe-detail-card__challenges-description">
{activeData.customer.description}
</p>
)}
<div className="globe-detail-card__challenges-row">
{activeData.customer.challenges.headlines.map((item, index) => (
<div
key={`${item.headline}-${index}`}
className="globe-detail-card__challenge-card globe-detail-card__challenge-card--icon">
{item.icon && (
<img
className="globe-detail-card__challenge-icon"
// item.icon is content-authored as a bare relative path
// ("assets/icons/sustainable.png", no leading slash) -
// resolveAssetPath needs the leading slash to
// recognize and rewrite it for sub-path deployments
src={resolveAssetPath(`/${item.icon}`)}
alt=""
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
<p>{item.headline}</p>
</div>
))}
</div>
<div className="globe-detail-card__challenges-row">
{activeData.customer.challenges.challenges.map((text, index) => (
<div key={`challenge-${index}`} className="globe-detail-card__challenge-card">
<h4>{labels.challenge}</h4>
<p className="globe-detail-card__challenge-card-text">{text}</p>
</div>
))}
</div>
<div className="globe-detail-card__challenges-row">
{activeData.customer.challenges.goals.map((text, index) => (
<div key={`goal-${index}`} className="globe-detail-card__challenge-card">
<h4>{labels.goal}</h4>
<p className="globe-detail-card__challenge-card-text">{text}</p>
</div>
))}
</div>
</div>
)}
</>
);
return (
<div className="globe-detail-card-overlay">
<UIMCControlButton
className="globe-detail-card__close globe-detail-card__close--floating"
icon="close"
title="Schließen"
onClick={onClose}
/>
<div className="globe-detail-card-wrapper">
<div className="globe-detail-card globe-detail-card--details">
<div className="globe-detail-card__tags">
{tagMeta && (
<div className={`globe-detail-card__tag globe-detail-card__tag--${tagMeta.modifier}`}>
{tagMeta.label}
</div>
)}
</div>
{tabs.length > 0 ? (
<div className="globe-detail-card__tabs">
<UIMCTabGroup
ref={tabGroupRef}
activeTabIndex={activeTabIndex}
className="globe-detail-card__tab-group">
{tabs.map(([key, label, icon], index) => (
<UIMCTab
key={key}
slot="nav"
size="medium"
for={key}
active={index === activeTabIndex}
className="globe-detail-card__tab">
<ui-marcom-icon
icon={icon}
className="globe-detail-card__tab-icon"></ui-marcom-icon>
{label}
</UIMCTab>
))}
{tabs.map(([key]) => (
<UIMCTabPanel key={key} id={key} active={key === activeCategory}>
{key === activeCategory &&
(key === "overview" ? renderOverview() : renderCategory())}
</UIMCTabPanel>
))}
</UIMCTabGroup>
</div>
) : (
renderOverview()
)}
<p className="globe-detail-card__disclaimer">{labels.uniqueDisclaimer}</p>
</div>
</div>
</div>
);
};
GlobeDetailCard.propTypes = {
point: PropTypes.object.isRequired,
labels: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
};