@bytebrand/fe-ui-core-autobahn 1.0.60 → 1.0.62

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
@@ -18,7 +18,7 @@
18
18
  import React, { useState } from 'react';
19
19
  import { TranslationFunction } from '../types';
20
20
 
21
- import { CardCarousel, IconButton, StatChip, SaveChip, Button } from '../ui';
21
+ import { CardCarousel, IconButton, SaveChip, Button } from '../ui';
22
22
  import {
23
23
  SpecLine,
24
24
  EcoLine,
@@ -28,15 +28,18 @@ import {
28
28
  OfferBadge,
29
29
  PriceTabs,
30
30
  PriceHero,
31
- PriceMeter,
31
+ PriceEvalCol,
32
+ PriceTerms,
32
33
  CardCtaRow,
33
34
  GalleryCounter,
35
+ GalleryActivityPill,
34
36
  cardSpec,
35
37
  PaymentMode,
36
- ratingTone,
37
38
  callDealerSalesPhone,
39
+ DEALER_SALES_PHONE,
38
40
  } from './cardParts';
39
41
  import { AutobahnCar } from './types';
42
+ import VdpViewer from '../vdp/VdpViewer';
40
43
 
41
44
  export interface CarCardProps {
42
45
  /** fully-resolved car view-model. */
@@ -85,6 +88,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
85
88
  }) => {
86
89
  const [mode, setMode] = useState<PaymentMode>('financing');
87
90
  const [photoIndex, setPhotoIndex] = useState(0);
91
+ const [viewerOpen, setViewerOpen] = useState(false);
88
92
  const showSave = car.savings != null && car.savings > 600;
89
93
  const showOfferBadge = !!(car.isNew || car.drop != null || showSave || car.dropPct != null || car.zeroDown);
90
94
  return (
@@ -102,6 +106,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
102
106
  <CardCarousel images={car.gallery} height="100%" fit="contain" onChange={setPhotoIndex} />
103
107
  {/* max two overlays: heart + ONE deal signal — rating moved next to the price */}
104
108
  <div
109
+ className="cc-fav"
105
110
  style={{ position: 'absolute', top: 6, right: 6, zIndex: 3 }}
106
111
  onClick={(e) => e.stopPropagation()}
107
112
  >
@@ -112,6 +117,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
112
117
  SaveChip; each only renders when its real data exists. */}
113
118
  {(showOfferBadge || car.dropPct != null) && (
114
119
  <div
120
+ className="cc-badges"
115
121
  style={{
116
122
  position: 'absolute',
117
123
  top: 10,
@@ -133,12 +139,22 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
133
139
  <OfferBadge show={showOfferBadge} t={t} />
134
140
  </div>
135
141
  )}
136
- {/* bottom-left: expand icon + "current|total" photo counter (Figma) */}
137
- <div style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}>
138
- <GalleryCounter current={photoIndex + 1} total={car.photos} />
142
+ {/* bottom-left: expand icon + "current | total" photo counter (Figma light pill, node
143
+ 281:1016) — opens the full VdpViewer lightbox; stopPropagation keeps the click off
144
+ the card's own onOpen (React portals still bubble through the React tree, not the
145
+ DOM tree, so this guard is needed even though VdpViewer renders via createPortal). */}
146
+ <div
147
+ style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}
148
+ onClick={(e) => e.stopPropagation()}
149
+ >
150
+ <GalleryCounter current={photoIndex + 1} total={car.photos} light onClick={() => setViewerOpen(true)} />
151
+ </div>
152
+ {/* bottom-right: eye/heart activity pill — real stats only (Figma "Push Chips / Likes", node 297:1037) */}
153
+ <div style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
154
+ <GalleryActivityPill viewsCount={car.viewsCount} favoritesCount={car.favoritesCount} />
139
155
  </div>
140
156
  </div>
141
- <div style={{ padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 9, flex: 1 }}>
157
+ <div style={{ padding: '10px 18px', display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
142
158
  <div>
143
159
  <div className="md-title" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
144
160
  {car.make} {car.model}
@@ -148,23 +164,30 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
148
164
  </div>
149
165
  </div>
150
166
  <SpecLine items={cardSpec(car)} oneLine />
151
- <div style={{ flex: 1 }} />
152
- <div className="cc-price" style={{ marginTop: 4, borderTop: '1px solid var(--md-outline-variant)', paddingTop: 10 }}>
167
+ <div className="cc-price" style={{ marginTop: 4, borderTop: '1px solid var(--md-outline-variant)' }}>
153
168
  <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
154
169
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
155
170
  <PriceHero car={car} mode={mode} t={t} />
156
- {car.rating && (
157
- <StatChip
158
- tone={ratingTone(car.rating)}
159
- icon={ratingTone(car.rating) === 'good' ? 'check' : 'minus'}
160
- >
161
- {t('autobahn:tier.' + car.rating)}
162
- </StatChip>
163
- )}
171
+ <PriceEvalCol car={car} t={t} />
164
172
  </div>
165
- <PriceMeter car={car} t={t} />
173
+ {/* same fallback CardCtaRow already uses below: pages that haven't wired a real
174
+ "Rate anpassen" sheet yet still get a working "Details" link (opens the car). */}
175
+ <PriceTerms car={car} mode={mode} onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))} t={t} />
166
176
  </div>
