@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.
- package/cards/CarCard.tsx +55 -19
- package/cards/CarRow.tsx +66 -16
- package/cards/cardParts.tsx +303 -31
- package/cards/index.ts +4 -0
- package/cards/types.ts +7 -0
- package/constants.ts +31 -31
- package/data/toAutobahnCar.ts +13 -3
- package/locales/de/autobahn.json +34 -20
- package/locales/en/autobahn.json +33 -19
- package/package.json +1 -1
- package/theme/md3.css +557 -51
- package/utils/financeFormat.ts +62 -62
- package/utils/priceRating.ts +58 -58
- package/vdp/FinanceCalculator.tsx +127 -96
- package/vdp/VdpModules.tsx +219 -44
package/cards/cardParts.tsx
CHANGED
|
@@ -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
|
|
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<{
|
|
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
|
|
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=
|
|
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=
|
|
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
|
-
{
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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=
|
|
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
|
-
|
|
610
|
-
|
|
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', {
|
|
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';
|
package/data/toAutobahnCar.ts
CHANGED
|
@@ -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
|
-
|
|
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 } : {}),
|