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

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,14 +51,8 @@ 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). 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;
54
+ /** fired when the card is clicked (opens the detail page). */
55
+ onOpen: (car: AutobahnCar) => void;
62
56
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
63
57
  onMediaEnter?: () => void;
64
58
  /**
@@ -119,16 +113,8 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
119
113
  onCall,
120
114
  onOpenRateAdjust,
121
115
  onShowFinancingDetails,
122
- onModeChange,
123
116
  }) => {
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
- };
117
+ const [mode, setMode] = useState<PaymentMode>('financing');
132
118
  const [photoIndex, setPhotoIndex] = useState(0);
133
119
  const [viewerOpen, setViewerOpen] = useState(false);
134
120
  const mediaRef = useRef<HTMLDivElement | null>(null);
@@ -174,7 +160,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
174
160
  return (
175
161
  <article
176
162
  className="mcard mcard--interactive cc-card"
177
- onClick={() => onOpen(car, mode)}
163
+ onClick={() => onOpen(car)}
178
164
  style={{ display: 'flex', flexDirection: 'column' }}
179
165
  >
180
166
  <div
@@ -289,7 +275,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
289
275
  </div>
290
276
  <SpecLine items={cardSpec(car)} oneLine />
291
277
  <div className="cc-price" style={{ marginTop: 0, borderTop: '1px solid var(--md-outline-variant)' }}>
292
- <PriceTabs car={car} mode={mode} onModeChange={changeMode} t={t} />
278
+ <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
293
279
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
294
280
  <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
295
281
  <PriceEvalCol car={car} t={t} />
package/cards/CarRow.tsx CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  PpActivity,
31
31
  GalleryBadges,
32
32
  GalleryCounter,
33
+ GalleryActivityPill,
33
34
  OfferBadge,
34
35
  PriceTabs,
35
36
  PriceHero,
@@ -51,13 +52,8 @@ export interface CarRowProps {
51
52
  isFavorite: boolean;
52
53
  /** toggle the favorite; the hearts call this (click is kept off the card). */
53
54
  onToggleFavorite: () => 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;
55
+ /** fired when the card (or the CTA) is clicked (opens the detail page). */
56
+ onOpen: (car: AutobahnCar) => void;
61
57
  /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
62
58
  onMediaEnter?: () => void;
63
59
  /**
@@ -96,16 +92,8 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
96
92
  t,
97
93
  adminAction,
98
94
  onShowFinancingDetails,
99
- onModeChange,
100
95
  }) => {
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
+ const [mode, setMode] = useState<PaymentMode>('financing');
109
97
  const [photoIndex, setPhotoIndex] = useState(0);
110
98
  const mediaRef = useRef<HTMLDivElement | null>(null);
111
99
  // always call the LATEST onMediaEnter without re-arming the observer on every render (the
@@ -158,7 +146,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
158
146
  return (
159
147
  <article
160
148
  className="mcard mcard--interactive listcard"
161
- onClick={() => onOpen(car, mode)}
149
+ onClick={() => onOpen(car)}
162
150
  style={{ background: 'var(--md-sc-lowest)' }}
163
151
  >
164
152
  <div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
@@ -224,44 +212,19 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
224
212
  <div className="show-mobile">
225
213
  <GalleryBadges car={car} t={t} />
226
214
  </div>
227
- {/* desktop: single row — light "1|N" photo counter + a combined eye/heart chip, no
228
- availability/Geprüft pill (not present in the Figma "mcard" export).
229
- Same 3-state policy as the mobile pills above (GalleryCounter/GalleryBadges,
230
- cardParts.tsx) and CarCard's GalleryCounter/GalleryActivityPill, just inlined here
231
- instead of reusing those components (different chip markup — one shared "N|M" +
232
- eye+heart pill instead of three separate ones):
233
- - single-image: car.photos is the real, eagerly-known count (not the lazily-loaded
234
- gallery array length) — accurate even before the extra photos have loaded.
235
- - loading: viewsCount/favoritesCount are undefined until stats resolve for this car
236
- — hidden, no skeleton, same as everywhere else.
237
- - zero-value: a real 0 is intentionally treated the same as "not loaded" — hidden,
238
- not shown as "eye 0" / "heart 0" (noise on a dense results list otherwise). */}
239
- <div
240
- className="hide-mobile"
241
- style={{ position: 'absolute', bottom: 20, left: 12, zIndex: 3, display: 'flex', alignItems: 'center', gap: 6 }}
242
- >
243
- {car.photos > 1 && (
244
- <span className="mcard-photo-chip">
245
- <Icon name="maximize" size={16} stroke={1.7} />
246
- {photoIndex + 1}|{car.photos}
247
- </span>
248
- )}
249
- {(!!car.viewsCount || !!car.favoritesCount) && (
250
- <span className="mcard-photo-chip">
251
- {!!car.viewsCount && (
252
- <>
253
- <Icon name="eye" size={16} stroke={1.7} />
254
- {car.viewsCount}
255
- </>
256
- )}
257
- {!!car.favoritesCount && (
258
- <>
259
- <Icon name="heart" size={16} stroke={1.7} />
260
- {car.favoritesCount}
261
- </>
262
- )}
263
- </span>
264
- )}
215
+ {/* desktop: same light "1|N" photo counter + eye/heart activity pill as CarCard (Figma
216
+ "mcard" export) — reusing GalleryCounter(light)/GalleryActivityPill (cardParts.tsx)
217
+ instead of the old inlined "N|M" + combined eye+heart chip this used to render, so
218
+ list view and grid view can never visually drift again. Same bottom:10/left:10 +
219
+ bottom:10/right:10 placement, same 3-state policy (both components self-guard:
220
+ GalleryCounter hides at total<=1, GalleryActivityPill hides when both counts are
221
+ falsy — loading/zero read the same as "nothing worth showing yet", see their own
222
+ doc comments in cardParts.tsx) — no manual guards needed here any more. */}
223
+ <div className="hide-mobile" style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}>
224
+ <GalleryCounter current={photoIndex + 1} total={car.photos} light />
225
+ </div>
226
+ <div className="hide-mobile" style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
227
+ <GalleryActivityPill viewsCount={car.viewsCount} favoritesCount={car.favoritesCount} />
265
228
  </div>
266
229
  </div>
267
230
 
@@ -305,7 +268,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
305
268
  the previously-unused PricePaths/PriceKauf pair. Both variants share the same
306
269
  Preisbewertung meter + CTA, anchored to the bottom (margin-top:auto on .pp-purchase). */}
307
270
  <div className="listcard-price">
