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