@bytebrand/fe-ui-core-autobahn 1.0.121 → 1.0.122

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
@@ -51,8 +51,14 @@ export interface CarCardProps {
51
51
  isFavorite: boolean;
52
52
  /** toggle the favorite; the heart calls this (click is kept off the card). */
53
53
  onToggleFavorite: () => void;
54
- /** fired when the card is clicked (opens the detail page). */
55
- onOpen: (car: AutobahnCar) => void;
54
+ /** fired when the card is clicked (opens the detail page). The second argument is the payment
55
+ * tab the card was showing at click time (ADV-181) — pages forward it to the VDP via the
56
+ * `?mode=` query flag so the originating card and the CDP always agree on the product. */
57
+ onOpen: (car: AutobahnCar, mode?: PaymentMode) => void;
58
+ /** ADV-181 — fired when the user explicitly switches the card's payment tab. Pages use it to
59
+ * persist the per-car product choice (FinancingStore.setSelectedProduct) + analytics. The
60
+ * card still owns the visual tab state; this is a notification, not a controlled input. */
61
+ onModeChange?: (mode: PaymentMode) => void;
56
62
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
57
63
  onMediaEnter?: () => void;
58
64
  /**
@@ -113,8 +119,16 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
113
119
  onCall,
114
120
  onOpenRateAdjust,
115
121
  onShowFinancingDetails,
122
+ onModeChange,
116
123
  }) => {
117
- const [mode, setMode] = useState<PaymentMode>('financing');
124
+ // ADV-181: start on the centrally-selected default product (cheaper valid rate of
125
+ // financing vs leasing, or the user's saved per-car choice) — computed by toAutobahnCar,
126
+ // so SSR/hydration agree. Absent (older producer) → financing, the pre-ADV-181 default.
127
+ const [mode, setMode] = useState<PaymentMode>(car.defaultMode ?? 'financing');
128
+ const changeMode = (m: PaymentMode) => {
129
+ setMode(m);
130
+ onModeChange && onModeChange(m);
131
+ };
118
132
  const [photoIndex, setPhotoIndex] = useState(0);
119
133
  const [viewerOpen, setViewerOpen] = useState(false);
120
134
  const mediaRef = useRef<HTMLDivElement | null>(null);
@@ -160,7 +174,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
160
174
  return (
161
175
  <article
162
176
  className="mcard mcard--interactive cc-card"
163
- onClick={() => onOpen(car)}
177
+ onClick={() => onOpen(car, mode)}
164
178
  style={{ display: 'flex', flexDirection: 'column' }}
165
179
  >
166
180
  <div
@@ -275,7 +289,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
275
289
  </div>
276
290
  <SpecLine items={cardSpec(car)} oneLine />
277
291
  <div className="cc-price" style={{ marginTop: 0, borderTop: '1px solid var(--md-outline-variant)' }}>
278
- <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
292
+ <PriceTabs car={car} mode={mode} onModeChange={changeMode} t={t} />
279
293
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
280
294
  <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
281
295
  <PriceEvalCol car={car} t={t} />
package/cards/CarRow.tsx CHANGED
@@ -51,8 +51,13 @@ export interface CarRowProps {
51
51
  isFavorite: boolean;
52
52
  /** toggle the favorite; the hearts call this (click is kept off the card). */
53
53
  onToggleFavorite: () => void;
54
- /** fired when the card (or the CTA) is clicked (opens the detail page). */
55
- onOpen: (car: AutobahnCar) => void;
54
+ /** fired when the card (or the CTA) is clicked (opens the detail page). The second argument is
55
+ * the payment tab/product the card was showing at click time (ADV-181) — pages forward it to
56
+ * the VDP via `?mode=` so the originating card and the CDP always agree on the product. */
57
+ onOpen: (car: AutobahnCar, mode?: PaymentMode) => void;
58
+ /** ADV-181 — fired when the user explicitly switches the mobile payment tab. Pages use it to
59
+ * persist the per-car product choice (FinancingStore.setSelectedProduct) + analytics. */
60
+ onModeChange?: (mode: PaymentMode) => void;
56
61
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
57
62
  onMediaEnter?: () => void;
58
63
  /**
@@ -91,8 +96,16 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
91
96
  t,
92
97
  adminAction,
93
98
  onShowFinancingDetails,
99
+ onModeChange,
94
100
  }) => {
95
- const [mode, setMode] = useState<PaymentMode>('financing');
101
+ // ADV-181: start on the centrally-selected default product (see CarCard's identical init).
102
+ // Drives the mobile tab switcher AND the mode reported to onOpen; the desktop branch's
103
+ // untabbed PricePaths reads car.defaultMode directly.
104
+ const [mode, setMode] = useState<PaymentMode>(car.defaultMode ?? 'financing');
105
+ const changeMode = (m: PaymentMode) => {
106
+ setMode(m);
107
+ onModeChange && onModeChange(m);
108
+ };
96
109
  const [photoIndex, setPhotoIndex] = useState(0);
97
110
  const mediaRef = useRef<HTMLDivElement | null>(null);
98
111
  // always call the LATEST onMediaEnter without re-arming the observer on every render (the
@@ -145,7 +158,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
145
158
  return (
146
159
  <article
147
160
  className="mcard mcard--interactive listcard"
148
- onClick={() => onOpen(car)}
161
+ onClick={() => onOpen(car, mode)}
149
162
  style={{ background: 'var(--md-sc-lowest)' }}
150
163
  >
151
164
  <div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
@@ -292,7 +305,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
292
305
  the previously-unused PricePaths/PriceKauf pair. Both variants share the same
293
306
  Preisbewertung meter + CTA, anchored to the bottom (margin-top:auto on .pp-purchase). */}
294
307
  <div className="listcard-price">
295
- <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} className="show-mobile" />
308
+ <PriceTabs car={car} mode={mode} onModeChange={changeMode} t={t} className="show-mobile" />
296
309
  <div className="pp-purchase show-mobile">
297
310
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
298
311
  <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
@@ -311,7 +324,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
311
324
  iconRight="arrow-right"
