@bytebrand/fe-ui-core-autobahn 1.0.59 → 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.
@@ -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
  /**
@@ -234,14 +241,18 @@ export const PercentChip: React.FunctionComponent<{ pct?: number }> = ({ pct })
234
241
  * there is room (grid cards / wide image) and uses the compact "0 € Anz." on the narrow list-card
235
242
  * image, so the badge row always stays on ONE line on both desktop and mobile.
236
243
  */
237
- export const ZeroDownChip: React.FunctionComponent<{ show?: boolean; compact?: boolean; t: TranslationFunction }> = ({
238
- show,
239
- compact,
240
- t,
241
- }) => {
244
+ export const ZeroDownChip: React.FunctionComponent<{
245
+ show?: boolean;
246
+ compact?: boolean;
247
+ t: TranslationFunction;
248
+ className?: string;
249
+ }> = ({ show, compact, t, className }) => {
242
250
  if (!show) return null;
243
251
  return (
244
- <span style={{ ...dealPill, background: 'var(--md-accent-container)', color: 'var(--md-on-accent-container)' }}>
252
+ <span
253
+ className={className}
254
+ style={{ ...dealPill, background: 'var(--md-accent-container)', color: 'var(--md-on-accent-container)' }}
255
+ >
245
256
  {compact ? t('autobahn:card.price.zeroDownCompact') : t('autobahn:card.price.zeroDown')}
246
257
  </span>
247
258
  );
@@ -341,6 +352,8 @@ export const EcoLine: React.FunctionComponent<EcoLineProps> = ({ car, style, t }
341
352
  export interface PricePathsProps {
342
353
  car: AutobahnCar;
343
354
  t: TranslationFunction;
355
+ /** extra class appended to the root (e.g. the SRP card's desktop-only `hide-mobile` marker). */
356
+ className?: string;
344
357
  }
345
358
 
346
359
  /** Map the AutobahnCar rating tone → meter segment index (0 best … 4 worst). */
@@ -372,6 +385,48 @@ export const PriceMeter: React.FunctionComponent<{ car: AutobahnCar; t: Translat
372
385
  return null;
373
386
  };
374
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
+
375
430
  export type PaymentMode = 'financing' | 'leasing' | 'buy';
376
431
 
377
432
  export interface PriceTabsProps {
@@ -379,6 +434,8 @@ export interface PriceTabsProps {
379
434
  mode: PaymentMode;
380
435
  onModeChange: (mode: PaymentMode) => void;
381
436
  t: TranslationFunction;
437
+ /** extra class appended to the root (e.g. the SRP card's mobile-only `show-mobile` marker). */
438
+ className?: string;
382
439
  }
383
440
 
384
441
  /**
@@ -387,14 +444,14 @@ export interface PriceTabsProps {
387
444
  * identical to the VDP's, not a second tab style. Leasing tab omitted when the car isn't
388
445
  * leasing-eligible (car.lease undefined) — same rule PricePaths already used.
389
446
  */
390
- export const PriceTabs: React.FunctionComponent<PriceTabsProps> = ({ car, mode, onModeChange, t }) => {
447
+ export const PriceTabs: React.FunctionComponent<PriceTabsProps> = ({ car, mode, onModeChange, t, className }) => {
391
448
  const tabs: Array<[PaymentMode, string]> = [
392
449
  ['financing', t('autobahn:finance.mode.financing')],
393
450
  ...(car.lease != null ? ([['leasing', t('autobahn:finance.mode.leasing')]] as Array<[PaymentMode, string]>) : []),
394
451
  ['buy', t('autobahn:card.price.buy')],
395
452
  ];
396
453
  return (
397
- <div className="fin-tabs" role="tablist" onClick={(e) => e.stopPropagation()}>
454
+ <div className={'fin-tabs' + (className ? ' ' + className : '')} role="tablist" onClick={(e) => e.stopPropagation()}>
398
455
  {tabs.map(([m, label]) => (
399
456
  <button
400
457
  key={m}
@@ -417,6 +474,50 @@ export interface PriceHeroProps {
417
474
  t: TranslationFunction;
418
475
  }
419
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
+
420
521
  /**
421
522
  * Hero price for the active tab — Finanzierung/Leasing show a monthly rate ("z.B. … €/mtl.")
422
523
  * with its own real struck-through old rate (getFinancing/getLeasing `oldMonthlyInstallment`,
@@ -426,26 +527,69 @@ export interface PriceHeroProps {
426
527
  * their legal text lives in the page's own footnote block, not duplicated per-card.
427
528
  */
428
529
  export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode, t }) => {
530
+ const [uvpTooltipOpen, setUvpTooltipOpen] = useState(false);
429
531
  const isMoney = mode === 'buy';
430
532
  const value = mode === 'financing' ? car.monthly : mode === 'leasing' ? (car.lease as number) : car.price;
431
533
  const oldValue = mode === 'financing' ? car.oldMonthly : mode === 'leasing' ? car.oldLease : car.listPrice;
432
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' : '';
433
547
  return (
434
- <div className="pp-hero">
435
- {!isMoney && <div className="pp-eyebrow">{t('autobahn:finance.exampleLabel')}</div>}
548
+ <div className={'pp-hero' + (isMoney ? ' pp-hero--buy' : '')}>
436
549
  <div className="pp-hero-rate">
437
- {showOld && (
438
- <span className="pp-hero-old">
439
- {(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>
440
576
  </span>
441
577
  )}
442
- <b className="num pp-hero-val">
443
- {isMoney ? value.toLocaleString('de-DE') : fmtRate(value)} €<sup>1</sup>
444
- </b>
445
- {!isMoney && (
446
- <span className="pp-hero-unit">
447
- {t('autobahn:card.price.perMonth')}
448
- <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>
449
593
  </span>
450
594
  )}
451
595
  </div>
@@ -453,6 +597,68 @@ export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode,
453
597
  );
454
598
  };
455
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
+
456
662
  // Same real sales number VehicleDetailedNextPage.tsx (VDP) already dials via its own onCall —
457
663
  // duplicated here (not imported) because that page doesn't export it; both should eventually
458
664
  // read from one shared constant.
@@ -527,6 +733,12 @@ export interface GalleryCounterProps {
527
733
  /** 1-based index of the currently shown photo. */
528
734
  current: number;
529
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;
530
742
  }
531
743
 
532
744
  /**
@@ -535,8 +747,16 @@ export interface GalleryCounterProps {
535
747
  * class — same dark translucent pill, just with the maximize icon instead of the image icon.
536
748
  * Hidden with 0/1 photos (nothing to expand into).
537
749
  */
538
- export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total }) => {
750
+ export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
539
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
+ }
540
760
  return (
541
761
  <span className="imglabel">
542
762
  <Icon name="maximize" size={13} stroke={2} />
@@ -545,6 +765,38 @@ export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ c
545
765
  );
546
766
  };
547
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
+
548
800
  interface PpRowProps {
549
801
  /** payment-path label (Finanzierung / Leasing / Kaufen). */
550
802
  label: string;
@@ -589,7 +841,7 @@ const PpRow: React.FunctionComponent<PpRowProps> = ({ label, value, unit, old, f
589
841
  * is omitted entirely when leasing isn't active. The Kaufen row is rendered separately
590
842
  * (PriceKauf) below a divider so only the financing rate stands out — no wall of blue prices.
591
843
  */
592
- export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t }) => {
844
+ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, className }) => {
593
845
  const hasLease = car.lease != null;
594
846
  // Terms of the config the displayed rate was actually computed with (per-car:
595
847
  // dealer rate + the user's saved localStorage adjustments) — default config
@@ -597,24 +849,40 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t })
597
849
  const finFirstInstallment =
598
850
  car.finFirstInstallment != null ? car.finFirstInstallment.toLocaleString('de-DE') : _finFirstInstallment;
599
851
  const finMonths = car.finMonths ?? _finMonths;
852
+ const leasFirstInstallment =
853
+ car.leaseFirstInstallment != null ? car.leaseFirstInstallment.toLocaleString('de-DE') : _leasFirstInstallment;
600
854
  const leasMileage = car.leaseMileage ?? _leasMileage;
601
855
  const leasMonths = car.leaseMonths ?? _leasPeriod;
602
856
  const hasLeasTerms = Boolean(leasMileage && leasMonths);
603
857
  const leasMileageStr = leasMileage ? Number(leasMileage).toLocaleString('de-DE') : '';
858
+ // same real struck-through reference rate PriceHero shows for the financing tab (oldMonthly) —
859
+ // shown here too now that both rows render at once instead of behind a tab.
860
+ const showOldMonthly = car.oldMonthly != null && car.oldMonthly > car.monthly;
604
861
  return (
605
- <div className="pricepaths">
862
+ <div className={'pricepaths' + (className ? ' ' + className : '')}>
606
863
  <div className="pp-hero">
607
864
  <div className="pp-eyebrow">{t('autobahn:card.price.financingFrom')}</div>
608
865
  <div className="pp-hero-rate">
609
- <b className="num pp-hero-val">{fmtRate(car.monthly)} €</b>
610
- <span className="pp-hero-unit">{t('autobahn:card.price.perMonth')}</span>
866
+ {showOldMonthly && (
867
+ <span className="pp-hero-old">
868
+ {(car.oldMonthly as number).toLocaleString('de-DE')} €<sup>2</sup>
869
+ </span>
870
+ )}
871
+ <b className="num pp-hero-val">
872
+ {fmtRate(car.monthly)} €<sup>1</sup>
873
+ </b>
874
+ <span className="pp-hero-unit">
875
+ {t('autobahn:card.price.perMonth')}
876
+ <sup>3</sup>
877
+ </span>
611
878
  </div>
612
879
  <div className="pp-hero-terms">
613
880
  {t('autobahn:card.price.financeTerms', {
614
881
  firstInstallment: finFirstInstallment,
615
882
  months: finMonths,
616
883
  count: finMonths,
617
- })}
884
+ })}{' '}
885
+ · <span className="pp-details-link">{t('autobahn:card.price.details')}</span>
618
886
  </div>
619
887
  </div>
620
888
  {hasLease && (
@@ -622,7 +890,11 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t })
622
890
  <PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." />
623
891
  {hasLeasTerms && (
624
892
  <div className="pp-sub-terms">
625
- {t('autobahn:card.price.leaseTerms', { mileage: leasMileageStr, months: leasMonths })}
893
+ {t('autobahn:card.price.leaseTerms', {
894
+ firstInstallment: leasFirstInstallment,
895
+ months: leasMonths,
896
+ mileage: leasMileageStr,
897
+ })}
626
898
  </div>
627
899
  )}
628
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';
@@ -225,11 +225,13 @@ function isPluginHybrid(fuel: any): boolean {
225
225
  return fuel === 'selector_fuel_hybridElectricalDiesel' || fuel === 'selector_fuel_hybridElectricalPetrol';
226
226
  }
227
227
 
228
- /** Build the gallery URL list from real images, falling back to main image, then placeholder. */
229
- function buildGallery(storeCar: any, deps: ToAutobahnCarDeps): string[] {
228
+ /** Build the gallery URL list from real images, falling back to main image, then placeholder.
229
+ * `sizeOverride` lets a caller ask for a different size than `deps.imageSize` (e.g. a full-res
230
+ * set for a lightbox, while the card thumbnail itself stays on the smaller default). */
231
+ function buildGallery(storeCar: any, deps: ToAutobahnCarDeps, sizeOverride?: 'small' | 'medium' | 'large'): string[] {
230
232
  const placeholder = deps.placeholder || PLACEHOLDER_IMAGE;
231
233
  // Only 'small' and 'large' variants are pre-generated by image-generator; 'medium' has no cached file.
232
- const size = deps.imageSize || 'small';
234
+ const size = sizeOverride || deps.imageSize || 'small';
233
235
 
234
236
  // 1) Real loaded images from imagesStore.images.get(id).
235
237
  if (Array.isArray(deps.loadedImages) && deps.loadedImages.length) {
@@ -362,6 +364,11 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
362
364
 
363
365
  // --- media ---
364
366
  const gallery = buildGallery(storeCar, deps);
367
+ // Card grids ask for a smaller `imageSize` (perf) — but a lightbox opened FROM that card
368
+ // (CarCard's photo-count pill → VdpViewer) shouldn't be stuck showing the small thumbnails
369
+ // blown up full-screen. Build a real full-res set alongside, from the same already-loaded
370
+ // images (no extra fetch) — only when the requested size isn't already 'large'.
371
+ const galleryLarge = (deps.imageSize || 'small') !== 'large' ? buildGallery(storeCar, deps, 'large') : undefined;
365
372
  const photos = num(storeCar.imagesCount) ?? 0;
366
373
 
367
374
  // --- optional: leasing rate — ONLY when leasing is actually active for this car.
@@ -376,6 +383,7 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
376
383
  // saved adjustments). Only meaningful alongside an active lease rate.
377
384
  const leaseMileage = leasingActive ? num(price?.leasing?.annualMileage) : undefined;
378
385
  const leaseMonths = leasingActive ? num(price?.leasing?.paybackPeriod) : undefined;
386
+ const leaseFirstInstallment = leasingActive ? num(price?.leasing?.firstInstallment) : undefined;
379
387
 
380
388
  // --- optional: list price / savings — ONLY from a real strike price ---
381
389
  // transformDataForVehicles exposes oldMonthlyInstallment / highestPrice via
@@ -447,6 +455,7 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
447
455
 
448
456
  // media
449
457
  gallery,
458
+ ...(galleryLarge ? { galleryLarge } : {}),
450
459
  photos,
451
460
 
452
461
  // EnVKV
@@ -474,6 +483,7 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
474
483
  ...(lease != null ? { lease } : {}),
475
484
  ...(leaseMileage != null ? { leaseMileage } : {}),
476
485
  ...(leaseMonths != null ? { leaseMonths } : {}),
486
+ ...(leaseFirstInstallment != null ? { leaseFirstInstallment } : {}),
477
487
  ...(drop != null ? { drop } : {}),
478
488
  ...(dropPct != null ? { dropPct } : {}),
479
489
  ...(zeroDown ? { zeroDown } : {}),