@bytebrand/fe-ui-core-autobahn 1.0.112 → 1.0.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cards/CarCard.tsx CHANGED
@@ -6,8 +6,10 @@
6
6
  // page, landing pages and "similar cars" grids.
7
7
  //
8
8
  // Pure / presentational: all data comes via the `AutobahnCar` prop, all behaviour
9
- // via injected callbacks. No store / API / `window` access. Renders correctly
10
- // only inside a `<div className="md3">` subtree.
9
+ // via injected callbacks. No store/API access — the one guarded `window`-adjacent
10
+ // read is IntersectionObserver (see `eagerMedia` below), feature-checked so a
11
+ // non-browser render (tests without a polyfill) degrades to a no-op, not a crash.
12
+ // Renders correctly only inside a `<div className="md3">` subtree.
11
13
  //
12
14
  // Differs from the design kit on purpose:
13
15
  // - favorite state is INJECTED (isFavorite + onToggleFavorite) instead of the
@@ -15,7 +17,7 @@
15
17
  // - the needs-data deal signals (DropChip / SaveChip) only appear when real
16
18
  // `drop` / `savings` data is present.
17
19
 
18
- import React, { useState } from 'react';
20
+ import React, { useState, useEffect, useRef } from 'react';
19
21
  import { TranslationFunction } from '../types';
20
22
 
21
23
  import { CardCarousel, IconButton, SaveChip, Button } from '../ui';
@@ -53,6 +55,20 @@ export interface CarCardProps {
53
55
  onOpen: (car: AutobahnCar) => void;
54
56
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
55
57
  onMediaEnter?: () => void;
58
+ /**
59
+ * Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
60
+ * hover/touch. Off by default — the SRP's grid is effectively unbounded (pagination/infinite
61
+ * scroll), and firing one getImages() request per card the instant it enters the viewport
62
+ * reintroduced exactly the failure mode `loadCarImages` was deliberately scoped away from
63
+ * (SearchNextPage.tsx: "eager per-card getImages() ... raised an error toast PER failed
64
+ * request, stacked toasts on mobile scroll" whenever the image service is degraded).
65
+ * Opt into this only for small, BOUNDED grids (e.g. the homepage's ≤6-card "Top-Angebote"),
66
+ * where without it the gallery's prev/next arrows and image-count dots stay hidden until the
67
+ * user's first hover/touch on THAT card resolves (async) — on touch devices this reads as
68
+ * "controls need a preliminary tap", because the very same tap that reveals them is usually
69
+ * also the tap the user meant as their real interaction with the card.
70
+ */
71
+ eagerMedia?: boolean;
56
72
  /** i18n translate fn (drilled from the page). */
57
73
  t: TranslationFunction;
58
74
  /**
@@ -90,6 +106,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
90
106
  onToggleFavorite,
91
107
  onOpen,
92
108
  onMediaEnter,
109
+ eagerMedia,
93
110
  t,
94
111
  adminAction,
95
112
  onRequestOffer,
@@ -100,6 +117,35 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
100
117
  const [mode, setMode] = useState<PaymentMode>('financing');
101
118
  const [photoIndex, setPhotoIndex] = useState(0);
102
119
  const [viewerOpen, setViewerOpen] = useState(false);
120
+ const mediaRef = useRef<HTMLDivElement | null>(null);
121
+ // always call the LATEST onMediaEnter without re-arming the observer on every render (the
122
+ // page callers below construct a fresh closure each render) — the effect itself only needs
123
+ // to run once per mount.
124
+ const onMediaEnterRef = useRef(onMediaEnter);
125
+ onMediaEnterRef.current = onMediaEnter;
126
+ useEffect(() => {
127
+ if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
128
+ const el = mediaRef.current;
129
+ if (!el) return undefined;
130
+ let fired = false;
131
+ // rootMargin starts the fetch a bit before the card is actually on-screen, so by the time
132
+ // the user's eye (or finger, on touch) reaches it the gallery has usually already resolved
133
+ // — the whole point being no tap is required to make prev/next + the counter "real".
134
+ const observer = new IntersectionObserver(
135
+ (entries) => {
136
+ entries.forEach((entry) => {
137
+ if (entry.isIntersecting && !fired) {
138
+ fired = true;
139
+ onMediaEnterRef.current && onMediaEnterRef.current();
140
+ observer.disconnect();
141
+ }
142
+ });
143
+ },
144
+ { rootMargin: '200px 0px', threshold: 0 },
145
+ );
146
+ observer.observe(el);
147
+ return () => observer.disconnect();
148
+ }, [eagerMedia]);
103
149
  // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
104
150
  // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
105
151
  const asGoodAsNew = car.asGoodAsNew !== false;
@@ -119,6 +165,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
119
165
  >
120
166
  <div
121
167
  className="cc-media"
168
+ ref={mediaRef}
122
169
  style={{ position: 'relative' }}
123
170
  onMouseEnter={onMediaEnter}
124
171
  onTouchStart={onMediaEnter}
package/cards/CarRow.tsx CHANGED
@@ -6,7 +6,9 @@
6
6
  // CTA stop propagation so they don't also trigger the card open.
7
7
  //
8
8
  // Pure / presentational: all data via the `AutobahnCar` prop, all behaviour via
9
- // injected callbacks. No store / API / `window` access. Renders correctly only
9
+ // injected callbacks. No store/API access — the one guarded `window`-adjacent read is
10
+ // IntersectionObserver (see `eagerMedia` below), feature-checked so a non-browser render
11
+ // (tests without a polyfill) degrades to a no-op, not a crash. Renders correctly only
10
12
  // inside a `<div className="md3">` subtree.
11
13
  //
12
14
  // Differs from the design kit on purpose:
@@ -18,7 +20,7 @@
18
20
  // no className prop) so the scoped `.pp-cta` margin / responsive rules and the
19
21
  // `.pp-cta + .pp-activity` adjacency in md3.css still apply.
20
22
 
21
- import React, { useState } from 'react';
23
+ import React, { useState, useEffect, useRef } from 'react';
22
24
  import { TranslationFunction } from '../types';
23
25
 
24
26
  import { CardCarousel, IconButton, SaveChip, Button, Icon, StatChip } from '../ui';
@@ -53,6 +55,16 @@ export interface CarRowProps {
53
55
  onOpen: (car: AutobahnCar) => void;
54
56
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
55
57
  onMediaEnter?: () => void;
58
+ /**
59
+ * Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
60
+ * hover/touch. Off by default. See CarCard's identically-named prop for the full rationale —
61
+ * same trade-off applies here (SRP's list view is the other consumer of this same concern).
62
+ * Safe to enable even on an effectively-unbounded list AS LONG AS the caller's `onMediaEnter`
63
+ * itself caps concurrent fetches (e.g. SearchNextPage.tsx's loadCarImages queues through
64
+ * runQueuedImageLoad) — this component has no opinion on that, it only decides WHEN to call
65
+ * onMediaEnter, never how many may run at once.
66
+ */
67
+ eagerMedia?: boolean;
56
68
  /** i18n translate fn (drilled from the page). */
57
69
  t: TranslationFunction;
58
70
  /**
@@ -75,12 +87,39 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
75
87
  onToggleFavorite,
76
88
  onOpen,
77
89
  onMediaEnter,
90
+ eagerMedia,
78
91
  t,
79
92
  adminAction,
80
93
  onShowFinancingDetails,
81
94
  }) => {
82
95
  const [mode, setMode] = useState<PaymentMode>('financing');
83
96
  const [photoIndex, setPhotoIndex] = useState(0);
97
+ const mediaRef = useRef<HTMLDivElement | null>(null);
98
+ // always call the LATEST onMediaEnter without re-arming the observer on every render (the
99
+ // page callers construct a fresh closure each render) — the effect itself only needs to run
100
+ // once per mount. Identical pattern to CarCard.tsx — see its own comment for the rationale.
101
+ const onMediaEnterRef = useRef(onMediaEnter);
102
+ onMediaEnterRef.current = onMediaEnter;
103
+ useEffect(() => {
104
+ if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
105
+ const el = mediaRef.current;
106
+ if (!el) return undefined;
107
+ let fired = false;
108
+ const observer = new IntersectionObserver(
109
+ (entries) => {
110
+ entries.forEach((entry) => {
111
+ if (entry.isIntersecting && !fired) {
112
+ fired = true;
113
+ onMediaEnterRef.current && onMediaEnterRef.current();
114
+ observer.disconnect();
115
+ }
116
+ });
117
+ },
118
+ { rootMargin: '200px 0px', threshold: 0 },
119
+ );
120
+ observer.observe(el);
121
+ return () => observer.disconnect();
122
+ }, [eagerMedia]);
84
123
  // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
85
124
  // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
86
125
  const asGoodAsNew = car.asGoodAsNew !== false;
@@ -109,7 +148,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
109
148
  onClick={() => onOpen(car)}
110
149
  style={{ background: 'var(--md-sc-lowest)' }}
111
150
  >
112
- <div className="listcard-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
151
+ <div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
113
152
  {/* 'auto': desktop picks cover/contain per photo (crops only when it wouldn't lose too
114
153
  much of the shot); below 601px this is identical to 'contain', so mobile is unchanged. */}