308
- <PriceTabs car={car} mode={mode} onModeChange={changeMode} t={t} className="show-mobile" />
271
+ <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} className="show-mobile" />
309
272
  <div className="pp-purchase show-mobile">
310
273
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
311
274
  <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
@@ -324,7 +287,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
324
287
  iconRight="arrow-right"
325
288
  onClick={(e) => {
326
289
  e.stopPropagation();
327
- onOpen(car, mode);
290
+ onOpen(car);
328
291
  }}
329
292
  >
330
293
  {t('autobahn:card.cta.viewOffer')}
@@ -373,7 +336,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
373
336
  iconRight="arrow-right"
374
337
  onClick={(e) => {
375
338
  e.stopPropagation();
376
- onOpen(car, mode);
339
+ onOpen(car);
377
340
  }}
378
341
  >
379
342
  {t('autobahn:card.cta.viewOffer')}
@@ -1103,11 +1103,6 @@ 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';
1111
1106
  // Terms of the config the displayed rate was actually computed with (per-car:
1112
1107
  // dealer rate + the user's saved localStorage adjustments) — default config
1113
1108
  // terms only when the car doesn't carry its own.
@@ -1135,70 +1130,42 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
1135
1130
  ) : (
1136
1131
  <span className="pp-details-link">{t('autobahn:card.price.details')}</span>
1137
1132
  );
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
- );
1164
1133
  return (
1165
1134
  <div className={'pricepaths' + (className ? ' ' + className : '')}>
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 ⓘ /
1135
+ <div className="pp-eyebrow">{t('autobahn:card.price.financingFrom')}</div>
1136
+ {/* reuses PriceHero's own financing rendering — the exact same stacked "UVP-Rate ⓘ /
1170
1137
  struck price / mtl." group beside the rate that .cc-price and mobile's tab switcher
1171
1138
  already use, so the rate + unit never fight for room in one flat row (the earlier
1172
1139
  hand-rolled version here put them side by side and wrapped for any 4+-digit rate). */}
1173
- <PriceHero car={car} mode={heroMode} t={t} alwaysShowUnit />
1174
- {heroMode === 'leasing' ? (
1175
- <>
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} />
1176
1152
  {hasLeasTerms && (
1177
- <div className="pp-hero-terms">
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. */}
1178
1159
  {t('autobahn:card.price.leaseTermsCompactLine1', {
1179
1160
  firstInstallment: leasFirstInstallment,
1180
1161
  mileage: leasMileageStr,
1181
1162
  })}{' · '}
1163
+ <br />
1182
1164
  {t('autobahn:card.price.leaseTermsCompactLine2', { months: leasMonths })}{' '}
1183
1165
  · {detailsLink('leasing')}
1184
1166
  </div>
1185
1167
  )}
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
- </>
1168
+ </div>
1202
1169
  )}
1203
1170
  </div>
1204
1171
  );
package/cards/types.ts CHANGED
@@ -57,16 +57,6 @@ 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';
70
60
  /**
71
61
  * financing terms the `monthly` rate was computed with — the per-car merged
72
62
  * config (dealer rate + the user's saved localStorage adjustments, ADV-157).
@@ -39,9 +39,10 @@ export interface IVehiclePriceItemProps {
39
39
  isMarge?: boolean;
40
40
  }
41
41
  class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
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=`).
42
+ routeToActiveTab = () => {
43
+ const { priceTitle } = this.props;
44
+ localStorage.setItem('activeTabCDP', priceTitle);
45
+ }
45
46
 
46
47
  render(): React.ReactNode {
47
48
  const {
@@ -102,6 +103,7 @@ class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
102
103
  ...routeObj,
103
104
  target,
104
105
  rel,
106
+ onClick: this.routeToActiveTab,
105
107
  className: priceItemCategoryClassName,
106
108
  href: url
107
109
  })}
@@ -186,6 +188,7 @@ class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
186
188
  ...routeObj,
187
189
  target,
188
190
  rel,
191
+ onClick: this.routeToActiveTab,
189
192
  className: styles.priceItemPrice,
190
193
  href: url
191
194
  })}
@@ -166,8 +166,8 @@ const VehicleInfo: React.FC<IVehicleInfoProps> = (props) => {
166
166
  ),
167
167
  ...routeObj,
168
168
  className: wrapClasses,
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.
169
+ // ADV-116: opening a CDP from a card defaults to the Financing tab.
170
+ onClick: () => localStorage.setItem('activeTabCDP', t('vehicleProps:title.financing')),
171
171
  href: url,
172
172
  target,
173
173
  rel
@@ -319,9 +319,8 @@ const VehicleSmallCard: FunctionComponent<IVehicleSmallCardProps> = (props) => {
319
319
  target,
320
320
  rel,
321
321
  className: `${styles.redirectClassLink} ${styles.gridAreaTitle}`,
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).
322
+ // ADV-116: opening a CDP from a card defaults to the Financing tab.
323
+ onClick: () => localStorage.setItem('activeTabCDP', t('vehicleProps:title.financing')),
325
324
  href: url
326
325
  })}
327
326
 
@@ -374,9 +373,8 @@ const VehicleSmallCard: FunctionComponent<IVehicleSmallCardProps> = (props) => {
374
373
  target,
375
374
  rel,
376
375
  className: `${styles.redirectClassLink} ${styles.gridAreaImage}`,
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).
376
+ // ADV-116: opening a CDP from a card defaults to the Financing tab.
377
+ onClick: () => localStorage.setItem('activeTabCDP', t('vehicleProps:title.financing')),
380
378
  href: url
381
379
  })}
382
380
  <VehicleInfo {...vehicleInfoProps} />
@@ -23,8 +23,6 @@
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
-
28
26
  import { getPriceRating, getPredictablePrice } from '../utils/priceRating';
29
27
  import { formatTimestamp } from '../utils/DateUtils';
30
28
  import { getPowerLabel } from '../utils/power';
@@ -429,27 +427,6 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
429
427
  const leaseMonths = leasingActive ? num(price?.leasing?.paybackPeriod) : undefined;
430
428
  const leaseFirstInstallment = leasingActive ? num(price?.leasing?.firstInstallment) : undefined;
431
429
 
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
-
453
430
  // --- optional: list price / savings — ONLY from a real strike price ---
454
431
  // transformDataForVehicles exposes oldMonthlyInstallment / highestPrice via
455
432
  // the financing spread, and price.historyPriceDifference. There is NO reliable
@@ -517,7 +494,6 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
517
494
 
518
495
  // price
519
496
  monthly,
520
- defaultMode,
521
497
  ...(finFirstInstallment != null ? { finFirstInstallment } : {}),
522
498
  ...(finMonths != null ? { finMonths } : {}),
523
499
  price: purchase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.122",
3
+ "version": "1.0.123",
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.20",
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
@@ -298,9 +298,38 @@ line-height: 31px;
298
298
  flex: none;
299
299
  transition: background 0.15s;
300
300
  }
301
- .md3 .icon-btn:hover {
301
+ /* :not(:disabled) — same convention as .co-rail-step:hover:not(:disabled) elsewhere in this file:
302
+ a disabled icon-btn (e.g. VdpViewer's zoom in/out at ZOOM_MIN/MAX) shouldn't show a hover fill
303
+ that implies it's still clickable. */
304
+ .md3 .icon-btn:hover:not(:disabled) {
302
305
  background: var(--md-sc-high);
303
306
  }