312
325
  onClick={(e) => {
313
326
  e.stopPropagation();
314
- onOpen(car);
327
+ onOpen(car, mode);
315
328
  }}
316
329
  >
317
330
  {t('autobahn:card.cta.viewOffer')}
@@ -360,7 +373,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
360
373
  iconRight="arrow-right"
361
374
  onClick={(e) => {
362
375
  e.stopPropagation();
363
- onOpen(car);
376
+ onOpen(car, mode);
364
377
  }}
365
378
  >
366
379
  {t('autobahn:card.cta.viewOffer')}
@@ -1103,6 +1103,11 @@ const PpRow: React.FunctionComponent<PpRowProps> = ({ label, value, unit, old, f
1103
1103
  */
1104
1104
  export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, className, onShowDetails }) => {
1105
1105
  const hasLease = car.lease != null;
1106
+ // ADV-181: the hero rate follows the centrally-selected default product (cheaper valid rate,
1107
+ // computed in toAutobahnCar). Leasing leads only when it actually won AND is displayable;
1108
+ // 'buy'/absent both fall back to the financing hero (pre-ADV-181 layout) — the purchase price
1109
+ // keeps its own PriceKauf row below either way, per the desktop rate-first rule.
1110
+ const heroMode: PaymentMode = car.defaultMode === 'leasing' && hasLease ? 'leasing' : 'financing';
1106
1111
  // Terms of the config the displayed rate was actually computed with (per-car:
1107
1112
  // dealer rate + the user's saved localStorage adjustments) — default config
1108
1113
  // terms only when the car doesn't carry its own.
@@ -1130,42 +1135,70 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
1130
1135
  ) : (
1131
1136
  <span className="pp-details-link">{t('autobahn:card.price.details')}</span>
1132
1137
  );