115
154
  <CardCarousel images={car.gallery} height="100%" fit="auto" onChange={setPhotoIndex} />
@@ -173,7 +212,17 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
173
212
  <GalleryBadges car={car} t={t} />
174
213
  </div>
175
214
  {/* desktop: single row — light "1|N" photo counter + a combined eye/heart chip, no
176
- availability/Geprüft pill (not present in the Figma "mcard" export). */}
215
+ availability/Geprüft pill (not present in the Figma "mcard" export).
216
+ Same 3-state policy as the mobile pills above (GalleryCounter/GalleryBadges,
217
+ cardParts.tsx) and CarCard's GalleryCounter/GalleryActivityPill, just inlined here
218
+ instead of reusing those components (different chip markup — one shared "N|M" +
219
+ eye+heart pill instead of three separate ones):
220
+ - single-image: car.photos is the real, eagerly-known count (not the lazily-loaded
221
+ gallery array length) — accurate even before the extra photos have loaded.
222
+ - loading: viewsCount/favoritesCount are undefined until stats resolve for this car
223
+ — hidden, no skeleton, same as everywhere else.
224
+ - zero-value: a real 0 is intentionally treated the same as "not loaded" — hidden,
225
+ not shown as "eye 0" / "heart 0" (noise on a dense results list otherwise). */}
177
226
  <div
178
227
  className="hide-mobile"
179
228
  style={{ position: 'absolute', bottom: 20, left: 12, zIndex: 3, display: 'flex', alignItems: 'center', gap: 6 }}
@@ -203,6 +203,7 @@ export const DropChip: React.FunctionComponent<DropChipProps> = ({ amount, t })
203
203
  color: 'var(--md-good)',
204
204
  fontSize: 10.5,
205
205
  fontWeight: 700,
206
+ lineHeight: '14px',
206
207
  boxShadow: 'var(--md-e1)',
207
208
  whiteSpace: 'nowrap',
208
209
  }}
