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

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
  };
@@ -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
  /**
@@ -378,6 +385,48 @@ export const PriceMeter: React.FunctionComponent<{ car: AutobahnCar; t: Translat
378
385
  return null;
379
386
  };
380
387
 
388
+ /**
389
+ * Preisbewertung pill + segmented tier bar, grouped in one column exactly like the VDP
390
+ * finance-calculator's mobile .fin-eval-col (FinanceCalculator.tsx/EvalBar) — the grid card's
391
+ * replacement for the old StatChip pill + full-width gradient .pp-bar pairing. Renders nothing
392
+ * without a rating (needs-data: never invent a tier).
393
+ */
394
+ export const PriceEvalCol: React.FunctionComponent<{ car: AutobahnCar; t: TranslationFunction }> = ({ car, t }) => {
395
+ const tier = car.rating ? TONE_TO_TIER[car.rating] : undefined;
396
+ if (tier == null) return null;
397
+ const hasMarket =
398
+ car.marketLow != null && car.marketAvg != null && car.marketHigh != null && car.marketHigh > car.marketLow;
399
+ // same continuous market-position math as PriceMeter's knob, reused as EvalBar's leftPct override.
400
+ const leftPct = hasMarket
401
+ ? Math.max(4, Math.min(96, ((car.price - car.marketLow!) / (car.marketHigh! - car.marketLow!)) * 100))
402
+ : undefined;
403
+ const isGood = TIER_KEYS[tier] === 'good';
404
+ // both green tiers ('top' and 'good') collapse onto one "Top-Preis" claim (Figma "similar
405
+ // cars" card) — same convention the VDP FinanceCalculator now also uses for its own pill.
406
+ // footnote 4 carries the disclaimer text, same convention as the hero price's 1/2/3 markers.
407
+ const tierLabel = isGood ? t('autobahn:tier.top') : t('autobahn:tier.' + car.rating);
408
+ // 'good' collapses onto the 'top' tier's own color too (#168a45), matching the fixed
409
+ // "Top-Preis" text above rather than tier 1's lighter TIER_COLORS[1] green.
410
+ const pillColor = isGood ? TIER_COLORS[0] : TIER_COLORS[tier];
411
+ return (
412
+ <div className="fin-eval-col">
413
+ <div className="fin-eval-pill-cell">
414
+ <span
415
+ className={`fin-eval-pill${isGood ? ' fin-eval-pill--good' : ''}`}
416
+ style={{ color: pillColor, background: `color-mix(in srgb, ${pillColor} 16%, transparent)` }}
417
+ aria-label={t('autobahn:finance.priceRatingAria', { rating: tierLabel })}
418
+ >
419
+ {tierLabel}
420
+ {isGood && <sup>4</sup>}
421
+ </span>
422
+ </div>
423
+ <div className="fin-eval-bar-cell">
424
+ <EvalBar tier={tier} leftPct={leftPct} />
425
+ </div>
426
+ </div>
427
+ );
428
+ };
429
+
381
430
  export type PaymentMode = 'financing' | 'leasing' | 'buy';
382
431
 