167
- <EcoLine t={t} car={car} style={{ marginTop: 8 }} />
177
+ <EcoLine
178
+ t={t}
179
+ car={car}
180
+ style={{
181
+ marginTop: 'auto',
182
+ fontFamily: 'Arial, Helvetica, sans-serif',
183
+ fontStyle: 'normal',
184
+ fontWeight: 400,
185
+ fontSize: 11,
186
+ lineHeight: '11px',
187
+ textAlign: 'justify',
188
+ color: '#0e2238',
189
+ }}
190
+ />
168
191
  {/* Mobile-only row (CardCtaRow itself carries the show-mobile class) — always
169
192
  rendered, with real fallbacks the page can override once it wires its own
170
193
  handlers (e.g. the "Rate anpassen" FinanceDetailsModal sheet). Never hidden. */}
@@ -191,6 +214,19 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
191
214
  </div>
192
215
  )}
193
216
  </div>
217
+ {viewerOpen && (
218
+ <div onClick={(e) => e.stopPropagation()}>
219
+ <VdpViewer
220
+ images={car.galleryLarge || car.gallery}
221
+ start={photoIndex}
222
+ onClose={() => setViewerOpen(false)}
223
+ t={t}
224
+ phone={DEALER_SALES_PHONE}
225
+ onCall={onCall || callDealerSalesPhone}
226
+ onEmail={onRequestOffer || (() => onOpen(car))}
227
+ />
228
+ </div>
229
+ )}
194
230
  </article>
195
231
  );
196
232
  };
package/cards/CarRow.tsx CHANGED
@@ -107,14 +107,28 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggl
107
107
  {car.isNew && <span className="neu-badge">NEU</span>}
108
108
  {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
109
109
  <ZeroDownChip t={t} show={car.zeroDown} compact className="show-mobile" />
110
- <ZeroDownChip t={t} show={car.zeroDown} className="hide-mobile" />
110
+ <ZeroDownChip
111
+ t={t}
112
+ show={car.zeroDown}
113
+ className="hide-mobile"
114
+ style={{
115
+ height: 30,
116
+ padding: '0 10px',
117
+ borderRadius: 15,
118
+ background: 'rgba(0, 92, 255, 0.2)',
119
+ border: '1px solid rgba(0, 92, 255, 0.05)',
120
+ color: '#005CFF',
121
+ fontSize: 13,
122
+ fontWeight: 700,
123
+ }}
124
+ />
111
125
  <PercentChip pct={car.dropPct} />
112
126
  </div>
113
127
  <OfferBadge show={showOfferBadge} t={t} />
114
128
  </div>
115
- {/* bottom-left: expand icon + "current|total" (Figma) — replaces the old plain photo-count
116
- pill; GalleryBadges (eye/heart/trust) keeps its own spot bottom-right, unchanged. */}
117
- <div style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}>
129
+ {/* mobile: expand icon + "current|total" pill, bottom-left; desktop uses its own lighter
130
+ counter chip further down (combined with the eye/heart chip). */}
131
+ <div className="show-mobile" style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}>
118
132
  <GalleryCounter current={photoIndex + 1} total={car.photos} />
119
133
  </div>
120
134
  <div
@@ -124,27 +138,58 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggl
124
138
  >
125
139
  <IconButton name="heart" filled active={isFavorite} onClick={onToggleFavorite} label={t('autobahn:card.action.save')} />
126
140
  </div>