@@ -230,7 +231,7 @@ const dealPill: React.CSSProperties = {
230
231
  export const PercentChip: React.FunctionComponent<{ pct?: number }> = ({ pct }) => {
231
232
  if (pct == null || pct <= 0) return null;
232
233
  return (
233
- <span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff' }}>
234
+ <span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff', lineHeight: '14px' }}>
234
235
  −{pct}%
235
236
  </span>
236
237
  );
@@ -835,7 +836,15 @@ export interface GalleryCounterProps {
835
836
  * Bottom-left gallery overlay: expand icon + "current|total" (Figma). Reuses the existing
836
837
  * `.imglabel` pill style (already used for the list-card's photo-count badge) rather than a new
837
838
  * class — same dark translucent pill, just with the maximize icon instead of the image icon.
838
- * Hidden with 0/1 photos (nothing to expand into).
839
+ *
840
+ * States (shared policy across GalleryCounter, the carousel's own arrows/dots in CardCarousel,
841
+ * and GalleryActivityPill below — see its own doc comment for the other two):
842
+ * - single-image: `total` is the car's REAL, eagerly-known photo count (car.photos, from the
843
+ * search payload — not the lazily-loaded gallery array length), so this is accurate even
844
+ * before any image beyond the first has actually loaded. total<=1 → hidden: nothing to expand
845
+ * into. Never a "1|1" placeholder.
846
+ * - loading: while `total` itself is still unknown (car not yet resolved) the caller simply
847
+ * hasn't rendered this component yet — there is no separate in-between skeleton state here.
839
848
  */
840
849
  export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
841
850
  if (total <= 1) return null;
@@ -864,8 +873,18 @@ export interface GalleryActivityPillProps {
864
873
 
865
874
  /**
866
875
  * Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
867
- * Likes", node 297:1037). Each half renders independently and the whole pill is omitted when
868
- * neither figure has loaded yet (same async-stats/NEEDS-DATA discipline as the rest of this file).
876
+ * Likes", node 297:1037). Each half renders independently.
877
+ *
878
+ * States (chosen ONE policy, applied identically to both halves):
879
+ * - loading: `viewsCount`/`favoritesCount` are `undefined` until the async stats call
880
+ * (UserActionsAPI 'stats/viewers') resolves for this car. No skeleton — the half (or the
881
+ * whole pill, if both are still pending) simply doesn't render yet, same "hidden, not a
882
+ * placeholder" discipline as GalleryCounter/CardCarousel's arrows above.
883
+ * - zero-value: a REAL `0` is treated the same as "not there" — hidden, not shown as "👁 0".
884
+ * Deliberate choice over spelling out "0" (the other option the ticket allows): on a dense
885
+ * grid of fresh listings a "👁 0 ❤ 0" pill on every card is noise, not signal. `!viewsCount`/
886
+ * `!favoritesCount` below intentionally don't distinguish real-0 from not-yet-loaded — both
887
+ * read as "nothing worth showing (yet)", which is the same outcome either way.
869
888
  */
870
889
  export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
871
890
  if (!viewsCount && !favoritesCount) return null;
package/chrome/TopBar.tsx CHANGED
@@ -110,9 +110,14 @@ export interface TopBarProps {
110
110
  * number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
111
111
  * href here once one is confirmed. */
112
112
  phoneHref?: string;
113
- /** mobile-only: slide the header out of view on scroll-down, back in on scroll-up (coordinated
114
- * with the VDP sticky offer bottom bar's own show/hide). Off by default — only the car-detail
115
- * page (topBarVariant 'conversion') opts in; every other page keeps the header always docked. */
113
+ /** mobile-only (the CSS that actually moves the header, `.topbar--hidden`, only exists inside
114
+ * the `@media (max-width: 600px)` block — desktop keeps this prop's `hidden` state but never
115
+ * renders it): slide the header out of view on scroll-down, back in on scroll-up. On the
116
+ * car-detail page this is additionally coordinated with the VDP sticky offer bottom bar (hides
117
+ * well before that bar would ever dock — see STICKY_SHOW_AT in VehicleDetailedNextPage.tsx).
118
+ * Off by default — the car-detail page (topBarVariant 'conversion'), the homepage ('/') and
119
+ * the SRP list page ('/search') opt in (see App.tsx); other pages keep the header always
120
+ * docked. */
116
121
  autoHideOnScroll?: boolean;
117
122
  }
118
123
 
@@ -382,6 +382,7 @@
382
382
  },
383
383
  "rate": {
384
384
  "upTo": "Rate bis",
385
+ "upToPlaceholder": "Rate bis, z. B. 350 €/mtl.",
385
386
  "perMonth": "/mtl.",
386
387
  "downPayment": "Anzahlung",
387
388
  "none": "keine",
@@ -389,6 +390,10 @@
389
390
  "months": "{{months}} Monate",
390
391
  "months_plural": "{{months}} Monate"
391
392
  },
393
+ "mileage": {
394
+ "upTo": "km bis",
395
+ "upToPlaceholder": "km bis, z. B. 50000 km"
396
+ },
392
397
  "consumption": {
393
398
  "unitLabel": "l / 100 km"
394
399
  },
@@ -723,7 +728,7 @@
723
728
  "leaseVehiclePrice": "Anschaffungspreis¹",
724
729
  "leaseDownPayment": "Einmalige Sonderzahlung",
725
730
  "finalPaymentDue": "Schlusszahlung (fällig am Vertragsende)",
726
- "residualValue": "Restwert (verpflichtend)",
731
+ "residualValue": "Verpflichtender Kaufpreis am Vertragsende",
727
732
  "rateSumLabel": "Summe der Leasingraten ({{term}} × {{amount}} €)",
728
733
  "addServicesRowLease": "Rundum-Sorglos-Paket (Einmalzahlung)",
729
734
  "subtotalWithPackage": "Gesamt inkl. Sonderzahl. & Paket",
@@ -382,6 +382,7 @@
382
382
  },
383
383
  "rate": {
384
384
  "upTo": "Rate up to",
385
+ "upToPlaceholder": "Rate up to, e.g. 350 €/month",
385
386
  "perMonth": "/month",
386
387
  "downPayment": "Down payment",
387
388
  "none": "none",
@@ -389,6 +390,10 @@
389
390
  "months": "{{months}} months",
390
391
  "months_plural": "{{months}} months"
391
392
  },
393
+ "mileage": {
394
+ "upTo": "Mileage up to",
395
+ "upToPlaceholder": "Mileage up to, e.g. 50000 km"
396
+ },
392
397
  "consumption": {
393
398
  "unitLabel": "l / 100 km"
394
399
  },
@@ -723,7 +728,7 @@
723
728
  "leaseVehiclePrice": "Acquisition price¹",
724
729
  "leaseDownPayment": "One-time special payment",
725
730
  "finalPaymentDue": "Final payment (due at contract end)",
726
- "residualValue": "Residual value (mandatory)",
731
+ "residualValue": "Mandatory purchase price at contract end",
727
732
  "rateSumLabel": "Sum of leasing installments ({{term}} × {{amount}} €)",
728
733
  "addServicesRowLease": "Complete care package (one-time payment)",
729
734
  "subtotalWithPackage": "Total incl. special payment & package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.112",
3
+ "version": "1.0.114",
4
4
  "description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
5
5
  "main": "index.ts",
6
6
  "license": "UNLICENSED",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@bytebrand/car-schema-selectors": "^2.0.7",
13
- "@bytebrand/fe-financing": "^5.14.18",
13
+ "@bytebrand/fe-financing": "^5.14.19",
14
14
  "classnames": "2.2.6",
15
15
  "clsx": "^2.1.1",
16
16
  "d3-format": "1.4.5",
package/theme/md3.css CHANGED
@@ -453,6 +453,44 @@ line-height: 31px;
453
453
  width: 14px;
454
454
  height: 14px;
455
455
  }
456
+ /* Figma spec for the rating chip in its two "Guter Preis" locations — the VDP header
457
+ (VdpHeaderSummary, .vdp-headrow) and the SRP list-card's price column (CarRow.tsx,
458
+ .listcard-price, both its mobile Finanzierung row and desktop Kaufen row share this one class).
459
+ Scoped to THESE locations only, not every .statchip site-wide (e.g. the price-history "drop"
460
+ chip a few sections down reuses tone="good" for a completely different purpose — a €-amount,
461
+ not a price rating — and keeps the theme-aware --md-good-container tokens untouched). min-width
462
+ (not a hard width) + white-space:nowrap: 108px is tight for icon+"Guter Preis"+⁴ on one line,
463
+ so this grows past it instead of wrapping/clipping rather than clamping to a width that doesn't fit. */
464
+ .md3 .vdp-headrow .statchip,
465
+ .md3 .listcard-price .statchip {
466
+ min-width: 108px;
467
+ height: 27px;
468
+ border-radius: 11px;
469
+ font-family: 'Arial';
470
+ font-weight: 700;
471
+ font-size: 12px;
472
+ line-height: 14px;
473
+ text-align: center;
474
+ white-space: nowrap;
475
+ justify-content: center;
476
+ }
477
+ .md3 .vdp-headrow .statchip sup,
478
+ .md3 .listcard-price .statchip sup {
479
+ position: relative;
480
+ top: -3px;
481
+ left: -4px;
482
+ font-size: 9px;
483
+ line-height: 9px;
484
+ }
485
+ /* 'good' tone here is specifically the "Guter Preis" rating (same canonical green as the
486
+ fin-eval-pill "good" tier elsewhere on this page) — literal color, not the --md-good-container
487
+ token, matching that other pill's own literal-color treatment. --warn (Fairer Preis) is
488
+ untouched, still tokenized. */
489
+ .md3 .vdp-headrow .statchip--good,
490
+ .md3 .listcard-price .statchip--good {
491
+ color: rgb(22, 138, 69);
492
+ background: color-mix(in srgb, rgb(22, 138, 69) 16%, transparent);
493
+ }
456
494
 
457
495
  /* small image label (views, photo count) */
458
496
  .md3 .imglabel {
@@ -3747,7 +3785,7 @@ line-height: 31px;
3747
3785
  min-width: 0;
3748
3786
  }
3749
3787
  /* "NEU" badge on car cards (legacy new-car label). */
3750
- .md3 .neu-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--md-r-full); background: var(--md-primary); color: #fff; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; white-space: nowrap; box-shadow: var(--md-e1); }
3788
+ .md3 .neu-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--md-r-full); background: var(--md-primary); color: #fff; font-size: 10.5px; font-weight: 700; line-height: 14px; letter-spacing: .3px; white-space: nowrap; box-shadow: var(--md-e1); }
3751
3789
  /* "⚡ Angebot" badge — dark neutral pill above the gallery (card mockup), distinct from NEU. */