383
432
  export interface PriceTabsProps {
@@ -425,6 +474,50 @@ export interface PriceHeroProps {
425
474
  t: TranslationFunction;
426
475
  }
427
476
 
477
+ /** footnote badge + click-to-open popover, shared by the Finanzierung/Leasing "UVP-Rate" (³) and
478
+ * Kaufen "Hersteller-UVP" (²) captions on the hero price — same circle/popover, different digit
479
+ * and body copy. */
480
+ const UvpBadge: React.FunctionComponent<{
481
+ n: number;
482
+ open: boolean;
483
+ onToggle: () => void;
484
+ onClose: () => void;
485
+ body: string;
486
+ closeLabel: string;
487
+ }> = ({ n, open, onToggle, onClose, body, closeLabel }) => (
488
+ <>
489
+ <span
490
+ className="pp-eyebrow-badge"
491
+ role="button"
492
+ tabIndex={0}
493
+ onClick={(e) => {
494
+ e.stopPropagation();
495
+ onToggle();
496
+ }}
497
+ >
498
+ {n}
499
+ </span>
500
+ {open && (
501
+ <div className="pp-uvp-tooltip" onClick={(e) => e.stopPropagation()}>
502
+ <button
503
+ type="button"
504
+ className="pp-uvp-tooltip-close"
505
+ aria-label={closeLabel}
506
+ onClick={(e) => {
507
+ e.stopPropagation();
508
+ onClose();
509
+ }}
510
+ >
511
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
512
+ <path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
513
+ </svg>
514
+ </button>
515
+ <div className="pp-uvp-tooltip-body">{body}</div>
516
+ </div>
517
+ )}
518
+ </>
519
+ );
520
+
428
521
  /**
429
522
  * Hero price for the active tab — Finanzierung/Leasing show a monthly rate ("z.B. … €/mtl.")
430
523
  * with its own real struck-through old rate (getFinancing/getLeasing `oldMonthlyInstallment`,
@@ -434,26 +527,69 @@ export interface PriceHeroProps {
434
527
  * their legal text lives in the page's own footnote block, not duplicated per-card.
435
528
  */
436
529
  export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode, t }) => {
530
+ const [uvpTooltipOpen, setUvpTooltipOpen] = useState(false);
437
531
  const isMoney = mode === 'buy';
438
532
  const value = mode === 'financing' ? car.monthly : mode === 'leasing' ? (car.lease as number) : car.price;
439
533
  const oldValue = mode === 'financing' ? car.oldMonthly : mode === 'leasing' ? car.oldLease : car.listPrice;
440
534
  const showOld = oldValue != null && oldValue > value;
535
+ // shrink the 65px hero rate for long values (e.g. 1.234 €) so it doesn't overflow the card —
536
+ // same digit-length technique as FinanceCalculator's own fin-rate-hero--len4/--len5/--len6.
537
+ const rateDigits = !isMoney ? String(Math.round(value)).length : 0;
538
+ const rateLenClass =
539
+ rateDigits >= 6 ? ' pp-hero-val--len6' : rateDigits === 5 ? ' pp-hero-val--len5' : rateDigits === 4 ? ' pp-hero-val--len4' : '';
540
+ // Kaufen: 6/7-digit purchase prices (e.g. 113.249 € / 1.553.249 €) shrink on desktop only —
541
+ // separate from the financing/leasing --len6 above since the desktop Kaufen sizes don't match
542
+ // the shared rate-based scale.
543
+ const buyDigits = isMoney ? String(Math.round(value)).length : 0;
544
+ const buyLen6Class = buyDigits >= 7 ? ' pp-hero-val--buy-len7' : buyDigits === 6 ? ' pp-hero-val--buy-len6' : '';
545
+ const oldDigits = showOld ? String(Math.round(oldValue as number)).length : 0;
546
+ const oldLenClass = oldDigits === 5 ? ' pp-hero-old--len5' : oldDigits === 4 ? ' pp-hero-old--len4' : '';
441
547
  return (
442
- <div className="pp-hero">
443
- {!isMoney && <div className="pp-eyebrow">{t('autobahn:finance.exampleLabel')}</div>}
548
+ <div className={'pp-hero' + (isMoney ? ' pp-hero--buy' : '')}>
444
549
  <div className="pp-hero-rate">
445
- {showOld && (
446
- <span className="pp-hero-old">
447
- {(oldValue as number).toLocaleString('de-DE')} €<sup>2</sup>
550
+ <b className={'num pp-hero-val' + rateLenClass + buyLen6Class}>
551
+ {isMoney ? value.toLocaleString('de-DE') : fmtRate(value)} €
552
+ </b>
553
+ {/* z.B./old-rate/unit grouped in one stacked column beside the hero figure — mirrors
554
+ the VDP finance-calculator's .fin-rate-unit layout (FinanceCalculator.tsx) exactly.
555
+ Only shown when there's a real old price to strike through — without a discount the
556
+ eyebrow/unit have nothing to caption, so the whole group stays hidden. */}
557
+ {!isMoney && showOld && (
558
+ <span className="pp-hero-unit-group">
559
+ <span className="pp-eyebrow">
560
+ {t('autobahn:card.price.uvpRateLabel')}
561
+ <UvpBadge
562
+ n={3}
563
+ open={uvpTooltipOpen}
564
+ onToggle={() => setUvpTooltipOpen((v) => !v)}
565
+ onClose={() => setUvpTooltipOpen(false)}
566
+ closeLabel={t('autobahn:modal.close')}
567
+ body={t(
568
+ mode === 'leasing'
569
+ ? 'autobahn:card.price.uvpRateTooltipBodyLeasing'
570
+ : 'autobahn:card.price.uvpRateTooltipBody',
571
+ )}
572
+ />
573
+ </span>
574
+ <span className={'pp-hero-old' + oldLenClass}>{Math.round(oldValue as number).toLocaleString('de-DE')} €</span>
575
+ <span className="pp-hero-unit">{t('autobahn:card.price.perMonth')}</span>
448
576
  </span>
449
577
  )}
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>
578
+ {/* Kaufen: no rate/unit to caption, just the real list price struck through — same
579
+ car.listPrice already used by DropChip/PpRow. */}
580
+ {isMoney && showOld && (
581
+ <span className="pp-hero-unit-group">
582
+ <span className={'pp-hero-old' + oldLenClass}>
583
+ {Math.round(oldValue as number).toLocaleString('de-DE')} €
584
+ <UvpBadge
585
+ n={2}
586
+ open={uvpTooltipOpen}
587
+ onToggle={() => setUvpTooltipOpen((v) => !v)}
588
+ onClose={() => setUvpTooltipOpen(false)}
589
+ closeLabel={t('autobahn:modal.close')}
590
+ body={t('autobahn:card.price.uvpListTooltipBody')}
591
+ />
592
+ </span>
457
593
  </span>
458
594
  )}
459
595
  </div>
@@ -461,6 +597,68 @@ export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode,
461
597
  );
462
598
  };
463
599
 
600
+ /**
601
+ * Financing/leasing terms line ("0 € Anzahlung · 48 Monate Laufzeit" / "0 € Sonderzahlung ·
602
+ * 48 Mon. · 10.000 km/Jahr") + a "Finanzierungsdetails"/"Details" link, full-width under the
603
+ * price/rating row — same real per-car (falling back to the default config) figures as
604
+ * PricePaths' own terms line, just also wired to the page's rate-adjust sheet. Renders nothing
605
+ * for the Kaufen tab (a cash purchase has no financing terms) or when neither a terms line nor
606
+ * a details link end up with anything real to show.
607
+ */
608
+ export const PriceTerms: React.FunctionComponent<{
609
+ car: AutobahnCar;
610
+ mode: PaymentMode;
611
+ onOpenRateAdjust?: (mode: PaymentMode) => void;
612
+ t: TranslationFunction;
613
+ }> = ({ car, mode, onOpenRateAdjust, t }) => {
614
+ if (mode === 'buy') return null;
615
+ const finFirstInstallment =
616
+ car.finFirstInstallment != null ? car.finFirstInstallment.toLocaleString('de-DE') : _finFirstInstallment;
617
+ const finMonths = car.finMonths ?? _finMonths;
618
+ const leasFirstInstallment =
619
+ car.leaseFirstInstallment != null ? car.leaseFirstInstallment.toLocaleString('de-DE') : _leasFirstInstallment;
620
+ const leasMileage = car.leaseMileage ?? _leasMileage;
621
+ const leasMonths = car.leaseMonths ?? _leasPeriod;
622
+ const hasLeasTerms = Boolean(leasMileage && leasMonths);
623
+ const leasMileageStr = leasMileage ? Number(leasMileage).toLocaleString('de-DE') : '';
624
+ const termsText =
625
+ mode === 'leasing'
626
+ ? hasLeasTerms
627
+ ? t('autobahn:card.price.leaseTerms', { firstInstallment: leasFirstInstallment, months: leasMonths, mileage: leasMileageStr })
628
+ : ''
629
+ : t('autobahn:card.price.financeTerms', { firstInstallment: finFirstInstallment, months: finMonths, count: finMonths });
630
+ if (!termsText && !onOpenRateAdjust) return null;
631
+ const detailsLabel = mode === 'leasing' ? t('autobahn:card.price.detailsLink') : t('autobahn:modal.financingDetails');
632
+ // split on the translation's own ' · ' separator so the dot can be styled lighter than the
633
+ // two figures around it (same literal separator convention as FinanceCalculator's condParts).
634
+ const termsParts = termsText ? termsText.split(' · ') : [];
635
+ return (
636
+ <div className="cc-terms">
637
+ {termsParts.map((part, i) => (
638
+ <React.Fragment key={i}>
639
+ {i > 0 && <span className="cc-terms-dot"> · </span>}
640
+ {part}
641
+ </React.Fragment>
642
+ ))}
643
+ {onOpenRateAdjust && (
644
+ <>
645
+ {termsText && <span className="cc-terms-dot"> · </span>}
646
+ <button
647
+ type="button"
648
+ className="cc-terms-link"
649
+ onClick={(e) => {
650
+ e.stopPropagation();
651
+ onOpenRateAdjust(mode);
652
+ }}
653
+ >
654
+ {detailsLabel}
655
+ </button>
656
+ </>
657
+ )}
658
+ </div>
659
+ );
660
+ };
661
+
464
662
  // Same real sales number VehicleDetailedNextPage.tsx (VDP) already dials via its own onCall —
465
663
  // duplicated here (not imported) because that page doesn't export it; both should eventually
466
664
  // read from one shared constant.
@@ -535,6 +733,12 @@ export interface GalleryCounterProps {
535
733
  /** 1-based index of the currently shown photo. */
536
734
  current: number;
537
735
  total: number;
736
+ /** Figma "Vehicle Gallery / Photos Button" light-pill treatment (Car Card, node 281:1016) —
737
+ * CarRow keeps the original dark `.imglabel` pill until it gets the same design pass. */
738
+ light?: boolean;
739
+ /** makes the light pill a real button (opens the full VdpViewer lightbox on the grid card) —
740
+ * the dark `.imglabel` variant stays a plain, non-interactive span either way. */
741
+ onClick?: () => void;
538
742
  }
539
743
 
540
744
  /**
@@ -543,8 +747,16 @@ export interface GalleryCounterProps {
543
747
  * class — same dark translucent pill, just with the maximize icon instead of the image icon.
544
748
  * Hidden with 0/1 photos (nothing to expand into).
545
749
  */
546
- export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total }) => {
750
+ export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
547
751
  if (total <= 1) return null;
752
+ if (light) {
753
+ return (
754
+ <button type="button" className="cc-photo-pill" onClick={onClick}>
755
+ <Icon name="maximize" size={18} stroke={2} color="#111820" />
756
+ {current} | {total}
757
+ </button>
758
+ );
759
+ }
548
760
  return (
549
761
  <span className="imglabel">
550
762
  <Icon name="maximize" size={13} stroke={2} />
@@ -553,6 +765,38 @@ export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ c
553
765
  );
554
766
  };