127
- {/* desktop: heart sits on the photo itself (Figma "mcard"), not next to the title. */}
141
+ {/* activity/trust badges — mobile only (dark photo-count pill + eye/heart/trust pills);
142
+ desktop replaces this whole row with the combined light chips below (Figma "mcard" —
143
+ the heart itself lives in the info column's title row on desktop, per the Figma export,
144
+ not on the photo). */}
145
+ <div className="show-mobile">
146
+ <GalleryBadges car={car} t={t} />
147
+ </div>
148
+ {/* desktop: single row — light "1|N" photo counter + a combined eye/heart chip, no
149
+ availability/Geprüft pill (not present in the Figma "mcard" export). */}
128
150
  <div
129
- onClick={(e) => e.stopPropagation()}
130
151
  className="hide-mobile"
131
- style={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}
152
+ style={{ position: 'absolute', bottom: 12, left: 12, zIndex: 3, display: 'flex', alignItems: 'center', gap: 6 }}
132
153
  >
133
- <IconButton name="heart" active={isFavorite} onClick={onToggleFavorite} label={t('autobahn:card.action.save')} />
154
+ {car.photos > 1 && (
155
+ <span className="mcard-photo-chip">
156
+ <Icon name="maximize" size={16} stroke={1.7} />
157
+ {photoIndex + 1}|{car.photos}
158
+ </span>
159
+ )}
160
+ {(!!car.viewsCount || !!car.favoritesCount) && (
161
+ <span className="mcard-photo-chip">
162
+ {!!car.viewsCount && (
163
+ <>
164
+ <Icon name="eye" size={16} stroke={1.7} />
165
+ {car.viewsCount}
166
+ </>
167
+ )}
168
+ {!!car.favoritesCount && (
169
+ <>
170
+ <Icon name="heart" size={16} stroke={1.7} />
171
+ {car.favoritesCount}
172
+ </>
173
+ )}
174
+ </span>
175
+ )}
134
176
  </div>
135
- {/* activity/trust badges — overlaid on the studio-white floor at the bottom of the photo
136
- itself (PM ref), not a separate divided strip below it. Bottom-right, raised above the
137
- carousel dots and the photo-count badge (positioning lives in .gallery-badges, md3.css). */}
138
- <GalleryBadges car={car} t={t} />
139
177
  </div>
140
178
 
141
179
  <div className="listcard-info">
142
- <div style={{ minWidth: 0 }}>
143
- <div className="md-title" style={{ fontSize: 18, lineHeight: 1.2 }}>
144
- {car.make} {car.model}
180
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
181
+ <div style={{ minWidth: 0 }}>
182
+ <div className="md-title" style={{ fontSize: 18, lineHeight: 1.2 }}>
183
+ {car.make} {car.model}
184
+ </div>
185
+ <div className="md-body-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
186
+ {car.trim}
187
+ </div>
145
188
  </div>
146
- <div className="md-body-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
147
- {car.trim}
189
+ {/* desktop only — the Figma export places the heart here (title row), not on the photo;
190
+ mobile keeps its own heart on the photo (show-mobile, above), untouched. */}
191
+ <div onClick={(e) => e.stopPropagation()} className="hide-mobile">
192
+ <IconButton name="heart" active={isFavorite} onClick={onToggleFavorite} label={t('autobahn:card.action.save')} />
148
193
  </div>
149
194
  </div>
150
195
  <div className="specstrip">
@@ -14,13 +14,13 @@
14
14
  // ('Daten folgt') marker instead of inventing a number — see PricePaths'
15
15
  // market band and PpActivity. DropChip renders nothing when there is no drop.
16
16
 
17
- import React from 'react';
17
+ import React, { useState } from 'react';
18
18
  import { TranslationFunction } from '../types';
19
19
  import { defaultConfig } from '@bytebrand/fe-financing';
20
20
 
21
21
  import { Icon, Button } from '../ui';
22
- import { DocCheckIcon } from '../vdp/FinanceCalculator';
23
- import { CO2_CLASSES, CO2_COLORS } from '../constants';
22
+ import { DocCheckIcon, EvalBar } from '../vdp/FinanceCalculator';
23
+ import { CO2_CLASSES, CO2_COLORS, TIER_KEYS, TIER_COLORS } from '../constants';
24
24
  import { AutobahnCar } from './types';
25
25
  // pure, non-React — shared with the Meta AIA marketing-image renderer (a plain Node service).