3752
3790
  .md3 .offer-badge { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px; border-radius: var(--md-r-full); background: var(--md-on-surface); color: #fff; font-size: 11px; font-weight: 600; white-space: nowrap; }
3753
3791
  .md3 .offer-badge .lucide { width: 13px; height: 13px; }
@@ -5164,37 +5202,50 @@ line-height: 31px;
5164
5202
  width: 112px;
5165
5203
  flex: none;
5166
5204
  }
5167
- .md3 .cc-price .fin-eval-pill {
5168
- font-family: Arial, Helvetica, sans-serif;
5169
- font-style: normal;
5205
+ /* Figma spec, now identical across all three "Guter Preis" pill locations (CarCard's own
5206
+ .cc-price, incl. the VDP "Ähnliche Fahrzeuge" rail; .fin-calc-card; .fin-modal) — one shared
5207
+ rule instead of three copies. `background`/`color` are deliberately NOT set here: they stay
5208
+ driven by the tier-color inline style each render site sets (see PriceEvalCol in
5209
+ cardParts.tsx / FinanceCalculator.tsx / VdpModules.tsx, all of which already resolve to the
5210
+ canonical rgb(22,138,69) for the 'good' tier) — a `!important` used to hardcode this pill's
5211
+ background to a fixed #dff3e5 here, silently overriding that inline value regardless of tier. */
5212
+ .md3 .cc-price .fin-eval-pill,
5213
+ .md3 .fin-calc-card .fin-eval-pill,
5214
+ .md3 .fin-modal .fin-eval-pill {
5215
+ font-family: 'Arial';
5170
5216
  font-weight: 700;
5171
5217
  font-size: 12px;
5172
5218
  line-height: 14px;
5173
5219
  text-align: center;
5174
5220
  }
5175
- .md3 .cc-price .fin-eval-pill--good {
5176
- background: #dff3e5 !important;
5177
- border-radius: 11px !important;
5178
- width: 100%;
5221
+ .md3 .cc-price .fin-eval-pill--good,
5222
+ .md3 .fin-calc-card .fin-eval-pill--good,
5223
+ .md3 .fin-modal .fin-eval-pill--good {
5224
+ width: 108px;
5179
5225
  height: 27px;
5226
+ border-radius: 11px;
5180
5227
  box-sizing: border-box;
5228
+ display: flex;
5229
+ align-items: center;
5181
5230
  justify-content: center;
5182
5231
  }
5183
5232
  /* footnote 4 ("Top-Preis" claim) — .fin-eval-pill is a flex container, so vertical-align has
5184
5233
  no effect on the sup (it's a flex item, not inline content); position:relative/top raises
5185
- it instead, same technique as the hero price's own 1/2/3 footnote markers. Shared by the
5186
- CarCard hero (.cc-price), the VDP FinanceCalculator's own pill (.fin-calc-card), and the
5187
- FinanceDetailsModal's embedded mobile rate sheet (.fin-modal). */
5234
+ it instead, same technique as the hero price's own 1/2/3 footnote markers. Same -3px lift now
5235
+ shared by all three locations above. */
5188
5236
  .md3 .cc-price .fin-eval-pill sup,
5189
5237
  .md3 .fin-calc-card .fin-eval-pill sup,
5190
5238
  .md3 .fin-modal .fin-eval-pill sup {
5191
5239
  position: relative;
5192
- top: 0px;
5240
+ top: -3px;
5193
5241
  left: -4px;
5194
5242
  font-size: 9px;
5195
5243
  line-height: 9px;
5196
5244
  }
5197
5245
  .md3 .cc-price .fin-eval-bar {
5246
+ /* matches the pill's own fixed 108px right above (both sit in the same 112px .fin-eval-col),
5247
+ so the two stay visually aligned instead of the bar stretching to the full column width. */
5248
+ width: 108px;
5198
5249
  height: 7px;
5199
5250
  }
5200
5251
  .md3 .cc-price .fin-eval-seg:first-child {
@@ -5424,14 +5475,8 @@ line-height: 31px;
5424
5475
  width: 112px;
5425
5476
  justify-self: end;
5426
5477
  }
5427
- /* pill now sits above the bar in a real flex column (see above) — stretch it to the bar's own
5428
- width instead of the base .fin-eval-pill's inline-flex/content-hugging size. */
5429
- .md3 .fin-calc-card .fin-eval-pill--good {
5430
- width: 100%;
5431
- display: flex;
5432
- align-items: center;
5433
- justify-content: center;
5434
- }
5478
+ /* .fin-calc-card/.fin-modal's own "Guter Preis" pill text/box/sup sizing now lives in the one
5479
+ shared rule (with .cc-price) further up this file, near .cc-price's own .fin-eval-* rules. */
5435
5480
  /* .fin-calc-card's own "Rate anpassen" panel is compact — the mobile-sheet/modal padding
5436
5481
  (22/28px top, 20px bottom) is too roomy here. */
5437
5482
  .md3 .fin-calc-card .fin-toggle-final,
@@ -6662,13 +6707,36 @@ line-height: 31px;
6662
6707
  }
6663
6708
  .md3 .srp-bar {
6664
6709
  position: sticky;
6665
- top: 64px;
6710
+ /* was 64px (docked under the always-visible header) — now 0: once the header slides away
6711
+ (autoHideOnScroll, TopBar.tsx) this bar takes its place at the very top instead of leaving
6712
+ a gap, and sits flush under it again the moment the header slides back in. */
6713
+ top: 0px;
6666
6714
  z-index: 20;
6667
- background: var(--md-surface);
6715
+ background: #fff;
6668
6716
  padding: 8px 0;
6669
6717
  margin: 0 0 8px;
6670
6718
  box-shadow: 0 6px 8px -8px rgba(0, 0, 0, 0.3);
6671
6719
  }
6720
+ /* full-bleed white to the true viewport edges, WITHOUT moving .srp-bar's own text — it stays
6721
+ inside .srp-main's own side padding, this pseudo-element just paints white past it on both
6722
+ sides so no gap of page background shows while the bar is sticky. NOT a box-shadow spread
6723
+ (that bleeds in all 4 directions equally, including a huge slab below the bar covering the
6724
+ cards underneath it — that was tried and broke the page) — an absolutely-positioned layer
6725
+ confined to top:0/bottom:0 of .srp-bar's own box (position:sticky is a containing block for
6726
+ it, same as position:relative) stays exactly as tall as the bar, only wider. z-index:-1 is
6727
+ scoped to .srp-bar's own stacking context (it has z-index:20), so it paints behind the bar's
6728
+ text/buttons but still above the page content scrolling underneath. */
6729
+ .md3 .srp-bar::before {
6730
+ content: '';
6731
+ position: absolute;
6732
+ top: 0;
6733
+ bottom: 0;
6734
+ left: 50%;
6735
+ width: 100vw;
6736
+ transform: translateX(-50%);
6737
+ background: #fff;
6738
+ z-index: -1;
6739
+ }
6672
6740
  .md3 .field-grid-2 {
6673
6741
  grid-template-columns: 1fr;
6674
6742
  gap: 10px;
@@ -8703,14 +8771,10 @@ color: #005CFF;
8703
8771
  gap: 16px;
8704
8772
  grid-area: evalcol;
8705
8773
  }
8706
- .md3 .fin-eval-pill--good {
8707
- background: #dff3e5 !important;
8708
- border-radius: 11px !important;
8709
- width: 100%;
8710
- height: 27px;
8711
- box-sizing: border-box;
8712
- justify-content: center;
8713
- }
8774
+ /* box sizing for this pill (all three of its locations) lives in the shared, higher-specificity
8775
+ .cc-price/.fin-calc-card/.fin-modal rule near the top of this file — this generic, unscoped
8776
+ copy used to ALSO force `background: #dff3e5 !important`, silently overriding every tier's
8777
+ real inline color (the 'good' tier's canonical rgb(22,138,69) included) at this breakpoint. */
8714
8778
  .md3 .fin-eval-bar {
8715
8779
  height: 7px;
8716
8780
  }
@@ -10334,10 +10398,14 @@ color: #005CFF;
10334
10398
  }