555
767
 
768
+ export interface GalleryActivityPillProps {
769
+ /** total detail-page views (stats totalCarImpCount) — real data only, never fabricated. */
770
+ viewsCount?: number;
771
+ /** total merkliste adds (stats totalFavCount) — real data only, never fabricated. */
772
+ favoritesCount?: number;
773
+ }
774
+
775
+ /**
776
+ * Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
777
+ * Likes", node 297:1037). Each half renders independently and the whole pill is omitted when
778
+ * neither figure has loaded yet (same async-stats/NEEDS-DATA discipline as the rest of this file).
779
+ */
780
+ export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
781
+ if (!viewsCount && !favoritesCount) return null;
782
+ return (
783
+ <span className="cc-activity-pill">
784
+ {!!viewsCount && (
785
+ <span className="cc-activity-pill-item">
786
+ <Icon name="eye" size={16} stroke={1.9} color="#1D2A38" />
787
+ {viewsCount}
788
+ </span>
789
+ )}
790
+ {!!favoritesCount && (
791
+ <span className="cc-activity-pill-item">
792
+ <Icon name="heart" size={16} stroke={1.9} color="#1D2A38" />
793
+ {favoritesCount}
794
+ </span>
795
+ )}
796
+ </span>
797
+ );
798
+ };
799
+
556
800
  interface PpRowProps {
557
801
  /** payment-path label (Finanzierung / Leasing / Kaufen). */
558
802
  label: string;
@@ -605,6 +849,8 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
605
849
  const finFirstInstallment =
606
850
  car.finFirstInstallment != null ? car.finFirstInstallment.toLocaleString('de-DE') : _finFirstInstallment;
607
851
  const finMonths = car.finMonths ?? _finMonths;
852
+ const leasFirstInstallment =
853
+ car.leaseFirstInstallment != null ? car.leaseFirstInstallment.toLocaleString('de-DE') : _leasFirstInstallment;
608
854
  const leasMileage = car.leaseMileage ?? _leasMileage;
609
855
  const leasMonths = car.leaseMonths ?? _leasPeriod;
610
856
  const hasLeasTerms = Boolean(leasMileage && leasMonths);
@@ -644,8 +890,11 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
644
890
  <PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." />
645
891
  {hasLeasTerms && (
646
892
  <div className="pp-sub-terms">
647
- {t('autobahn:card.price.leaseTerms', { mileage: leasMileageStr, months: leasMonths })}{' '}
648
- · <span className="pp-details-link">{t('autobahn:card.price.details')}</span>
893
+ {t('autobahn:card.price.leaseTerms', {
894
+ firstInstallment: leasFirstInstallment,
895
+ months: leasMonths,
896
+ mileage: leasMileageStr,
897
+ })}
649
898
  </div>
650
899
  )}
651
900
  </div>
package/cards/index.ts CHANGED
@@ -21,6 +21,8 @@ export {
21
21
  PricePaths,
22
22
  PriceKauf,
23
23
  PriceMeter,
24
+ PriceEvalCol,
25
+ PriceTerms,
24
26
  PpActivity,
25
27
  GalleryBadges,
26
28
  OfferBadge,
@@ -28,6 +30,7 @@ export {
28
30
  PriceHero,
29
31
  CardCtaRow,
30
32
  GalleryCounter,
33
+ GalleryActivityPill,
31
34
  ratingTone,
32
35
  DEALER_SALES_PHONE,
33
36
  callDealerSalesPhone,
@@ -45,6 +48,7 @@ export type {
45
48
  PriceHeroProps,
46
49
  CardCtaRowProps,
47
50
  GalleryCounterProps,
51
+ GalleryActivityPillProps,
48
52
  } from './cardParts';
49
53
 
50
54
  export type { AutobahnCar, AutobahnPhev } from './types';
package/cards/types.ts CHANGED
@@ -70,6 +70,12 @@ export interface AutobahnCar {
70
70
  // --- media (always real) ---
71
71
  /** ready-to-use image URLs for the carousel. */
72
72
  gallery: string[];
73
+ /**
74
+ * Full-resolution counterpart of `gallery`, same length/order — only set when the card was
75
+ * built with a smaller `imageSize` (grid cards). A lightbox opened from the card (CarCard's
76
+ * photo-count pill → VdpViewer) should prefer this over the small thumbnail URLs.
77
+ */
78
+ galleryLarge?: string[];
73
79
  /** total photo count for the image-count badge. */
74
80
  photos: number;
75
81
 
@@ -128,6 +134,7 @@ export interface AutobahnCar {
128
134
  */
129
135
  leaseMileage?: number;
130
136
  leaseMonths?: number;
137
+ leaseFirstInstallment?: number;
131
138
  /** number of buyers currently watching this car. */
132
139
  watchers?: number;
133
140
  /** number of recent views (drives the 'vor N Std' cue). */
package/constants.ts CHANGED
@@ -1,31 +1,31 @@
1
- // Autobahn (auto.de redesign) — shared lookup constants barrel.
2
- // Pure data re-exported from ./constants/autobahn (kept import-safe for plain Node consumers,
3
- // see that file's header) plus the legacy fe-ui-core shim re-exports below.
4
- export * from './constants/autobahn';
5
-
6
- // ---------------------------------------------------------------------------
7
- // Legacy shared constants (moved here from fe-ui-core).
8
- //
9
- // This block mirrors the public symbol set of the old
10
- // `@bytebrand/fe-ui-core/constants` barrel so consumers can swap that
11
- // specifier for `@bytebrand/fe-ui-core-autobahn/constants` 1:1. The
12
- // implementations live under ./constants/ (core.ts, app.ts, price.ts,
13
- // Search.ts, …); fe-ui-core re-exports them via shims until it is deleted.
14
- // ---------------------------------------------------------------------------
15
-
16
- export {
17
- offers,
18
- checkoutOffers,
19
- checkoutWarranties,
20
- checkoutSteps,
21
- checkoutSubStep,
22
- DELIVERY_PERIODS_EXTRA,
23
- THREE,
24
- WLTP_ENERGY_EFFICIENCY_CLASS
25
- } from './constants/core';
26
-
27
- export { CUSTOM_ERROR_MESSAGES, supportPhone, supportPhoneLink } from './constants/app';
28
-
29
- export { priceRatings } from './constants/price';
30
-
31
- export { THROTTLE_DELAY } from './constants/Search';
1
+ // Autobahn (auto.de redesign) — shared lookup constants barrel.
2
+ // Pure data re-exported from ./constants/autobahn (kept import-safe for plain Node consumers,
3
+ // see that file's header) plus the legacy fe-ui-core shim re-exports below.
4
+ export * from './constants/autobahn';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Legacy shared constants (moved here from fe-ui-core).
8
+ //
9
+ // This block mirrors the public symbol set of the old
10
+ // `@bytebrand/fe-ui-core/constants` barrel so consumers can swap that
11
+ // specifier for `@bytebrand/fe-ui-core-autobahn/constants` 1:1. The
12
+ // implementations live under ./constants/ (core.ts, app.ts, price.ts,
13
+ // Search.ts, …); fe-ui-core re-exports them via shims until it is deleted.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export {
17
+ offers,
18
+ checkoutOffers,
19
+ checkoutWarranties,
20
+ checkoutSteps,
21
+ checkoutSubStep,
22
+ DELIVERY_PERIODS_EXTRA,
23
+ THREE,
24
+ WLTP_ENERGY_EFFICIENCY_CLASS
25
+ } from './constants/core';
26
+
27
+ export { CUSTOM_ERROR_MESSAGES, supportPhone, supportPhoneLink } from './constants/app';
28
+
29
+ export { priceRatings } from './constants/price';
30
+
31
+ export { THROTTLE_DELAY } from './constants/Search';