26
26
  export { ratingTone } from '../data/toAutobahnCar';
@@ -35,6 +35,7 @@ const _finMonths = _finCfg.rawPaybackPeriod || _finCfg.paybackPeriod || 48;
35
35
  /** leasing terms fallbacks (used when the car doesn't carry its own), e.g. "10.000 km/Jahr · 48 Monate". */
36
36
  const _leasPeriod = _leasCfg.rawPaybackPeriod || _leasCfg.paybackPeriod;
37
37
  const _leasMileage = _leasCfg.annualMileage || _leasCfg.mileage;
38
+ const _leasFirstInstallment = (_leasCfg.firstInstallment || 0).toLocaleString('de-DE');
38
39
 
39
40
  /** mileage → 'X.XXX km' (de-DE grouping). */
40
41
  export function fmtKm(n: number): string {
@@ -90,6 +91,7 @@ export interface SpecLineProps {
90
91
  /** Compact pipe-separated spec line (AutoScout pattern). */
91
92
  export const SpecLine: React.FunctionComponent<SpecLineProps> = ({ items, size = 12.5, oneLine = false }) => (
92
93
  <div
94
+ className="cc-spec"
93
95
  style={{
94
96
  display: 'flex',
95
97
  flexWrap: oneLine ? 'nowrap' : 'wrap',
@@ -189,6 +191,7 @@ export const DropChip: React.FunctionComponent<DropChipProps> = ({ amount, t })
189
191
  }
190
192
  return (
191
193
  <span
194
+ className="deal-chip deal-chip--drop"
192
195
  style={{
193
196
  display: 'inline-flex',
194
197
  alignItems: 'center',
@@ -226,7 +229,11 @@ const dealPill: React.CSSProperties = {
226
229
  /** Percent price-drop chip (−X%). Red pill — pairs with DropChip. Renders nothing without a real pct. */
227
230
  export const PercentChip: React.FunctionComponent<{ pct?: number }> = ({ pct }) => {
228
231
  if (pct == null || pct <= 0) return null;
229
- return <span style={{ ...dealPill, background: 'var(--md-error)', color: '#fff' }}>−{pct}%</span>;
232
+ return (
233
+ <span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff' }}>
234
+ −{pct}%
235
+ </span>
236
+ );
230
237
  };
231
238
 
232
239
  /**
@@ -239,12 +246,15 @@ export const ZeroDownChip: React.FunctionComponent<{
239
246
  compact?: boolean;
240
247
  t: TranslationFunction;
241
248
  className?: string;
242
- }> = ({ show, compact, t, className }) => {
249
+ /** overrides the default pill look (e.g. the desktop "mcard" full-text badge's own blue tint/size
250
+ * from the Figma export) — merged last so it wins over the shared dealPill defaults below. */
251
+ style?: React.CSSProperties;
252
+ }> = ({ show, compact, t, className, style }) => {
243
253
  if (!show) return null;
244
254
  return (
245
255
  <span
246
256
  className={className}
247
- style={{ ...dealPill, background: 'var(--md-accent-container)', color: 'var(--md-on-accent-container)' }}
257
+ style={{ ...dealPill, background: 'var(--md-accent-container)', color: 'var(--md-on-accent-container)', ...style }}
248
258
  >
249
259
  {compact ? t('autobahn:card.price.zeroDownCompact') : t('autobahn:card.price.zeroDown')}
250
260
  </span>
@@ -378,6 +388,48 @@ export const PriceMeter: React.FunctionComponent<{ car: AutobahnCar; t: Translat
378
388
  return null;
379
389
  };
380
390
 
391
+ /**
392
+ * Preisbewertung pill + segmented tier bar, grouped in one column exactly like the VDP
393
+ * finance-calculator's mobile .fin-eval-col (FinanceCalculator.tsx/EvalBar) — the grid card's
394
+ * replacement for the old StatChip pill + full-width gradient .pp-bar pairing. Renders nothing
395
+ * without a rating (needs-data: never invent a tier).
396
+ */
397
+ export const PriceEvalCol: React.FunctionComponent<{ car: AutobahnCar; t: TranslationFunction }> = ({ car, t }) => {
398
+ const tier = car.rating ? TONE_TO_TIER[car.rating] : undefined;
399
+ if (tier == null) return null;
400
+ const hasMarket =
401
+ car.marketLow != null && car.marketAvg != null && car.marketHigh != null && car.marketHigh > car.marketLow;
402
+ // same continuous market-position math as PriceMeter's knob, reused as EvalBar's leftPct override.
403
+ const leftPct = hasMarket
404
+ ? Math.max(4, Math.min(96, ((car.price - car.marketLow!) / (car.marketHigh! - car.marketLow!)) * 100))
405
+ : undefined;
406
+ const isGood = TIER_KEYS[tier] === 'good';
407
+ // both green tiers ('top' and 'good') collapse onto one "Top-Preis" claim (Figma "similar
408
+ // cars" card) — same convention the VDP FinanceCalculator now also uses for its own pill.
409
+ // footnote 4 carries the disclaimer text, same convention as the hero price's 1/2/3 markers.
410
+ const tierLabel = isGood ? t('autobahn:tier.top') : t('autobahn:tier.' + car.rating);
411
+ // 'good' collapses onto the 'top' tier's own color too (#168a45), matching the fixed
412
+ // "Top-Preis" text above rather than tier 1's lighter TIER_COLORS[1] green.
413
+ const pillColor = isGood ? TIER_COLORS[0] : TIER_COLORS[tier];
414
+ return (
415
+ <div className="fin-eval-col">
416
+ <div className="fin-eval-pill-cell">
417
+ <span
418
+ className={`fin-eval-pill${isGood ? ' fin-eval-pill--good' : ''}`}
419
+ style={{ color: pillColor, background: `color-mix(in srgb, ${pillColor} 16%, transparent)` }}
420
+ aria-label={t('autobahn:finance.priceRatingAria', { rating: tierLabel })}
421
+ >
422
+ {tierLabel}
423
+ {isGood && <sup>4</sup>}
424
+ </span>
425
+ </div>
426
+ <div className="fin-eval-bar-cell">
427
+ <EvalBar tier={tier} leftPct={leftPct} />
428
+ </div>
429
+ </div>
430
+ );
431
+ };
432
+
381
433
  export type PaymentMode = 'financing' | 'leasing' | 'buy';
382
434
 
383
435
  export interface PriceTabsProps {
@@ -425,6 +477,50 @@ export interface PriceHeroProps {
425
477
  t: TranslationFunction;
426
478
  }
427
479
 
480
+ /** footnote badge + click-to-open popover, shared by the Finanzierung/Leasing "UVP-Rate" (³) and
481
+ * Kaufen "Hersteller-UVP" (²) captions on the hero price — same circle/popover, different digit
482
+ * and body copy. */
483
+ const UvpBadge: React.FunctionComponent<{
484
+ n: number;
485
+ open: boolean;
486
+ onToggle: () => void;
487
+ onClose: () => void;
488
+ body: string;
489
+ closeLabel: string;
490
+ }> = ({ n, open, onToggle, onClose, body, closeLabel }) => (
491
+ <>
492
+ <span
493
+ className="pp-eyebrow-badge"
494
+ role="button"
495
+ tabIndex={0}
496
+ onClick={(e) => {
497
+ e.stopPropagation();
498
+ onToggle();
499
+ }}
500
+ >
501
+ {n}
502
+ </span>
503
+ {open && (
504
+ <div className="pp-uvp-tooltip" onClick={(e) => e.stopPropagation()}>
505
+ <button
506
+ type="button"
507
+ className="pp-uvp-tooltip-close"
508
+ aria-label={closeLabel}
509
+ onClick={(e) => {
510
+ e.stopPropagation();
511
+ onClose();
512
+ }}
513
+ >
514
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
515
+ <path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
516
+ </svg>
517
+ </button>
518
+ <div className="pp-uvp-tooltip-body">{body}</div>
519
+ </div>
520
+ )}
521
+ </>
522
+ );
523
+
428
524
  /**
429
525
  * Hero price for the active tab — Finanzierung/Leasing show a monthly rate ("z.B. … €/mtl.")
430
526
  * with its own real struck-through old rate (getFinancing/getLeasing `oldMonthlyInstallment`,
@@ -434,26 +530,69 @@ export interface PriceHeroProps {
434
530
  * their legal text lives in the page's own footnote block, not duplicated per-card.
435
531
  */
436
532
  export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode, t }) => {
533
+ const [uvpTooltipOpen, setUvpTooltipOpen] = useState(false);
437
534
  const isMoney = mode === 'buy';
438
535
  const value = mode === 'financing' ? car.monthly : mode === 'leasing' ? (car.lease as number) : car.price;
439
536
  const oldValue = mode === 'financing' ? car.oldMonthly : mode === 'leasing' ? car.oldLease : car.listPrice;
440
537
  const showOld = oldValue != null && oldValue > value;
538
+ // shrink the 65px hero rate for long values (e.g. 1.234 €) so it doesn't overflow the card —
539
+ // same digit-length technique as FinanceCalculator's own fin-rate-hero--len4/--len5/--len6.
540
+ const rateDigits = !isMoney ? String(Math.round(value)).length : 0;
541
+ const rateLenClass =
542
+ rateDigits >= 6 ? ' pp-hero-val--len6' : rateDigits === 5 ? ' pp-hero-val--len5' : rateDigits === 4 ? ' pp-hero-val--len4' : '';
543
+ // Kaufen: 6/7-digit purchase prices (e.g. 113.249 € / 1.553.249 €) shrink on desktop only —
544
+ // separate from the financing/leasing --len6 above since the desktop Kaufen sizes don't match
545
+ // the shared rate-based scale.
546
+ const buyDigits = isMoney ? String(Math.round(value)).length : 0;
547
+ const buyLen6Class = buyDigits >= 7 ? ' pp-hero-val--buy-len7' : buyDigits === 6 ? ' pp-hero-val--buy-len6' : '';
548
+ const oldDigits = showOld ? String(Math.round(oldValue as number)).length : 0;
549
+ const oldLenClass = oldDigits === 5 ? ' pp-hero-old--len5' : oldDigits === 4 ? ' pp-hero-old--len4' : '';
441
550
  return (
442
- <div className="pp-hero">
443
- {!isMoney && <div className="pp-eyebrow">{t('autobahn:finance.exampleLabel')}</div>}
551
+ <div className={'pp-hero' + (isMoney ? ' pp-hero--buy' : '')}>
444
552
  <div className="pp-hero-rate">
445
- {showOld && (
446
- <span className="pp-hero-old">
447
- {(oldValue as number).toLocaleString('de-DE')} €<sup>2</sup>
553
+ <b className={'num pp-hero-val' + rateLenClass + buyLen6Class}>
554
+ {isMoney ? value.toLocaleString('de-DE') : fmtRate(value)} €
555
+ </b>
556
+ {/* z.B./old-rate/unit grouped in one stacked column beside the hero figure — mirrors
557
+ the VDP finance-calculator's .fin-rate-unit layout (FinanceCalculator.tsx) exactly.
558
+ Only shown when there's a real old price to strike through — without a discount the
559
+ eyebrow/unit have nothing to caption, so the whole group stays hidden. */}
560
+ {!isMoney && showOld && (
561
+ <span className="pp-hero-unit-group">
562
+ <span className="pp-eyebrow">
563
+ {t('autobahn:card.price.uvpRateLabel')}
564
+ <UvpBadge
565
+ n={3}
566
+ open={uvpTooltipOpen}
567
+ onToggle={() => setUvpTooltipOpen((v) => !v)}
568
+ onClose={() => setUvpTooltipOpen(false)}
569
+ closeLabel={t('autobahn:modal.close')}
570
+ body={t(
571
+ mode === 'leasing'
572
+ ? 'autobahn:card.price.uvpRateTooltipBodyLeasing'
573
+ : 'autobahn:card.price.uvpRateTooltipBody',
574
+ )}
575
+ />
576
+ </span>
577
+ <span className={'pp-hero-old' + oldLenClass}>{Math.round(oldValue as number).toLocaleString('de-DE')} €</span>
578
+ <span className="pp-hero-unit">{t('autobahn:card.price.perMonth')}</span>
448
579
  </span>
449
580
  )}
450
- <b className="num pp-hero-val">
451
- {isMoney ? value.toLocaleString('de-DE') : fmtRate(value)} €<sup>1</sup>
452
- </b>
453
- {!isMoney && (
454
- <span className="pp-hero-unit">
455
- {t('autobahn:card.price.perMonth')}
456
- <sup>3</sup>
581
+ {/* Kaufen: no rate/unit to caption, just the real list price struck through — same
582
+ car.listPrice already used by DropChip/PpRow. */}
583
+ {isMoney && showOld && (
584
+ <span className="pp-hero-unit-group">
585
+ <span className={'pp-hero-old' + oldLenClass}>
586
+ {Math.round(oldValue as number).toLocaleString('de-DE')} €
587
+ <UvpBadge
588
+ n={2}
589
+ open={uvpTooltipOpen}
590
+ onToggle={() => setUvpTooltipOpen((v) => !v)}
591
+ onClose={() => setUvpTooltipOpen(false)}
592
+ closeLabel={t('autobahn:modal.close')}
593
+ body={t('autobahn:card.price.uvpListTooltipBody')}
594
+ />
595
+ </span>
457
596
  </span>
458
597
  )}
459
598
  </div>
@@ -461,6 +600,68 @@ export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode,
461
600
  );
462
601
  };
463
602
 
603
+ /**
604
+ * Financing/leasing terms line ("0 € Anzahlung · 48 Monate Laufzeit" / "0 € Sonderzahlung ·
605
+ * 48 Mon. · 10.000 km/Jahr") + a "Finanzierungsdetails"/"Details" link, full-width under the
606
+ * price/rating row — same real per-car (falling back to the default config) figures as
607
+ * PricePaths' own terms line, just also wired to the page's rate-adjust sheet. Renders nothing
608
+ * for the Kaufen tab (a cash purchase has no financing terms) or when neither a terms line nor
609
+ * a details link end up with anything real to show.
610
+ */
611
+ export const PriceTerms: React.FunctionComponent<{
612
+ car: AutobahnCar;
613
+ mode: PaymentMode;
614
+ onOpenRateAdjust?: (mode: PaymentMode) => void;
615
+ t: TranslationFunction;
616
+ }> = ({ car, mode, onOpenRateAdjust, t }) => {
617
+ if (mode === 'buy') return null;
618
+ const finFirstInstallment =
619
+ car.finFirstInstallment != null ? car.finFirstInstallment.toLocaleString('de-DE') : _finFirstInstallment;
620
+ const finMonths = car.finMonths ?? _finMonths;
621
+ const leasFirstInstallment =
622
+ car.leaseFirstInstallment != null ? car.leaseFirstInstallment.toLocaleString('de-DE') : _leasFirstInstallment;
623
+ const leasMileage = car.leaseMileage ?? _leasMileage;
624
+ const leasMonths = car.leaseMonths ?? _leasPeriod;
625
+ const hasLeasTerms = Boolean(leasMileage && leasMonths);
626
+ const leasMileageStr = leasMileage ? Number(leasMileage).toLocaleString('de-DE') : '';
627
+ const termsText =
628
+ mode === 'leasing'
629
+ ? hasLeasTerms
630
+ ? t('autobahn:card.price.leaseTerms', { firstInstallment: leasFirstInstallment, months: leasMonths, mileage: leasMileageStr })
631
+ : ''
632
+ : t('autobahn:card.price.financeTerms', { firstInstallment: finFirstInstallment, months: finMonths, count: finMonths });
633
+ if (!termsText && !onOpenRateAdjust) return null;
634
+ const detailsLabel = mode === 'leasing' ? t('autobahn:card.price.detailsLink') : t('autobahn:modal.financingDetails');
635
+ // split on the translation's own ' · ' separator so the dot can be styled lighter than the
636
+ // two figures around it (same literal separator convention as FinanceCalculator's condParts).
637
+ const termsParts = termsText ? termsText.split(' · ') : [];
638
+ return (
639
+ <div className="cc-terms">
640
+ {termsParts.map((part, i) => (
641
+ <React.Fragment key={i}>
642
+ {i > 0 && <span className="cc-terms-dot"> · </span>}
643
+ {part}
644
+ </React.Fragment>
645
+ ))}
646
+ {onOpenRateAdjust && (
647
+ <>
648
+ {termsText && <span className="cc-terms-dot"> · </span>}
649
+ <button
650
+ type="button"
651
+ className="cc-terms-link"
652
+ onClick={(e) => {
653
+ e.stopPropagation();
654
+ onOpenRateAdjust(mode);
655
+ }}
656
+ >
657
+ {detailsLabel}
658
+ </button>
659
+ </>
660
+ )}
661
+ </div>
662
+ );
663
+ };
664
+
464
665
  // Same real sales number VehicleDetailedNextPage.tsx (VDP) already dials via its own onCall —
465
666
  // duplicated here (not imported) because that page doesn't export it; both should eventually
466
667
  // read from one shared constant.
@@ -535,6 +736,12 @@ export interface GalleryCounterProps {
535
736
  /** 1-based index of the currently shown photo. */
536
737
  current: number;
537
738
  total: number;
739
+ /** Figma "Vehicle Gallery / Photos Button" light-pill treatment (Car Card, node 281:1016) —
740
+ * CarRow keeps the original dark `.imglabel` pill until it gets the same design pass. */
741
+ light?: boolean;
742
+ /** makes the light pill a real button (opens the full VdpViewer lightbox on the grid card) —
743
+ * the dark `.imglabel` variant stays a plain, non-interactive span either way. */
744
+ onClick?: () => void;
538
745
  }
539
746
 
540
747
  /**
@@ -543,8 +750,16 @@ export interface GalleryCounterProps {
543
750
  * class — same dark translucent pill, just with the maximize icon instead of the image icon.
544
751
  * Hidden with 0/1 photos (nothing to expand into).
545
752
  */
546
- export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total }) => {
753
+ export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
547
754
  if (total <= 1) return null;
755
+ if (light) {
756
+ return (
757
+ <button type="button" className="cc-photo-pill" onClick={onClick}>
758
+ <Icon name="maximize" size={18} stroke={2} color="#111820" />
759
+ {current} | {total}
760
+ </button>
761
+ );
762
+ }
548
763
  return (
549
764
  <span className="imglabel">
550
765
  <Icon name="maximize" size={13} stroke={2} />
@@ -553,6 +768,38 @@ export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ c
553
768
  );
554
769
  };
555
770
 
771
+ export interface GalleryActivityPillProps {
772
+ /** total detail-page views (stats totalCarImpCount) — real data only, never fabricated. */
773
+ viewsCount?: number;
774
+ /** total merkliste adds (stats totalFavCount) — real data only, never fabricated. */
775
+ favoritesCount?: number;
776
+ }
777
+
778
+ /**
779
+ * Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
780
+ * Likes", node 297:1037). Each half renders independently and the whole pill is omitted when
781
+ * neither figure has loaded yet (same async-stats/NEEDS-DATA discipline as the rest of this file).
782
+ */
783
+ export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
784
+ if (!viewsCount && !favoritesCount) return null;
785
+ return (
786
+ <span className="cc-activity-pill">
787
+ {!!viewsCount && (
788
+ <span className="cc-activity-pill-item">
789
+ <Icon name="eye" size={16} stroke={1.9} color="#1D2A38" />
790
+ {viewsCount}
791
+ </span>
792
+ )}
793
+ {!!favoritesCount && (
794
+ <span className="cc-activity-pill-item">
795
+ <Icon name="heart" size={16} stroke={1.9} color="#1D2A38" />
796
+ {favoritesCount}
797
+ </span>
798
+ )}
799
+ </span>
800
+ );
801
+ };
802
+
556
803
  interface PpRowProps {
557
804
  /** payment-path label (Finanzierung / Leasing / Kaufen). */
558
805
  label: string;
@@ -605,6 +852,8 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
605
852
  const finFirstInstallment =
606
853
  car.finFirstInstallment != null ? car.finFirstInstallment.toLocaleString('de-DE') : _finFirstInstallment;
607
854
  const finMonths = car.finMonths ?? _finMonths;
855
+ const leasFirstInstallment =
856
+ car.leaseFirstInstallment != null ? car.leaseFirstInstallment.toLocaleString('de-DE') : _leasFirstInstallment;
608
857
  const leasMileage = car.leaseMileage ?? _leasMileage;
609
858
  const leasMonths = car.leaseMonths ?? _leasPeriod;
610
859
  const hasLeasTerms = Boolean(leasMileage && leasMonths);
@@ -644,7 +893,11 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
644
893
  <PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." />
645
894
  {hasLeasTerms && (
646
895
  <div className="pp-sub-terms">
647
- {t('autobahn:card.price.leaseTerms', { mileage: leasMileageStr, months: leasMonths })}{' '}
896
+ {t('autobahn:card.price.leaseTerms', {
897
+ firstInstallment: leasFirstInstallment,
898
+ months: leasMonths,
899
+ mileage: leasMileageStr,
900
+ })}{' '}
648
901
  · <span className="pp-details-link">{t('autobahn:card.price.details')}</span>
649
902
  </div>
650
903
  )}