10335
10399
  .md3 .vdp-photosheet {
10336
10400
  position: absolute;
10337
- left: 0;
10338
- right: 0;
10339
- bottom: 0;
10340
- height: 100vh;
10401
+ /* was `left/right/bottom: 0` + `height: 100vh` — over-constrained (top was the free value, so
10402
+ it was computed as containerHeight - 100vh) and, on the affected mobile browsers, 100vh can
10403
+ exceed the CURRENT visible height (e.g. address bar still shown), which solved to a NEGATIVE
10404
+ top: the sheet's top edge — its own header, with a close button — rendered above y=0 of this
10405
+ already-safely-sized fixed overlay (see VdpViewer.tsx's own top-of-file portal comment), i.e.
10406
+ behind the browser's UI. `inset: 0` instead just fills the (correctly-sized) positioned
10407
+ ancestor exactly, no viewport unit involved. */
10408
+ inset: 0;
10341
10409
  z-index: 211;
10342
10410
  display: flex;
10343
10411
  flex-direction: column;
@@ -10356,7 +10424,9 @@ color: #005CFF;
10356
10424
  align-items: center;
10357
10425
  justify-content: center;
10358
10426
  min-height: 88px;
10359
- padding-top: 10px;
10427
+ /* safe-area-aware: with the `inset: 0` fix above this sits flush against the true top edge of
10428
+ the fullscreen overlay — same notch/status-bar territory as the main viewer's own header. */
10429
+ padding-top: max(10px, env(safe-area-inset-top));
10360
10430
  touch-action: none;
10361
10431
  }
10362
10432
  .md3 .vdp-photosheet__handle {
@@ -10371,7 +10441,8 @@ color: #005CFF;
10371
10441
  }
10372
10442
  .md3 .vdp-photosheet__close {
10373
10443
  position: absolute;
10374
- left: 12px;
10444
+ /* safe-area-aware: in the landscape layout the notch can rotate onto this side. */
10445
+ left: max(12px, env(safe-area-inset-left));
10375
10446
  top: 50%;
10376
10447
  transform: translateY(-50%);
10377
10448
  }
@@ -10385,7 +10456,8 @@ color: #005CFF;
10385
10456
  }
10386
10457
  .md3 .vdp-photosheet__titles {
10387
10458
  flex: none;
10388
- padding: 0 16px 12px;
10459
+ /* safe-area-aware left/right: landscape can rotate the notch onto either side. */
10460
+ padding: 0 max(16px, env(safe-area-inset-right)) 12px max(16px, env(safe-area-inset-left));
10389
10461
  text-align: left;
10390
10462
  }
10391
10463
  .md3 .vdp-photosheet__title {
@@ -10408,7 +10480,9 @@ color: #005CFF;
10408
10480
  flex: 1;
10409
10481
  min-height: 0;
10410
10482
  overflow-y: auto;
10411
- padding: 0 12px 12px;
10483
+ /* safe-area-aware left/right — otherwise the outer grid tiles can sit partly under a
10484
+ landscape-rotated notch, tappable in theory but visually cut off. */
10485
+ padding: 0 max(12px, env(safe-area-inset-right)) 12px max(12px, env(safe-area-inset-left));
10412
10486
  }