307
+ .md3 .icon-btn:disabled {
308
+ cursor: not-allowed;
309
+ }
310
+ /* VDP viewer (desktop) toolbar — zoom out/in, 360°, and close share this white-circle+shadow
311
+ look; email/call get the primary-blue one below. Both are CLASSES, not inline `style` (an
312
+ inline style background permanently pins it, blocking :hover entirely — no exceptions, so
313
+ this used to be the one thing silently killing hover on all five of these buttons). Works
314
+ without any extra :hover rule of its own for the light variant: .icon-btn:hover:not(:disabled)
315
+ above is more specific (4 selectors vs. this rule's 2) so it already wins on hover for free. */
316
+ .md3 .vdp-viewer-light {
317
+ background: var(--md-sc-lowest, #fff);
318
+ box-shadow: var(--md-e1);
319
+ }
320
+ .md3 .vdp-viewer-contact {
321
+ background: var(--md-primary);
322
+ box-shadow: var(--md-e1);
323
+ }
324
+ /* the contact (email/call) pair needs its OWN :hover, and it needs the explicit `.icon-btn`
325
+ repeated in the selector to match .icon-btn:hover:not(:disabled) above's specificity (4
326
+ selectors each, :not()'s specificity is its argument's — a plain `.vdp-viewer-contact:hover`
327
+ is only 3 and would silently lose, tinting these the light pair's neutral grey instead of the
328
+ deeper blue below, the same "primary button, one shade darker" treatment .mbtn--filled:hover
329
+ already uses). Source order (this comes after) breaks the tie once specificity is equal. */
330
+ .md3 .icon-btn.vdp-viewer-contact:hover {
331
+ background: var(--autode-blau-strong);
332
+ }
304
333
  .md3 .icon-btn--filled {
305
334
  background: #FFFFFF;
306
335
  border: 1px solid #D4DDE7;
@@ -2263,6 +2292,12 @@ line-height: 31px;
2263
2292
  display: inline-flex;
2264
2293
  gap: 6px;
2265
2294
  margin: 12px 0;
2295
+ /* .dash-card is display:flex/flex-direction:column (legacy .section) with the default
2296
+ align-items:stretch, so this box (its width left auto) stretches to the card's full width
2297
+ as a flex item regardless of being inline-flex — without justify-content the "0 0 0" digits
2298
+ then just sit at the box's flex-start (left) edge instead of centering like the icon above
2299
+ them (EmployeeIcon centers via its own margin:0 auto, unaffected by this). */
2300
+ justify-content: center;
2266
2301
  }
2267
2302
  .md3 .dash-odo span {
2268
2303
  display: inline-flex;
@@ -2924,11 +2959,132 @@ line-height: 31px;
2924
2959
  .md3 .co-step-label {
2925
2960
  display: none;
2926
2961
  }
2927
- .md3 .co-step {
2962
+ /* CheckoutPage/CheckoutChromeNext never actually add the `.md3` class anywhere in their own
2963
+ tree (only comments say they expect it — confirmed by grep), so a lone .md3-prefixed rule
2964
+ here would silently never match on this page at all. Every rule below is written twice —
2965
+ `.md3 .x` (wins by source order over the un-media-queried base rule of the same
2966
+ specificity, IF `.md3` ever is present) and plain `.x` (the one that actually matches
2967
+ today, and stays correct even if that .md3 gap gets fixed later) — so the fix holds
2968
+ regardless of that gap. */
2969
+ .md3 .co-step,
2970
+ .co-step {
2971
+ /* was flex: none — with .co-step-label hidden two rules up, each step's only remaining
2972
+ content is a fixed 30px dot + this connector bar; flex:none sized every step off the
2973
+ bar's own min-width instead of sharing the row, and together with the bar's min-width
2974
+ below (was 24px, MORE than the base 16px) the 5 steps' combined min-content width ran
2975
+ past a 390px viewport — hence the horizontal scroll. flex:1 (matches the desktop base)
2976
+ lets them actually share the row and shrink together instead. */
2977
+ flex: 1;
2978
+ }
2979
+ /* last step (5) has no .co-step-bar (nothing after the final dot to connect to) — at an
2980
+ equal flex:1 share like its siblings, its box was still as wide as theirs, leaving empty
2981
+ trailing space after its dot that read as "not stretched to the edge". flex:none sizes it
2982
+ down to just its dot's own 30px instead, and steps 1-4 (still flex:1) automatically absorb
2983
+ the freed-up space into their own bars — dot 5 ends up flush at the row's right edge. */
2984
+ .md3 .co-step:last-child,
2985
+ .co-step:last-child {
2928
2986
  flex: none;
2929
2987
  }
2930
- .md3 .co-step-bar {
2931
- min-width: 24px;
2988
+ .md3 .co-step-bar,
2989
+ .co-step-bar {
2990
+ min-width: 10px;
2991
+ margin: 0 4px;
2992
+ }
2993
+ /* was 28px auto 4px / 0 24px (desktop values) at every width — no dedicated mobile value. */
2994
+ .md3 .co-stepper,
2995
+ .co-stepper {
2996
+ margin: 10px auto 10px;
2997
+ padding: 0 14px;
2998
+ }
2999
+ .md3 .co-header-row,
3000
+ .co-header-row {
3001
+ /* was 12px 24px (desktop) at every width — no dedicated mobile value. */
3002
+ padding: 18px 14px;
3003
+ /* was 16px (desktop) at every width. */
3004
+ gap: 10px;
3005
+ }
3006
+ .md3 .co-header-car,
3007
+ .co-header-car {
3008
+ /* was 16px (desktop) at every width. */
3009
+ gap: 10px;
3010
+ }
3011
+ .md3 .co-car-img,
3012
+ .co-car-img {
3013
+ /* was 64x44 (desktop) at every width — same 64:44 aspect ratio, just bigger. */
3014
+ width: 80px;
3015
+ height: 55px;
3016
+ }
3017
+ .md3 .co-price-amount,
3018
+ .co-price-amount {
3019
+ /* was 20px (desktop) at every width. */
3020
+ font-size: 22px;
3021
+ }
3022
+ /* "Weiter"/"Zurück" (.co-next) — the shared Button component's size="lg" gives every mbtn
3023
+ here .mbtn--lg's bigger dimensions (h:48/padding 0 30, or h:46 on phone via the NewMainPage
3024
+ mobile rule elsewhere); on this narrow a sheet that reads oversized next to the rest of the
3025
+ form. Drops back to plain .mbtn's own base sizing instead. (.co-next .mbtn--lg is already
3026
+ 2 classes, matching .md3 .mbtn--lg's own specificity, so this one didn't need the
3027
+ dual-selector treatment above to win either way.) */
3028
+ .co-next .mbtn--lg {
3029
+ height: 40px;
3030
+ padding: 0 24px;
3031
+ font-size: 14px;
3032
+ /* same size for both — flex:1 splits whatever's left on their shared row evenly, so
3033
+ Weiter's longer label/icon can't make it wider than back. */
3034
+ flex: 1;
3035
+ }
3036
+ /* .mbtn--outlined ("back"): explicit base .mbtn + .mbtn--outlined properties, not just the
3037
+ size tweak above — same reasoning as the dual-selector rules elsewhere in this block
3038
+ (CheckoutPage never actually adds the `.md3` class anywhere in its own tree), just spelled
3039
+ out in full this time since the request was for this button's whole look, not one property.
3040
+ border-radius/border are explicit per-request overrides (10px + a real 1px border), not
3041
+ the shared design-system's var(--md-r-full) pill + inset-box-shadow border trick — a
3042
+ deliberately different, more "squared" look for just this button. */
3043
+ .md3 .co-next .mbtn--outlined,
3044
+ .co-next .mbtn--outlined {
3045
+ font-family: var(--md-font);
3046
+ font-weight: 600;
3047
+ letter-spacing: 0.1px;
3048
+ display: inline-flex;
3049
+ align-items: center;
3050
+ justify-content: center;
3051
+ gap: 8px;
3052
+ white-space: nowrap;
3053
+ background: transparent;
3054
+ color: #005cff;
3055
+ /* label text renders as the literal lowercase "back" (no matching key in the CheckoutPage
3056
+ locale files — i18next falls back to printing the raw t('back') key itself), next to
3057
+ Weiter's already-capitalized German label. Fixed here rather than chasing down/adding
3058
+ the missing translation key, since the ask was specifically about the letter case. */
3059
+ text-transform: capitalize;
3060
+ border-radius: 10px;
3061
+ border: 1px solid #005cff;
3062
+ box-shadow: none;
3063
+ }
3064
+ /* .mbtn--filled ("Weiter"): hardcoded #005cff per explicit request, not var(--md-primary) —
3065
+ same dual-selector reasoning as above. */
3066
+ .md3 .co-next .mbtn--filled,
3067
+ .co-next .mbtn--filled {
3068
+ background: #005cff;
3069
+ }
3070
+ /* back on the left, Weiter on the right, on their OWN row — .co-error (when a step's
3071
+ validation fails) must not crowd into that same row with them. flex-wrap + the .co-error
3072
+ rule below (flex-basis:100%, forcing it to consume its whole line) does this with the
3073
+ existing flat DOM (.co-next's 3 children are plain siblings — .co-error, then both
3074
+ buttons, no wrapping element around the buttons) — .co-error always lands on its own
3075
+ first line, and whatever's left (the two buttons — or, when there's no error, ALL of
3076
+ .co-next's content) wraps onto the next line and is laid out by flex-direction:row same
3077
+ as before. The existing gap:8px (base .co-next, unmodified) applies between wrapped lines
3078
+ too, so the error/button rows don't need their own separate margin. */
3079
+ .md3 .co-next,
3080
+ .co-next {
3081
+ flex-direction: row;
3082
+ flex-wrap: wrap;
3083
+ align-items: stretch;
3084
+ }
3085
+ .md3 .co-next .co-error,
3086
+ .co-next .co-error {
3087
+ flex: 1 0 100%;
2932
3088
  }
2933
3089
  }
2934
3090
  /* below the 992px desktop breakpoint the content no longer reserves the 424px left
@@ -3456,12 +3612,14 @@ line-height: 31px;
3456
3612
  box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.08);
3457
3613
  }
3458
3614
  /* pin the VDP gallery's own edge offset — the base .car-arrow--l/--r rule above was moved to
3459
- 10px for the SRP card carousels; the VDP viewer keeps its original, larger offset. */
3615
+ 10px for the SRP card carousels; the VDP gallery box's OWN overlay elements (push-badges,
3616
+ favorite/share, the photo-count/activity pills) all sit at a 14px inset, so the arrows match
3617
+ that instead (was 22px — inconsistent with the rest of this box). */
3460
3618
  .md3 .vdp-gallerybox .car-arrow--l {
3461
- left: 22px;
3619
+ left: 14px;
3462
3620
  }
3463
3621
  .md3 .vdp-gallerybox .car-arrow--r {
3464
- right: 22px;
3622
+ right: 14px;
3465
3623
  }
3466
3624
 
3467
3625
  /* ==========================================================================
@@ -3657,7 +3815,7 @@ line-height: 31px;
3657
3815
  .md3 .vdp-meta > span {
3658
3816
  display: inline-flex;
3659
3817
  align-items: center;
3660
- gap: 0px;
3818
+ gap: 6px;
3661
3819
  white-space: nowrap;
3662
3820
  }
3663
3821
  .md3 .vdp-meta > span + span::before {
@@ -4082,8 +4240,7 @@ line-height: 31px;
4082
4240
  }
4083
4241
  /* consistent studio treatment for ALL inventory photos: white backdrop + soft grounding floor
4084
4242
  shadow, so mixed-source cutouts look uniform (Carvana/Cinch catalog look) */
4085
- .md3 .cc-media,
4086
- .md3 .listcard-media {
4243
+ .md3 .cc-media {
4087
4244
  background: radial-gradient(
4088
4245
  58% 13px at 50% 90%,
4089
4246
  rgba(16, 22, 42, 0.15),
@@ -4091,6 +4248,12 @@ line-height: 31px;
4091
4248
  ),
4092
4249
  #fff;
4093
4250
  }
4251
+ /* SRP list card: plain white, no floor shadow under the car — the wider (320px) media column
4252
+ made that soft dark oval read as an actual smudge/shadow under the vehicle instead of a
4253
+ subtle grounding cue. .cc-media (home/offer grid cards) keeps it, unaffected. */
4254
+ .md3 .listcard-media {
4255
+ background: #fff;
4256
+ }
4094
4257
  /* offer cards: border + soft shadow so they sit on the page (not borderless-floaty) */
4095
4258
  .md3 .cc-card {
4096
4259
  box-shadow: var(--md-e1);
@@ -4099,7 +4262,12 @@ line-height: 31px;
4099
4262
  /* horizontal listing card (SRP): image | info | price */
4100
4263
  .md3 .listcard {
4101
4264
  display: grid;
4102
- grid-template-columns: 300px 1fr 240px;
4265
+ /* media column widened 300→320 (340 was too much): at the max 6 dots (CardCarousel slices
4266
+ images to 6), the centered dots row got wide enough to visually collide with
4267
+ .cc-activity-pill (bottom-right, anchored 10px from the edge) and came within ~1.5px of
4268
+ .cc-photo-pill (bottom-left) too — both pills are anchored a fixed 10px from the column's
4269
+ edges, so a wider column pushes them further apart without anything else needing to change. */
4270
+ grid-template-columns: 320px 1fr 240px;
4103
4271
  align-items: stretch;
4104
4272
  }
4105
4273
  .md3 .listcard-media {
@@ -4491,6 +4659,19 @@ line-height: 31px;
4491
4659
  .md3 .body-tile .md-label-sm {
4492
4660
  color: var(--md-on-surface-variant);
4493
4661
  }
4662
+ /* the label span has no explicit width — as a column-flex item with align-items:center it
4663
+ sizes to its own natural (unwrapped) content width, so a long compound label like
4664
+ "SUV/Geländewagen" (no spaces to wrap at) overflows past the tile's rounded edges instead
4665
+ of wrapping to a 2nd line. Cap it to the tile's own content width so the browser is forced
4666
+ to wrap — plain `overflow-wrap: break-word` prefers a normal break point ("/" counts, same
4667
+ as "and/or" wrapping at the slash in body text) and only breaks mid-word as a genuine last
4668
+ resort. `word-break: break-word` looked like the same fix but isn't: it forces min-content
4669
+ sizing as if EVERY character were a break point, which made the browser ignore the "/" and
4670
+ split "Geländewa" / "gen" instead — do not add it back. */
4671
+ .md3 .body-tile > span:not(.md-label-sm) {
4672
+ max-width: 100%;
4673
+ overflow-wrap: break-word;
4674
+ }
4494
4675
  /* payment paths: financing hero (eyebrow + big rate + terms) on top, Leasing/Kaufen quiet rows */
4495
4676
  .md3 .pricepaths {
4496
4677
  min-width: 0;
@@ -6495,7 +6676,10 @@ line-height: 31px;
6495
6676
  gap: 24px;
6496
6677
  }
6497
6678
  .md3 .listcard {
6498
- grid-template-columns: 260px 1fr;
6679
+ /* same dots/pill clearance reasoning as the base 300→320 bump above — this range (601–980px,
6680
+ price column dropped to 1 row below) still shows the desktop pills (.hide-mobile only
6681
+ disappears at 600px), so it needs the same margin, proportionally applied. */
6682
+ grid-template-columns: 280px 1fr;
6499
6683
  }
6500
6684
  /* tablet: price column spans full width below image+info, zones stack in a normal column */
6501
6685
  .md3 .listcard-price {
@@ -7432,7 +7616,6 @@ line-height: 31px;
7432
7616
  .md3 .vdp-push-badges {
7433
7617
  gap: 6px;
7434
7618
  margin-bottom: 13px;
7435
- padding-left: 12px;
7436
7619
  }
7437
7620
  }
7438
7621
 
@@ -7750,9 +7933,14 @@ color: #005CFF;
7750
7933
  @media (max-width: 600px) {
7751
7934
  /* full-bleed gallery: edge-to-edge, app-like. Extra height (vs. the plain photo height)
7752
7935
  reserves room at the bottom for the controls row, now overlaid on the image itself
7753
- (round 15 — supersedes the earlier "no overlay" layout). */
7936
+ (round 15 — supersedes the earlier "no overlay" layout).
7937
+ -14px, not -24px: cancels .vdp-main's OWN mobile side padding (14px !important, below —
7938
+ .vdp-gallerybox's direct containing block), not the 24px desktop inline value. Was still
7939
+ -24px from before that 14px override existed, so this was overshooting the true viewport
7940
+ edge by 10px — .car-arrow/.vdp-push-badges (positioned against .cardcar, i.e. against THIS
7941
+ box) were landing ~4px from the real edge instead of their own stated 14px. */
7754
7942
  .md3 .vdp-gallerybox {
7755
- margin: 0 -24px;
7943
+ margin: 0 -14px;
7756
7944
  border-radius: 0;
7757
7945
  border: 0;
7758
7946
  box-shadow: none;
@@ -7764,11 +7952,16 @@ color: #005CFF;
7764
7952
  .md3 .vdp-gallery {
7765
7953
  position: relative;
7766
7954
  }
7767
- /* controls row now sits ON the image as an overlay, bottom-aligned (round 15) */
7955
+ /* controls row now sits ON the image as an overlay, bottom-aligned (round 15).
7956
+ left/right: 0, not 14px — unlike .vdp-gallerybox above, .vdp-gallery is NOT full-bleed
7957
+ (no negative margin of its own), so it's still sitting inside .vdp-main's 14px padding —
7958
+ that padding alone already supplies the 14px gutter from the true edge. The extra 14px
7959
+ here was stacking on top of it (14+14=28px), pulling .gallery-photos-btn/the share+save
7960
+ icons noticeably further in than .car-arrow/.vdp-push-badges' real 14px on the same edges. */
7768
7961
  .md3 .gallery-controls-row {
7769
7962
  position: absolute;
7770
- left: 14px;
7771
- right: 14px;
7963
+ left: 0;
7964
+ right: 0;
7772
7965
  bottom: 12px;
7773
7966
  z-index: 3;
7774
7967
  padding: 0;
@@ -9075,6 +9268,15 @@ color: #005CFF;
9075
9268
  line-height: 16px;
9076
9269
  color: #ffffff;
9077
9270
  }
9271
+ /* the base .mbtn .lucide rule (width/height:18px) was overriding FinanceCalculator.tsx's own
9272
+ size={23} prop on this icon — CSS width/height on an <svg> always wins over its own
9273
+ width/height attributes, so the component-level size bump alone did nothing visible.
9274
+ Scoped to just this button (not .fin-cta-row .mbtn--outlined/"Anrufen" next to it, whose
9275
+ 18px icon wasn't part of this request). */
9276
+ .md3 .fin-cta-row .mbtn--filled .lucide {
9277
+ width: 23px;
9278
+ height: 23px;
9279
+ }
9078
9280
  .md3 .fin-cta-row .mbtn--outlined {
9079
9281
  width: 108px;
9080
9282
  background: #ffffff;
@@ -9349,7 +9551,31 @@ color: #005CFF;
9349
9551
  border-radius: var(--md-r-lg, 16px);
9350
9552
  box-shadow: var(--md-e1);
9351
9553
  padding: 20px;
9352
- margin-bottom: 20px;
9554
+ margin-bottom: 15px;
9555
+ /* the legacy .section class (still applied alongside dash-card — see DashboardOverview.tsx
9556
+ and its sibling sections) sets a fixed height: 361px/195px per tier with
9557
+ display:flex/flex-direction:column; min-height:auto only raises the floor, it never cancels
9558
+ that fixed height, so once a card's content (e.g. the table after the padding fix above)
9559
+ grew past 361px it kept overflowing the rounded border instead of growing the card — that
9560
+ was the "676 more vehicles" sitting outside the block.
9561
+ height:100% (not auto) on purpose: each dash-card's immediate parent is a react-flexbox-grid
9562
+ <Col>, and every Row of <Col>s already stretches its columns to the tallest one by default
9563
+ (align-items: normal → stretch) — 100% lets the card fill that already-equalized column
9564
+ height instead of only fitting its own content, which is what keeps e.g. "Create Car" (tall
9565
+ icon + copy) and "Base data" (three short lines) sitting in the same Row at equal height with
9566
+ their bottom edges aligned, the way the old fixed-height tiers did whenever content happened
9567
+ to fit them. A lone/tallest card in a Row still just gets its own content height back, since
9568
+ 100% then resolves to exactly that — no clipping either way.
9569
+ BUT plain height:100% + margin-bottom on the SAME box fights itself: the <Col> is a flex
9570
+ item, which per spec always establishes its own block-formatting-context for its children —
9571
+ so dash-card's margin-bottom can never collapse through it into extra Row spacing, it can
9572
+ only overflow past the (exactly 100%-filled) Col edge, which does NOT push the next Row down
9573
+ — net visible gap: 0, cards touching, no matter what the margin-bottom value is set to. Carve
9574
+ the same margin OUT of the height instead (100% − margin) so box + margin together still sum
9575
+ to exactly the stretched Col height, but the box itself stops short and the margin is real,
9576
+ visible empty space above the next Row rather than overflow past this one. Keep this in sync
9577
+ with margin-bottom above if that value ever changes. */
9578
+ height: calc(100% - 15px);
9353
9579
  min-height: auto;
9354
9580
  }
9355
9581
  .md3 .dash-card-title {
@@ -9363,9 +9589,24 @@ color: #005CFF;
9363
9589
  font: 600 14px var(--md-font);
9364
9590
  color: var(--md-primary);
9365
9591
  text-decoration: none;
9592
+ /* the legacy .secondaryLink class (still applied alongside dash-link — see
9593
+ DashboardOverview.tsx) draws a bordered, uppercase, fixed-210px button; dash-link on its own
9594
+ only ever added color/font on top of that, so the "To Overview" link still rendered as the
9595
+ old boxy button underneath. Reset every legacy box property so it reads as a plain md3 text
9596
+ link. */
9597
+ width: auto;
9598
+ min-width: 0;
9599
+ padding: 0;
9600
+ border: 0;
9601
+ border-radius: 0;
9602
+ background: transparent;
9603
+ box-shadow: none;
9604
+ text-align: left;
9605
+ text-transform: none;
9366
9606
  }
9367
9607
  .md3 .dash-link:hover {
9368
9608
  text-decoration: underline;
9609
+ background: transparent;
9369
9610
  }
9370
9611
  .md3 .dash-link--primary {
9371
9612
  background: var(--md-primary);
@@ -9378,27 +9619,82 @@ color: #005CFF;
9378
9619
  text-decoration: none;
9379
9620
  opacity: 0.92;
9380
9621
  }
9622
+ /* DashboardBaseData/CreateCar/DataExport/DataImport render their one trailing link/button as a
9623
+ DIRECT child of the dash-card section (no wrapper) — .dash-card is display:flex;
9624
+ flex-direction:column (from the legacy .section class), so margin-top:auto on it consumes all
9625
+ the column's leftover space and pins it flush to the card's bottom edge, matching cards whose
9626
+ content happens to fill the row exactly. Scoped to a DIRECT child so it does NOT reach
9627
+ DashboardEmployee's two links — those sit inside .linksWrapper (its own already-working
9628
+ margin-top:auto), one level deeper, so this selector never matches them. */
9629
+ .md3 .dash-card > .dash-link {
9630
+ margin-top: auto;
9631
+ }
9632
+ /* the legacy DealerDashboardPage__table/__tableRow classes (still applied alongside dash-table —
9633
+ see DashboardOverview.tsx) render each cell as its own coloured, gapped box (border-spacing:
9634
+ separate + a per-cell background) and split the columns 55/15/15/15/15 (=115%, doesn't even
9635
+ add up) under table-layout:fixed — that's the mismatched-column / disjointed-boxes look
9636
+ reported live. Every property that leaked through below is reset here at matching or higher
9637
+ specificity so it can't win regardless of stylesheet load order. */
9381
9638
  .md3 .dash-table {
9639
+ /* legacy .table also sets width:calc(100% + 30px) + margin-left/right:-15px — a full-bleed
9640
+ trick tuned for .section's OLD 15px padding. width:100% above already wins on specificity,
9641
+ but the negative margins were never a competing declaration (nothing here set `margin`), so
9642
+ they kept shifting the whole table 15px left, leaving a lopsided ~15px extra gap on the
9643
+ right — that's the "more to the left" look reported live. */
9644
+ margin: 0;
9382
9645
  width: 100%;
9383
9646
  border-collapse: collapse;
9647
+ table-layout: fixed;
9384
9648
  font-size: 13.5px;
9385
9649
  }
9650
+ .md3 .dash-table th,
9651
+ .md3 .dash-table td {
9652
+ background: transparent;
9653
+ width: auto;
9654
+ padding: 10px 12px;
9655
+ text-align: center;
9656
+ }
9386
9657
  .md3 .dash-table th {
9387
- text-align: left;
9388
9658
  font: 600 12.5px var(--md-font);
9389
9659
  color: var(--md-on-surface-variant);
9390
- padding: 6px 8px;
9660
+ padding-top: 6px;
9661
+ padding-bottom: 6px;
9391
9662
  border-bottom: 1px solid var(--md-outline-variant);
9663
+ white-space: nowrap;
9392
9664
  }
9393
9665
  .md3 .dash-table td {
9394
- padding: 9px 8px;
9395
9666
  border-bottom: 1px solid var(--md-outline-variant);
9396
9667
  color: var(--md-on-surface);
9397
9668
  vertical-align: middle;
9398
9669
  }
9670
+ /* :first-child/:last-child match the legacy rules' own specificity tier (class + pseudo-class +
9671
+ element) — a plain .dash-table td here would only TIE the legacy selector and could lose on
9672
+ load order, so `width` is repeated on :first-child rather than left to the shared rule above. */
9673
+ .md3 .dash-table th:first-child,
9674
+ .md3 .dash-table td:first-child {
9675
+ /* the 3 middle (★/24h/Total) columns stay unset — table-layout:fixed shares whatever's left
9676
+ over (100 - 46 - 10 = 44, ~14.7% each) evenly between them. */
9677
+ width: 46%;
9678
+ text-align: left;
9679
+ padding-left: 0;
9680
+ }
9681
+ .md3 .dash-table th:last-child,
9682
+ .md3 .dash-table td:last-child {
9683
+ width: 10%;
9684
+ text-align: right;
9685
+ padding-right: 0;
9686
+ }
9399
9687
  .md3 .dash-table tr:last-child td {
9400
9688
  border-bottom: 0;
9401
9689
  }
9690
+ .md3 .dash-table tbody tr:hover td {
9691
+ background: var(--md-sc-low);
9692
+ }
9693
+ /* StarIcon's <path> has no fill prop (defaults to solid black) — pick up the same muted grey as
9694
+ the rest of the header text instead of a heavy pure-black star. */
9695
+ .md3 .dash-table th [class*="tableIcon"] {
9696
+ fill: var(--md-on-surface-variant);
9697
+ }
9402
9698
 
9403
9699
  /* ============================================================================
9404
9700
  LEGACY → md3 IN-PLACE BRIDGE (account / dealer / admin pages)
@@ -168,7 +168,12 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
168
168
  className="cardcar-dots"
169
169
  style={{
170
170
  position: 'absolute',
171
- bottom: 23,
171
+ // vertically centered against the sibling bottom-left/right pills (.cc-photo-pill /
172
+ // .cc-activity-pill, both height:30 at bottom:10 → vertical center at 25px from the
173
+ // card's bottom edge): dots are 7px tall, so bottom:21.5 puts THEIR center at
174
+ // 21.5 + 3.5 = 25 too. Shared by CarCard (grid) and CarRow (list) alike — both use
175
+ // the same pill sizing/position, so one value serves both.
176
+ bottom: 21.5,
172
177
  left: -5,
173
178
  right: 0,
174
179
  display: 'flex',
package/ui/IconButton.tsx CHANGED
@@ -14,6 +14,19 @@ export interface IconButtonProps {
14
14
  active?: boolean;
15
15
  size?: number;
16
16
  style?: React.CSSProperties;
17
+ /** native disabled — also suppresses :hover (.icon-btn:hover:not(:disabled), md3.css), so a
18
+ * visually-dimmed/at-limit button (e.g. VdpViewer's zoom in/out at ZOOM_MIN/MAX) doesn't
19
+ * still show a hover fill implying it's clickable when the action would be a no-op anyway. */
20
+ disabled?: boolean;
21
+ /**
22
+ * Extra class(es) appended after 'icon-btn'/'icon-btn--filled'. Prefer this over `style` for
23
+ * anything that should still respond to `:hover` — an inline `style` background always beats
24
+ * a stylesheet rule (the base `.icon-btn:hover` included), so a caller reaching for `style` to
25
+ * tint the button silently kills its own hover in the process. A class-based tint doesn't:
26
+ * `.icon-btn:hover` is more specific than a single extra class, so it keeps winning on hover
27
+ * for free (see VdpViewer.tsx's `.vdp-viewer-light`/`.vdp-viewer-contact` for exactly this).
28
+ */
29
+ className?: string;
17
30
  }
18
31
 
19
32
  const IconButton: React.FunctionComponent<IconButtonProps> = ({
@@ -25,12 +38,15 @@ const IconButton: React.FunctionComponent<IconButtonProps> = ({
25
38
  active,
26
39
  size = 20,
27
40
  style,
41
+ className,
42
+ disabled,
28
43
  }) => (
29
44
  <button
30
- className={'icon-btn' + (filled ? ' icon-btn--filled' : '')}
45
+ className={'icon-btn' + (filled ? ' icon-btn--filled' : '') + (className ? ` ${className}` : '')}
31
46
  onClick={onClick}
32
47
  aria-label={label}
33
48
  style={style}
49
+ disabled={disabled}
34
50
  >
35
51
  <Icon
36
52
  name={name}
@@ -1009,10 +1009,14 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
1009
1009
  variant="filled"
1010
1010
  size="lg"
1011
1011
  className={secured ? 'fin-cta-secured' : undefined}
1012
- style={{ width: 216, height: 50, paddingLeft: 0, paddingRight: 9, gap: 18 }}
1012
+ style={{ width: 216, height: 50, paddingLeft: 0, paddingRight: 9, gap: secured ? 3 : 18 }}
1013
1013
  onClick={onSecure}
1014
1014
  >
1015
- {secured ? <Icon name="shield-check" size={18} /> : <DocCheckIcon size={23} />}
1015
+ {/* size 23 either way — was 18 for the secured (shield-check) state, notably
1016
+ smaller than DocCheckIcon's 23 in the default state despite sharing the exact
1017
+ same 50px button box; nothing about switching to "already secured" should
1018
+ shrink the icon. */}
1019
+ {secured ? <Icon name="shield-check" size={23} /> : <DocCheckIcon size={23} />}
1016
1020
  {secured && secureLabel ? secureLabel : t('autobahn:finance.secureOffer')}
1017
1021
  </Button>
1018
1022
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
@@ -252,7 +252,7 @@ export const VdpHeaderCondition: React.FunctionComponent<VdpHeaderCardProps> = (
252
252
  * splits these 3 pieces across the "before/after finance card" layout — see the page).
253
253
  */
254
254
  export const VdpHeaderCard: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => (
255
- <div className="mcard" style={{ marginTop: 16, padding: '22px 24px 18px' }}>
255
+ <div className="mcard" style={{ marginTop: 16, padding: '18px 14px 18px' }}>
256
256
  <VdpHeaderSummary vehicle={vehicle} t={t} />
257
257
  <div style={{ marginTop: 14 }}>
258
258
  <VdpHeaderHighlights vehicle={vehicle} t={t} />
@@ -1424,18 +1424,12 @@ 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;
1433
1427
  /** i18n function (drilled from the page). */
1434
1428
  t: TranslationFunction;
1435
1429
  }
1436
1430
 
1437
1431
  /** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
1438
- export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, monthly, productLabel, t }) => (
1432
+ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
1439
1433
  <div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
1440
1434
  <div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
1441
1435
  <img
@@ -1461,9 +1455,9 @@ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehic
1461
1455
  </div>
1462
1456
  <div style={{ textAlign: 'right', flex: 'none' }}>
1463
1457
  <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
1464
- <span className="md-label-sm">{productLabel || t('autobahn:sticky.from')}</span>
1458
+ <span className="md-label-sm">{t('autobahn:sticky.from')}</span>
1465
1459
  <span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
1466
- {monthly ?? vehicle.monthly} €
1460
+ {vehicle.monthly} €
1467
1461
  </span>
1468
1462
  <span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
1469
1463
  </div>
package/vdp/VdpViewer.tsx CHANGED
@@ -761,9 +761,6 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
761
761
  return () => window.removeEventListener('resize', measure);
762
762
  }, [images.length, layout]);
763
763
 
764
- // Desktop-only styles, unchanged from the original layout.
765
- const iconBtnStyle: React.CSSProperties = { background: 'var(--md-sc-lowest, #fff)', boxShadow: 'var(--md-e1)' };
766
- const contactBtnStyle: React.CSSProperties = { background: 'var(--md-primary)', boxShadow: 'var(--md-e1)' };
767
764
 
768
765
  const overlay = (
769
766
  <div className="md3">
@@ -1469,45 +1466,67 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1469
1466
  {i + 1} / {n}
1470
1467
  </span>
1471
1468
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1469
+ {/* background/shadow now come from vdp-viewer-light/vdp-viewer-contact CSS classes,
1470
+ not inline `style` (that used to be iconBtnStyle/contactBtnStyle, see their old
1471
+ definitions above) — an inline style background permanently pins it, blocking
1472
+ :hover entirely (that's what killed hover on ALL of these, close included, until
1473
+ close got fixed the same way just above). Zoom's opacity IS still inline — that
1474
+ part is genuinely per-render dynamic (zoom-limit state), and plain opacity
1475
+ doesn't fight :hover the way background did. `disabled` at the zoom limit is
1476
+ new too — was opacity-only before, so the button still showed a hover fill at
1477
+ ZOOM_MIN/MAX implying it was clickable when zoomOut/zoomIn there is a no-op;
1478
+ the portrait/landscape zoom buttons further up already use real `disabled` the
1479
+ same way (:disabled also suppresses :hover, see .icon-btn:hover:not(:disabled)
1480
+ in md3.css), this desktop pair was just the one place still missing it. */}
1472
1481
  <IconButton
1473
1482
  name="minus"
1474
1483
  label={t('autobahn:viewer.zoomOutLabel')}
1475
1484
  onClick={zoomOut}
1476
- style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MIN ? 0.4 : 1 }}
1485
+ className="vdp-viewer-light"
1486
+ style={{ opacity: zoom === ZOOM_MIN ? 0.4 : 1 }}
1487
+ disabled={zoom === ZOOM_MIN}
1477
1488
  />
1478
1489
  <IconButton
1479
1490
  name="plus"
1480
1491
  label={t('autobahn:viewer.zoomInLabel')}
1481
1492
  onClick={zoomIn}
1482
- style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
1493
+ className="vdp-viewer-light"
1494
+ style={{ opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
1495
+ disabled={zoom === ZOOM_MAX}
1483
1496
  />
1484
1497
  {has360 && (
1485
1498
  // IconButton only takes a lucide `name` — Icon360 is a one-off inlined SVG (see
1486
- // its own comment above), so this is IconButton's own markup (.icon-btn + the
1487
- // same iconBtnStyle) built by hand instead of a name it can't render.
1499
+ // its own comment above), so this is IconButton's own markup (.icon-btn +
1500
+ // vdp-viewer-light) built by hand instead of a name it can't render.
1488
1501
  <button
1489
1502
  type="button"
1490
- className="icon-btn"
1503
+ className="icon-btn vdp-viewer-light"
1491
1504
  aria-label={t('autobahn:viewer.threeSixtyLabel')}
1492
1505
  onClick={() => on360 && on360()}
1493
- style={iconBtnStyle}
1494
1506
  >
1495
1507
  <Icon360 color="currentColor" />
1496
1508
  </button>
1497
1509
  )}
1498
1510
  {onEmail && (
1499
- <IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
1511
+ <IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} className="vdp-viewer-contact" color="#fff" />
1500
1512
  )}
1501
1513
  {onCall && (
1502
1514
  <IconButton
1503
1515
  name="phone"
1504
1516
  label={t('autobahn:viewer.callLabel', { phone })}
1505
1517
  onClick={onCall}
1506
- style={contactBtnStyle}
1518
+ className="vdp-viewer-contact"
1507
1519
  color="#fff"
1508
1520
  />
1509
1521
  )}
1510
- <IconButton name="x" filled label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
1522
+ {/* was `filled` (.icon-btn--filled forces a hard 34×34, !important) — every other
1523
+ button in this row (zoom/360/email/call) sits on the plain .icon-btn 40×40 box,
1524
+ so `filled` made this one visibly smaller than its neighbors. size=24 (was the
1525
+ IconButton default, 20) scales the X up with the button so it reads just as
1526
+ filled inside the now-bigger circle as it did in the old 34px one. Same
1527
+ vdp-viewer-light CLASS the zoom/360 buttons use (not an inline `style`) so
1528
+ :hover keeps working — see the className doc comment on IconButton itself. */}
1529
+ <IconButton name="x" size={24} className="vdp-viewer-light" label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
1511
1530
  </div>
1512
1531
  </div>
1513
1532
  <div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>