1138
+ const finTermsLine = (
1139
+ <>
1140
+ {t('autobahn:card.price.financeTerms', {
1141
+ firstInstallment: finFirstInstallment,
1142
+ months: finMonths,
1143
+ count: finMonths,
1144
+ })}{' '}
1145
+ · {detailsLink('financing')}
1146
+ </>
1147
+ );
1148
+ const leasCompactTerms = hasLeasTerms && (
1149
+ <div className="pp-sub-terms">
1150
+ {/* dedicated keys (not a rework of leaseTerms) so mobile/VDP's own reviewed
1151
+ wording there stays untouched, but includes the same Sonderzahlung figure.
1152
+ Split across two lines with an explicit <br/> (column is too narrow for all
1153
+ three figures + Details on one line) — forced right after km/Jahr so "36" and
1154
+ "Mon." always land on the same line instead of an awkward mid-number wrap. */}
1155
+ {t('autobahn:card.price.leaseTermsCompactLine1', {
1156
+ firstInstallment: leasFirstInstallment,
1157
+ mileage: leasMileageStr,
1158
+ })}{' · '}
1159
+ <br />
1160
+ {t('autobahn:card.price.leaseTermsCompactLine2', { months: leasMonths })}{' '}
1161
+ · {detailsLink('leasing')}
1162
+ </div>
1163
+ );
1133
1164
  return (
1134
1165
  <div className={'pricepaths' + (className ? ' ' + className : '')}>
1135
- <div className="pp-eyebrow">{t('autobahn:card.price.financingFrom')}</div>
1136
- {/* reuses PriceHero's own financing rendering — the exact same stacked "UVP-Rate ⓘ /
1166
+ <div className="pp-eyebrow">
1167
+ {t(heroMode === 'leasing' ? 'autobahn:card.price.leasingFrom' : 'autobahn:card.price.financingFrom')}
1168
+ </div>
1169
+ {/* reuses PriceHero's own rendering — the exact same stacked "UVP-Rate ⓘ /
1137
1170
  struck price / mtl." group beside the rate that .cc-price and mobile's tab switcher
1138
1171
  already use, so the rate + unit never fight for room in one flat row (the earlier
1139
1172
  hand-rolled version here put them side by side and wrapped for any 4+-digit rate). */}
1140
- <PriceHero car={car} mode="financing" t={t} alwaysShowUnit />
1141
- <div className="pp-hero-terms">
1142
- {t('autobahn:card.price.financeTerms', {
1143
- firstInstallment: finFirstInstallment,
1144
- months: finMonths,
1145
- count: finMonths,
1146
- })}{' '}
1147
- · {detailsLink('financing')}
1148
- </div>
1149
- {hasLease && (
1150
- <div className="pp-lease-row">
1151
- <PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." t={t} />
1173
+ <PriceHero car={car} mode={heroMode} t={t} alwaysShowUnit />
1174
+ {heroMode === 'leasing' ? (
1175
+ <>
1152
1176
  {hasLeasTerms && (
1153
- <div className="pp-sub-terms">
1154
- {/* dedicated keys (not a rework of leaseTerms) so mobile/VDP's own reviewed
1155
- wording there stays untouched, but includes the same Sonderzahlung figure.
1156
- Split across two lines with an explicit <br/> (column is too narrow for all
1157
- three figures + Details on one line) — forced right after km/Jahr so "36" and
1158
- "Mon." always land on the same line instead of an awkward mid-number wrap. */}
1177
+ <div className="pp-hero-terms">
1159
1178
  {t('autobahn:card.price.leaseTermsCompactLine1', {
1160
1179
  firstInstallment: leasFirstInstallment,
1161
1180
  mileage: leasMileageStr,
1162
1181
  })}{' · '}
1163
- <br />
1164
1182
  {t('autobahn:card.price.leaseTermsCompactLine2', { months: leasMonths })}{' '}
1165
1183
  · {detailsLink('leasing')}
1166
1184
  </div>
1167
1185
  )}
1168
- </div>
1186
+ {/* financing follows as the quiet row — same layout slot the leasing row used before. */}
1187
+ <div className="pp-lease-row">
1188
+ <PpRow label={t('autobahn:finance.mode.financing')} value={car.monthly} unit="mtl." t={t} />
1189
+ <div className="pp-sub-terms">{finTermsLine}</div>
1190
+ </div>
1191
+ </>
1192
+ ) : (
1193
+ <>
1194
+ <div className="pp-hero-terms">{finTermsLine}</div>
1195
+ {hasLease && (
1196
+ <div className="pp-lease-row">
1197
+ <PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." t={t} />
1198
+ {leasCompactTerms}
1199
+ </div>
1200
+ )}
1201
+ </>
1169
1202
  )}
1170
1203
  </div>
1171
1204
  );
package/cards/types.ts CHANGED
@@ -57,6 +57,16 @@ export interface AutobahnCar {
57
57
  // --- price (always real) ---
58
58
  /** financing monthly rate in EUR — the LEAD price on every card. */
59
59
  monthly: number;
60
+ /**
61
+ * ADV-181 — which payment product leads on this card. Computed centrally by
62
+ * toAutobahnCar via fe-financing's pickDefaultProduct (cheaper valid rate of
63
+ * financing vs leasing; tie → financing; neither valid → 'buy'), then
64
+ * overridden by the user's saved explicit per-car selection when the fe-main
65
+ * transform stamped one (storeCar.selectedProduct). Cards initialise their
66
+ * tab state from this; absent (older producer) → 'financing', the pre-ADV-181
67
+ * behavior. Derived purely from card data, so SSR and hydration agree.
68
+ */
69
+ defaultMode?: 'financing' | 'leasing' | 'buy';
60
70
  /**
61
71
  * financing terms the `monthly` rate was computed with — the per-car merged
62
72
  * config (dealer rate + the user's saved localStorage adjustments, ADV-157).
@@ -39,10 +39,9 @@ export interface IVehiclePriceItemProps {
39
39
  isMarge?: boolean;
40
40
  }
41
41
  class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
42
- routeToActiveTab = () => {
43
- const { priceTitle } = this.props;
44
- localStorage.setItem('activeTabCDP', priceTitle);
45
- }
42
+ // ADV-181: the old routeToActiveTab (localStorage 'activeTabCDP' write) was removed — its only
43
+ // reader was the unrouted legacy VDP sidebar, so the write was a no-op on the live site. The
44
+ // live VDP resolves its tab centrally (lowest-rate rule / saved per-car choice / `?mode=`).
46
45
 
47
46
  render(): React.ReactNode {
48
47
  const {
@@ -103,7 +102,6 @@ class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
103
102
  ...routeObj,
104
103
  target,
105
104
  rel,
106
- onClick: this.routeToActiveTab,
107
105
  className: priceItemCategoryClassName,
108
106
  href: url
109
107
  })}
@@ -188,7 +186,6 @@ class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
188
186
  ...routeObj,
189
187
  target,
190
188
  rel,
191
- onClick: this.routeToActiveTab,
192
189
  className: styles.priceItemPrice,
193
190
  href: url
194
191
  })}
@@ -166,8 +166,8 @@ const VehicleInfo: React.FC<IVehicleInfoProps> = (props) => {
166
166
  ),
167
167
  ...routeObj,
168
168
  className: wrapClasses,
169
- // ADV-116: opening a CDP from a card defaults to the Financing tab.
170
- onClick: () => localStorage.setItem('activeTabCDP', t('vehicleProps:title.financing')),
169
+ // ADV-181: the dead activeTabCDP write removed (its only reader was the unrouted legacy
170
+ // VDP sidebar) — the live VDP resolves its tab centrally.
171
171
  href: url,
172
172
  target,
173
173
  rel
@@ -319,8 +319,9 @@ const VehicleSmallCard: FunctionComponent<IVehicleSmallCardProps> = (props) => {
319
319
  target,
320
320
  rel,
321
321
  className: `${styles.redirectClassLink} ${styles.gridAreaTitle}`,
322
- // ADV-116: opening a CDP from a card defaults to the Financing tab.
323
- onClick: () => localStorage.setItem('activeTabCDP', t('vehicleProps:title.financing')),
322
+ // ADV-181: the dead activeTabCDP write removed (its only reader was the unrouted
323
+ // legacy VDP sidebar) — the live VDP resolves its tab centrally (lowest-rate rule /
324
+ // saved per-car choice / `?mode=` query flag).
324
325
  href: url
325
326
  })}
326
327
 
@@ -373,8 +374,9 @@ const VehicleSmallCard: FunctionComponent<IVehicleSmallCardProps> = (props) => {
373
374
  target,
374
375
  rel,
375
376
  className: `${styles.redirectClassLink} ${styles.gridAreaImage}`,
376
- // ADV-116: opening a CDP from a card defaults to the Financing tab.
377
- onClick: () => localStorage.setItem('activeTabCDP', t('vehicleProps:title.financing')),
377
+ // ADV-181: the dead activeTabCDP write removed (its only reader was the unrouted
378
+ // legacy VDP sidebar) — the live VDP resolves its tab centrally (lowest-rate rule /
379
+ // saved per-car choice / `?mode=` query flag).
378
380
  href: url
379
381
  })}
380
382
  <VehicleInfo {...vehicleInfoProps} />
@@ -23,6 +23,8 @@
23
23
  // Every field path below was confirmed against the real code (file:line in
24
24
  // ./fieldMapping.ts), not guessed.
25
25
 
26
+ import { pickDefaultProduct } from '@bytebrand/fe-financing';
27
+
26
28
  import { getPriceRating, getPredictablePrice } from '../utils/priceRating';
27
29
  import { formatTimestamp } from '../utils/DateUtils';
28
30
  import { getPowerLabel } from '../utils/power';
@@ -427,6 +429,27 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
427
429
  const leaseMonths = leasingActive ? num(price?.leasing?.paybackPeriod) : undefined;
428
430
  const leaseFirstInstallment = leasingActive ? num(price?.leasing?.firstInstallment) : undefined;
429
431
 
432
+ // --- ADV-181: leading payment product — ONE central rule for every card surface. ---
433
+ // Automatic default = fe-financing's pickDefaultProduct on the same displayed rates the
434
+ // card renders (cheaper valid rate wins, tie → financing, neither valid → 'buy'). The
435
+ // user's saved explicit per-car choice (storeCar.selectedProduct, stamped by fe-main's
436
+ // transformDataForVehicles from FinancingStore) overrides the automatic rule — but only
437
+ // while that product is actually displayable on this card, so a stale "leasing" choice on
438
+ // a car that aged out of leasing eligibility falls back to the automatic rule instead of
439
+ // selecting a product the card can't show. Pure input → pure output: SSR (fe-landing)
440
+ // computes the identical value from the same payload, so hydration never flips the tab.
441
+ const picked = pickDefaultProduct({
442
+ financing: { monthlyInstallment: monthly },
443
+ leasing: { isActive: leasingActive, monthlyInstallment: lease ?? null },
444
+ });
445
+ const savedProduct = (storeCar as any).selectedProduct;
446
+ const defaultMode: 'financing' | 'leasing' | 'buy' =
447
+ savedProduct === 'leasing' && lease != null
448
+ ? 'leasing'
449
+ : savedProduct === 'financing' && monthly > 0
450
+ ? 'financing'
451
+ : picked.product;
452
+
430
453
  // --- optional: list price / savings — ONLY from a real strike price ---
431
454
  // transformDataForVehicles exposes oldMonthlyInstallment / highestPrice via
432
455
  // the financing spread, and price.historyPriceDifference. There is NO reliable
@@ -494,6 +517,7 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
494
517
 
495
518
  // price
496
519
  monthly,
520
+ defaultMode,
497
521
  ...(finFirstInstallment != null ? { finFirstInstallment } : {}),
498
522
  ...(finMonths != null ? { finMonths } : {}),
499
523
  price: purchase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.121",
3
+ "version": "1.0.122",
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.19",
13
+ "@bytebrand/fe-financing": "^5.14.20",
14
14
  "classnames": "2.2.6",
15
15
  "clsx": "^2.1.1",
16
16
  "d3-format": "1.4.5",
@@ -1,213 +1,213 @@
1
- // Autobahn (auto.de redesign) — shared body-portal positioning for md3 popover menus.
2
- //
3
- // Extracted out of Dropdown.tsx (the single-select) so MultiDropdown (FormDropdown.tsx's
4
- // isMulti checklist) can get the exact same `menuPortal` escape-hatch — same clipping problem
5
- // (a checklist menu inside a VehicleEdit Accordion section gets cut off by `.macc-region-inner`'s
6
- // overflow:hidden, see md3.css), same fix, and every hard-won timing/CSS fix that went into this
7
- // belongs in ONE place so the two consumers can't drift out of sync. See Dropdown.tsx's own
8
- // `menuPortal` doc comment for the user-facing behaviour this produces (escapes clipping
9
- // ancestors, tracks the trigger on scroll/resize while open, flips above when there's no room
10
- // below, closes instead of trying to visually "chase" the trigger on a real user scroll).
11
- //
12
- // Consumers own their OWN <ul>/menu markup and className — this hook only owns: the body-portal
13
- // root element, the trigger-relative position math, and the effect timing/scroll-listener
14
- // plumbing. Apply `menuPos` to whatever floating element you render into `portalRoot` via
15
- // ReactDOM.createPortal, and pass that same element's ref as `menuElRef` so its own internal
16
- // scrolling doesn't get misread as a user scrolling the trigger away.
17
-
18
- import * as React from 'react';
19
-
20
- export interface PortalMenuPos {
21
- width: number;
22
- // always a concrete value — a real px number for whichever side is anchoring the box, 'auto'
23
- // (never omitted) for the other. React drops a style property from the DOM entirely when its
24
- // value is `undefined`, so `undefined` is NOT "unset" the way it is in the style object here in
25
- // JS — it just means "don't touch whatever's already cascading in from CSS", and the menu's own
26
- // stylesheet rule (`top: calc(100% + 4px); left: 0;`) was right there to fill back in and fight
27
- // the side that WAS set inline, forcing a negative used-height (CSS 2.1 §10.6.4, clamped to 0 —
28
- // the "opens but nothing visible" collapse). Explicit 'auto' writes a real `top: auto` / `left:
29
- // auto` inline, which — unlike an omitted property — reliably beats the stylesheet rule
30
- // regardless of specificity, so only the intended side ever constrains the box.
31
- top: number | 'auto';
32
- bottom: number | 'auto';
33
- left: number | 'auto';
34
- right: number | 'auto';
35
- // the space actually available in whichever direction the menu opened, clamped to a sane
36
- // [120, ESTIMATED_MENU_HEIGHT-12] range — apply as the floating element's OWN maxHeight (plus
37
- // its padding/border, box-sizing:border-box) so it never relies on shrink-to-fit auto-sizing.
38
- maxHeight: number;
39
- }
40
-
41
- export interface UsePortalMenuPositionArgs {
42
- /** opt-in flag (the `menuPortal` prop on the consumer). */
43
- enabled: boolean;
44
- open: boolean;
45
- /** the trigger's wrapping element — always rendered (regardless of `open`), measured for position. */
46
- rootRef: React.RefObject<HTMLElement>;
47
- menuAlign?: 'left' | 'right';
48
- /** called when the trigger scrolls out of view entirely, or a real user scroll happens after
49
- * the post-open grace window — the menu has nothing sensible left to anchor to or chase. */
50
- onCloseAway: () => void;
51
- }
52
-
53
- // TMenuEl: the concrete element type of whatever you portal (Dropdown.tsx portals a wrapping
54
- // `<div>`; MultiDropdown's checklist has no separate wrapper and portals its `<ul>` directly) —
55
- // keeps `menuElRef` assignable straight to that element's own `ref` without a cast.
56
- export interface UsePortalMenuPositionResult<TMenuEl extends HTMLElement = HTMLDivElement> {
57
- /** a fresh `div.md3` appended to `document.body` while open (so --md-* tokens and every
58
- * `.md3 .mselect-*` rule still resolve at body level, same technique as ui/Modal.tsx) — pass
59
- * as the portal target to `ReactDOM.createPortal`. `null` when `!enabled`. */
60
- portalRoot: HTMLDivElement | null;
61
- /** `null` for one frame on open, before position is first measured — render `visibility:hidden`
62
- * (not just omit inline styles) in that case, see Dropdown.tsx's render for the exact pattern. */
63
- menuPos: PortalMenuPos | null;
64
- /** attach to the actual floating menu element you portal — excludes its own internal scrolling
65
- * (e.g. a long option list) from being misread as the user scrolling the trigger away, and is
66
- * also what click-away logic should check alongside the trigger's own root. */
67
- menuElRef: React.RefObject<TMenuEl>;
68
- }
69
-
70
- // the list's own max-height guess (plus the menu box's padding/border) — only needs to be a
71
- // reasonable estimate; it decides whether to flip ABOVE the trigger. The real size is then
72
- // clamped to the space actually available (see `maxHeight` above), not just guessed at.
73
- const ESTIMATED_MENU_HEIGHT = 300;
74
- // a scroll within this window of opening REPOSITIONS instead of closing — see the effect below.
75
- const SELF_SCROLL_GRACE_MS = 500;
76
-
77
- export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivElement>({
78
- enabled,
79
- open,
80
- rootRef,
81
- menuAlign = 'left',
82
- onCloseAway,
83
- }: UsePortalMenuPositionArgs): UsePortalMenuPositionResult<TMenuEl> {
84
- const menuElRef = React.useRef<TMenuEl | null>(null);
85
- // timestamp of the last open — lets the close-on-scroll listener below tell a genuine user
86
- // scroll apart from a scroll OUR OWN opening sequence causes (e.g. a searchable dropdown's own
87
- // scrollIntoView, or the browser's native "scroll a just-focused element into view" behaviour).
88
- // useLayoutEffect (not useEffect): must be set before the scroll-listener effect further down
89
- // can possibly attach and fire — same synchronous pre-paint phase as the portal-attach/position
90
- // effects below, so there's no window where a scroll could be misjudged against a stale (or
91
- // zero) timestamp.
92
- const openedAtRef = React.useRef(0);
93
- React.useLayoutEffect(() => {
94
- if (open) openedAtRef.current = Date.now();
95
- }, [open]);
96
-
97
- const portalRoot = React.useMemo<HTMLDivElement | null>(() => {
98
- if (!enabled || typeof document === 'undefined') return null;
99
- const el = document.createElement('div');
100
- el.className = 'md3';
101
- return el;
102
- }, [enabled]);
103
-
104
- // useLayoutEffect, not useEffect: createPortal(children, portalRoot) renders content INTO
105
- // portalRoot the moment `open` becomes true, but that's a detached node until THIS effect
106
- // appends it to document.body; a plain useEffect only runs after the browser paints, so there
107
- // was a real window where the (correctly rendered, correctly positioned) menu simply wasn't
108
- // attached to the visible page yet — reading as "opens but nothing appears" rather than a late
109
- // attach.
110
- React.useLayoutEffect(() => {
111
- if (!enabled || !open || !portalRoot || typeof document === 'undefined') return undefined;
112
- document.body.appendChild(portalRoot);
113
- return () => {
114
- if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
115
- };
116
- }, [enabled, open, portalRoot]);
117
-
118
- // the portal menu's z-index (1400, .mselect-menu--portal) is intentionally higher than every
119
- // other fixed overlay it must paint over (checkout sheets/modals) — which also means it would
120
- // paint over the sticky app header (.topbar, position:sticky/top:0/z-index:50) if left
121
- // unconstrained. Reading the header's live bottom edge (0 when absent, e.g. inside a modal, or
122
- // when TopBar's autoHideOnScroll has slid it away) gives a correct top boundary without
123
- // hardcoding a pixel height that could drift from the header's own CSS.
124
- const getSafeTop = (): number => {
125
- if (typeof document === 'undefined') return 0;
126
- const header = document.querySelector('.topbar');
127
- return header ? Math.max(0, header.getBoundingClientRect().bottom) : 0;
128
- };
129
-
130
- const [menuPos, setMenuPos] = React.useState<PortalMenuPos | null>(null);
131
-
132
- const positionMenu = React.useCallback(() => {
133
- if (!enabled || !rootRef.current || typeof window === 'undefined') return;
134
- const rect = rootRef.current.getBoundingClientRect();
135
- const GAP = 4;
136
- const safeTop = getSafeTop();
137
- // the trigger itself scrolled out of view (fully under the header or past either viewport
138
- // edge) — position:fixed keeps the menu exactly where it last was, visually "stuck" over
139
- // unrelated content instead of tracking a trigger that isn't on screen to track anymore. Just
140
- // dismiss it, same as a native <select>'s options close the instant you scroll the trigger away.
141
- if (rect.bottom <= safeTop || rect.top >= window.innerHeight) {
142
- onCloseAway();
143
- return;
144
- }
145
- const spaceBelow = window.innerHeight - rect.bottom - GAP;
146
- const spaceAbove = rect.top - safeTop - GAP;
147
- const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow;
148
- // cap the menu to the space it ACTUALLY has in whichever direction it opens, instead of
149
- // trusting a static CSS max-height on its own — near the header or viewport edge that static
150
- // cap can still be taller than what's really available, which is what let the menu paint over
151
- // the header in the first place. The consumer's own overflow-y:auto takes it from there once
152
- // content exceeds this.
153
- const available = Math.max(120, Math.min(ESTIMATED_MENU_HEIGHT - 12, openAbove ? spaceAbove : spaceBelow));
154
- setMenuPos({
155
- width: rect.width,
156
- left: menuAlign === 'right' ? 'auto' : rect.left,
157
- right: menuAlign === 'right' ? window.innerWidth - rect.right : 'auto',
158
- top: openAbove ? 'auto' : rect.bottom + GAP,
159
- bottom: openAbove ? window.innerHeight - rect.top + GAP : 'auto',
160
- maxHeight: available,
161
- });
162
- }, [enabled, menuAlign, onCloseAway, rootRef]);
163
-
164
- // Synchronous (pre-paint) position computation. useLayoutEffect — not useEffect — matters here:
165
- // it runs right after the DOM mutation that mounted the portal but BEFORE the browser paints, so
166
- // there is no frame where the menu is mounted-but-unpositioned. positionMenu also needs rootRef
167
- // already attached, which it is by the time this runs — the wrapper element it measures is
168
- // always rendered regardless of `open`, so its ref was set on an earlier commit already.
169
- React.useLayoutEffect(() => {
170
- if (!open || !enabled) return;
171
- positionMenu();
172
- }, [open, enabled, positionMenu]);
173
-
174
- React.useEffect(() => {
175
- if (!open || !enabled) return undefined;
176
- // A scroll of the page/an ancestor CLOSES the menu instead of repositioning it. Continuously
177
- // chasing the trigger during a scroll (recompute + setState + re-render) always lags a frame or
178
- // more behind the browser's own native, synchronous scroll — the fixed-position menu visibly
179
- // trails the trigger and then "snaps" into place once scrolling settles. A native <select> (and
180
- // most real-world custom ones) just closes on scroll instead; that reads as correct rather than
181
- // janky, and sidesteps the lag entirely instead of trying to out-run it. capture:true so a
182
- // scroll on any nested scrollable ancestor is caught too, not just the window. Scrolling INSIDE
183
- // the menu itself (menuElRef's own subtree) must NOT close it — that's just browsing a long
184
- // list, not scrolling the trigger away — so that's the one scroll target excluded.
185
- // A short grace window after opening REPOSITIONS instead of closing on scroll — two different
186
- // self-triggered scrolls land in this window: a searchable dropdown's own scrollIntoView effect
187
- // smooth-scrolls the trigger into view the instant the menu opens, AND — for every dropdown —
188
- // the BROWSER ITSELF auto-scrolls a just-focused element into view when it was partially cut
189
- // off at the viewport edge. Both were previously just swallowed (ignored) here, on the theory
190
- // that "our own open" scroll isn't a real user scroll-away — true, but positionMenu() was
191
- // computed ONCE, before that scroll happened, so the menu was left floating at its PRE-scroll
192
- // coordinates while the page settled at a new scroll offset under it. Resyncing the position on
193
- // every grace-window scroll fixes that (cheap: one rect read + setState) without reintroducing
194
- // the janky continuous-chase during a real user scroll — this only runs for the ~500ms settle
195
- // window right after open, not for scrolling in general (that still closes, below).
196
- const onScroll = (e: Event) => {
197
- if (menuElRef.current && e.target instanceof Node && menuElRef.current.contains(e.target)) return;
198
- if (Date.now() - openedAtRef.current < SELF_SCROLL_GRACE_MS) {
199
- positionMenu();
200
- return;
201
- }
202
- onCloseAway();
203
- };
204
- window.addEventListener('scroll', onScroll, true);
205
- window.addEventListener('resize', positionMenu);
206
- return () => {
207
- window.removeEventListener('scroll', onScroll, true);
208
- window.removeEventListener('resize', positionMenu);
209
- };
210
- }, [open, enabled, positionMenu, onCloseAway]);
211
-
212
- return { portalRoot, menuPos, menuElRef };
213
- }
1
+ // Autobahn (auto.de redesign) — shared body-portal positioning for md3 popover menus.
2
+ //
3
+ // Extracted out of Dropdown.tsx (the single-select) so MultiDropdown (FormDropdown.tsx's
4
+ // isMulti checklist) can get the exact same `menuPortal` escape-hatch — same clipping problem
5
+ // (a checklist menu inside a VehicleEdit Accordion section gets cut off by `.macc-region-inner`'s
6
+ // overflow:hidden, see md3.css), same fix, and every hard-won timing/CSS fix that went into this
7
+ // belongs in ONE place so the two consumers can't drift out of sync. See Dropdown.tsx's own
8
+ // `menuPortal` doc comment for the user-facing behaviour this produces (escapes clipping
9
+ // ancestors, tracks the trigger on scroll/resize while open, flips above when there's no room
10
+ // below, closes instead of trying to visually "chase" the trigger on a real user scroll).
11
+ //
12
+ // Consumers own their OWN <ul>/menu markup and className — this hook only owns: the body-portal
13
+ // root element, the trigger-relative position math, and the effect timing/scroll-listener
14
+ // plumbing. Apply `menuPos` to whatever floating element you render into `portalRoot` via
15
+ // ReactDOM.createPortal, and pass that same element's ref as `menuElRef` so its own internal
16
+ // scrolling doesn't get misread as a user scrolling the trigger away.
17
+
18
+ import * as React from 'react';
19
+
20
+ export interface PortalMenuPos {
21
+ width: number;
22
+ // always a concrete value — a real px number for whichever side is anchoring the box, 'auto'
23
+ // (never omitted) for the other. React drops a style property from the DOM entirely when its
24
+ // value is `undefined`, so `undefined` is NOT "unset" the way it is in the style object here in
25
+ // JS — it just means "don't touch whatever's already cascading in from CSS", and the menu's own
26
+ // stylesheet rule (`top: calc(100% + 4px); left: 0;`) was right there to fill back in and fight
27
+ // the side that WAS set inline, forcing a negative used-height (CSS 2.1 §10.6.4, clamped to 0 —
28
+ // the "opens but nothing visible" collapse). Explicit 'auto' writes a real `top: auto` / `left:
29
+ // auto` inline, which — unlike an omitted property — reliably beats the stylesheet rule
30
+ // regardless of specificity, so only the intended side ever constrains the box.
31
+ top: number | 'auto';
32
+ bottom: number | 'auto';
33
+ left: number | 'auto';
34
+ right: number | 'auto';
35
+ // the space actually available in whichever direction the menu opened, clamped to a sane
36
+ // [120, ESTIMATED_MENU_HEIGHT-12] range — apply as the floating element's OWN maxHeight (plus
37
+ // its padding/border, box-sizing:border-box) so it never relies on shrink-to-fit auto-sizing.
38
+ maxHeight: number;
39
+ }
40
+
41
+ export interface UsePortalMenuPositionArgs {
42
+ /** opt-in flag (the `menuPortal` prop on the consumer). */
43
+ enabled: boolean;
44
+ open: boolean;
45
+ /** the trigger's wrapping element — always rendered (regardless of `open`), measured for position. */
46
+ rootRef: React.RefObject<HTMLElement>;
47
+ menuAlign?: 'left' | 'right';
48
+ /** called when the trigger scrolls out of view entirely, or a real user scroll happens after
49
+ * the post-open grace window — the menu has nothing sensible left to anchor to or chase. */
50
+ onCloseAway: () => void;
51
+ }
52
+
53
+ // TMenuEl: the concrete element type of whatever you portal (Dropdown.tsx portals a wrapping
54
+ // `<div>`; MultiDropdown's checklist has no separate wrapper and portals its `<ul>` directly) —
55
+ // keeps `menuElRef` assignable straight to that element's own `ref` without a cast.
56
+ export interface UsePortalMenuPositionResult<TMenuEl extends HTMLElement = HTMLDivElement> {
57
+ /** a fresh `div.md3` appended to `document.body` while open (so --md-* tokens and every
58
+ * `.md3 .mselect-*` rule still resolve at body level, same technique as ui/Modal.tsx) — pass
59
+ * as the portal target to `ReactDOM.createPortal`. `null` when `!enabled`. */
60
+ portalRoot: HTMLDivElement | null;
61
+ /** `null` for one frame on open, before position is first measured — render `visibility:hidden`
62
+ * (not just omit inline styles) in that case, see Dropdown.tsx's render for the exact pattern. */
63
+ menuPos: PortalMenuPos | null;
64
+ /** attach to the actual floating menu element you portal — excludes its own internal scrolling
65
+ * (e.g. a long option list) from being misread as the user scrolling the trigger away, and is
66
+ * also what click-away logic should check alongside the trigger's own root. */
67
+ menuElRef: React.RefObject<TMenuEl>;
68
+ }
69
+
70
+ // the list's own max-height guess (plus the menu box's padding/border) — only needs to be a
71
+ // reasonable estimate; it decides whether to flip ABOVE the trigger. The real size is then
72
+ // clamped to the space actually available (see `maxHeight` above), not just guessed at.
73
+ const ESTIMATED_MENU_HEIGHT = 300;
74
+ // a scroll within this window of opening REPOSITIONS instead of closing — see the effect below.
75
+ const SELF_SCROLL_GRACE_MS = 500;
76
+
77
+ export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivElement>({
78
+ enabled,
79
+ open,
80
+ rootRef,
81
+ menuAlign = 'left',
82
+ onCloseAway,
83
+ }: UsePortalMenuPositionArgs): UsePortalMenuPositionResult<TMenuEl> {
84
+ const menuElRef = React.useRef<TMenuEl | null>(null);
85
+ // timestamp of the last open — lets the close-on-scroll listener below tell a genuine user
86
+ // scroll apart from a scroll OUR OWN opening sequence causes (e.g. a searchable dropdown's own
87
+ // scrollIntoView, or the browser's native "scroll a just-focused element into view" behaviour).
88
+ // useLayoutEffect (not useEffect): must be set before the scroll-listener effect further down
89
+ // can possibly attach and fire — same synchronous pre-paint phase as the portal-attach/position
90
+ // effects below, so there's no window where a scroll could be misjudged against a stale (or
91
+ // zero) timestamp.
92
+ const openedAtRef = React.useRef(0);
93
+ React.useLayoutEffect(() => {
94
+ if (open) openedAtRef.current = Date.now();
95
+ }, [open]);
96
+
97
+ const portalRoot = React.useMemo<HTMLDivElement | null>(() => {
98
+ if (!enabled || typeof document === 'undefined') return null;
99
+ const el = document.createElement('div');
100
+ el.className = 'md3';
101
+ return el;
102
+ }, [enabled]);
103
+
104
+ // useLayoutEffect, not useEffect: createPortal(children, portalRoot) renders content INTO
105
+ // portalRoot the moment `open` becomes true, but that's a detached node until THIS effect
106
+ // appends it to document.body; a plain useEffect only runs after the browser paints, so there
107
+ // was a real window where the (correctly rendered, correctly positioned) menu simply wasn't
108
+ // attached to the visible page yet — reading as "opens but nothing appears" rather than a late
109
+ // attach.
110
+ React.useLayoutEffect(() => {
111
+ if (!enabled || !open || !portalRoot || typeof document === 'undefined') return undefined;
112
+ document.body.appendChild(portalRoot);
113
+ return () => {
114
+ if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
115
+ };
116
+ }, [enabled, open, portalRoot]);
117
+
118
+ // the portal menu's z-index (1400, .mselect-menu--portal) is intentionally higher than every
119
+ // other fixed overlay it must paint over (checkout sheets/modals) — which also means it would
120
+ // paint over the sticky app header (.topbar, position:sticky/top:0/z-index:50) if left
121
+ // unconstrained. Reading the header's live bottom edge (0 when absent, e.g. inside a modal, or
122
+ // when TopBar's autoHideOnScroll has slid it away) gives a correct top boundary without
123
+ // hardcoding a pixel height that could drift from the header's own CSS.
124
+ const getSafeTop = (): number => {
125
+ if (typeof document === 'undefined') return 0;
126
+ const header = document.querySelector('.topbar');
127
+ return header ? Math.max(0, header.getBoundingClientRect().bottom) : 0;
128
+ };
129
+
130
+ const [menuPos, setMenuPos] = React.useState<PortalMenuPos | null>(null);
131
+
132
+ const positionMenu = React.useCallback(() => {
133
+ if (!enabled || !rootRef.current || typeof window === 'undefined') return;
134
+ const rect = rootRef.current.getBoundingClientRect();
135
+ const GAP = 4;
136
+ const safeTop = getSafeTop();
137
+ // the trigger itself scrolled out of view (fully under the header or past either viewport
138
+ // edge) — position:fixed keeps the menu exactly where it last was, visually "stuck" over
139
+ // unrelated content instead of tracking a trigger that isn't on screen to track anymore. Just
140
+ // dismiss it, same as a native <select>'s options close the instant you scroll the trigger away.
141
+ if (rect.bottom <= safeTop || rect.top >= window.innerHeight) {
142
+ onCloseAway();
143
+ return;
144
+ }
145
+ const spaceBelow = window.innerHeight - rect.bottom - GAP;
146
+ const spaceAbove = rect.top - safeTop - GAP;
147
+ const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow;
148
+ // cap the menu to the space it ACTUALLY has in whichever direction it opens, instead of
149
+ // trusting a static CSS max-height on its own — near the header or viewport edge that static
150
+ // cap can still be taller than what's really available, which is what let the menu paint over
151
+ // the header in the first place. The consumer's own overflow-y:auto takes it from there once
152
+ // content exceeds this.
153
+ const available = Math.max(120, Math.min(ESTIMATED_MENU_HEIGHT - 12, openAbove ? spaceAbove : spaceBelow));
154
+ setMenuPos({
155
+ width: rect.width,
156
+ left: menuAlign === 'right' ? 'auto' : rect.left,
157
+ right: menuAlign === 'right' ? window.innerWidth - rect.right : 'auto',
158
+ top: openAbove ? 'auto' : rect.bottom + GAP,
159
+ bottom: openAbove ? window.innerHeight - rect.top + GAP : 'auto',
160
+ maxHeight: available,
161
+ });
162
+ }, [enabled, menuAlign, onCloseAway, rootRef]);
163
+
164
+ // Synchronous (pre-paint) position computation. useLayoutEffect — not useEffect — matters here:
165
+ // it runs right after the DOM mutation that mounted the portal but BEFORE the browser paints, so
166
+ // there is no frame where the menu is mounted-but-unpositioned. positionMenu also needs rootRef
167
+ // already attached, which it is by the time this runs — the wrapper element it measures is
168
+ // always rendered regardless of `open`, so its ref was set on an earlier commit already.
169
+ React.useLayoutEffect(() => {
170
+ if (!open || !enabled) return;
171
+ positionMenu();
172
+ }, [open, enabled, positionMenu]);
173
+
174
+ React.useEffect(() => {
175
+ if (!open || !enabled) return undefined;
176
+ // A scroll of the page/an ancestor CLOSES the menu instead of repositioning it. Continuously
177
+ // chasing the trigger during a scroll (recompute + setState + re-render) always lags a frame or
178
+ // more behind the browser's own native, synchronous scroll — the fixed-position menu visibly
179
+ // trails the trigger and then "snaps" into place once scrolling settles. A native <select> (and
180
+ // most real-world custom ones) just closes on scroll instead; that reads as correct rather than
181
+ // janky, and sidesteps the lag entirely instead of trying to out-run it. capture:true so a
182
+ // scroll on any nested scrollable ancestor is caught too, not just the window. Scrolling INSIDE
183
+ // the menu itself (menuElRef's own subtree) must NOT close it — that's just browsing a long
184
+ // list, not scrolling the trigger away — so that's the one scroll target excluded.
185
+ // A short grace window after opening REPOSITIONS instead of closing on scroll — two different
186
+ // self-triggered scrolls land in this window: a searchable dropdown's own scrollIntoView effect
187
+ // smooth-scrolls the trigger into view the instant the menu opens, AND — for every dropdown —
188
+ // the BROWSER ITSELF auto-scrolls a just-focused element into view when it was partially cut
189
+ // off at the viewport edge. Both were previously just swallowed (ignored) here, on the theory
190
+ // that "our own open" scroll isn't a real user scroll-away — true, but positionMenu() was
191
+ // computed ONCE, before that scroll happened, so the menu was left floating at its PRE-scroll
192
+ // coordinates while the page settled at a new scroll offset under it. Resyncing the position on
193
+ // every grace-window scroll fixes that (cheap: one rect read + setState) without reintroducing
194
+ // the janky continuous-chase during a real user scroll — this only runs for the ~500ms settle
195
+ // window right after open, not for scrolling in general (that still closes, below).
196
+ const onScroll = (e: Event) => {
197
+ if (menuElRef.current && e.target instanceof Node && menuElRef.current.contains(e.target)) return;
198
+ if (Date.now() - openedAtRef.current < SELF_SCROLL_GRACE_MS) {
199
+ positionMenu();
200
+ return;
201
+ }
202
+ onCloseAway();
203
+ };
204
+ window.addEventListener('scroll', onScroll, true);
205
+ window.addEventListener('resize', positionMenu);
206
+ return () => {
207
+ window.removeEventListener('scroll', onScroll, true);
208
+ window.removeEventListener('resize', positionMenu);
209
+ };
210
+ }, [open, enabled, positionMenu, onCloseAway]);
211
+
212
+ return { portalRoot, menuPos, menuElRef };
213
+ }
@@ -1424,12 +1424,18 @@ export interface VdpStickyBarProps {
1424
1424
  show: boolean;
1425
1425
  /** primary CTA click. */
1426
1426
  onSecure?: () => void;
1427
+ /** ADV-181: monthly rate for the CURRENTLY selected payment product (fin/leasing differ) —
1428
+ * falls back to vehicle.monthly (the financing rate) when omitted, the pre-ADV-181 display. */
1429
+ monthly?: number;
1430
+ /** ADV-181: label of the selected product ("Finanzierung"/"Leasing") — replaces the generic
1431
+ * "ab" eyebrow so the sticky bar's label always matches the rate it shows. */
1432
+ productLabel?: string;
1427
1433
  /** i18n function (drilled from the page). */
1428
1434
  t: TranslationFunction;
1429
1435
  }
1430
1436
 
1431
1437
  /** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
1432
- export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
1438
+ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, monthly, productLabel, t }) => (
1433
1439
  <div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
1434
1440
  <div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
1435
1441
  <img
@@ -1455,9 +1461,9 @@ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehic
1455
1461
  </div>
1456
1462
  <div style={{ textAlign: 'right', flex: 'none' }}>
1457
1463
  <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
1458
- <span className="md-label-sm">{t('autobahn:sticky.from')}</span>
1464
+ <span className="md-label-sm">{productLabel || t('autobahn:sticky.from')}</span>
1459
1465
  <span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
1460
- {vehicle.monthly} €
1466
+ {monthly ?? vehicle.monthly} €
1461
1467
  </span>
1462
1468
  <span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
1463
1469
  </div>