10413
10487
  .md3 .vdp-photosheet__grid {
10414
10488
  display: grid;
@@ -10454,8 +10528,10 @@ color: #005CFF;
10454
10528
  align-items: center;
10455
10529
  justify-content: flex-start;
10456
10530
  gap: 6px;
10457
- height: 44px;
10458
- padding: 0 16px;
10531
+ /* was a fixed `height: 44px` — swapped for `min-height` so the safe-area bottom padding below
10532
+ (home indicator) grows the bar instead of pushing its text outside a clipped fixed box. */
10533
+ min-height: 44px;
10534
+ padding: 0 max(16px, env(safe-area-inset-right)) max(0px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
10459
10535
  /* rgba(0,0,0,0.5) was invisible here — it darkens whatever's behind it, and the sheet's own
10460
10536
  panel (rgb(6,11,18)) is already almost black, so a 50%-black overlay on top of near-black
10461
10537
  reads as no change at all. #1c222c is lighter/bluer and already used elsewhere in this same
@@ -132,7 +132,18 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
132
132
  ))}
133
133
  </div>
134
134
  {/* arrows + dots only when there is actually something to switch to — with a single
135
- image (e.g. SRP cards before their gallery is lazy-loaded) they are dead controls. */}
135
+ image (e.g. SRP cards before their gallery is lazy-loaded) they are dead controls.
136
+ States: single-image → n<=1 → hidden here (same "nothing to expand into" case as
137
+ GalleryCounter, cardParts.tsx). Loading → `images` starts as a 1-element array (the
138
+ card's main thumbnail) until the caller's lazy-load resolves, so this rule ALSO covers
139
+ "still loading" — arrows/dots simply aren't rendered yet, no skeleton, same discipline
140
+ as the other overlay controls. On touch devices this used to mean "hidden until the
141
+ user's first hover/touch on this exact card" (mouseenter/touchstart was the ONLY lazy-
142
+ load trigger) — CarCard's `eagerMedia` (see its own doc comment) starts that same load
143
+ on scroll-into-view instead, so this loading window is normally already closed by the
144
+ time the user reaches the card, without changing anything in this component. Desktop
145
+ hover-to-reveal (`.cardcar:hover .car-arrow`) vs. always-visible on touch is a CSS-only
146
+ concern — see `@media (hover: none)` in md3.css, not component state. */}
136
147
  {n > 1 && (
137
148
  <>
138
149
  <button className="car-arrow car-arrow--l" aria-label="zurück" onClick={(e) => go(-1, e)}>
@@ -1890,6 +1890,12 @@ export interface FinanceDetailsModalProps {
1890
1890
  oldMonthly?: number;
1891
1891
  /** struck list price (common.highestPrice, isStrikeShown-gated) — cash-tab strike (Figma). */
1892
1892
  listPrice?: number;
1893
+ /** UVP (manufacturer's recommended retail price) for the legal "Repräsentatives Beispiel"
1894
+ * Listenpreis disclosure row — shown whenever the car has a real UVP on file, UNGATED by
1895
+ * isStrikeShown (unlike `listPrice` above, which only drives the cash-tab strike-through
1896
+ * badge off a DIFFERENT figure, the price-drop-tracking highestPrice): a car with no discount
1897
+ * badge still has a real UVP, and this row must still show it. */
1898
+ listPriceExample?: number;
1893
1899
  apr: number;
1894
1900
  down: number;
1895
1901
  term: number;
@@ -1907,6 +1913,11 @@ export interface FinanceDetailsModalProps {
1907
1913
  * the simpler Repräsentatives-Beispiel total (uniform Folgerate, no separate 1.Rate), financing
1908
1914
  * only. Falls back to grossAmount when not supplied. */
1909
1915
  totalFinancingAmount?: number;
1916
+ /** The lender actually driving this calculation (the active mode's config
1917
+ * `financialInstitution`: the dealer/car bank for custom rateMode/leasingMode, else the
1918
+ * fe-financing platform default). Shown as the Darlehensgeber/Leasinggeber line; falls
1919
+ * back to the locale's default lender text when not supplied. */
1920
+ lenderName?: string;
1910
1921
  /** Sollzinssatz p.a. (%); shown only when > 0. */
1911
1922
  nominalRate?: number;
1912
1923
  /** slider bounds — when the change handlers are supplied, the modal shows adjustment sliders. */
@@ -1971,6 +1982,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
1971
1982
  monthly,
1972
1983
  oldMonthly,
1973
1984
  listPrice,
1985
+ listPriceExample,
1974
1986
  apr,
1975
1987
  down,
1976
1988
  term,
@@ -1979,6 +1991,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
1979
1991
  finalInstallment,
1980
1992
  grossAmount,
1981
1993
  totalFinancingAmount,
1994
+ lenderName,
1982
1995
  nominalRate,
1983
1996
  downMin,
1984
1997
  downMax,
@@ -2087,6 +2100,14 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2087
2100
  const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
2088
2101
  const evalTier = tierIdx >= 0 ? tierIdx : 1;
2089
2102
  const evalTierLabel = t('autobahn:tier.' + TIER_KEYS[evalTier]);
2103
+ // 'good' ("Guter Preis") always paints with the TOP tier's brighter green (TIER_COLORS[0]),
2104
+ // not its own dimmer TIER_COLORS[1] — same rule FinanceCalculator's collapsed card already
2105
+ // applies (FinanceCalculator.tsx's evalPillColor). The two render blocks below (desktop +
2106
+ // mobile sheet) used TIER_COLORS[evalTier] directly, which for the 'good' tier painted this
2107
+ // modal's pill a visibly different, dimmer green than the same-labeled pill on the card that
2108
+ // opened it — same text, two colors.
2109
+ const isGoodTier = TIER_KEYS[evalTier] === 'good';
2110
+ const evalPillColor = isGoodTier ? TIER_COLORS[0] : TIER_COLORS[evalTier];
2090
2111
  // adjustable only for fin/leasing, and only when the page supplies the change handlers + bounds.
2091
2112
  const adjustable = mode !== 'cash' && !!onDownChange && downMin != null && downMax != null && termMin != null && termMax != null;
2092
2113
  // discrete term chip values (supplied periods, else derived from the bounds).
@@ -2176,7 +2197,9 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2176
2197
  ...(grandTotal != null
2177
2198
  ? ([[t('autobahn:modal.grandTotalWithResidual'), `${eurCents(grandTotal)} €`, true]] as Array<[string, string, boolean]>)
2178
2199
  : []),
2179
- ...(listPrice != null && listPrice > 0 ? ([[t('autobahn:modal.listPriceLease'), `${eurCents(listPrice)} €`]] as Array<[string, string]>) : []),
2200
+ ...(listPriceExample != null && listPriceExample > 0
2201
+ ? ([[t('autobahn:modal.listPriceLease'), `${eurCents(listPriceExample)} €`]] as Array<[string, string]>)
2202
+ : []),
2180
2203
  ];
2181
2204
  } else {
2182
2205
  // Ballonfinanzierung: `term` includes the Schlussrate as its final slot, so only
@@ -2208,7 +2231,9 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2208
2231
  [string, string, boolean]
2209
2232
  >)
2210
2233
  : []),
2211
- ...(listPrice != null && listPrice > 0 ? ([[t('autobahn:modal.listPrice'), `${eurCents(listPrice)} €`]] as Array<[string, string]>) : []),
2234
+ ...(listPriceExample != null && listPriceExample > 0
2235
+ ? ([[t('autobahn:modal.listPrice'), `${eurCents(listPriceExample)} €`]] as Array<[string, string]>)
2236
+ : []),
2212
2237
  ];
