@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 +55 -19
- package/cards/cardParts.tsx +268 -19
- 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 +32 -19
- package/locales/en/autobahn.json +31 -18
- package/package.json +1 -1
- package/theme/md3.css +537 -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/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,
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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: '
|
|
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={{
|
|
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
|
|
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
|
-
|
|
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
|
|
177
|
+
<EcoLine
|
|
178
|
+
t={t}
|
|
179
|
+
car={car}
|
|
180
|
+
style={{
|
|
181
|
+
marginTop: 'auto',
|
|
182
|
+
fontFamily: 'Arial, Helvetica, sans-serif',
|
|
183
|
+
fontStyle: 'normal',
|
|
184
|
+
fontWeight: 400,
|
|
185
|
+
fontSize: 11,
|
|
186
|
+
lineHeight: '11px',
|
|
187
|
+
textAlign: 'justify',
|
|
188
|
+
color: '#0e2238',
|
|
189
|
+
}}
|
|
190
|
+
/>
|
|
168
191
|
{/* Mobile-only row (CardCtaRow itself carries the show-mobile class) — always
|
|
169
192
|
rendered, with real fallbacks the page can override once it wires its own
|
|
170
193
|
handlers (e.g. the "Rate anpassen" FinanceDetailsModal sheet). Never hidden. */}
|
|
@@ -191,6 +214,19 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
191
214
|
</div>
|
|
192
215
|
)}
|
|
193
216
|
</div>
|
|
217
|
+
{viewerOpen && (
|
|
218
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
219
|
+
<VdpViewer
|
|
220
|
+
images={car.galleryLarge || car.gallery}
|
|
221
|
+
start={photoIndex}
|
|
222
|
+
onClose={() => setViewerOpen(false)}
|
|
223
|
+
t={t}
|
|
224
|
+
phone={DEALER_SALES_PHONE}
|
|
225
|
+
onCall={onCall || callDealerSalesPhone}
|
|
226
|
+
onEmail={onRequestOffer || (() => onOpen(car))}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
194
230
|
</article>
|
|
195
231
|
);
|
|
196
232
|
};
|
package/cards/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
|
/**
|
|
@@ -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=
|
|
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
|
-
{
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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', {
|
|
648
|
-
|
|
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';
|