2213
2238
  }
2214
2239
  const breakdownRows = rows.map(([l, v, bold], i) => (
@@ -2438,11 +2463,11 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2438
2463
  <div className="fin-eval-pill-cell">
2439
2464
  {ratingLabel && (
2440
2465
  <span
2441
- className={`fin-eval-pill${TIER_KEYS[evalTier] === 'good' ? ' fin-eval-pill--good' : ''}`}
2442
- style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
2466
+ className={`fin-eval-pill${isGoodTier ? ' fin-eval-pill--good' : ''}`}
2467
+ style={{ color: evalPillColor, background: `color-mix(in srgb, ${evalPillColor} 16%, transparent)` }}
2443
2468
  >
2444
2469
  {evalTierLabel}
2445
- {TIER_KEYS[evalTier] === 'good' && <sup>4</sup>}
2470
+ {isGoodTier && <sup>4</sup>}
2446
2471
  </span>
2447
2472
  )}
2448
2473
  </div>
@@ -2605,7 +2630,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2605
2630
  margin: 0,
2606
2631
  }}
2607
2632
  >
2608
- {isLease ? t('autobahn:modal.leaseLenderName') : t('autobahn:modal.lenderName')}
2633
+ {lenderName || (isLease ? t('autobahn:modal.leaseLenderName') : t('autobahn:modal.lenderName'))}
2609
2634
  </p>
2610
2635
  {isLease && (
2611
2636
  <>
@@ -2968,11 +2993,11 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2968
2993
  <div className="fin-eval-pill-cell">
2969
2994
  {ratingLabel && (
2970
2995
  <span
2971
- className={`fin-eval-pill${TIER_KEYS[evalTier] === 'good' ? ' fin-eval-pill--good' : ''}`}
2972
- style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
2996
+ className={`fin-eval-pill${isGoodTier ? ' fin-eval-pill--good' : ''}`}
2997
+ style={{ color: evalPillColor, background: `color-mix(in srgb, ${evalPillColor} 16%, transparent)` }}
2973
2998
  >
2974
2999
  {evalTierLabel}
2975
- {TIER_KEYS[evalTier] === 'good' && <sup>4</sup>}
3000
+ {isGoodTier && <sup>4</sup>}
2976
3001
  </span>
2977
3002
  )}
2978
3003
  </div>
@@ -3286,7 +3311,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
3286
3311
  margin: 0,
3287
3312
  }}
3288
3313
  >
3289
- {isLease ? t('autobahn:modal.leaseLenderName') : t('autobahn:modal.lenderName')}
3314
+ {lenderName || (isLease ? t('autobahn:modal.leaseLenderName') : t('autobahn:modal.lenderName'))}
3290
3315
  </p>
3291
3316
  {/* leasing's Darlehensgeber-style card also discloses the contract type
3292
3317
  (Figma: "Vertragsart: Restwertleasing"), split off by its own divider */}
package/vdp/VdpViewer.tsx CHANGED
@@ -64,6 +64,27 @@ const MOBILE_BREAKPOINT = 860;
64
64
  /** Landscape thumbnails (Figma node 424:7566 etc.) are 68px, not portrait's 64. */
65
65
  const LS_THUMB_W = 68;
66
66
 
67
+ /** Safe-area helpers — keep overlay controls (especially the close button) clear of the notch /
68
+ * Dynamic Island / status bar (top), home indicator (bottom) and, in landscape, whichever side the
69
+ * notch rotates onto. `env(safe-area-inset-*)` only resolves to anything but 0px when the page's
70
+ * `<meta name="viewport">` declares `viewport-fit=cover` (see services/fe-main/app.html) — the
71
+ * 0px fallback inside every `env()` call here means both of these are complete no-ops (identical
72
+ * to the previous hardcoded numbers) on any browser/device that has no safe area to begin with, so
73
+ * there's no behavioural change for the common case, only where a notch/indicator is actually
74
+ * present. Two shapes, used in different structural contexts below:
75
+ * - `withInset` ADDS the inset to a design offset — for absolute-positioned controls (landscape's
76
+ * floating close/logo/counter/prev-next/zoom/etc.) whose `top`/`right`/… is a gap from the edge
77
+ * of the *safe* content area, which needs to grow by however much the notch/indicator eats into
78
+ * the raw viewport edge.
79
+ * - `atLeastInset` guarantees a MINIMUM gap instead — for padding on a bar that already paints its
80
+ * own edge-to-edge background (portrait's header/bottom-CTA bar, the photosheet), where a small
81
+ * or absent safe area should still leave the existing 12px breathing room, not shrink it.
82
+ */
83
+ const withInset = (px: number, side: 'top' | 'right' | 'bottom' | 'left'): string =>
84
+ `calc(${px}px + env(safe-area-inset-${side}, 0px))`;
85
+ const atLeastInset = (px: number, side: 'top' | 'right' | 'bottom' | 'left'): string =>
86
+ `max(${px}px, env(safe-area-inset-${side}, 0px))`;
87
+
67
88
  type Layout = 'portrait' | 'landscape' | 'desktop';
68
89
  const layoutForWidth = (w: number): Layout => (w <= PORTRAIT_MAX ? 'portrait' : w <= MOBILE_BREAKPOINT ? 'landscape' : 'desktop');
69
90
 
@@ -719,7 +740,10 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
719
740
  display: 'flex',
720
741
  alignItems: 'flex-start',
721
742
  justifyContent: 'space-between',
722
- padding: '12px 12px 10px',
743
+ // safe-area-aware: this bar sits flush against the top of the fullscreen overlay
744
+ // (the notch/Dynamic Island/status bar's exact territory), and its close button is
745
+ // the primary control the "always tappable" acceptance criterion is about.
746
+ padding: `${atLeastInset(12, 'top')} ${atLeastInset(12, 'right')} 10px ${atLeastInset(12, 'left')}`,
723
747
  background: 'rgba(6,11,18,0.8)',
724
748
  borderBottom: '1px solid rgba(255,255,255,0.12)',
725
749
  }}
@@ -730,7 +754,10 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
730
754
  ariaLabel={t('autobahn:viewer.closeLabel')}
731
755
  onClick={onClose}
732
756
  />
733
- <div style={{ position: 'absolute', left: '50%', top: 10, transform: 'translateX(-50%)', width: 160, height: 71 }}>
757
+ {/* absolutely positioned (unlike its close/call siblings), so it does NOT
758
+ automatically shift down with the header's safe-area padding above — needs its
759
+ own top offset. */}
760
+ <div style={{ position: 'absolute', left: '50%', top: withInset(10, 'top'), transform: 'translateX(-50%)', width: 160, height: 71 }}>
734
761
  <LogoMark />
735
762
  </div>
736
763
  {onCall ? (
@@ -914,7 +941,23 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
914
941
  {/* full-width bottom bar — "Jetzt Anfragen" (primary) + "Anrufen" (dark), Figma nodes
915
942
  424:8128-8134. */}
916
943
  {(onEmail || onCall) && (
917
- <div style={{ display: 'flex', gap: THUMB_GAP, padding: 12, height: 82, background: '#797979', flex: 'none' }}>
944
+ // safe-area-aware: last item before the true bottom edge — height is deliberately
945
+ // NOT fixed anymore (was 82px) so the extra bottom padding on a home-indicator device
946
+ // grows the bar instead of squeezing its 56px-tall buttons; `alignItems: 'center'`
947
+ // keeps them centered in that now-variable padding box.
948
+ <div
949
+ style={{
950
+ display: 'flex',
951
+ alignItems: 'center',
952
+ gap: THUMB_GAP,
953
+ paddingTop: 12,
954
+ paddingRight: atLeastInset(12, 'right'),
955
+ paddingBottom: atLeastInset(12, 'bottom'),
956
+ paddingLeft: atLeastInset(12, 'left'),
957
+ background: '#797979',
958
+ flex: 'none',
959
+ }}
960
+ >
918
961
  {onEmail && (
919
962
  <button
920
963
  type="button"
@@ -1035,25 +1078,54 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1035
1078
  brand-blue/gradient mark (verified against its raw export), i.e. the SAME svg this
1036
1079
  codebase already ships as `assets/autode-logo.svg` — reused directly, not portrait's
1037
1080
  white LogoMark (landscape has no dark bar behind it to need a white variant). */}
1038
- <div style={{ position: 'absolute', left: 4, top: 10, width: 93, height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
1081
+ <div
1082
+ style={{
1083
+ position: 'absolute',
1084
+ left: withInset(4, 'left'),
1085
+ top: withInset(10, 'top'),
1086
+ width: 93,
1087
+ height: 42,
1088
+ display: 'flex',
1089
+ alignItems: 'center',
1090
+ justifyContent: 'center',
1091
+ }}
1092
+ >
1039
1093
  <img src={logoUrl} alt="auto.de" style={{ width: 74, height: 21.46, display: 'block' }} />
1040
1094
  </div>
1041
1095
 
1042
1096
  {/* counter pill — Figma node 424:9277 "Counter / BG" */}
1043
- <div style={lsCounterStyle} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
1097
+ <div style={{ ...lsCounterStyle, top: withInset(12, 'top') }} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
1044
1098
  <span className="num">
1045
1099
  {i + 1} | {n}
1046
1100
  </span>
1047
1101
  </div>
1048
1102
 
1049
- {/* close — Figma nodes 424:7556/7557, no caption text in this layout */}
1050
- <LsCircleButton icon={<IconClose />} ariaLabel={t('autobahn:viewer.closeLabel')} onClick={onClose} style={{ right: 12, top: 12 }} />
1103
+ {/* close — Figma nodes 424:7556/7557, no caption text in this layout. Rotating the
1104
+ phone moves the notch/Dynamic Island to whichever side is now "up" (device-relative
1105
+ left/right, not a fixed screen side) — safe-area-aware on both axes since this sits
1106
+ at the true top-right corner, the corner most likely to overlap it. */}
1107
+ <LsCircleButton
1108
+ icon={<IconClose />}
1109
+ ariaLabel={t('autobahn:viewer.closeLabel')}
1110
+ onClick={onClose}
1111
+ style={{ right: withInset(12, 'right'), top: withInset(12, 'top') }}
1112
+ />
1051
1113
 
1052
1114
  {/* prev/next — Figma nodes 424:7560/7561 and 424:7563/7564 */}
1053
1115
  {n > 1 && (
1054
1116
  <>
1055
- <LsCircleButton icon={<IconChevronLeft />} ariaLabel={t('autobahn:viewer.previousLabel')} onClick={() => go(-1)} style={{ left: 12, top: 126 }} />
1056
- <LsCircleButton icon={<IconChevronRight />} ariaLabel={t('autobahn:viewer.nextLabel')} onClick={() => go(1)} style={{ right: 12, top: 126 }} />
1117
+ <LsCircleButton
1118
+ icon={<IconChevronLeft />}
1119
+ ariaLabel={t('autobahn:viewer.previousLabel')}
1120
+ onClick={() => go(-1)}
1121
+ style={{ left: withInset(12, 'left'), top: 126 }}
1122
+ />
1123
+ <LsCircleButton
1124
+ icon={<IconChevronRight />}
1125
+ ariaLabel={t('autobahn:viewer.nextLabel')}
1126
+ onClick={() => go(1)}
1127
+ style={{ right: withInset(12, 'right'), top: 126 }}
1128
+ />
1057
1129
  </>
1058
1130
  )}
1059
1131
 
@@ -1062,8 +1134,8 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1062
1134
  <div
1063
1135
  style={{
1064
1136
  position: 'absolute',
1065
- right: 12,
1066
- bottom: 12,
1137
+ right: withInset(12, 'right'),
1138
+ bottom: withInset(12, 'bottom'),
1067
1139
  width: 48,
1068
1140
  height: 150,
1069
1141
  borderRadius: 'var(--md-r-full)',
@@ -1126,7 +1198,20 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1126
1198
  align the way the design does, without hardcoding a container width that would break
1127
1199
  across the 693-860 range. `right: 81` keeps this band clear of the zoom/360 column
1128
1200
  (12 right-margin + 48 wide + the 21px gap Figma leaves before it = 81). */}
1129
- <div style={{ position: 'absolute', left: 12, right: 81, bottom: 12, height: 50, display: 'flex', alignItems: 'center' }}>
1201
+ <div
1202
+ style={{
1203
+ position: 'absolute',
1204
+ left: withInset(12, 'left'),
1205
+ // 81 = 12 right-margin + 48 zoom-column width + 21 Figma gap before it; add the
1206
+ // same right inset the zoom/360 column itself got above so this band still clears
1207
+ // it once that column has shifted in from a notched edge.
1208
+ right: withInset(81, 'right'),
1209
+ bottom: withInset(12, 'bottom'),
1210
+ height: 50,
1211
+ display: 'flex',
1212
+ alignItems: 'center',
1213
+ }}
1214
+ >
1130
1215
  {(() => {
1131
1216
  const overflow = images.length > lsFitCount;
1132
1217
  const visibleCount = overflow ? lsFitCount - 1 : images.length;