@bytebrand/fe-ui-core-autobahn 1.0.32 → 1.0.34
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/package.json +1 -1
- package/theme/md3.css +2 -0
- package/vdp/VdpModules.tsx +2035 -2034
package/vdp/VdpModules.tsx
CHANGED
|
@@ -1,2034 +1,2035 @@
|
|
|
1
|
-
// Autobahn (auto.de redesign) — Vehicle-Detail (VDP) presentational modules.
|
|
2
|
-
//
|
|
3
|
-
// Typed React 18 + TS port of the remaining VDP pieces from the design kit
|
|
4
|
-
// (docs/redesign/design-source/ui_kits/website/CdpModules.jsx + ScreenVDP.jsx).
|
|
5
|
-
// Each is a pure / presentational ES named export with an explicit props
|
|
6
|
-
// interface; none touches a store, the API or `window`. The page wiring that
|
|
7
|
-
// feeds these from the real stores is a SEPARATE later step (W3b).
|
|
8
|
-
//
|
|
9
|
-
// Replaces the design kit's globals:
|
|
10
|
-
// - `window.CO2_CLASSES` / `window.CO2_COLORS` → ../constants
|
|
11
|
-
// - `equipFor(id)` / `car.priceHistory` / market band → typed props on VehicleDetail
|
|
12
|
-
// - `assetUrl(...)` → pre-resolved image URLs (caller's job)
|
|
13
|
-
// - `data-lucide` icons → the ui kit's <Icon />
|
|
14
|
-
// The DealerBlock from CdpModules is intentionally NOT ported (auto.de is the
|
|
15
|
-
// seller, not a dealer marketplace).
|
|
16
|
-
//
|
|
17
|
-
// NEEDS-DATA discipline (the point of the ticket): wherever the design showed a
|
|
18
|
-
// figure we have no real source for, these render the NEEDS_DATA_LABEL
|
|
19
|
-
// ('Daten folgt') marker rather than inventing a value — see PriceHistory (whole
|
|
20
|
-
// card) and its market band.
|
|
21
|
-
//
|
|
22
|
-
// All components render correctly only inside a `<div className="md3">` subtree.
|
|
23
|
-
|
|
24
|
-
import React, { useState } from 'react';
|
|
25
|
-
import { TranslationFunction } from '../types';
|
|
26
|
-
|
|
27
|
-
import { Button, Icon, Spec, StatChip, Stars, Switch } from '../ui';
|
|
28
|
-
import { CO2_CLASSES, CO2_COLORS, TIER_LABELS, TIER_KEYS, TIER_COLORS } from '../constants';
|
|
29
|
-
import { defaultVdpSpecs, EquipmentGroups, VehicleDetail, VdpSpec, VdpReview } from './types';
|
|
30
|
-
import { CalcRow, expanderBtnStyle, FinanceMode, Slider, TermChips } from './FinanceCalculator';
|
|
31
|
-
|
|
32
|
-
const eur = (n: number): string => n.toLocaleString('de-DE');
|
|
33
|
-
|
|
34
|
-
/** mileage → 'X.XXX km' (de-DE grouping). */
|
|
35
|
-
function fmtKm(n: number): string {
|
|
36
|
-
return `${n.toLocaleString('de-DE')} km`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** strip a trailing parenthetical, e.g. '110 kW (150 PS)' → '110 kW'. */
|
|
40
|
-
function stripParen(s: string): string {
|
|
41
|
-
return s.replace(/\s*\(.*\)/, '');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// Header card — title, fact line, highlight chips, condition tiles
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
export interface VdpHeaderCardProps {
|
|
49
|
-
vehicle: VehicleDetail;
|
|
50
|
-
/** i18n function (drilled from the page). */
|
|
51
|
-
t: TranslationFunction;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** headline + trim/power + rating pill, then the dot-separated fact line — the vehicle's
|
|
55
|
-
* identity block. Split out so the mobile-only layout can show this BEFORE the finance
|
|
56
|
-
* card (see VdpTrustRow) while the highlights/condition tiles move after it. */
|
|
57
|
-
export const VdpHeaderSummary: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => {
|
|
58
|
-
const factLine: ReadonlyArray<readonly [string, string]> = [
|
|
59
|
-
['gauge', fmtKm(vehicle.km)],
|
|
60
|
-
['calendar', vehicle.reg],
|
|
61
|
-
['cog', vehicle.gear],
|
|
62
|
-
['fuel', stripParen(vehicle.fuel)],
|
|
63
|
-
['user', t('autobahn:header.owners', { owners: vehicle.owners, count: vehicle.owners })],
|
|
64
|
-
// body type closes the fact grid (mockup: km|EZ / Getriebe|Kraftstoff / Hand|Karosserie);
|
|
65
|
-
// the compact phone dot-line hides everything from "Hand" on via CSS (nth-child(n+5)).
|
|
66
|
-
...(vehicle.body ? ([['car-front', vehicle.body]] as const) : []),
|
|
67
|
-
];
|
|
68
|
-
return (
|
|
69
|
-
<>
|
|
70
|
-
<div className="vdp-headrow">
|
|
71
|
-
<div style={{ minWidth: 0 }}>
|
|
72
|
-
<h1 className="md-headline" style={{ margin: '0 0 4px' }}>
|
|
73
|
-
{vehicle.make} {vehicle.model}
|
|
74
|
-
</h1>
|
|
75
|
-
<p className="md-body vdp-trim" style={{ margin: 0, fontSize: 15 }}>
|
|
76
|
-
{vehicle.trim} · {vehicle.power}
|
|
77
|
-
</p>
|
|
78
|
-
{/* auto.de-ID lives in the offer card (next to Finanzierungsdetails), not here */}
|
|
79
|
-
</div>
|
|
80
|
-
{/* BL-08: no chip at all when there's no real rating (empty/broken price) — never a
|
|
81
|
-
fabricated "Top Preis"-shaped badge for `rating === ''`. */}
|
|
82
|
-
{vehicle.rating && (
|
|
83
|
-
<StatChip
|
|
84
|
-
tone={vehicle.rating === 'fair' ? 'warn' : 'good'}
|
|
85
|
-
icon={vehicle.rating === 'fair' ? 'minus' : 'check'}
|
|
86
|
-
>
|
|
87
|
-
{t('autobahn:tier.' + vehicle.rating)}
|
|
88
|
-
</StatChip>
|
|
89
|
-
)}
|
|
90
|
-
</div>
|
|
91
|
-
|
|
92
|
-
{/* fact line — plain text with dot separators, not pills */}
|
|
93
|
-
<div className="vdp-meta">
|
|
94
|
-
{factLine.map(([ic, v]) => (
|
|
95
|
-
<span key={ic}>
|
|
96
|
-
<Icon name={ic} size={16} color="var(--md-on-surface-variant)" stroke={1.8} />
|
|
97
|
-
{v}
|
|
98
|
-
</span>
|
|
99
|
-
))}
|
|
100
|
-
</div>
|
|
101
|
-
</>
|
|
102
|
-
);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/** hand-picked highlights — the accent rows of the header. ALL highlights are shown
|
|
106
|
-
* (review round 9: "all highlights shown", no 6-chip cap); the Figma pill colors live
|
|
107
|
-
* on .vdp-highlights .chip in the theme. */
|
|
108
|
-
export const VdpHeaderHighlights: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle }) => (
|
|
109
|
-
<div className="vdp-highlights" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
|
110
|
-
{vehicle.highlights.map((f) => (
|
|
111
|
-
<span key={f} className="chip chip--filter chip--selected" style={{ cursor: 'default' }}>
|
|
112
|
-
<Icon name="check" size={14} stroke={2.4} />
|
|
113
|
-
{f}
|
|
114
|
-
</span>
|
|
115
|
-
))}
|
|
116
|
-
</div>
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
/** condition (zustand) tiles — TÜV/Unfallfrei/Scheckheft/Garantie. */
|
|
120
|
-
export const VdpHeaderCondition: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => {
|
|
121
|
-
const { condition } = vehicle;
|
|
122
|
-
// condition.hu === 'Neu' compares the RAW backend data value, not a display string — left as-is.
|
|
123
|
-
const tiles: ReadonlyArray<readonly [string, string, string]> = [
|
|
124
|
-
['wrench', t('autobahn:header.huLabel'), condition.hu === 'Neu' ? t('autobahn:header.huNew') : t('autobahn:header.huUntil', { date: condition.hu })],
|
|
125
|
-
['shield-check', t('autobahn:header.accidentFree'), condition.unfallfrei ? t('autobahn:header.yes') : t('autobahn:header.no')],
|
|
126
|
-
['clipboard-check', t('autobahn:header.serviceHistory'), condition.scheckheft ? t('autobahn:header.yes') : t('autobahn:header.no')],
|
|
127
|
-
['award', t('autobahn:header.warranty'), condition.garantie],
|
|
128
|
-
];
|
|
129
|
-
return (
|
|
130
|
-
<div className="zustand-grid">
|
|
131
|
-
{tiles.map(([ic, l, v]) => (
|
|
132
|
-
<div key={l} className="zustand-tile">
|
|
133
|
-
<Icon name={ic} size={19} color="var(--md-primary)" stroke={1.9} style={{ flex: 'none' }} />
|
|
134
|
-
<span style={{ minWidth: 0 }}>
|
|
135
|
-
<span
|
|
136
|
-
className="md-label-sm"
|
|
137
|
-
style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
138
|
-
>
|
|
139
|
-
{l}
|
|
140
|
-
</span>
|
|
141
|
-
<span className="md-title-sm" style={{ display: 'block' }}>
|
|
142
|
-
{v}
|
|
143
|
-
</span>
|
|
144
|
-
</span>
|
|
145
|
-
</div>
|
|
146
|
-
))}
|
|
147
|
-
</div>
|
|
148
|
-
);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* One container holding the entire vehicle identity: headline + trim/power, the
|
|
153
|
-
* dot-separated fact line, the hand-picked highlight chips, and the condition
|
|
154
|
-
* (zustand) tiles footer. The gallery itself is composed by the page (W3b) — this
|
|
155
|
-
* is just the textual header card that sits beneath it. Desktop-only usage (mobile
|
|
156
|
-
* splits these 3 pieces across the "before/after finance card" layout — see the page).
|
|
157
|
-
*/
|
|
158
|
-
export const VdpHeaderCard: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => (
|
|
159
|
-
<div className="mcard" style={{ marginTop: 16, padding: '22px 24px 18px' }}>
|
|
160
|
-
<VdpHeaderSummary vehicle={vehicle} t={t} />
|
|
161
|
-
<div style={{ marginTop: 14 }}>
|
|
162
|
-
<VdpHeaderHighlights vehicle={vehicle} t={t} />
|
|
163
|
-
</div>
|
|
164
|
-
<VdpHeaderCondition vehicle={vehicle} t={t} />
|
|
165
|
-
</div>
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
export interface VdpTrustRowProps {
|
|
169
|
-
/** maps to carStore.isAvailable / state.isOnline — "Sofort verfügbar". */
|
|
170
|
-
available: boolean;
|
|
171
|
-
/** real totalCarImpCount from /service/user-actions/api/v1/stats/viewers — omit while loading. */
|
|
172
|
-
viewCount?: number;
|
|
173
|
-
/** real totalFavCount from the same endpoint — omit while loading. */
|
|
174
|
-
favCount?: number;
|
|
175
|
-
/** i18n function (drilled from the page). */
|
|
176
|
-
t: TranslationFunction;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** mobile-only trust-signal row shown between the identity block and the finance card:
|
|
180
|
-
* availability · view count · favorite count · "Geprüft" (every auto.de listing is
|
|
181
|
-
* 130-point inspected, so this one is always shown, not conditional real data). */
|
|
182
|
-
export const VdpTrustRow: React.FunctionComponent<VdpTrustRowProps> = ({ available, viewCount, favCount, t }) => (
|
|
183
|
-
// one swipeable line (mockup), never a ragged second row
|
|
184
|
-
<div style={{ display: 'flex', flexWrap: 'nowrap', overflowX: 'auto', scrollbarWidth: 'none', alignItems: 'center', gap: 5, marginTop: 10 }}>
|
|
185
|
-
{available && (
|
|
186
|
-
<span className="vdp-trust-chip">
|
|
187
|
-
<Icon name="check" size={13} stroke={2.4} color="var(--md-good)" />
|
|
188
|
-
{t('autobahn:availability.immediately')}
|
|
189
|
-
</span>
|
|
190
|
-
)}
|
|
191
|
-
{viewCount != null && (
|
|
192
|
-
<span className="vdp-trust-chip">
|
|
193
|
-
<Icon name="eye" size={13} />
|
|
194
|
-
{t('autobahn:card.activity.viewsCount', { count: viewCount })}
|
|
195
|
-
</span>
|
|
196
|
-
)}
|
|
197
|
-
{favCount != null && (
|
|
198
|
-
<span className="vdp-trust-chip">
|
|
199
|
-
<Icon name="heart" size={13} />
|
|
200
|
-
{t('autobahn:card.activity.favoritesCount', { count: favCount })}
|
|
201
|
-
</span>
|
|
202
|
-
)}
|
|
203
|
-
<span className="vdp-trust-chip">
|
|
204
|
-
<Icon name="shield-check" size={13} />
|
|
205
|
-
{t('autobahn:card.trust.checked')}
|
|
206
|
-
</span>
|
|
207
|
-
</div>
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
// ---------------------------------------------------------------------------
|
|
211
|
-
// Fahrzeugdaten spec table (mobile clamp + "Mehr anzeigen")
|
|
212
|
-
// ---------------------------------------------------------------------------
|
|
213
|
-
|
|
214
|
-
export interface VdpSpecTableProps {
|
|
215
|
-
vehicle: VehicleDetail;
|
|
216
|
-
/** i18n function (drilled from the page). */
|
|
217
|
-
t: TranslationFunction;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Fahrzeugdaten grid of Spec items. Uses the vehicle's explicit `specs` when
|
|
222
|
-
* present, else derives the design's twelve-row layout via `defaultVdpSpecs`. On
|
|
223
|
-
* phones the table clamps to a few rows (`vdp-specs--clamp`) with a "Mehr
|
|
224
|
-
* anzeigen" toggle (pure view state).
|
|
225
|
-
*/
|
|
226
|
-
export const VdpSpecTable: React.FunctionComponent<VdpSpecTableProps> = ({ vehicle, t }) => {
|
|
227
|
-
const [allSpecs, setAllSpecs] = useState(false);
|
|
228
|
-
const specs: VdpSpec[] = vehicle.specs && vehicle.specs.length ? vehicle.specs : defaultVdpSpecs(vehicle);
|
|
229
|
-
return (
|
|
230
|
-
<div className="mcard" id="vdp-daten" style={{ padding: 22 }}>
|
|
231
|
-
<h2 className="md-headline-sm" style={{ marginBottom: 16 }}>
|
|
232
|
-
{t('autobahn:specTable.title')}
|
|
233
|
-
</h2>
|
|
234
|
-
<div className={'vdp-specs' + (allSpecs ? '' : ' vdp-specs--clamp')}>
|
|
235
|
-
{specs.map(([ic, l, v]) => (
|
|
236
|
-
<Spec key={l} icon={ic} label={l}>
|
|
237
|
-
{v}
|
|
238
|
-
</Spec>
|
|
239
|
-
))}
|
|
240
|
-
</div>
|
|
241
|
-
{/* phone only: clamp button only meaningful when there are more than 6 specs */}
|
|
242
|
-
{specs.length > 6 && (
|
|
243
|
-
<button
|
|
244
|
-
className="mbtn mbtn--text show-mobile"
|
|
245
|
-
style={{ marginTop: 12, padding: 0 }}
|
|
246
|
-
onClick={() => setAllSpecs((a) => !a)}
|
|
247
|
-
>
|
|
248
|
-
{allSpecs ? t('autobahn:vdp.specs.showLess') : t('autobahn:specTable.showAll', { count: specs.length })}
|
|
249
|
-
<Icon name={allSpecs ? 'chevron-up' : 'chevron-down'} size={16} />
|
|
250
|
-
</button>
|
|
251
|
-
)}
|
|
252
|
-
</div>
|
|
253
|
-
);
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
// Equipment list (grouped + searchable)
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
|
|
260
|
-
export interface EquipmentListProps {
|
|
261
|
-
/** grouped equipment, e.g. { Komfort: [...], Sicherheit: [...] }. */
|
|
262
|
-
groups: EquipmentGroups;
|
|
263
|
-
/** i18n function (drilled from the page). */
|
|
264
|
-
t: TranslationFunction;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Grouped + searchable equipment list (Autohero / AutoScout pattern). */
|
|
268
|
-
export const EquipmentList: React.FunctionComponent<EquipmentListProps> = ({ groups, t }) => {
|
|
269
|
-
const [q, setQ] = useState('');
|
|
270
|
-
const [openAll, setOpenAll] = useState(false);
|
|
271
|
-
const ql = q.trim().toLowerCase();
|
|
272
|
-
const entries = Object.entries(groups)
|
|
273
|
-
.map(([g, items]): [string, string[]] => [g, items.filter((it) => !ql || it.toLowerCase().includes(ql))])
|
|
274
|
-
.filter(([, items]) => items.length);
|
|
275
|
-
const total = Object.values(groups).reduce((s, a) => s + a.length, 0);
|
|
276
|
-
return (
|
|
277
|
-
<div className="mcard" id="vdp-ausstattung" style={{ padding: 22 }}>
|
|
278
|
-
<div
|
|
279
|
-
style={{
|
|
280
|
-
display: 'flex',
|
|
281
|
-
alignItems: 'center',
|
|
282
|
-
justifyContent: 'space-between',
|
|
283
|
-
gap: 12,
|
|
284
|
-
marginBottom: 14,
|
|
285
|
-
flexWrap: 'wrap',
|
|
286
|
-
}}
|
|
287
|
-
>
|
|
288
|
-
<h2 className="md-headline-sm">
|
|
289
|
-
{t('autobahn:equipment.title')}{' '}
|
|
290
|
-
<span style={{ color: 'var(--md-on-surface-variant)', fontWeight: 600 }}>({total})</span>
|
|
291
|
-
</h2>
|
|
292
|
-
<label className="mfield" style={{ height: 40, maxWidth: 240, flex: 1, minWidth: 170 }}>
|
|
293
|
-
<Icon name="search" size={18} />
|
|
294
|
-
<input placeholder={t('autobahn:equipment.search.placeholder')} value={q} onChange={(e) => setQ(e.target.value)} />
|
|
295
|
-
{q && (
|
|
296
|
-
<button className="icon-btn" style={{ width: 28, height: 28 }} onClick={() => setQ('')}>
|
|
297
|
-
<Icon name="x" size={15} />
|
|
298
|
-
</button>
|
|
299
|
-
)}
|
|
300
|
-
</label>
|
|
301
|
-
</div>
|
|
302
|
-
<div className="eq-cols">
|
|
303
|
-
{entries.map(([g, items]) => (
|
|
304
|
-
<div key={g} style={{ breakInside: 'avoid', marginBottom: 18 }}>
|
|
305
|
-
<div className="md-title-sm" style={{ color: 'var(--md-link)', marginBottom: 8 }}>
|
|
306
|
-
{g}
|
|
307
|
-
</div>
|
|
308
|
-
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
|
309
|
-
{(openAll || ql ? items : items.slice(0, 4)).map((it) => (
|
|
310
|
-
<li
|
|
311
|
-
key={it}
|
|
312
|
-
style={{
|
|
313
|
-
display: 'flex',
|
|
314
|
-
gap: 9,
|
|
315
|
-
alignItems: 'flex-start',
|
|
316
|
-
fontSize: 13.5,
|
|
317
|
-
color: 'var(--md-on-surface-variant)',
|
|
318
|
-
}}
|
|
319
|
-
>
|
|
320
|
-
<Icon name="check" size={16} color="var(--md-good)" stroke={2.4} style={{ flex: 'none', marginTop: 1 }} />
|
|
321
|
-
{it}
|
|
322
|
-
</li>
|
|
323
|
-
))}
|
|
324
|
-
</ul>
|
|
325
|
-
</div>
|
|
326
|
-
))}
|
|
327
|
-
</div>
|
|
328
|
-
{!ql && !openAll && (
|
|
329
|
-
<button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setOpenAll(true)}>
|
|
330
|
-
{t('autobahn:equipment.showAll', { total })}
|
|
331
|
-
<Icon name="chevron-down" size={16} />
|
|
332
|
-
</button>
|
|
333
|
-
)}
|
|
334
|
-
{entries.length === 0 && <p className="md-body-sm">{t('autobahn:equipment.noResults', { q })}</p>}
|
|
335
|
-
</div>
|
|
336
|
-
);
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
// CO₂ / energy-efficiency label
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
export interface Co2LabelProps {
|
|
344
|
-
vehicle: VehicleDetail;
|
|
345
|
-
/** i18n function (drilled from the page). */
|
|
346
|
-
t: TranslationFunction;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** CO₂ / energy-efficiency label (EU-style stepped colour band, Figma pastel scale).
|
|
350
|
-
* Handles PHEV with BOTH class markers (gew. komb. + entladen komb.) on the scale. */
|
|
351
|
-
export const Co2Label: React.FunctionComponent<Co2LabelProps> = ({ vehicle, t }) => {
|
|
352
|
-
const p = vehicle.phev;
|
|
353
|
-
const idx = Math.max(0, CO2_CLASSES.indexOf((p ? p.co2classW : vehicle.co2class) as (typeof CO2_CLASSES)[number]));
|
|
354
|
-
// discharged-class marker (PHEV only); -1 when absent or same as the weighted class.
|
|
355
|
-
const idxD = p ? CO2_CLASSES.indexOf(p.co2classD as (typeof CO2_CLASSES)[number]) : -1;
|
|
356
|
-
const hasMarks = !!p && (idx >= 0 || idxD >= 0);
|
|
357
|
-
const stats: ReadonlyArray<readonly [string, string]> = p
|
|
358
|
-
? [
|
|
359
|
-
[t('autobahn:eco.stat.powerConsumptionWeighted'), p.consEl],
|
|
360
|
-
[t('autobahn:eco.stat.fuelConsumptionWeighted'), p.consFuel],
|
|
361
|
-
[t('autobahn:eco.stat.fuelConsumptionDischarged'), p.consDis],
|
|
362
|
-
[t('autobahn:eco.stat.co2WeightedCombined'), p.co2w],
|
|
363
|
-
]
|
|
364
|
-
: [
|
|
365
|
-
[t('autobahn:eco.stat.consumptionCombined'), vehicle.cons],
|
|
366
|
-
[t('autobahn:eco.stat.co2Emissions'), vehicle.co2],
|
|
367
|
-
[t('autobahn:eco.stat.co2Class'), vehicle.co2class],
|
|
368
|
-
];
|
|
369
|
-
return (
|
|
370
|
-
<div className="mcard" style={{ padding: 22 }}>
|
|
371
|
-
<h2 className="md-headline-sm" style={{ marginBottom: 14 }}>
|
|
372
|
-
{t('autobahn:eco.heading')}
|
|
373
|
-
</h2>
|
|
374
|
-
<div className="co2-stats">
|
|
375
|
-
{stats.map(([l, v]) => (
|
|
376
|
-
<div key={l}>
|
|
377
|
-
<div className="md-label-sm">{l}</div>
|
|
378
|
-
<div className="md-title-sm" style={{ fontSize: 16 }}>
|
|
379
|
-
{v}
|
|
380
|
-
</div>
|
|
381
|
-
</div>
|
|
382
|
-
))}
|
|
383
|
-
</div>
|
|
384
|
-
<div className="co2-scale">
|
|
385
|
-
{CO2_CLASSES.map((c, i) => (
|
|
386
|
-
<div key={c} className="co2-col">
|
|
387
|
-
{/* PHEV: tiny class markers above the weighted + discharged segments (mockup) */}
|
|
388
|
-
{hasMarks && (
|
|
389
|
-
<span className="co2-mark">
|
|
390
|
-
{i === idx
|
|
391
|
-
? t('autobahn:eco.weightedShort')
|
|
392
|
-
: i === idxD && idxD !== idx
|
|
393
|
-
? t('autobahn:eco.dischargedShort')
|
|
394
|
-
: ' '}
|
|
395
|
-
</span>
|
|
396
|
-
)}
|
|
397
|
-
<div
|
|
398
|
-
className={
|
|
399
|
-
'co2-seg' +
|
|
400
|
-
(i === idx ? ' co2-seg--on' : '') +
|
|
401
|
-
(i === idxD && idxD !== idx ? ' co2-seg--on2' : '')
|
|
402
|
-
}
|
|
403
|
-
style={{ ['--seg' as string]: CO2_COLORS[i] } as React.CSSProperties}
|
|
404
|
-
>
|
|
405
|
-
{c}
|
|
406
|
-
</div>
|
|
407
|
-
</div>
|
|
408
|
-
))}
|
|
409
|
-
</div>
|
|
410
|
-
{p ? (
|
|
411
|
-
<p className="md-label-sm" style={{ margin: '10px 0 0' }}>
|
|
412
|
-
{t('autobahn:eco.co2ClassPrefix')} <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classW}</b>{' '}
|
|
413
|
-
{t('autobahn:eco.weightedCombined')} · <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classD}</b>{' '}
|
|
414
|
-
{t('autobahn:eco.dischargedCombined')} · {t('autobahn:eco.energyClassWltpSuffix')}
|
|
415
|
-
</p>
|
|
416
|
-
) : (
|
|
417
|
-
<p className="md-label-sm" style={{ margin: '10px 0 0' }}>
|
|
418
|
-
{t('autobahn:eco.energyClass')}
|
|
419
|
-
</p>
|
|
420
|
-
)}
|
|
421
|
-
</div>
|
|
422
|
-
);
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
// ---------------------------------------------------------------------------
|
|
426
|
-
// Free-text vehicle description (Fahrzeugbeschreibung)
|
|
427
|
-
// ---------------------------------------------------------------------------
|
|
428
|
-
|
|
429
|
-
export interface VehicleDescriptionProps {
|
|
430
|
-
vehicle: VehicleDetail;
|
|
431
|
-
/**
|
|
432
|
-
* Pre-composed description text. When omitted a sensible default is built from
|
|
433
|
-
* the vehicle's typed fields (matching the design's template sentence).
|
|
434
|
-
*/
|
|
435
|
-
text?: string;
|
|
436
|
-
/** i18n function (drilled from the page). */
|
|
437
|
-
t: TranslationFunction;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function defaultDescription(v: VehicleDetail, t: TranslationFunction): string {
|
|
441
|
-
return t('autobahn:description.defaultBody', {
|
|
442
|
-
make: v.make,
|
|
443
|
-
model: v.model,
|
|
444
|
-
trim: v.trim,
|
|
445
|
-
power: v.power,
|
|
446
|
-
gear: v.gear,
|
|
447
|
-
owners: v.owners,
|
|
448
|
-
reg: v.reg,
|
|
449
|
-
km: v.km.toLocaleString('de-DE'),
|
|
450
|
-
color: v.color,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/** Free-text vehicle description with a "Mehr anzeigen" clamp (pure view state). */
|
|
455
|
-
export const VehicleDescription: React.FunctionComponent<VehicleDescriptionProps> = ({ vehicle, text, t }) => {
|
|
456
|
-
const [open, setOpen] = useState(false);
|
|
457
|
-
const body = text ?? defaultDescription(vehicle, t);
|
|
458
|
-
return (
|
|
459
|
-
<div className="mcard" style={{ padding: 22 }}>
|
|
460
|
-
<h2 className="md-headline-sm" style={{ marginBottom: 12 }}>
|
|
461
|
-
{t('autobahn:description.heading')}
|
|
462
|
-
</h2>
|
|
463
|
-
<p
|
|
464
|
-
className="md-body"
|
|
465
|
-
style={{ margin: 0, maxHeight: open ? 'none' : 96, overflow: 'hidden', position: 'relative' }}
|
|
466
|
-
>
|
|
467
|
-
{body}
|
|
468
|
-
</p>
|
|
469
|
-
<button className="mbtn mbtn--text" style={{ marginTop: 8, padding: 0 }} onClick={() => setOpen((o) => !o)}>
|
|
470
|
-
{open ? t('autobahn:description.showLess') : t('autobahn:description.showMore')}
|
|
471
|
-
<Icon name={open ? 'chevron-up' : 'chevron-down'} size={16} />
|
|
472
|
-
</button>
|
|
473
|
-
</div>
|
|
474
|
-
);
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
// ---------------------------------------------------------------------------
|
|
478
|
-
// Trust strip
|
|
479
|
-
// ---------------------------------------------------------------------------
|
|
480
|
-
|
|
481
|
-
/** [kebab-case lucide icon, label, optional subline (mobile Figma tiles)]. */
|
|
482
|
-
export type TrustItem = readonly [icon: string, label: string, sub?: string];
|
|
483
|
-
|
|
484
|
-
export interface TrustStripProps {
|
|
485
|
-
/** trust badges; defaults to auto.de's four standard promises. */
|
|
486
|
-
items?: ReadonlyArray<TrustItem>;
|
|
487
|
-
/** i18n function (drilled from the page). */
|
|
488
|
-
t: TranslationFunction;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/** auto.de's four standard promises — [icon, i18n label key, i18n subline key]. */
|
|
492
|
-
const DEFAULT_TRUST: ReadonlyArray<readonly [string, string, string]> = [
|
|
493
|
-
['shield-check', 'autobahn:trust.warranty', 'autobahn:trust.warrantySub'],
|
|
494
|
-
['undo-2', 'autobahn:trust.return', 'autobahn:trust.returnSub'],
|
|
495
|
-
['truck', 'autobahn:trust.delivery', 'autobahn:trust.deliverySub'],
|
|
496
|
-
['badge-check', 'autobahn:trust.inspection', 'autobahn:trust.inspectionSub'],
|
|
497
|
-
];
|
|
498
|
-
|
|
499
|
-
/** Trust-signal strip: quiet one-liner on desktop, Figma 2×2 tile card on mobile
|
|
500
|
-
* (the .trust-sub sublines only surface in the mobile tile layout via CSS). */
|
|
501
|
-
export const TrustStrip: React.FunctionComponent<TrustStripProps> = ({ items, t }) => (
|
|
502
|
-
<div className="trust-strip">
|
|
503
|
-
{(items ?? DEFAULT_TRUST.map(([ic, key, subKey]) => [ic, t(key), t(subKey)] as TrustItem)).map(([ic, label, sub]) => (
|
|
504
|
-
<div key={label}>
|
|
505
|
-
<Icon name={ic} size={17} color="var(--md-primary)" stroke={2} style={{ flex: 'none' }} />
|
|
506
|
-
<span style={{ minWidth: 0 }}>
|
|
507
|
-
{label}
|
|
508
|
-
{sub && <span className="trust-sub">{sub}</span>}
|
|
509
|
-
</span>
|
|
510
|
-
</div>
|
|
511
|
-
))}
|
|
512
|
-
</div>
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
// ---------------------------------------------------------------------------
|
|
516
|
-
// Price history + market band (NEEDS-DATA by default)
|
|
517
|
-
// ---------------------------------------------------------------------------
|
|
518
|
-
|
|
519
|
-
export interface PriceHistoryProps {
|
|
520
|
-
vehicle: VehicleDetail;
|
|
521
|
-
/** i18n function (drilled from the page). */
|
|
522
|
-
t: TranslationFunction;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Price-history sparkline + market-value position bar.
|
|
527
|
-
*
|
|
528
|
-
* NEEDS-DATA: the sparkline needs `priceHistory` (with at least two points) and
|
|
529
|
-
* the position bar needs the full market band (`marketLow` / `marketAvg` /
|
|
530
|
-
* `marketHigh`). Either section that lacks real data renders the
|
|
531
|
-
* NEEDS_DATA_LABEL ('Daten folgt') marker — we never draw a fake trend or
|
|
532
|
-
* position. Both are needs-data by default, so until W3b supplies them this card
|
|
533
|
-
* shows the markers.
|
|
534
|
-
*/
|
|
535
|
-
export const PriceHistory: React.FunctionComponent<PriceHistoryProps> = ({ vehicle, t }) => {
|
|
536
|
-
const pts = vehicle.priceHistory;
|
|
537
|
-
const hasHistory = !!pts && pts.length >= 2;
|
|
538
|
-
const hasMarket =
|
|
539
|
-
vehicle.marketLow != null &&
|
|
540
|
-
vehicle.marketAvg != null &&
|
|
541
|
-
vehicle.marketHigh != null &&
|
|
542
|
-
vehicle.marketHigh > vehicle.marketLow;
|
|
543
|
-
|
|
544
|
-
// Nothing real to show yet — hide the whole card instead of rendering stubs.
|
|
545
|
-
if (!hasHistory && !hasMarket) return null;
|
|
546
|
-
|
|
547
|
-
// --- history sparkline (only when real points exist) ---
|
|
548
|
-
let historyBlock: React.ReactNode;
|
|
549
|
-
if (hasHistory) {
|
|
550
|
-
const prices = pts!.map((p) => p.price);
|
|
551
|
-
const lo = Math.min(...prices);
|
|
552
|
-
const hi = Math.max(...prices);
|
|
553
|
-
const W = 280;
|
|
554
|
-
const H = 70;
|
|
555
|
-
const x = (i: number) => (i / (pts!.length - 1)) * W;
|
|
556
|
-
const y = (p: number) => H - ((p - lo) / (hi - lo || 1)) * H;
|
|
557
|
-
const last = pts![pts!.length - 1];
|
|
558
|
-
const line = pts!.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(p.price).toFixed(1)}`).join(' ');
|
|
559
|
-
const area = `${line} L ${W} ${H} L 0 ${H} Z`;
|
|
560
|
-
const drop = vehicle.listPrice != null ? vehicle.listPrice - vehicle.price : null;
|
|
561
|
-
historyBlock = (
|
|
562
|
-
<div>
|
|
563
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
564
|
-
{drop != null && drop > 0 ? (
|
|
565
|
-
<>
|
|
566
|
-
<StatChip tone="good" icon="trending-down">
|
|
567
|
-
{t('autobahn:priceHistory.dropAmount', { amount: eur(drop) })}
|
|
568
|
-
</StatChip>
|
|
569
|
-
<span className="md-label-sm">{t('autobahn:priceHistory.sinceListing')}</span>
|
|
570
|
-
</>
|
|
571
|
-
) : (
|
|
572
|
-
<span className="md-label-sm">{t('autobahn:priceHistory.label')}</span>
|
|
573
|
-
)}
|
|
574
|
-
</div>
|
|
575
|
-
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height="78" preserveAspectRatio="none" style={{ overflow: 'visible' }}>
|
|
576
|
-
<defs>
|
|
577
|
-
<linearGradient id="phg" x1="0" y1="0" x2="0" y2="1">
|
|
578
|
-
<stop offset="0%" stopColor="var(--md-primary)" stopOpacity=".22" />
|
|
579
|
-
<stop offset="100%" stopColor="var(--md-primary)" stopOpacity="0" />
|
|
580
|
-
</linearGradient>
|
|
581
|
-
</defs>
|
|
582
|
-
<path d={area} fill="url(#phg)" />
|
|
583
|
-
<path d={line} fill="none" stroke="var(--md-primary)" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
|
584
|
-
<circle cx={x(pts!.length - 1)} cy={y(last.price)} r="4.5" fill="var(--md-primary)" stroke="#fff" strokeWidth="2" />
|
|
585
|
-
</svg>
|
|
586
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
|
587
|
-
<span className="md-label-sm">{pts![0].label}</span>
|
|
588
|
-
<span className="md-label-sm">{last.label}</span>
|
|
589
|
-
</div>
|
|
590
|
-
</div>
|
|
591
|
-
);
|
|
592
|
-
} else {
|
|
593
|
-
historyBlock = (
|
|
594
|
-
<div>
|
|
595
|
-
<div className="md-title-sm" style={{ marginBottom: 12 }}>
|
|
596
|
-
{t('autobahn:priceHistory.title')}
|
|
597
|
-
</div>
|
|
598
|
-
<p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
|
|
599
|
-
{t('autobahn:common.needsData')}
|
|
600
|
-
</p>
|
|
601
|
-
</div>
|
|
602
|
-
);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// --- market-value position (only when the full band exists) ---
|
|
606
|
-
let marketBlock: React.ReactNode;
|
|
607
|
-
if (hasMarket) {
|
|
608
|
-
const mpos = Math.max(
|
|
609
|
-
0,
|
|
610
|
-
Math.min(1, (vehicle.price - vehicle.marketLow!) / (vehicle.marketHigh! - vehicle.marketLow!)),
|
|
611
|
-
);
|
|
612
|
-
const delta = vehicle.marketAvg! - vehicle.price;
|
|
613
|
-
marketBlock = (
|
|
614
|
-
<div>
|
|
615
|
-
<div className="md-title-sm" style={{ marginBottom: 12 }}>
|
|
616
|
-
{t('autobahn:marketValue.heading')}
|
|
617
|
-
</div>
|
|
618
|
-
<div
|
|
619
|
-
style={{
|
|
620
|
-
position: 'relative',
|
|
621
|
-
height: 10,
|
|
622
|
-
borderRadius: 'var(--md-r-full)',
|
|
623
|
-
background: 'linear-gradient(90deg,#0F7B43,#C7B53A 55%,#D4502B)',
|
|
624
|
-
}}
|
|
625
|
-
>
|
|
626
|
-
<span
|
|
627
|
-
style={{
|
|
628
|
-
position: 'absolute',
|
|
629
|
-
top: '50%',
|
|
630
|
-
left: `calc(${mpos * 100}% - 9px)`,
|
|
631
|
-
transform: 'translateY(-50%)',
|
|
632
|
-
width: 18,
|
|
633
|
-
height: 18,
|
|
634
|
-
borderRadius: '50%',
|
|
635
|
-
background: '#fff',
|
|
636
|
-
boxShadow: 'var(--md-e2), 0 0 0 2px var(--md-primary)',
|
|
637
|
-
}}
|
|
638
|
-
/>
|
|
639
|
-
</div>
|
|
640
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
|
|
641
|
-
<span className="md-label-sm">{eur(vehicle.marketLow!)} €</span>
|
|
642
|
-
<span className="md-label-sm">{eur(vehicle.marketHigh!)} €</span>
|
|
643
|
-
</div>
|
|
644
|
-
{delta > 0 && (
|
|
645
|
-
<div
|
|
646
|
-
style={{
|
|
647
|
-
display: 'flex',
|
|
648
|
-
alignItems: 'center',
|
|
649
|
-
gap: 8,
|
|
650
|
-
marginTop: 12,
|
|
651
|
-
padding: '10px 12px',
|
|
652
|
-
borderRadius: 'var(--md-r-sm)',
|
|
653
|
-
background: 'var(--md-good-container)',
|
|
654
|
-
}}
|
|
655
|
-
>
|
|
656
|
-
<Icon name="badge-check" size={18} color="var(--md-on-good-container)" />
|
|
657
|
-
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-on-good-container)' }}>
|
|
658
|
-
{t('autobahn:marketValue.belowAverage', { rating: t('autobahn:tier.' + vehicle.rating), amount: eur(delta) })}
|
|
659
|
-
</span>
|
|
660
|
-
</div>
|
|
661
|
-
)}
|
|
662
|
-
</div>
|
|
663
|
-
);
|
|
664
|
-
} else {
|
|
665
|
-
marketBlock = (
|
|
666
|
-
<div>
|
|
667
|
-
<div className="md-title-sm" style={{ marginBottom: 12 }}>
|
|
668
|
-
{t('autobahn:marketValue.heading')}
|
|
669
|
-
</div>
|
|
670
|
-
<p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
|
|
671
|
-
{t('autobahn:common.needsData')}
|
|
672
|
-
</p>
|
|
673
|
-
</div>
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
return (
|
|
678
|
-
<div className="mcard" id="vdp-preis" style={{ padding: 22 }}>
|
|
679
|
-
<h2 className="md-headline-sm" style={{ marginBottom: 4 }}>
|
|
680
|
-
{t('autobahn:priceMarket.heading')}
|
|
681
|
-
</h2>
|
|
682
|
-
<p className="md-body-sm" style={{ marginBottom: 18 }}>
|
|
683
|
-
{t('autobahn:priceMarket.description')}
|
|
684
|
-
</p>
|
|
685
|
-
<div className="pmv-grid">
|
|
686
|
-
{historyBlock}
|
|
687
|
-
{marketBlock}
|
|
688
|
-
</div>
|
|
689
|
-
</div>
|
|
690
|
-
);
|
|
691
|
-
};
|
|
692
|
-
|
|
693
|
-
// ---------------------------------------------------------------------------
|
|
694
|
-
// Protection products (CNP Santander upsell) — optional, non-functional UI
|
|
695
|
-
// ---------------------------------------------------------------------------
|
|
696
|
-
|
|
697
|
-
/** [kebab-case lucide icon, name, description, monthly price EUR]. */
|
|
698
|
-
export type ProtectionProduct = readonly [icon: string, name: string, desc: string, price: number];
|
|
699
|
-
|
|
700
|
-
export interface ProtectionProductsProps {
|
|
701
|
-
/** the upsell products; defaults to the design's four CNP Santander packages. */
|
|
702
|
-
items?: ReadonlyArray<ProtectionProduct>;
|
|
703
|
-
/** i18n function (drilled from the page). */
|
|
704
|
-
t: TranslationFunction;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* The design's four CNP Santander packages — [icon, product name (proper noun,
|
|
709
|
-
* also the selection key), i18n description key, monthly example price EUR].
|
|
710
|
-
*/
|
|
711
|
-
const DEFAULT_PROTECTION: ReadonlyArray<ProtectionProduct> = [
|
|
712
|
-
['shield-check', 'Safe Plus', 'autobahn:protection.items.safePlus.desc', 9],
|
|
713
|
-
['life-buoy', 'Drive Pro', 'autobahn:protection.items.drivePro.desc', 7],
|
|
714
|
-
['wrench', 'Smart Repair', 'autobahn:protection.items.smartRepair.desc', 12],
|
|
715
|
-
['rotate-ccw', 'Smart Return', 'autobahn:protection.items.smartReturn.desc', 10],
|
|
716
|
-
];
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Partner attach products (CNP Santander) sold alongside the financing. Kept as a
|
|
720
|
-
* presentational upsell demo: the selectable rows are pure VIEW state (no store /
|
|
721
|
-
* order side effects, the prices are example values), and the catalogue is a prop
|
|
722
|
-
* with sane defaults. Functional ordering is out of scope for W3a.
|
|
723
|
-
*/
|
|
724
|
-
export const ProtectionProducts: React.FunctionComponent<ProtectionProductsProps> = ({ items, t }) => {
|
|
725
|
-
const products: ReadonlyArray<ProtectionProduct> =
|
|
726
|
-
items ?? DEFAULT_PROTECTION.map(([ic, name, descKey, price]) => [ic, name, t(descKey), price] as ProtectionProduct);
|
|
727
|
-
const [sel, setSel] = useState<Record<string, boolean>>({});
|
|
728
|
-
const chosen = products.filter(([, n]) => sel[n]);
|
|
729
|
-
const sum = chosen.reduce((a, [, , , p]) => a + p, 0);
|
|
730
|
-
return (
|
|
731
|
-
<div className="mcard" style={{ padding: 20 }}>
|
|
732
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
733
|
-
<span
|
|
734
|
-
style={{
|
|
735
|
-
width: 34,
|
|
736
|
-
height: 34,
|
|
737
|
-
borderRadius: 'var(--md-r-full)',
|
|
738
|
-
background: 'var(--md-sc)',
|
|
739
|
-
display: 'flex',
|
|
740
|
-
alignItems: 'center',
|
|
741
|
-
justifyContent: 'center',
|
|
742
|
-
flex: 'none',
|
|
743
|
-
}}
|
|
744
|
-
>
|
|
745
|
-
<Icon name="umbrella" size={18} color="var(--md-on-surface-variant)" />
|
|
746
|
-
</span>
|
|
747
|
-
<div>
|
|
748
|
-
<div className="md-title-sm">{t('autobahn:protection.heading')}</div>
|
|
749
|
-
<div className="md-label-sm">{t('autobahn:protection.subtitle')}</div>
|
|
750
|
-
</div>
|
|
751
|
-
</div>
|
|
752
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
753
|
-
{products.map(([ic, name, desc, price]) => (
|
|
754
|
-
<button
|
|
755
|
-
key={name}
|
|
756
|
-
onClick={() => setSel((s) => ({ ...s, [name]: !s[name] }))}
|
|
757
|
-
style={{
|
|
758
|
-
display: 'flex',
|
|
759
|
-
alignItems: 'center',
|
|
760
|
-
gap: 12,
|
|
761
|
-
width: '100%',
|
|
762
|
-
textAlign: 'left',
|
|
763
|
-
cursor: 'pointer',
|
|
764
|
-
padding: '10px 12px',
|
|
765
|
-
borderRadius: 'var(--md-r-md)',
|
|
766
|
-
background: sel[name] ? 'var(--md-primary-container)' : 'var(--md-sc-low)',
|
|
767
|
-
border: '1px solid ' + (sel[name] ? 'var(--md-primary)' : 'transparent'),
|
|
768
|
-
transition: 'background var(--dur-1), border-color var(--dur-1)',
|
|
769
|
-
}}
|
|
770
|
-
>
|
|
771
|
-
<Icon
|
|
772
|
-
name={ic}
|
|
773
|
-
size={19}
|
|
774
|
-
stroke={1.8}
|
|
775
|
-
color={sel[name] ? 'var(--md-on-primary-container)' : 'var(--md-on-surface-variant)'}
|
|
776
|
-
style={{ flex: 'none' }}
|
|
777
|
-
/>
|
|
778
|
-
<span style={{ flex: 1, minWidth: 0 }}>
|
|
779
|
-
<span className="md-title-sm" style={{ display: 'block', fontSize: 13.5 }}>
|
|
780
|
-
{name}
|
|
781
|
-
</span>
|
|
782
|
-
<span className="md-label-sm" style={{ display: 'block', fontSize: 11.5 }}>
|
|
783
|
-
{desc}
|
|
784
|
-
</span>
|
|
785
|
-
</span>
|
|
786
|
-
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
787
|
-
<span className="num md-label-sm" style={{ fontWeight: 600 }}>
|
|
788
|
-
+{price} €/mtl.
|
|
789
|
-
</span>
|
|
790
|
-
<span
|
|
791
|
-
style={{
|
|
792
|
-
width: 20,
|
|
793
|
-
height: 20,
|
|
794
|
-
borderRadius: '50%',
|
|
795
|
-
display: 'flex',
|
|
796
|
-
alignItems: 'center',
|
|
797
|
-
justifyContent: 'center',
|
|
798
|
-
background: sel[name] ? 'var(--md-primary)' : 'transparent',
|
|
799
|
-
border: sel[name] ? 0 : '1.5px solid var(--md-outline)',
|
|
800
|
-
}}
|
|
801
|
-
>
|
|
802
|
-
{sel[name] && <Icon name="check" size={13} stroke={3} color="#fff" />}
|
|
803
|
-
</span>
|
|
804
|
-
</span>
|
|
805
|
-
</button>
|
|
806
|
-
))}
|
|
807
|
-
</div>
|
|
808
|
-
{sum > 0 && (
|
|
809
|
-
<div
|
|
810
|
-
style={{
|
|
811
|
-
display: 'flex',
|
|
812
|
-
justifyContent: 'space-between',
|
|
813
|
-
alignItems: 'baseline',
|
|
814
|
-
marginTop: 12,
|
|
815
|
-
paddingTop: 10,
|
|
816
|
-
borderTop: '1px solid var(--md-outline-variant)',
|
|
817
|
-
}}
|
|
818
|
-
>
|
|
819
|
-
<span className="md-label-sm">
|
|
820
|
-
{t('autobahn:protection.packagesSelected', { count: chosen.length })}
|
|
821
|
-
</span>
|
|
822
|
-
<span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
|
|
823
|
-
+{sum} €/mtl.
|
|
824
|
-
</span>
|
|
825
|
-
</div>
|
|
826
|
-
)}
|
|
827
|
-
<div className="md-label-sm" style={{ marginTop: 10, fontSize: 11.5 }}>
|
|
828
|
-
{t('autobahn:protection.exampleValues', { partner: 'CNP Santander Insurance' })}
|
|
829
|
-
</div>
|
|
830
|
-
</div>
|
|
831
|
-
);
|
|
832
|
-
};
|
|
833
|
-
|
|
834
|
-
// ---------------------------------------------------------------------------
|
|
835
|
-
// In-page anchor nav (mobile)
|
|
836
|
-
// ---------------------------------------------------------------------------
|
|
837
|
-
|
|
838
|
-
/** [target element id, label]. */
|
|
839
|
-
export type VdpAnchor = readonly [id: string, label: string];
|
|
840
|
-
|
|
841
|
-
export interface VdpAnchorsProps {
|
|
842
|
-
/** anchor chips; defaults to the four VDP sections. */
|
|
843
|
-
items?: ReadonlyArray<VdpAnchor>;
|
|
844
|
-
/**
|
|
845
|
-
* Optional navigation handler. When provided it OWNS the scroll (e.g. the page
|
|
846
|
-
* in W3b). When omitted the component falls back to scrolling the page's own
|
|
847
|
-
* element with that id into view — intrinsic anchor-nav behaviour on the
|
|
848
|
-
* document, not a data source.
|
|
849
|
-
*/
|
|
850
|
-
onNavigate?: (id: string) => void;
|
|
851
|
-
/** i18n function (drilled from the page). */
|
|
852
|
-
t: TranslationFunction;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/** the four VDP sections — [target element id, i18n label key]. */
|
|
856
|
-
const DEFAULT_ANCHORS: ReadonlyArray<VdpAnchor> = [
|
|
857
|
-
['vdp-daten', 'autobahn:anchors.data'],
|
|
858
|
-
['vdp-ausstattung', 'autobahn:anchors.equipment'],
|
|
859
|
-
['vdp-preis', 'autobahn:anchors.priceMarket'],
|
|
860
|
-
['vdp-check', 'autobahn:anchors.inspection'],
|
|
861
|
-
];
|
|
862
|
-
|
|
863
|
-
/** Sticky in-page anchor nav (mobile) — quiet chips. */
|
|
864
|
-
export const VdpAnchors: React.FunctionComponent<VdpAnchorsProps> = ({ items, onNavigate, t }) => {
|
|
865
|
-
const anchors: ReadonlyArray<VdpAnchor> =
|
|
866
|
-
items ?? DEFAULT_ANCHORS.map(([id, key]) => [id, t(key)] as VdpAnchor);
|
|
867
|
-
const go = (id: string) => {
|
|
868
|
-
if (onNavigate) {
|
|
869
|
-
onNavigate(id);
|
|
870
|
-
return;
|
|
871
|
-
}
|
|
872
|
-
if (typeof document === 'undefined') return;
|
|
873
|
-
const el = document.getElementById(id);
|
|
874
|
-
if (!el) return;
|
|
875
|
-
let r = el.getBoundingClientRect();
|
|
876
|
-
if (!r.height && el.firstElementChild) r = el.firstElementChild.getBoundingClientRect();
|
|
877
|
-
window.scrollTo({ top: r.top + window.scrollY - 60, behavior: 'smooth' });
|
|
878
|
-
};
|
|
879
|
-
return (
|
|
880
|
-
<div className="vdp-anchors show-mobile" style={{ background: 'var(--md-sc-low)' }}>
|
|
881
|
-
{anchors.map(([id, l]) => (
|
|
882
|
-
<button key={id} className="chip" onClick={() => go(id)} style={{ flex: 'none' }}>
|
|
883
|
-
{l}
|
|
884
|
-
</button>
|
|
885
|
-
))}
|
|
886
|
-
</div>
|
|
887
|
-
);
|
|
888
|
-
};
|
|
889
|
-
|
|
890
|
-
// ---------------------------------------------------------------------------
|
|
891
|
-
// Desktop sticky summary bar
|
|
892
|
-
// ---------------------------------------------------------------------------
|
|
893
|
-
|
|
894
|
-
export interface VdpStickyBarProps {
|
|
895
|
-
vehicle: VehicleDetail;
|
|
896
|
-
/** thumbnail image URL (already resolved) for the docked bar. */
|
|
897
|
-
image: string;
|
|
898
|
-
/**
|
|
899
|
-
* Whether the bar is docked/visible. Controlled by the page (W3b owns the
|
|
900
|
-
* scroll position → show toggle), keeping this component pure — no window
|
|
901
|
-
* scroll listener here.
|
|
902
|
-
*/
|
|
903
|
-
show: boolean;
|
|
904
|
-
/** primary CTA click. */
|
|
905
|
-
onSecure?: () => void;
|
|
906
|
-
/** i18n function (drilled from the page). */
|
|
907
|
-
t: TranslationFunction;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
|
|
911
|
-
export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
|
|
912
|
-
<div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
|
|
913
|
-
<div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
|
|
914
|
-
<img
|
|
915
|
-
src={image}
|
|
916
|
-
alt=""
|
|
917
|
-
style={{
|
|
918
|
-
width: 64,
|
|
919
|
-
height: 44,
|
|
920
|
-
objectFit: 'cover',
|
|
921
|
-
borderRadius: 'var(--md-r-sm)',
|
|
922
|
-
background: '#fff',
|
|
923
|
-
border: '1px solid var(--md-outline-variant)',
|
|
924
|
-
flex: 'none',
|
|
925
|
-
}}
|
|
926
|
-
/>
|
|
927
|
-
<div style={{ minWidth: 0, flex: 1 }}>
|
|
928
|
-
<div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
929
|
-
{vehicle.make} {vehicle.model} · {vehicle.trim}
|
|
930
|
-
</div>
|
|
931
|
-
<div className="md-label-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
932
|
-
{fmtKm(vehicle.km)} · {vehicle.reg} · {vehicle.gear}
|
|
933
|
-
</div>
|
|
934
|
-
</div>
|
|
935
|
-
<div style={{ textAlign: 'right', flex: 'none' }}>
|
|
936
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
|
|
937
|
-
<span className="md-label-sm">{t('autobahn:sticky.from')}</span>
|
|
938
|
-
<span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
|
|
939
|
-
{vehicle.monthly} €
|
|
940
|
-
</span>
|
|
941
|
-
<span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
|
|
942
|
-
</div>
|
|
943
|
-
<div className="md-label-sm">{t('autobahn:sticky.purchasePrice', { price: eur(vehicle.price) })}</div>
|
|
944
|
-
</div>
|
|
945
|
-
<Button variant="filled" icon="shield-check" style={{ flex: 'none' }} onClick={onSecure}>
|
|
946
|
-
{t('autobahn:sticky.secureOffer')}
|
|
947
|
-
</Button>
|
|
948
|
-
</div>
|
|
949
|
-
</div>
|
|
950
|
-
);
|
|
951
|
-
|
|
952
|
-
// ---------------------------------------------------------------------------
|
|
953
|
-
// Mobile sticky CTA bar
|
|
954
|
-
// ---------------------------------------------------------------------------
|
|
955
|
-
|
|
956
|
-
export interface VdpBottomBarProps {
|
|
957
|
-
vehicle: VehicleDetail;
|
|
958
|
-
/**
|
|
959
|
-
* Whether the bar is docked/visible — mirrors VdpStickyBar's `show` (same
|
|
960
|
-
* page-owned scroll-position toggle, STICKY_SHOW_AT). Was unconditionally
|
|
961
|
-
* visible before, unlike the desktop VdpStickyBar; the mockup only shows it
|
|
962
|
-
* after scrolling past the main offer card.
|
|
963
|
-
*/
|
|
964
|
-
show: boolean;
|
|
965
|
-
/** active payment mode — drives the tabs + the displayed figure (rate vs price). */
|
|
966
|
-
mode?: FinanceMode;
|
|
967
|
-
/** mode tab tapped; when omitted the tabs row is not rendered. */
|
|
968
|
-
onModeChange?: (mode: FinanceMode) => void;
|
|
969
|
-
/** monthly rate for the CURRENT mode (fin/leasing differ); falls back to vehicle.monthly. */
|
|
970
|
-
monthly?: number;
|
|
971
|
-
/** price-rating label — drives the eval pill + gradient bar (same as the offer card). */
|
|
972
|
-
ratingLabel?: string;
|
|
973
|
-
/** primary CTA click. */
|
|
974
|
-
onSecure?: () => void;
|
|
975
|
-
/** phone CTA click. */
|
|
976
|
-
onCall?: () => void;
|
|
977
|
-
/** opens the "Rate anpassen" sheet; when omitted the button is not rendered. */
|
|
978
|
-
onOpenAdjust?: () => void;
|
|
979
|
-
/** i18n function (drilled from the page). */
|
|
980
|
-
t: TranslationFunction;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/** Mobile sticky bottom bar (Figma "Sticky Footer"): mode tabs, rate + price rating,
|
|
984
|
-
* then Angebot anfragen · phone · Rate anpassen. No struck old rate — there is no
|
|
985
|
-
* real previous-rate source (needs-data discipline), so nothing is fabricated. */
|
|
986
|
-
export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({
|
|
987
|
-
vehicle,
|
|
988
|
-
show,
|
|
989
|
-
mode = 'fin',
|
|
990
|
-
onModeChange,
|
|
991
|
-
monthly,
|
|
992
|
-
ratingLabel,
|
|
993
|
-
onSecure,
|
|
994
|
-
onCall,
|
|
995
|
-
onOpenAdjust,
|
|
996
|
-
t,
|
|
997
|
-
}) => {
|
|
998
|
-
const isCash = mode === 'cash';
|
|
999
|
-
const rate = monthly != null ? monthly : vehicle.monthly;
|
|
1000
|
-
const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
|
|
1001
|
-
const evalTier = tierIdx >= 0 ? tierIdx : 1;
|
|
1002
|
-
return (
|
|
1003
|
-
<div className={'vdp-bottom-bar show-mobile' + (show ? ' vdp-bottom-bar--on' : '')}>
|
|
1004
|
-
{onModeChange && (
|
|
1005
|
-
<div className="fin-tabs bb-tabs" role="tablist">
|
|
1006
|
-
{(
|
|
1007
|
-
[
|
|
1008
|
-
['fin', 'autobahn:finance.mode.financing'],
|
|
1009
|
-
['leasing', 'autobahn:finance.mode.leasing'],
|
|
1010
|
-
['cash', 'autobahn:card.price.buy'],
|
|
1011
|
-
] as Array<[FinanceMode, string]>
|
|
1012
|
-
).map(([k, labelKey]) => (
|
|
1013
|
-
<button
|
|
1014
|
-
key={k}
|
|
1015
|
-
type="button"
|
|
1016
|
-
role="tab"
|
|
1017
|
-
aria-selected={mode === k}
|
|
1018
|
-
className={'fin-tab' + (mode === k ? ' on' : '')}
|
|
1019
|
-
onClick={() => onModeChange(k)}
|
|
1020
|
-
>
|
|
1021
|
-
{t(labelKey)}
|
|
1022
|
-
</button>
|
|
1023
|
-
))}
|
|
1024
|
-
</div>
|
|
1025
|
-
)}
|
|
1026
|
-
<div className="bb-price-row">
|
|
1027
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 5, whiteSpace: 'nowrap', minWidth: 0 }}>
|
|
1028
|
-
<span className="num" style={{ fontWeight: 800, fontSize: 30, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
|
|
1029
|
-
{isCash ? eur(vehicle.price) : eur(Math.round(rate))} €
|
|
1030
|
-
</span>
|
|
1031
|
-
<span className="md-label-sm" style={{ fontWeight: 600 }}>
|
|
1032
|
-
{isCash ? t('autobahn:finance.fixedPrice') : `${t('autobahn:finance.exampleLabel')} ${t('autobahn:bottom.perMonth')}`}
|
|
1033
|
-
</span>
|
|
1034
|
-
</div>
|
|
1035
|
-
{ratingLabel && (
|
|
1036
|
-
<div className="bb-eval">
|
|
1037
|
-
<span
|
|
1038
|
-
className="fin-eval-pill"
|
|
1039
|
-
style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
|
|
1040
|
-
>
|
|
1041
|
-
<Icon
|
|
1042
|
-
name={evalTier <= 1 ? 'check' : evalTier === 2 ? 'minus' : 'trending-up'}
|
|
1043
|
-
size={13}
|
|
1044
|
-
stroke={2.6}
|
|
1045
|
-
color={TIER_COLORS[evalTier]}
|
|
1046
|
-
/>
|
|
1047
|
-
{t('autobahn:tier.' + TIER_KEYS[evalTier])}
|
|
1048
|
-
</span>
|
|
1049
|
-
<div className="fin-eval-bar">
|
|
1050
|
-
<div className="fin-eval-thumb" style={{ left: `${(evalTier + 0.5) * 20}%`, borderColor: TIER_COLORS[evalTier] }} />
|
|
1051
|
-
</div>
|
|
1052
|
-
</div>
|
|
1053
|
-
)}
|
|
1054
|
-
</div>
|
|
1055
|
-
<div className="bb-cta-row">
|
|
1056
|
-
<Button variant="filled" icon="file-check" style={{ flex: 1, minWidth: 0 }} onClick={onSecure}>
|
|
1057
|
-
{t('autobahn:bottom.secureOffer')}
|
|
1058
|
-
</Button>
|
|
1059
|
-
{/* padding:0 — icon-only button (same fix as the sheet's phone button) */}
|
|
1060
|
-
{onCall && <Button variant="outlined" icon="phone" style={{ width: 46, flex: 'none', padding: 0 }} onClick={onCall} />}
|
|
1061
|
-
{onOpenAdjust && !isCash && (
|
|
1062
|
-
<Button variant="outlined" icon="sliders-horizontal" className="bb-adjust" style={{ flex: 'none' }} onClick={onOpenAdjust}>
|
|
1063
|
-
{t('autobahn:finance.adjustRate')}
|
|
1064
|
-
</Button>
|
|
1065
|
-
)}
|
|
1066
|
-
</div>
|
|
1067
|
-
</div>
|
|
1068
|
-
);
|
|
1069
|
-
};
|
|
1070
|
-
|
|
1071
|
-
// ---------------------------------------------------------------------------
|
|
1072
|
-
// 130-Punkte-Check block
|
|
1073
|
-
// ---------------------------------------------------------------------------
|
|
1074
|
-
|
|
1075
|
-
export interface InspectionBlockProps {
|
|
1076
|
-
/** pass categories; e.g. ['Karosserie & Lack', 'Motor & Antrieb', …]. */
|
|
1077
|
-
categories: string[];
|
|
1078
|
-
/** i18n function (drilled from the page). */
|
|
1079
|
-
t: TranslationFunction;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
/** 130-Punkte-Check block — the auto.de inspection promise, all categories pass. */
|
|
1083
|
-
export const InspectionBlock: React.FunctionComponent<InspectionBlockProps> = ({ categories, t }) => (
|
|
1084
|
-
<div className="mcard mcard--filled" id="vdp-check" style={{ padding: 22 }}>
|
|
1085
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
|
1086
|
-
<Icon name="badge-check" size={24} color="var(--md-primary)" />
|
|
1087
|
-
<h2 className="md-headline-sm">{t('autobahn:inspection.passed')}</h2>
|
|
1088
|
-
</div>
|
|
1089
|
-
<div className="eq-cols">
|
|
1090
|
-
{categories.map((c) => (
|
|
1091
|
-
<div
|
|
1092
|
-
key={c}
|
|
1093
|
-
style={{
|
|
1094
|
-
display: 'flex',
|
|
1095
|
-
alignItems: 'center',
|
|
1096
|
-
gap: 9,
|
|
1097
|
-
fontSize: 13.5,
|
|
1098
|
-
color: 'var(--md-on-surface-variant)',
|
|
1099
|
-
marginBottom: 10,
|
|
1100
|
-
}}
|
|
1101
|
-
>
|
|
1102
|
-
<Icon name="check-circle" size={18} color="var(--md-good)" stroke={2} style={{ flex: 'none' }} />
|
|
1103
|
-
{c}
|
|
1104
|
-
</div>
|
|
1105
|
-
))}
|
|
1106
|
-
</div>
|
|
1107
|
-
</div>
|
|
1108
|
-
);
|
|
1109
|
-
|
|
1110
|
-
// ---------------------------------------------------------------------------
|
|
1111
|
-
// Finanzierungs-/Leasingdetails popup (desktop modal / mobile bottom-sheet)
|
|
1112
|
-
// ---------------------------------------------------------------------------
|
|
1113
|
-
|
|
1114
|
-
/** full-width label(+optional trailing price) / Switch row — Switch itself is inline-flex
|
|
1115
|
-
* (sized to its own content), so the space-between has to live on this wrapper, not on
|
|
1116
|
-
* Switch's `label` prop (which renders track-then-label, not a full-width justified row). */
|
|
1117
|
-
const ToggleRow: React.FunctionComponent<{
|
|
1118
|
-
label: string;
|
|
1119
|
-
price?: string;
|
|
1120
|
-
checked: boolean;
|
|
1121
|
-
disabled?: boolean;
|
|
1122
|
-
onChange: (checked: boolean) => void;
|
|
1123
|
-
}> = ({ label, price, checked, disabled, onChange }) => (
|
|
1124
|
-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
1125
|
-
<span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
1126
|
-
{label}
|
|
1127
|
-
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1128
|
-
</span>
|
|
1129
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
1130
|
-
{price && (
|
|
1131
|
-
<span className="num" style={{ fontWeight: 700, fontSize: 13.5 }}>
|
|
1132
|
-
{price}
|
|
1133
|
-
</span>
|
|
1134
|
-
)}
|
|
1135
|
-
<Switch checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} ariaLabel={label} />
|
|
1136
|
-
</div>
|
|
1137
|
-
</div>
|
|
1138
|
-
);
|
|
1139
|
-
|
|
1140
|
-
export interface FinanceDetailsModalProps {
|
|
1141
|
-
/** open flag (controlled by the page). */
|
|
1142
|
-
open: boolean;
|
|
1143
|
-
/** active payment mode — drives labels + which legal example shows. */
|
|
1144
|
-
mode: 'fin' | 'leasing' | 'cash';
|
|
1145
|
-
/** car make+model — mobile sheet subtitle under the title (mockup). */
|
|
1146
|
-
vehicleName?: string;
|
|
1147
|
-
price: number;
|
|
1148
|
-
monthly: number;
|
|
1149
|
-
apr: number;
|
|
1150
|
-
down: number;
|
|
1151
|
-
term: number;
|
|
1152
|
-
/** leasing annual mileage (km/yr), shown only in leasing mode. */
|
|
1153
|
-
annualMileage?: number;
|
|
1154
|
-
/** REAL figures from the financing config (NOT computed — balloon/Schlussrate makes monthly·term wrong). */
|
|
1155
|
-
netLoanAmount?: number;
|
|
1156
|
-
/** Schlussrate (balloon final installment); shown only when > 0. */
|
|
1157
|
-
finalInstallment?: number;
|
|
1158
|
-
/** Gesamt(rückzahlungs)betrag from the config; shown only when > 0 (never the wrong monthly·term). */
|
|
1159
|
-
grossAmount?: number;
|
|
1160
|
-
/** Sollzinssatz p.a. (%); shown only when > 0. */
|
|
1161
|
-
nominalRate?: number;
|
|
1162
|
-
/** slider bounds — when the change handlers are supplied, the modal shows adjustment sliders. */
|
|
1163
|
-
downMin?: number;
|
|
1164
|
-
downMax?: number;
|
|
1165
|
-
downStep?: number;
|
|
1166
|
-
termMin?: number;
|
|
1167
|
-
termMax?: number;
|
|
1168
|
-
termStep?: number;
|
|
1169
|
-
/** discrete selectable terms (months) for the Laufzeit chips. */
|
|
1170
|
-
termOptions?: number[];
|
|
1171
|
-
annualMileageMin?: number;
|
|
1172
|
-
annualMileageMax?: number;
|
|
1173
|
-
annualMileageStep?: number;
|
|
1174
|
-
/** slider change handlers (same as the calculator); they recompute the rate via the page. */
|
|
1175
|
-
onDownChange?: (down: number) => void;
|
|
1176
|
-
onTermChange?: (term: number) => void;
|
|
1177
|
-
onAnnualMileageChange?: (km: number) => void;
|
|
1178
|
-
onClose: () => void;
|
|
1179
|
-
/** mobile only — start with the Finanzierungs-/Leasingdetails breakdown already expanded
|
|
1180
|
-
* (opened directly via that link, skipping the "Rate anpassen" collapsed default view). */
|
|
1181
|
-
initialShowBreakdown?: boolean;
|
|
1182
|
-
/** mobile only — price-rating label, drives the eval pill/bar (same as the collapsed card). */
|
|
1183
|
-
ratingLabel?: string;
|
|
1184
|
-
/** mobile only — mode tabs inside the sheet (mirrors the collapsed card's tabs). */
|
|
1185
|
-
onModeChange?: (mode: FinanceMode) => void;
|
|
1186
|
-
/** mobile only — primary "Rate anfragen"/"Angebot anfragen" CTA. */
|
|
1187
|
-
onSecure?: () => void;
|
|
1188
|
-
/** mobile only — secondary call CTA. */
|
|
1189
|
-
onCall?: () => void;
|
|
1190
|
-
/** mobile only — "Mit Schlussrate" toggle (financing only; leasing has no balloon concept). */
|
|
1191
|
-
withFinalInstallment?: boolean;
|
|
1192
|
-
onToggleFinalInstallment?: (checked: boolean) => void;
|
|
1193
|
-
/** mobile only — "Rundum-Sorglos-Paket" toggle (all modes; forced-on + disabled for cash). */
|
|
1194
|
-
withAddServices?: boolean;
|
|
1195
|
-
addServicesCost?: number;
|
|
1196
|
-
addServicesDisabled?: boolean;
|
|
1197
|
-
onToggleAddServices?: (checked: boolean) => void;
|
|
1198
|
-
/** i18n function (drilled from the page). */
|
|
1199
|
-
t: TranslationFunction;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
/**
|
|
1203
|
-
* Full payment breakdown + legal example, opened from the calculator's
|
|
1204
|
-
* "Finanzierungsdetails" / "Leasingdetails" link (matches production, which uses a popup).
|
|
1205
|
-
* Desktop = centered modal, mobile = bottom-sheet (CSS). All figures are derived from the
|
|
1206
|
-
* live calculator props (no fabricated Sollzins/Schlussrate — only what we can compute).
|
|
1207
|
-
*/
|
|
1208
|
-
export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalProps> = ({
|
|
1209
|
-
open,
|
|
1210
|
-
mode,
|
|
1211
|
-
vehicleName,
|
|
1212
|
-
price,
|
|
1213
|
-
monthly,
|
|
1214
|
-
apr,
|
|
1215
|
-
down,
|
|
1216
|
-
term,
|
|
1217
|
-
annualMileage,
|
|
1218
|
-
netLoanAmount,
|
|
1219
|
-
finalInstallment,
|
|
1220
|
-
grossAmount,
|
|
1221
|
-
nominalRate,
|
|
1222
|
-
downMin,
|
|
1223
|
-
downMax,
|
|
1224
|
-
downStep = 100,
|
|
1225
|
-
termMin,
|
|
1226
|
-
termMax,
|
|
1227
|
-
termStep = 6,
|
|
1228
|
-
termOptions,
|
|
1229
|
-
annualMileageMin = 5000,
|
|
1230
|
-
annualMileageMax = 60000,
|
|
1231
|
-
annualMileageStep = 5000,
|
|
1232
|
-
onDownChange,
|
|
1233
|
-
onTermChange,
|
|
1234
|
-
onAnnualMileageChange,
|
|
1235
|
-
onClose,
|
|
1236
|
-
initialShowBreakdown,
|
|
1237
|
-
ratingLabel,
|
|
1238
|
-
onModeChange,
|
|
1239
|
-
onSecure,
|
|
1240
|
-
onCall,
|
|
1241
|
-
withFinalInstallment,
|
|
1242
|
-
onToggleFinalInstallment,
|
|
1243
|
-
withAddServices,
|
|
1244
|
-
addServicesCost,
|
|
1245
|
-
addServicesDisabled,
|
|
1246
|
-
onToggleAddServices,
|
|
1247
|
-
t,
|
|
1248
|
-
}) => {
|
|
1249
|
-
// mobile-only: the Finanzierungs-/Leasingdetails breakdown starts collapsed (opened via "Rate
|
|
1250
|
-
// anpassen") or expanded (opened directly via the details link) — reset on every open, since
|
|
1251
|
-
// this component stays mounted (the page always renders it, gated by `open`, not unmounted).
|
|
1252
|
-
const [showBreakdown, setShowBreakdown] = React.useState(!!initialShowBreakdown);
|
|
1253
|
-
React.useEffect(() => {
|
|
1254
|
-
if (open) setShowBreakdown(!!initialShowBreakdown);
|
|
1255
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1256
|
-
}, [open, initialShowBreakdown]);
|
|
1257
|
-
|
|
1258
|
-
if (!open) return null;
|
|
1259
|
-
const isLease = mode === 'leasing';
|
|
1260
|
-
const isCash = mode === 'cash';
|
|
1261
|
-
const title = isLease ? t('autobahn:modal.leasingDetails') : t('autobahn:modal.financingDetails');
|
|
1262
|
-
const mobileTitle = isCash
|
|
1263
|
-
? t('autobahn:modal.sheetTitleCash')
|
|
1264
|
-
: isLease
|
|
1265
|
-
? t('autobahn:modal.sheetTitleLeasing')
|
|
1266
|
-
: t('autobahn:modal.sheetTitleFinancing');
|
|
1267
|
-
// price-evaluation tier — identical derivation to the collapsed card (FinanceCalculator).
|
|
1268
|
-
const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
|
|
1269
|
-
const evalTier = tierIdx >= 0 ? tierIdx : 1;
|
|
1270
|
-
const evalTierLabel = t('autobahn:tier.' + TIER_KEYS[evalTier]);
|
|
1271
|
-
// adjustable only for fin/leasing, and only when the page supplies the change handlers + bounds.
|
|
1272
|
-
const adjustable = mode !== 'cash' && !!onDownChange && downMin != null && downMax != null && termMin != null && termMax != null;
|
|
1273
|
-
// discrete term chip values (supplied periods, else derived from the bounds).
|
|
1274
|
-
const termVals =
|
|
1275
|
-
termOptions && termOptions.length
|
|
1276
|
-
? termOptions
|
|
1277
|
-
: (() => {
|
|
1278
|
-
const out: number[] = [];
|
|
1279
|
-
for (let v = termMin as number; v <= (termMax as number); v += termStep || 12) out.push(v);
|
|
1280
|
-
return out.length ? out : [term];
|
|
1281
|
-
})();
|
|
1282
|
-
const pct = (n: number) => `${n.toFixed(2).replace('.', ',')} %`;
|
|
1283
|
-
// Only REAL figures (from the financing config). Balloon financing means monthly·term is NOT the
|
|
1284
|
-
// total, so Schlussrate + Gesamtbetrag come from the config and are omitted when unavailable —
|
|
1285
|
-
// never a fabricated/misleading total.
|
|
1286
|
-
const netto = netLoanAmount != null ? netLoanAmount : Math.max(0, price - down);
|
|
1287
|
-
const rows: Array<[string, string]> = [
|
|
1288
|
-
[t('autobahn:modal.vehiclePrice'), `${eur(price)} €`],
|
|
1289
|
-
[isLease ? t('autobahn:modal.leasingAmount') : t('autobahn:modal.netLoanAmount'), `${eur(netto)} €`],
|
|
1290
|
-
[t('autobahn:modal.downPayment'), `${eur(down)} €`],
|
|
1291
|
-
...(isLease && annualMileage != null ? ([[t('autobahn:modal.annualMileage'), `${eur(annualMileage)} km`]] as Array<[string, string]>) : []),
|
|
1292
|
-
[t('autobahn:modal.term'), t('autobahn:modal.months', { months: term, count: term })],
|
|
1293
|
-
...(nominalRate != null && nominalRate > 0 ? ([[t('autobahn:modal.nominalRate'), pct(nominalRate)]] as Array<[string, string]>) : []),
|
|
1294
|
-
[t('autobahn:modal.effectiveRate'), pct(apr)],
|
|
1295
|
-
...(finalInstallment != null && finalInstallment > 0 ? ([[t('autobahn:modal.finalInstallment'), `${eur(finalInstallment)} €`]] as Array<[string, string]>) : []),
|
|
1296
|
-
[isLease ? t('autobahn:modal.leasingRate') : t('autobahn:modal.monthlyRate'), t('autobahn:modal.ratePerMonth', { amount: eur(monthly) })],
|
|
1297
|
-
...(grossAmount != null && grossAmount > 0 ? ([[isLease ? t('autobahn:modal.totalAmount') : t('autobahn:modal.totalRepayment'), `${eur(grossAmount)} €`]] as Array<[string, string]>) : []),
|
|
1298
|
-
];
|
|
1299
|
-
const breakdownRows = rows.map(([l, v], i) => (
|
|
1300
|
-
<div
|
|
1301
|
-
key={l}
|
|
1302
|
-
style={{
|
|
1303
|
-
display: 'flex',
|
|
1304
|
-
justifyContent: 'space-between',
|
|
1305
|
-
gap: 12,
|
|
1306
|
-
padding: '9px 0',
|
|
1307
|
-
borderBottom: i < rows.length - 1 ? '1px solid var(--md-outline-variant)' : 0,
|
|
1308
|
-
}}
|
|
1309
|
-
>
|
|
1310
|
-
<span className="md-label-sm">{l}</span>
|
|
1311
|
-
<span className="num" style={{ fontSize: 13.5, fontWeight: 600 }}>
|
|
1312
|
-
{v}
|
|
1313
|
-
</span>
|
|
1314
|
-
</div>
|
|
1315
|
-
));
|
|
1316
|
-
|
|
1317
|
-
return (
|
|
1318
|
-
<div className="fin-modal-scrim" onClick={onClose}>
|
|
1319
|
-
<div className="fin-modal" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
|
1320
|
-
<div className="fin-modal-head">
|
|
1321
|
-
<h2 className="md-headline-sm hide-mobile" style={{ margin: 0 }}>
|
|
1322
|
-
{title}
|
|
1323
|
-
</h2>
|
|
1324
|
-
<h2 className="md-headline-sm show-mobile" style={{ margin: 0, flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
|
|
1325
|
-
{mobileTitle}
|
|
1326
|
-
{vehicleName && (
|
|
1327
|
-
<span className="md-label-sm" style={{ fontWeight: 400 }}>
|
|
1328
|
-
{vehicleName}
|
|
1329
|
-
</span>
|
|
1330
|
-
)}
|
|
1331
|
-
</h2>
|
|
1332
|
-
<button className="icon-btn" onClick={onClose} aria-label={t('autobahn:modal.close')}>
|
|
1333
|
-
<Icon name="x" size={20} />
|
|
1334
|
-
</button>
|
|
1335
|
-
</div>
|
|
1336
|
-
<div className="fin-modal-body">
|
|
1337
|
-
{/* ============================== DESKTOP (≥601px) — unchanged ============================== */}
|
|
1338
|
-
<div className="hide-mobile">
|
|
1339
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 14 }}>
|
|
1340
|
-
<span className="num" style={{ fontSize: 34, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
|
|
1341
|
-
{eur(monthly)} €
|
|
1342
|
-
</span>
|
|
1343
|
-
<span className="md-label-sm">{t('autobahn:finance.perMonth')}</span>
|
|
1344
|
-
</div>
|
|
1345
|
-
{/* adjustment sliders — change a value and every figure below recomputes live (via the page) */}
|
|
1346
|
-
{adjustable && (
|
|
1347
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginBottom: 18 }}>
|
|
1348
|
-
<div>
|
|
1349
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
|
1350
|
-
<span className="md-title-sm">{t('autobahn:finance.downPaymentSliderLabel')}</span>
|
|
1351
|
-
<span className="num" style={{ fontWeight: 700 }}>
|
|
1352
|
-
{eur(down)} €
|
|
1353
|
-
</span>
|
|
1354
|
-
</div>
|
|
1355
|
-
<Slider
|
|
1356
|
-
min={downMin as number}
|
|
1357
|
-
max={downMax as number}
|
|
1358
|
-
step={downStep}
|
|
1359
|
-
value={down}
|
|
1360
|
-
onChange={onDownChange as (n: number) => void}
|
|
1361
|
-
fmt={(v) => `${eur(Number(v))} €`}
|
|
1362
|
-
/>
|
|
1363
|
-
</div>
|
|
1364
|
-
{isLease && annualMileage != null && onAnnualMileageChange && (
|
|
1365
|
-
<div>
|
|
1366
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
|
1367
|
-
<span className="md-title-sm">{t('autobahn:finance.annualMileageLabel')}</span>
|
|
1368
|
-
<span className="num" style={{ fontWeight: 700 }}>
|
|
1369
|
-
{eur(annualMileage)} km
|
|
1370
|
-
</span>
|
|
1371
|
-
</div>
|
|
1372
|
-
<Slider
|
|
1373
|
-
min={annualMileageMin}
|
|
1374
|
-
max={annualMileageMax}
|
|
1375
|
-
step={annualMileageStep}
|
|
1376
|
-
value={annualMileage}
|
|
1377
|
-
onChange={onAnnualMileageChange}
|
|
1378
|
-
fmt={(v) => `${eur(Number(v))} km`}
|
|
1379
|
-
/>
|
|
1380
|
-
</div>
|
|
1381
|
-
)}
|
|
1382
|
-
<div>
|
|
1383
|
-
<div className="md-title-sm" style={{ marginBottom: 8 }}>
|
|
1384
|
-
{t('autobahn:finance.termSelection')}
|
|
1385
|
-
</div>
|
|
1386
|
-
<TermChips options={termVals} value={term} onChange={onTermChange as (n: number) => void} t={t} />
|
|
1387
|
-
</div>
|
|
1388
|
-
</div>
|
|
1389
|
-
)}
|
|
1390
|
-
{breakdownRows}
|
|
1391
|
-
<p className="md-label-sm" style={{ marginTop: 16, lineHeight: 1.55 }}>
|
|
1392
|
-
{isLease ? t('autobahn:modal.legalLeasing') : t('autobahn:modal.legalFinancing')}
|
|
1393
|
-
</p>
|
|
1394
|
-
</div>
|
|
1395
|
-
|
|
1396
|
-
{/* ============================== MOBILE (≤600px) — full "Rate anpassen" sheet ============================== */}
|
|
1397
|
-
<div className="show-mobile" style={{ flexDirection: 'column', gap: 18 }}>
|
|
1398
|
-
{onModeChange && (
|
|
1399
|
-
<div className="fin-tabs" role="tablist">
|
|
1400
|
-
{(
|
|
1401
|
-
[
|
|
1402
|
-
['fin', 'autobahn:finance.mode.financing'],
|
|
1403
|
-
['leasing', 'autobahn:finance.mode.leasing'],
|
|
1404
|
-
// this sheet is mobile-only, so the cash tab is "Kaufen" here (desktop card says "Barzahlung")
|
|
1405
|
-
['cash', 'autobahn:card.price.buy'],
|
|
1406
|
-
] as Array<[FinanceMode, string]>
|
|
1407
|
-
).map(([k, labelKey]) => (
|
|
1408
|
-
<button
|
|
1409
|
-
key={k}
|
|
1410
|
-
type="button"
|
|
1411
|
-
role="tab"
|
|
1412
|
-
aria-selected={mode === k}
|
|
1413
|
-
className={'fin-tab' + (mode === k ? ' on' : '')}
|
|
1414
|
-
onClick={() => onModeChange(k)}
|
|
1415
|
-
>
|
|
1416
|
-
{t(labelKey)}
|
|
1417
|
-
</button>
|
|
1418
|
-
))}
|
|
1419
|
-
</div>
|
|
1420
|
-
)}
|
|
1421
|
-
|
|
1422
|
-
<div className="fin-price-grid">
|
|
1423
|
-
<div className="fin-rate-main">
|
|
1424
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 5 }}>
|
|
1425
|
-
<span className="num" style={{ fontSize: 34, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
|
|
1426
|
-
{isCash ? eur(price) : eur(Math.round(monthly))}
|
|
1427
|
-
</span>
|
|
1428
|
-
<span className="md-body-sm" style={{ fontWeight: 600 }}>
|
|
1429
|
-
{isCash ? '€' : `€ ${t('autobahn:finance.perMonth')}`}
|
|
1430
|
-
</span>
|
|
1431
|
-
</div>
|
|
1432
|
-
</div>
|
|
1433
|
-
<div>
|
|
1434
|
-
{ratingLabel && (
|
|
1435
|
-
<span
|
|
1436
|
-
className="fin-eval-pill"
|
|
1437
|
-
style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
|
|
1438
|
-
>
|
|
1439
|
-
<Icon
|
|
1440
|
-
name={evalTier <= 1 ? 'check' : evalTier === 2 ? 'minus' : 'trending-up'}
|
|
1441
|
-
size={14}
|
|
1442
|
-
stroke={2.6}
|
|
1443
|
-
color={TIER_COLORS[evalTier]}
|
|
1444
|
-
/>
|
|
1445
|
-
{evalTierLabel}
|
|
1446
|
-
</span>
|
|
1447
|
-
)}
|
|
1448
|
-
</div>
|
|
1449
|
-
<div />
|
|
1450
|
-
<div>
|
|
1451
|
-
{ratingLabel && (
|
|
1452
|
-
<div className="fin-eval-bar">
|
|
1453
|
-
<div className="fin-eval-thumb" style={{ left: `${(evalTier + 0.5) * 20}%`, borderColor: TIER_COLORS[evalTier] }} />
|
|
1454
|
-
</div>
|
|
1455
|
-
)}
|
|
1456
|
-
</div>
|
|
1457
|
-
</div>
|
|
1458
|
-
|
|
1459
|
-
{isCash ? (
|
|
1460
|
-
<>
|
|
1461
|
-
<div>
|
|
1462
|
-
<CalcRow label={t('autobahn:finance.paymentLabel')}>{t('autobahn:finance.paymentValue')}</CalcRow>
|
|
1463
|
-
<CalcRow label={t('autobahn:finance.warrantyLabel')}>{t('autobahn:finance.warrantyValue')}</CalcRow>
|
|
1464
|
-
<CalcRow label={t('autobahn:finance.deliveryLabel')} last>
|
|
1465
|
-
{t('autobahn:finance.deliveryValue')}
|
|
1466
|
-
</CalcRow>
|
|
1467
|
-
</div>
|
|
1468
|
-
{addServicesCost != null && (
|
|
1469
|
-
<ToggleRow
|
|
1470
|
-
label={t('autobahn:finance.addServicesToggle')}
|
|
1471
|
-
price={t('autobahn:finance.addServicesOnce', { value: eur(addServicesCost) })}
|
|
1472
|
-
checked={!!withAddServices}
|
|
1473
|
-
disabled={addServicesDisabled}
|
|
1474
|
-
onChange={(checked) => onToggleAddServices && onToggleAddServices(checked)}
|
|
1475
|
-
/>
|
|
1476
|
-
)}
|
|
1477
|
-
<div style={{ display: 'flex', gap: 10 }}>
|
|
1478
|
-
<Button variant="filled" size="lg" icon="shield-check" style={{ flex: 1 }} onClick={onSecure}>
|
|
1479
|
-
{t('autobahn:finance.secureOffer')}
|
|
1480
|
-
</Button>
|
|
1481
|
-
{/* padding:0 — mbtn--lg's 30px side padding left no room for the icon at width 52 */}
|
|
1482
|
-
<Button variant="outlined" size="lg" icon="phone" style={{ width: 52, flex: 'none', padding: 0, borderRadius:
|
|
1483
|
-
</div>
|
|
1484
|
-
</>
|
|
1485
|
-
) : (
|
|
1486
|
-
<>
|
|
1487
|
-
{adjustable && (
|
|
1488
|
-
<div className="fin-sheet-adjust" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
1489
|
-
<div>
|
|
1490
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
1491
|
-
<span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
1492
|
-
{t('autobahn:finance.downPaymentSliderLabel')}
|
|
1493
|
-
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1494
|
-
</span>
|
|
1495
|
-
<span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
|
|
1496
|
-
{eur(down)} €
|
|
1497
|
-
</span>
|
|
1498
|
-
</div>
|
|
1499
|
-
<span className="fin-val-box num">{eur(down)} €</span>
|
|
1500
|
-
<Slider
|
|
1501
|
-
min={downMin as number}
|
|
1502
|
-
max={downMax as number}
|
|
1503
|
-
step={downStep}
|
|
1504
|
-
value={down}
|
|
1505
|
-
onChange={onDownChange as (n: number) => void}
|
|
1506
|
-
fmt={(v) => `${eur(Number(v))} €`}
|
|
1507
|
-
/>
|
|
1508
|
-
</div>
|
|
1509
|
-
{isLease && annualMileage != null && onAnnualMileageChange && (
|
|
1510
|
-
<div>
|
|
1511
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
1512
|
-
<span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
1513
|
-
{t('autobahn:finance.annualMileageLabel')}
|
|
1514
|
-
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1515
|
-
</span>
|
|
1516
|
-
<span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
|
|
1517
|
-
{eur(annualMileage)} km
|
|
1518
|
-
</span>
|
|
1519
|
-
</div>
|
|
1520
|
-
<span className="fin-val-box num">{eur(annualMileage)} km</span>
|
|
1521
|
-
<Slider
|
|
1522
|
-
min={annualMileageMin}
|
|
1523
|
-
max={annualMileageMax}
|
|
1524
|
-
step={annualMileageStep}
|
|
1525
|
-
value={annualMileage}
|
|
1526
|
-
onChange={onAnnualMileageChange}
|
|
1527
|
-
fmt={(v) => `${eur(Number(v))} km`}
|
|
1528
|
-
/>
|
|
1529
|
-
</div>
|
|
1530
|
-
)}
|
|
1531
|
-
<div className="fin-sheet-terms">
|
|
1532
|
-
<div className="md-title-sm" style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 8 }}>
|
|
1533
|
-
{t('autobahn:finance.termSelectionMonths')}
|
|
1534
|
-
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1535
|
-
</div>
|
|
1536
|
-
<TermChips options={termVals} value={term} onChange={onTermChange as (n: number) => void} t={t} />
|
|
1537
|
-
</div>
|
|
1538
|
-
</div>
|
|
1539
|
-
)}
|
|
1540
|
-
|
|
1541
|
-
{!isLease && onToggleFinalInstallment && (
|
|
1542
|
-
<ToggleRow
|
|
1543
|
-
label={t('autobahn:finance.finalInstallmentToggle')}
|
|
1544
|
-
checked={!!withFinalInstallment}
|
|
1545
|
-
onChange={onToggleFinalInstallment}
|
|
1546
|
-
/>
|
|
1547
|
-
)}
|
|
1548
|
-
{addServicesCost != null && onToggleAddServices && (
|
|
1549
|
-
<ToggleRow
|
|
1550
|
-
label={t('autobahn:finance.addServicesToggle')}
|
|
1551
|
-
price={t('autobahn:finance.addServicesPerMonth', { value: eur(addServicesCost) })}
|
|
1552
|
-
checked={!!withAddServices}
|
|
1553
|
-
disabled={addServicesDisabled}
|
|
1554
|
-
onChange={onToggleAddServices}
|
|
1555
|
-
/>
|
|
1556
|
-
)}
|
|
1557
|
-
|
|
1558
|
-
<div style={{ display: 'flex', gap: 10 }}>
|
|
1559
|
-
<Button variant="filled" size="lg" icon="shield-check" style={{ flex: 1 }} onClick={onSecure}>
|
|
1560
|
-
{t('autobahn:finance.requestRate')}
|
|
1561
|
-
</Button>
|
|
1562
|
-
{/* padding:0 — mbtn--lg's 30px side padding left no room for the icon at width 52 */}
|
|
1563
|
-
<Button variant="outlined" size="lg" icon="phone" style={{ width: 52, flex: 'none', padding: 0, borderRadius:
|
|
1564
|
-
</div>
|
|
1565
|
-
|
|
1566
|
-
{/* centered ⓘ details toggle (mockup shows no chevron) */}
|
|
1567
|
-
<button type="button" onClick={() => setShowBreakdown((v) => !v)} style={{ ...expanderBtnStyle, justifyContent: 'center', padding: '2px 0' }}>
|
|
1568
|
-
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
|
1569
|
-
<Icon name="info" size={16} color="var(--md-primary)" />
|
|
1570
|
-
{isLease ? t('autobahn:modal.leasingDetails') : t('autobahn:modal.financingDetails')}
|
|
1571
|
-
</span>
|
|
1572
|
-
</button>
|
|
1573
|
-
{
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
reviews
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
* the
|
|
1613
|
-
*
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
const
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
<
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
{
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
['
|
|
1743
|
-
['
|
|
1744
|
-
]
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
const [
|
|
1760
|
-
const [
|
|
1761
|
-
const [
|
|
1762
|
-
const [
|
|
1763
|
-
const [
|
|
1764
|
-
const [
|
|
1765
|
-
const [
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
const [
|
|
1772
|
-
const [
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
const
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
//
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
<
|
|
1815
|
-
|
|
1816
|
-
<div className="md-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
{
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
{
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
{
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
{
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
{
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
<
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
{
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
{
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
steps
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
['autobahn:steps.
|
|
2006
|
-
['autobahn:steps.
|
|
2007
|
-
['autobahn:steps.
|
|
2008
|
-
]
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
<
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
<
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
1
|
+
// Autobahn (auto.de redesign) — Vehicle-Detail (VDP) presentational modules.
|
|
2
|
+
//
|
|
3
|
+
// Typed React 18 + TS port of the remaining VDP pieces from the design kit
|
|
4
|
+
// (docs/redesign/design-source/ui_kits/website/CdpModules.jsx + ScreenVDP.jsx).
|
|
5
|
+
// Each is a pure / presentational ES named export with an explicit props
|
|
6
|
+
// interface; none touches a store, the API or `window`. The page wiring that
|
|
7
|
+
// feeds these from the real stores is a SEPARATE later step (W3b).
|
|
8
|
+
//
|
|
9
|
+
// Replaces the design kit's globals:
|
|
10
|
+
// - `window.CO2_CLASSES` / `window.CO2_COLORS` → ../constants
|
|
11
|
+
// - `equipFor(id)` / `car.priceHistory` / market band → typed props on VehicleDetail
|
|
12
|
+
// - `assetUrl(...)` → pre-resolved image URLs (caller's job)
|
|
13
|
+
// - `data-lucide` icons → the ui kit's <Icon />
|
|
14
|
+
// The DealerBlock from CdpModules is intentionally NOT ported (auto.de is the
|
|
15
|
+
// seller, not a dealer marketplace).
|
|
16
|
+
//
|
|
17
|
+
// NEEDS-DATA discipline (the point of the ticket): wherever the design showed a
|
|
18
|
+
// figure we have no real source for, these render the NEEDS_DATA_LABEL
|
|
19
|
+
// ('Daten folgt') marker rather than inventing a value — see PriceHistory (whole
|
|
20
|
+
// card) and its market band.
|
|
21
|
+
//
|
|
22
|
+
// All components render correctly only inside a `<div className="md3">` subtree.
|
|
23
|
+
|
|
24
|
+
import React, { useState } from 'react';
|
|
25
|
+
import { TranslationFunction } from '../types';
|
|
26
|
+
|
|
27
|
+
import { Button, Icon, Spec, StatChip, Stars, Switch } from '../ui';
|
|
28
|
+
import { CO2_CLASSES, CO2_COLORS, TIER_LABELS, TIER_KEYS, TIER_COLORS } from '../constants';
|
|
29
|
+
import { defaultVdpSpecs, EquipmentGroups, VehicleDetail, VdpSpec, VdpReview } from './types';
|
|
30
|
+
import { CalcRow, expanderBtnStyle, FinanceMode, Slider, TermChips } from './FinanceCalculator';
|
|
31
|
+
|
|
32
|
+
const eur = (n: number): string => n.toLocaleString('de-DE');
|
|
33
|
+
|
|
34
|
+
/** mileage → 'X.XXX km' (de-DE grouping). */
|
|
35
|
+
function fmtKm(n: number): string {
|
|
36
|
+
return `${n.toLocaleString('de-DE')} km`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** strip a trailing parenthetical, e.g. '110 kW (150 PS)' → '110 kW'. */
|
|
40
|
+
function stripParen(s: string): string {
|
|
41
|
+
return s.replace(/\s*\(.*\)/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Header card — title, fact line, highlight chips, condition tiles
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface VdpHeaderCardProps {
|
|
49
|
+
vehicle: VehicleDetail;
|
|
50
|
+
/** i18n function (drilled from the page). */
|
|
51
|
+
t: TranslationFunction;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** headline + trim/power + rating pill, then the dot-separated fact line — the vehicle's
|
|
55
|
+
* identity block. Split out so the mobile-only layout can show this BEFORE the finance
|
|
56
|
+
* card (see VdpTrustRow) while the highlights/condition tiles move after it. */
|
|
57
|
+
export const VdpHeaderSummary: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => {
|
|
58
|
+
const factLine: ReadonlyArray<readonly [string, string]> = [
|
|
59
|
+
['gauge', fmtKm(vehicle.km)],
|
|
60
|
+
['calendar', vehicle.reg],
|
|
61
|
+
['cog', vehicle.gear],
|
|
62
|
+
['fuel', stripParen(vehicle.fuel)],
|
|
63
|
+
['user', t('autobahn:header.owners', { owners: vehicle.owners, count: vehicle.owners })],
|
|
64
|
+
// body type closes the fact grid (mockup: km|EZ / Getriebe|Kraftstoff / Hand|Karosserie);
|
|
65
|
+
// the compact phone dot-line hides everything from "Hand" on via CSS (nth-child(n+5)).
|
|
66
|
+
...(vehicle.body ? ([['car-front', vehicle.body]] as const) : []),
|
|
67
|
+
];
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<div className="vdp-headrow">
|
|
71
|
+
<div style={{ minWidth: 0 }}>
|
|
72
|
+
<h1 className="md-headline" style={{ margin: '0 0 4px' }}>
|
|
73
|
+
{vehicle.make} {vehicle.model}
|
|
74
|
+
</h1>
|
|
75
|
+
<p className="md-body vdp-trim" style={{ margin: 0, fontSize: 15 }}>
|
|
76
|
+
{vehicle.trim} · {vehicle.power}
|
|
77
|
+
</p>
|
|
78
|
+
{/* auto.de-ID lives in the offer card (next to Finanzierungsdetails), not here */}
|
|
79
|
+
</div>
|
|
80
|
+
{/* BL-08: no chip at all when there's no real rating (empty/broken price) — never a
|
|
81
|
+
fabricated "Top Preis"-shaped badge for `rating === ''`. */}
|
|
82
|
+
{vehicle.rating && (
|
|
83
|
+
<StatChip
|
|
84
|
+
tone={vehicle.rating === 'fair' ? 'warn' : 'good'}
|
|
85
|
+
icon={vehicle.rating === 'fair' ? 'minus' : 'check'}
|
|
86
|
+
>
|
|
87
|
+
{t('autobahn:tier.' + vehicle.rating)}
|
|
88
|
+
</StatChip>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* fact line — plain text with dot separators, not pills */}
|
|
93
|
+
<div className="vdp-meta">
|
|
94
|
+
{factLine.map(([ic, v]) => (
|
|
95
|
+
<span key={ic}>
|
|
96
|
+
<Icon name={ic} size={16} color="var(--md-on-surface-variant)" stroke={1.8} />
|
|
97
|
+
{v}
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** hand-picked highlights — the accent rows of the header. ALL highlights are shown
|
|
106
|
+
* (review round 9: "all highlights shown", no 6-chip cap); the Figma pill colors live
|
|
107
|
+
* on .vdp-highlights .chip in the theme. */
|
|
108
|
+
export const VdpHeaderHighlights: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle }) => (
|
|
109
|
+
<div className="vdp-highlights" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
|
110
|
+
{vehicle.highlights.map((f) => (
|
|
111
|
+
<span key={f} className="chip chip--filter chip--selected" style={{ cursor: 'default' }}>
|
|
112
|
+
<Icon name="check" size={14} stroke={2.4} />
|
|
113
|
+
{f}
|
|
114
|
+
</span>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/** condition (zustand) tiles — TÜV/Unfallfrei/Scheckheft/Garantie. */
|
|
120
|
+
export const VdpHeaderCondition: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => {
|
|
121
|
+
const { condition } = vehicle;
|
|
122
|
+
// condition.hu === 'Neu' compares the RAW backend data value, not a display string — left as-is.
|
|
123
|
+
const tiles: ReadonlyArray<readonly [string, string, string]> = [
|
|
124
|
+
['wrench', t('autobahn:header.huLabel'), condition.hu === 'Neu' ? t('autobahn:header.huNew') : t('autobahn:header.huUntil', { date: condition.hu })],
|
|
125
|
+
['shield-check', t('autobahn:header.accidentFree'), condition.unfallfrei ? t('autobahn:header.yes') : t('autobahn:header.no')],
|
|
126
|
+
['clipboard-check', t('autobahn:header.serviceHistory'), condition.scheckheft ? t('autobahn:header.yes') : t('autobahn:header.no')],
|
|
127
|
+
['award', t('autobahn:header.warranty'), condition.garantie],
|
|
128
|
+
];
|
|
129
|
+
return (
|
|
130
|
+
<div className="zustand-grid">
|
|
131
|
+
{tiles.map(([ic, l, v]) => (
|
|
132
|
+
<div key={l} className="zustand-tile">
|
|
133
|
+
<Icon name={ic} size={19} color="var(--md-primary)" stroke={1.9} style={{ flex: 'none' }} />
|
|
134
|
+
<span style={{ minWidth: 0 }}>
|
|
135
|
+
<span
|
|
136
|
+
className="md-label-sm"
|
|
137
|
+
style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
138
|
+
>
|
|
139
|
+
{l}
|
|
140
|
+
</span>
|
|
141
|
+
<span className="md-title-sm" style={{ display: 'block' }}>
|
|
142
|
+
{v}
|
|
143
|
+
</span>
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One container holding the entire vehicle identity: headline + trim/power, the
|
|
153
|
+
* dot-separated fact line, the hand-picked highlight chips, and the condition
|
|
154
|
+
* (zustand) tiles footer. The gallery itself is composed by the page (W3b) — this
|
|
155
|
+
* is just the textual header card that sits beneath it. Desktop-only usage (mobile
|
|
156
|
+
* splits these 3 pieces across the "before/after finance card" layout — see the page).
|
|
157
|
+
*/
|
|
158
|
+
export const VdpHeaderCard: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => (
|
|
159
|
+
<div className="mcard" style={{ marginTop: 16, padding: '22px 24px 18px' }}>
|
|
160
|
+
<VdpHeaderSummary vehicle={vehicle} t={t} />
|
|
161
|
+
<div style={{ marginTop: 14 }}>
|
|
162
|
+
<VdpHeaderHighlights vehicle={vehicle} t={t} />
|
|
163
|
+
</div>
|
|
164
|
+
<VdpHeaderCondition vehicle={vehicle} t={t} />
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
export interface VdpTrustRowProps {
|
|
169
|
+
/** maps to carStore.isAvailable / state.isOnline — "Sofort verfügbar". */
|
|
170
|
+
available: boolean;
|
|
171
|
+
/** real totalCarImpCount from /service/user-actions/api/v1/stats/viewers — omit while loading. */
|
|
172
|
+
viewCount?: number;
|
|
173
|
+
/** real totalFavCount from the same endpoint — omit while loading. */
|
|
174
|
+
favCount?: number;
|
|
175
|
+
/** i18n function (drilled from the page). */
|
|
176
|
+
t: TranslationFunction;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** mobile-only trust-signal row shown between the identity block and the finance card:
|
|
180
|
+
* availability · view count · favorite count · "Geprüft" (every auto.de listing is
|
|
181
|
+
* 130-point inspected, so this one is always shown, not conditional real data). */
|
|
182
|
+
export const VdpTrustRow: React.FunctionComponent<VdpTrustRowProps> = ({ available, viewCount, favCount, t }) => (
|
|
183
|
+
// one swipeable line (mockup), never a ragged second row
|
|
184
|
+
<div style={{ display: 'flex', flexWrap: 'nowrap', overflowX: 'auto', scrollbarWidth: 'none', alignItems: 'center', gap: 5, marginTop: 10 }}>
|
|
185
|
+
{available && (
|
|
186
|
+
<span className="vdp-trust-chip">
|
|
187
|
+
<Icon name="check" size={13} stroke={2.4} color="var(--md-good)" />
|
|
188
|
+
{t('autobahn:availability.immediately')}
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
{viewCount != null && (
|
|
192
|
+
<span className="vdp-trust-chip">
|
|
193
|
+
<Icon name="eye" size={13} />
|
|
194
|
+
{t('autobahn:card.activity.viewsCount', { count: viewCount })}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
{favCount != null && (
|
|
198
|
+
<span className="vdp-trust-chip">
|
|
199
|
+
<Icon name="heart" size={13} />
|
|
200
|
+
{t('autobahn:card.activity.favoritesCount', { count: favCount })}
|
|
201
|
+
</span>
|
|
202
|
+
)}
|
|
203
|
+
<span className="vdp-trust-chip">
|
|
204
|
+
<Icon name="shield-check" size={13} />
|
|
205
|
+
{t('autobahn:card.trust.checked')}
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Fahrzeugdaten spec table (mobile clamp + "Mehr anzeigen")
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
export interface VdpSpecTableProps {
|
|
215
|
+
vehicle: VehicleDetail;
|
|
216
|
+
/** i18n function (drilled from the page). */
|
|
217
|
+
t: TranslationFunction;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Fahrzeugdaten grid of Spec items. Uses the vehicle's explicit `specs` when
|
|
222
|
+
* present, else derives the design's twelve-row layout via `defaultVdpSpecs`. On
|
|
223
|
+
* phones the table clamps to a few rows (`vdp-specs--clamp`) with a "Mehr
|
|
224
|
+
* anzeigen" toggle (pure view state).
|
|
225
|
+
*/
|
|
226
|
+
export const VdpSpecTable: React.FunctionComponent<VdpSpecTableProps> = ({ vehicle, t }) => {
|
|
227
|
+
const [allSpecs, setAllSpecs] = useState(false);
|
|
228
|
+
const specs: VdpSpec[] = vehicle.specs && vehicle.specs.length ? vehicle.specs : defaultVdpSpecs(vehicle);
|
|
229
|
+
return (
|
|
230
|
+
<div className="mcard" id="vdp-daten" style={{ padding: 22 }}>
|
|
231
|
+
<h2 className="md-headline-sm" style={{ marginBottom: 16 }}>
|
|
232
|
+
{t('autobahn:specTable.title')}
|
|
233
|
+
</h2>
|
|
234
|
+
<div className={'vdp-specs' + (allSpecs ? '' : ' vdp-specs--clamp')}>
|
|
235
|
+
{specs.map(([ic, l, v]) => (
|
|
236
|
+
<Spec key={l} icon={ic} label={l}>
|
|
237
|
+
{v}
|
|
238
|
+
</Spec>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
{/* phone only: clamp button only meaningful when there are more than 6 specs */}
|
|
242
|
+
{specs.length > 6 && (
|
|
243
|
+
<button
|
|
244
|
+
className="mbtn mbtn--text show-mobile"
|
|
245
|
+
style={{ marginTop: 12, padding: 0 }}
|
|
246
|
+
onClick={() => setAllSpecs((a) => !a)}
|
|
247
|
+
>
|
|
248
|
+
{allSpecs ? t('autobahn:vdp.specs.showLess') : t('autobahn:specTable.showAll', { count: specs.length })}
|
|
249
|
+
<Icon name={allSpecs ? 'chevron-up' : 'chevron-down'} size={16} />
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Equipment list (grouped + searchable)
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export interface EquipmentListProps {
|
|
261
|
+
/** grouped equipment, e.g. { Komfort: [...], Sicherheit: [...] }. */
|
|
262
|
+
groups: EquipmentGroups;
|
|
263
|
+
/** i18n function (drilled from the page). */
|
|
264
|
+
t: TranslationFunction;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Grouped + searchable equipment list (Autohero / AutoScout pattern). */
|
|
268
|
+
export const EquipmentList: React.FunctionComponent<EquipmentListProps> = ({ groups, t }) => {
|
|
269
|
+
const [q, setQ] = useState('');
|
|
270
|
+
const [openAll, setOpenAll] = useState(false);
|
|
271
|
+
const ql = q.trim().toLowerCase();
|
|
272
|
+
const entries = Object.entries(groups)
|
|
273
|
+
.map(([g, items]): [string, string[]] => [g, items.filter((it) => !ql || it.toLowerCase().includes(ql))])
|
|
274
|
+
.filter(([, items]) => items.length);
|
|
275
|
+
const total = Object.values(groups).reduce((s, a) => s + a.length, 0);
|
|
276
|
+
return (
|
|
277
|
+
<div className="mcard" id="vdp-ausstattung" style={{ padding: 22 }}>
|
|
278
|
+
<div
|
|
279
|
+
style={{
|
|
280
|
+
display: 'flex',
|
|
281
|
+
alignItems: 'center',
|
|
282
|
+
justifyContent: 'space-between',
|
|
283
|
+
gap: 12,
|
|
284
|
+
marginBottom: 14,
|
|
285
|
+
flexWrap: 'wrap',
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<h2 className="md-headline-sm">
|
|
289
|
+
{t('autobahn:equipment.title')}{' '}
|
|
290
|
+
<span style={{ color: 'var(--md-on-surface-variant)', fontWeight: 600 }}>({total})</span>
|
|
291
|
+
</h2>
|
|
292
|
+
<label className="mfield" style={{ height: 40, maxWidth: 240, flex: 1, minWidth: 170 }}>
|
|
293
|
+
<Icon name="search" size={18} />
|
|
294
|
+
<input placeholder={t('autobahn:equipment.search.placeholder')} value={q} onChange={(e) => setQ(e.target.value)} />
|
|
295
|
+
{q && (
|
|
296
|
+
<button className="icon-btn" style={{ width: 28, height: 28 }} onClick={() => setQ('')}>
|
|
297
|
+
<Icon name="x" size={15} />
|
|
298
|
+
</button>
|
|
299
|
+
)}
|
|
300
|
+
</label>
|
|
301
|
+
</div>
|
|
302
|
+
<div className="eq-cols">
|
|
303
|
+
{entries.map(([g, items]) => (
|
|
304
|
+
<div key={g} style={{ breakInside: 'avoid', marginBottom: 18 }}>
|
|
305
|
+
<div className="md-title-sm" style={{ color: 'var(--md-link)', marginBottom: 8 }}>
|
|
306
|
+
{g}
|
|
307
|
+
</div>
|
|
308
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
|
309
|
+
{(openAll || ql ? items : items.slice(0, 4)).map((it) => (
|
|
310
|
+
<li
|
|
311
|
+
key={it}
|
|
312
|
+
style={{
|
|
313
|
+
display: 'flex',
|
|
314
|
+
gap: 9,
|
|
315
|
+
alignItems: 'flex-start',
|
|
316
|
+
fontSize: 13.5,
|
|
317
|
+
color: 'var(--md-on-surface-variant)',
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
<Icon name="check" size={16} color="var(--md-good)" stroke={2.4} style={{ flex: 'none', marginTop: 1 }} />
|
|
321
|
+
{it}
|
|
322
|
+
</li>
|
|
323
|
+
))}
|
|
324
|
+
</ul>
|
|
325
|
+
</div>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
{!ql && !openAll && (
|
|
329
|
+
<button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setOpenAll(true)}>
|
|
330
|
+
{t('autobahn:equipment.showAll', { total })}
|
|
331
|
+
<Icon name="chevron-down" size={16} />
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
334
|
+
{entries.length === 0 && <p className="md-body-sm">{t('autobahn:equipment.noResults', { q })}</p>}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// CO₂ / energy-efficiency label
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export interface Co2LabelProps {
|
|
344
|
+
vehicle: VehicleDetail;
|
|
345
|
+
/** i18n function (drilled from the page). */
|
|
346
|
+
t: TranslationFunction;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** CO₂ / energy-efficiency label (EU-style stepped colour band, Figma pastel scale).
|
|
350
|
+
* Handles PHEV with BOTH class markers (gew. komb. + entladen komb.) on the scale. */
|
|
351
|
+
export const Co2Label: React.FunctionComponent<Co2LabelProps> = ({ vehicle, t }) => {
|
|
352
|
+
const p = vehicle.phev;
|
|
353
|
+
const idx = Math.max(0, CO2_CLASSES.indexOf((p ? p.co2classW : vehicle.co2class) as (typeof CO2_CLASSES)[number]));
|
|
354
|
+
// discharged-class marker (PHEV only); -1 when absent or same as the weighted class.
|
|
355
|
+
const idxD = p ? CO2_CLASSES.indexOf(p.co2classD as (typeof CO2_CLASSES)[number]) : -1;
|
|
356
|
+
const hasMarks = !!p && (idx >= 0 || idxD >= 0);
|
|
357
|
+
const stats: ReadonlyArray<readonly [string, string]> = p
|
|
358
|
+
? [
|
|
359
|
+
[t('autobahn:eco.stat.powerConsumptionWeighted'), p.consEl],
|
|
360
|
+
[t('autobahn:eco.stat.fuelConsumptionWeighted'), p.consFuel],
|
|
361
|
+
[t('autobahn:eco.stat.fuelConsumptionDischarged'), p.consDis],
|
|
362
|
+
[t('autobahn:eco.stat.co2WeightedCombined'), p.co2w],
|
|
363
|
+
]
|
|
364
|
+
: [
|
|
365
|
+
[t('autobahn:eco.stat.consumptionCombined'), vehicle.cons],
|
|
366
|
+
[t('autobahn:eco.stat.co2Emissions'), vehicle.co2],
|
|
367
|
+
[t('autobahn:eco.stat.co2Class'), vehicle.co2class],
|
|
368
|
+
];
|
|
369
|
+
return (
|
|
370
|
+
<div className="mcard" style={{ padding: 22 }}>
|
|
371
|
+
<h2 className="md-headline-sm" style={{ marginBottom: 14 }}>
|
|
372
|
+
{t('autobahn:eco.heading')}
|
|
373
|
+
</h2>
|
|
374
|
+
<div className="co2-stats">
|
|
375
|
+
{stats.map(([l, v]) => (
|
|
376
|
+
<div key={l}>
|
|
377
|
+
<div className="md-label-sm">{l}</div>
|
|
378
|
+
<div className="md-title-sm" style={{ fontSize: 16 }}>
|
|
379
|
+
{v}
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
<div className="co2-scale">
|
|
385
|
+
{CO2_CLASSES.map((c, i) => (
|
|
386
|
+
<div key={c} className="co2-col">
|
|
387
|
+
{/* PHEV: tiny class markers above the weighted + discharged segments (mockup) */}
|
|
388
|
+
{hasMarks && (
|
|
389
|
+
<span className="co2-mark">
|
|
390
|
+
{i === idx
|
|
391
|
+
? t('autobahn:eco.weightedShort')
|
|
392
|
+
: i === idxD && idxD !== idx
|
|
393
|
+
? t('autobahn:eco.dischargedShort')
|
|
394
|
+
: ' '}
|
|
395
|
+
</span>
|
|
396
|
+
)}
|
|
397
|
+
<div
|
|
398
|
+
className={
|
|
399
|
+
'co2-seg' +
|
|
400
|
+
(i === idx ? ' co2-seg--on' : '') +
|
|
401
|
+
(i === idxD && idxD !== idx ? ' co2-seg--on2' : '')
|
|
402
|
+
}
|
|
403
|
+
style={{ ['--seg' as string]: CO2_COLORS[i] } as React.CSSProperties}
|
|
404
|
+
>
|
|
405
|
+
{c}
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
))}
|
|
409
|
+
</div>
|
|
410
|
+
{p ? (
|
|
411
|
+
<p className="md-label-sm" style={{ margin: '10px 0 0' }}>
|
|
412
|
+
{t('autobahn:eco.co2ClassPrefix')} <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classW}</b>{' '}
|
|
413
|
+
{t('autobahn:eco.weightedCombined')} · <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classD}</b>{' '}
|
|
414
|
+
{t('autobahn:eco.dischargedCombined')} · {t('autobahn:eco.energyClassWltpSuffix')}
|
|
415
|
+
</p>
|
|
416
|
+
) : (
|
|
417
|
+
<p className="md-label-sm" style={{ margin: '10px 0 0' }}>
|
|
418
|
+
{t('autobahn:eco.energyClass')}
|
|
419
|
+
</p>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Free-text vehicle description (Fahrzeugbeschreibung)
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
export interface VehicleDescriptionProps {
|
|
430
|
+
vehicle: VehicleDetail;
|
|
431
|
+
/**
|
|
432
|
+
* Pre-composed description text. When omitted a sensible default is built from
|
|
433
|
+
* the vehicle's typed fields (matching the design's template sentence).
|
|
434
|
+
*/
|
|
435
|
+
text?: string;
|
|
436
|
+
/** i18n function (drilled from the page). */
|
|
437
|
+
t: TranslationFunction;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function defaultDescription(v: VehicleDetail, t: TranslationFunction): string {
|
|
441
|
+
return t('autobahn:description.defaultBody', {
|
|
442
|
+
make: v.make,
|
|
443
|
+
model: v.model,
|
|
444
|
+
trim: v.trim,
|
|
445
|
+
power: v.power,
|
|
446
|
+
gear: v.gear,
|
|
447
|
+
owners: v.owners,
|
|
448
|
+
reg: v.reg,
|
|
449
|
+
km: v.km.toLocaleString('de-DE'),
|
|
450
|
+
color: v.color,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Free-text vehicle description with a "Mehr anzeigen" clamp (pure view state). */
|
|
455
|
+
export const VehicleDescription: React.FunctionComponent<VehicleDescriptionProps> = ({ vehicle, text, t }) => {
|
|
456
|
+
const [open, setOpen] = useState(false);
|
|
457
|
+
const body = text ?? defaultDescription(vehicle, t);
|
|
458
|
+
return (
|
|
459
|
+
<div className="mcard" style={{ padding: 22 }}>
|
|
460
|
+
<h2 className="md-headline-sm" style={{ marginBottom: 12 }}>
|
|
461
|
+
{t('autobahn:description.heading')}
|
|
462
|
+
</h2>
|
|
463
|
+
<p
|
|
464
|
+
className="md-body"
|
|
465
|
+
style={{ margin: 0, maxHeight: open ? 'none' : 96, overflow: 'hidden', position: 'relative' }}
|
|
466
|
+
>
|
|
467
|
+
{body}
|
|
468
|
+
</p>
|
|
469
|
+
<button className="mbtn mbtn--text" style={{ marginTop: 8, padding: 0 }} onClick={() => setOpen((o) => !o)}>
|
|
470
|
+
{open ? t('autobahn:description.showLess') : t('autobahn:description.showMore')}
|
|
471
|
+
<Icon name={open ? 'chevron-up' : 'chevron-down'} size={16} />
|
|
472
|
+
</button>
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// Trust strip
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/** [kebab-case lucide icon, label, optional subline (mobile Figma tiles)]. */
|
|
482
|
+
export type TrustItem = readonly [icon: string, label: string, sub?: string];
|
|
483
|
+
|
|
484
|
+
export interface TrustStripProps {
|
|
485
|
+
/** trust badges; defaults to auto.de's four standard promises. */
|
|
486
|
+
items?: ReadonlyArray<TrustItem>;
|
|
487
|
+
/** i18n function (drilled from the page). */
|
|
488
|
+
t: TranslationFunction;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** auto.de's four standard promises — [icon, i18n label key, i18n subline key]. */
|
|
492
|
+
const DEFAULT_TRUST: ReadonlyArray<readonly [string, string, string]> = [
|
|
493
|
+
['shield-check', 'autobahn:trust.warranty', 'autobahn:trust.warrantySub'],
|
|
494
|
+
['undo-2', 'autobahn:trust.return', 'autobahn:trust.returnSub'],
|
|
495
|
+
['truck', 'autobahn:trust.delivery', 'autobahn:trust.deliverySub'],
|
|
496
|
+
['badge-check', 'autobahn:trust.inspection', 'autobahn:trust.inspectionSub'],
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
/** Trust-signal strip: quiet one-liner on desktop, Figma 2×2 tile card on mobile
|
|
500
|
+
* (the .trust-sub sublines only surface in the mobile tile layout via CSS). */
|
|
501
|
+
export const TrustStrip: React.FunctionComponent<TrustStripProps> = ({ items, t }) => (
|
|
502
|
+
<div className="trust-strip">
|
|
503
|
+
{(items ?? DEFAULT_TRUST.map(([ic, key, subKey]) => [ic, t(key), t(subKey)] as TrustItem)).map(([ic, label, sub]) => (
|
|
504
|
+
<div key={label}>
|
|
505
|
+
<Icon name={ic} size={17} color="var(--md-primary)" stroke={2} style={{ flex: 'none' }} />
|
|
506
|
+
<span style={{ minWidth: 0 }}>
|
|
507
|
+
{label}
|
|
508
|
+
{sub && <span className="trust-sub">{sub}</span>}
|
|
509
|
+
</span>
|
|
510
|
+
</div>
|
|
511
|
+
))}
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Price history + market band (NEEDS-DATA by default)
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
export interface PriceHistoryProps {
|
|
520
|
+
vehicle: VehicleDetail;
|
|
521
|
+
/** i18n function (drilled from the page). */
|
|
522
|
+
t: TranslationFunction;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Price-history sparkline + market-value position bar.
|
|
527
|
+
*
|
|
528
|
+
* NEEDS-DATA: the sparkline needs `priceHistory` (with at least two points) and
|
|
529
|
+
* the position bar needs the full market band (`marketLow` / `marketAvg` /
|
|
530
|
+
* `marketHigh`). Either section that lacks real data renders the
|
|
531
|
+
* NEEDS_DATA_LABEL ('Daten folgt') marker — we never draw a fake trend or
|
|
532
|
+
* position. Both are needs-data by default, so until W3b supplies them this card
|
|
533
|
+
* shows the markers.
|
|
534
|
+
*/
|
|
535
|
+
export const PriceHistory: React.FunctionComponent<PriceHistoryProps> = ({ vehicle, t }) => {
|
|
536
|
+
const pts = vehicle.priceHistory;
|
|
537
|
+
const hasHistory = !!pts && pts.length >= 2;
|
|
538
|
+
const hasMarket =
|
|
539
|
+
vehicle.marketLow != null &&
|
|
540
|
+
vehicle.marketAvg != null &&
|
|
541
|
+
vehicle.marketHigh != null &&
|
|
542
|
+
vehicle.marketHigh > vehicle.marketLow;
|
|
543
|
+
|
|
544
|
+
// Nothing real to show yet — hide the whole card instead of rendering stubs.
|
|
545
|
+
if (!hasHistory && !hasMarket) return null;
|
|
546
|
+
|
|
547
|
+
// --- history sparkline (only when real points exist) ---
|
|
548
|
+
let historyBlock: React.ReactNode;
|
|
549
|
+
if (hasHistory) {
|
|
550
|
+
const prices = pts!.map((p) => p.price);
|
|
551
|
+
const lo = Math.min(...prices);
|
|
552
|
+
const hi = Math.max(...prices);
|
|
553
|
+
const W = 280;
|
|
554
|
+
const H = 70;
|
|
555
|
+
const x = (i: number) => (i / (pts!.length - 1)) * W;
|
|
556
|
+
const y = (p: number) => H - ((p - lo) / (hi - lo || 1)) * H;
|
|
557
|
+
const last = pts![pts!.length - 1];
|
|
558
|
+
const line = pts!.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(p.price).toFixed(1)}`).join(' ');
|
|
559
|
+
const area = `${line} L ${W} ${H} L 0 ${H} Z`;
|
|
560
|
+
const drop = vehicle.listPrice != null ? vehicle.listPrice - vehicle.price : null;
|
|
561
|
+
historyBlock = (
|
|
562
|
+
<div>
|
|
563
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
564
|
+
{drop != null && drop > 0 ? (
|
|
565
|
+
<>
|
|
566
|
+
<StatChip tone="good" icon="trending-down">
|
|
567
|
+
{t('autobahn:priceHistory.dropAmount', { amount: eur(drop) })}
|
|
568
|
+
</StatChip>
|
|
569
|
+
<span className="md-label-sm">{t('autobahn:priceHistory.sinceListing')}</span>
|
|
570
|
+
</>
|
|
571
|
+
) : (
|
|
572
|
+
<span className="md-label-sm">{t('autobahn:priceHistory.label')}</span>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height="78" preserveAspectRatio="none" style={{ overflow: 'visible' }}>
|
|
576
|
+
<defs>
|
|
577
|
+
<linearGradient id="phg" x1="0" y1="0" x2="0" y2="1">
|
|
578
|
+
<stop offset="0%" stopColor="var(--md-primary)" stopOpacity=".22" />
|
|
579
|
+
<stop offset="100%" stopColor="var(--md-primary)" stopOpacity="0" />
|
|
580
|
+
</linearGradient>
|
|
581
|
+
</defs>
|
|
582
|
+
<path d={area} fill="url(#phg)" />
|
|
583
|
+
<path d={line} fill="none" stroke="var(--md-primary)" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
|
584
|
+
<circle cx={x(pts!.length - 1)} cy={y(last.price)} r="4.5" fill="var(--md-primary)" stroke="#fff" strokeWidth="2" />
|
|
585
|
+
</svg>
|
|
586
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
|
587
|
+
<span className="md-label-sm">{pts![0].label}</span>
|
|
588
|
+
<span className="md-label-sm">{last.label}</span>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
);
|
|
592
|
+
} else {
|
|
593
|
+
historyBlock = (
|
|
594
|
+
<div>
|
|
595
|
+
<div className="md-title-sm" style={{ marginBottom: 12 }}>
|
|
596
|
+
{t('autobahn:priceHistory.title')}
|
|
597
|
+
</div>
|
|
598
|
+
<p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
|
|
599
|
+
{t('autobahn:common.needsData')}
|
|
600
|
+
</p>
|
|
601
|
+
</div>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// --- market-value position (only when the full band exists) ---
|
|
606
|
+
let marketBlock: React.ReactNode;
|
|
607
|
+
if (hasMarket) {
|
|
608
|
+
const mpos = Math.max(
|
|
609
|
+
0,
|
|
610
|
+
Math.min(1, (vehicle.price - vehicle.marketLow!) / (vehicle.marketHigh! - vehicle.marketLow!)),
|
|
611
|
+
);
|
|
612
|
+
const delta = vehicle.marketAvg! - vehicle.price;
|
|
613
|
+
marketBlock = (
|
|
614
|
+
<div>
|
|
615
|
+
<div className="md-title-sm" style={{ marginBottom: 12 }}>
|
|
616
|
+
{t('autobahn:marketValue.heading')}
|
|
617
|
+
</div>
|
|
618
|
+
<div
|
|
619
|
+
style={{
|
|
620
|
+
position: 'relative',
|
|
621
|
+
height: 10,
|
|
622
|
+
borderRadius: 'var(--md-r-full)',
|
|
623
|
+
background: 'linear-gradient(90deg,#0F7B43,#C7B53A 55%,#D4502B)',
|
|
624
|
+
}}
|
|
625
|
+
>
|
|
626
|
+
<span
|
|
627
|
+
style={{
|
|
628
|
+
position: 'absolute',
|
|
629
|
+
top: '50%',
|
|
630
|
+
left: `calc(${mpos * 100}% - 9px)`,
|
|
631
|
+
transform: 'translateY(-50%)',
|
|
632
|
+
width: 18,
|
|
633
|
+
height: 18,
|
|
634
|
+
borderRadius: '50%',
|
|
635
|
+
background: '#fff',
|
|
636
|
+
boxShadow: 'var(--md-e2), 0 0 0 2px var(--md-primary)',
|
|
637
|
+
}}
|
|
638
|
+
/>
|
|
639
|
+
</div>
|
|
640
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
|
|
641
|
+
<span className="md-label-sm">{eur(vehicle.marketLow!)} €</span>
|
|
642
|
+
<span className="md-label-sm">{eur(vehicle.marketHigh!)} €</span>
|
|
643
|
+
</div>
|
|
644
|
+
{delta > 0 && (
|
|
645
|
+
<div
|
|
646
|
+
style={{
|
|
647
|
+
display: 'flex',
|
|
648
|
+
alignItems: 'center',
|
|
649
|
+
gap: 8,
|
|
650
|
+
marginTop: 12,
|
|
651
|
+
padding: '10px 12px',
|
|
652
|
+
borderRadius: 'var(--md-r-sm)',
|
|
653
|
+
background: 'var(--md-good-container)',
|
|
654
|
+
}}
|
|
655
|
+
>
|
|
656
|
+
<Icon name="badge-check" size={18} color="var(--md-on-good-container)" />
|
|
657
|
+
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-on-good-container)' }}>
|
|
658
|
+
{t('autobahn:marketValue.belowAverage', { rating: t('autobahn:tier.' + vehicle.rating), amount: eur(delta) })}
|
|
659
|
+
</span>
|
|
660
|
+
</div>
|
|
661
|
+
)}
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
} else {
|
|
665
|
+
marketBlock = (
|
|
666
|
+
<div>
|
|
667
|
+
<div className="md-title-sm" style={{ marginBottom: 12 }}>
|
|
668
|
+
{t('autobahn:marketValue.heading')}
|
|
669
|
+
</div>
|
|
670
|
+
<p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
|
|
671
|
+
{t('autobahn:common.needsData')}
|
|
672
|
+
</p>
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return (
|
|
678
|
+
<div className="mcard" id="vdp-preis" style={{ padding: 22 }}>
|
|
679
|
+
<h2 className="md-headline-sm" style={{ marginBottom: 4 }}>
|
|
680
|
+
{t('autobahn:priceMarket.heading')}
|
|
681
|
+
</h2>
|
|
682
|
+
<p className="md-body-sm" style={{ marginBottom: 18 }}>
|
|
683
|
+
{t('autobahn:priceMarket.description')}
|
|
684
|
+
</p>
|
|
685
|
+
<div className="pmv-grid">
|
|
686
|
+
{historyBlock}
|
|
687
|
+
{marketBlock}
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
);
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Protection products (CNP Santander upsell) — optional, non-functional UI
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
/** [kebab-case lucide icon, name, description, monthly price EUR]. */
|
|
698
|
+
export type ProtectionProduct = readonly [icon: string, name: string, desc: string, price: number];
|
|
699
|
+
|
|
700
|
+
export interface ProtectionProductsProps {
|
|
701
|
+
/** the upsell products; defaults to the design's four CNP Santander packages. */
|
|
702
|
+
items?: ReadonlyArray<ProtectionProduct>;
|
|
703
|
+
/** i18n function (drilled from the page). */
|
|
704
|
+
t: TranslationFunction;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The design's four CNP Santander packages — [icon, product name (proper noun,
|
|
709
|
+
* also the selection key), i18n description key, monthly example price EUR].
|
|
710
|
+
*/
|
|
711
|
+
const DEFAULT_PROTECTION: ReadonlyArray<ProtectionProduct> = [
|
|
712
|
+
['shield-check', 'Safe Plus', 'autobahn:protection.items.safePlus.desc', 9],
|
|
713
|
+
['life-buoy', 'Drive Pro', 'autobahn:protection.items.drivePro.desc', 7],
|
|
714
|
+
['wrench', 'Smart Repair', 'autobahn:protection.items.smartRepair.desc', 12],
|
|
715
|
+
['rotate-ccw', 'Smart Return', 'autobahn:protection.items.smartReturn.desc', 10],
|
|
716
|
+
];
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Partner attach products (CNP Santander) sold alongside the financing. Kept as a
|
|
720
|
+
* presentational upsell demo: the selectable rows are pure VIEW state (no store /
|
|
721
|
+
* order side effects, the prices are example values), and the catalogue is a prop
|
|
722
|
+
* with sane defaults. Functional ordering is out of scope for W3a.
|
|
723
|
+
*/
|
|
724
|
+
export const ProtectionProducts: React.FunctionComponent<ProtectionProductsProps> = ({ items, t }) => {
|
|
725
|
+
const products: ReadonlyArray<ProtectionProduct> =
|
|
726
|
+
items ?? DEFAULT_PROTECTION.map(([ic, name, descKey, price]) => [ic, name, t(descKey), price] as ProtectionProduct);
|
|
727
|
+
const [sel, setSel] = useState<Record<string, boolean>>({});
|
|
728
|
+
const chosen = products.filter(([, n]) => sel[n]);
|
|
729
|
+
const sum = chosen.reduce((a, [, , , p]) => a + p, 0);
|
|
730
|
+
return (
|
|
731
|
+
<div className="mcard" style={{ padding: 20 }}>
|
|
732
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
733
|
+
<span
|
|
734
|
+
style={{
|
|
735
|
+
width: 34,
|
|
736
|
+
height: 34,
|
|
737
|
+
borderRadius: 'var(--md-r-full)',
|
|
738
|
+
background: 'var(--md-sc)',
|
|
739
|
+
display: 'flex',
|
|
740
|
+
alignItems: 'center',
|
|
741
|
+
justifyContent: 'center',
|
|
742
|
+
flex: 'none',
|
|
743
|
+
}}
|
|
744
|
+
>
|
|
745
|
+
<Icon name="umbrella" size={18} color="var(--md-on-surface-variant)" />
|
|
746
|
+
</span>
|
|
747
|
+
<div>
|
|
748
|
+
<div className="md-title-sm">{t('autobahn:protection.heading')}</div>
|
|
749
|
+
<div className="md-label-sm">{t('autobahn:protection.subtitle')}</div>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
753
|
+
{products.map(([ic, name, desc, price]) => (
|
|
754
|
+
<button
|
|
755
|
+
key={name}
|
|
756
|
+
onClick={() => setSel((s) => ({ ...s, [name]: !s[name] }))}
|
|
757
|
+
style={{
|
|
758
|
+
display: 'flex',
|
|
759
|
+
alignItems: 'center',
|
|
760
|
+
gap: 12,
|
|
761
|
+
width: '100%',
|
|
762
|
+
textAlign: 'left',
|
|
763
|
+
cursor: 'pointer',
|
|
764
|
+
padding: '10px 12px',
|
|
765
|
+
borderRadius: 'var(--md-r-md)',
|
|
766
|
+
background: sel[name] ? 'var(--md-primary-container)' : 'var(--md-sc-low)',
|
|
767
|
+
border: '1px solid ' + (sel[name] ? 'var(--md-primary)' : 'transparent'),
|
|
768
|
+
transition: 'background var(--dur-1), border-color var(--dur-1)',
|
|
769
|
+
}}
|
|
770
|
+
>
|
|
771
|
+
<Icon
|
|
772
|
+
name={ic}
|
|
773
|
+
size={19}
|
|
774
|
+
stroke={1.8}
|
|
775
|
+
color={sel[name] ? 'var(--md-on-primary-container)' : 'var(--md-on-surface-variant)'}
|
|
776
|
+
style={{ flex: 'none' }}
|
|
777
|
+
/>
|
|
778
|
+
<span style={{ flex: 1, minWidth: 0 }}>
|
|
779
|
+
<span className="md-title-sm" style={{ display: 'block', fontSize: 13.5 }}>
|
|
780
|
+
{name}
|
|
781
|
+
</span>
|
|
782
|
+
<span className="md-label-sm" style={{ display: 'block', fontSize: 11.5 }}>
|
|
783
|
+
{desc}
|
|
784
|
+
</span>
|
|
785
|
+
</span>
|
|
786
|
+
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
787
|
+
<span className="num md-label-sm" style={{ fontWeight: 600 }}>
|
|
788
|
+
+{price} €/mtl.
|
|
789
|
+
</span>
|
|
790
|
+
<span
|
|
791
|
+
style={{
|
|
792
|
+
width: 20,
|
|
793
|
+
height: 20,
|
|
794
|
+
borderRadius: '50%',
|
|
795
|
+
display: 'flex',
|
|
796
|
+
alignItems: 'center',
|
|
797
|
+
justifyContent: 'center',
|
|
798
|
+
background: sel[name] ? 'var(--md-primary)' : 'transparent',
|
|
799
|
+
border: sel[name] ? 0 : '1.5px solid var(--md-outline)',
|
|
800
|
+
}}
|
|
801
|
+
>
|
|
802
|
+
{sel[name] && <Icon name="check" size={13} stroke={3} color="#fff" />}
|
|
803
|
+
</span>
|
|
804
|
+
</span>
|
|
805
|
+
</button>
|
|
806
|
+
))}
|
|
807
|
+
</div>
|
|
808
|
+
{sum > 0 && (
|
|
809
|
+
<div
|
|
810
|
+
style={{
|
|
811
|
+
display: 'flex',
|
|
812
|
+
justifyContent: 'space-between',
|
|
813
|
+
alignItems: 'baseline',
|
|
814
|
+
marginTop: 12,
|
|
815
|
+
paddingTop: 10,
|
|
816
|
+
borderTop: '1px solid var(--md-outline-variant)',
|
|
817
|
+
}}
|
|
818
|
+
>
|
|
819
|
+
<span className="md-label-sm">
|
|
820
|
+
{t('autobahn:protection.packagesSelected', { count: chosen.length })}
|
|
821
|
+
</span>
|
|
822
|
+
<span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
|
|
823
|
+
+{sum} €/mtl.
|
|
824
|
+
</span>
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
<div className="md-label-sm" style={{ marginTop: 10, fontSize: 11.5 }}>
|
|
828
|
+
{t('autobahn:protection.exampleValues', { partner: 'CNP Santander Insurance' })}
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
);
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// In-page anchor nav (mobile)
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
/** [target element id, label]. */
|
|
839
|
+
export type VdpAnchor = readonly [id: string, label: string];
|
|
840
|
+
|
|
841
|
+
export interface VdpAnchorsProps {
|
|
842
|
+
/** anchor chips; defaults to the four VDP sections. */
|
|
843
|
+
items?: ReadonlyArray<VdpAnchor>;
|
|
844
|
+
/**
|
|
845
|
+
* Optional navigation handler. When provided it OWNS the scroll (e.g. the page
|
|
846
|
+
* in W3b). When omitted the component falls back to scrolling the page's own
|
|
847
|
+
* element with that id into view — intrinsic anchor-nav behaviour on the
|
|
848
|
+
* document, not a data source.
|
|
849
|
+
*/
|
|
850
|
+
onNavigate?: (id: string) => void;
|
|
851
|
+
/** i18n function (drilled from the page). */
|
|
852
|
+
t: TranslationFunction;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** the four VDP sections — [target element id, i18n label key]. */
|
|
856
|
+
const DEFAULT_ANCHORS: ReadonlyArray<VdpAnchor> = [
|
|
857
|
+
['vdp-daten', 'autobahn:anchors.data'],
|
|
858
|
+
['vdp-ausstattung', 'autobahn:anchors.equipment'],
|
|
859
|
+
['vdp-preis', 'autobahn:anchors.priceMarket'],
|
|
860
|
+
['vdp-check', 'autobahn:anchors.inspection'],
|
|
861
|
+
];
|
|
862
|
+
|
|
863
|
+
/** Sticky in-page anchor nav (mobile) — quiet chips. */
|
|
864
|
+
export const VdpAnchors: React.FunctionComponent<VdpAnchorsProps> = ({ items, onNavigate, t }) => {
|
|
865
|
+
const anchors: ReadonlyArray<VdpAnchor> =
|
|
866
|
+
items ?? DEFAULT_ANCHORS.map(([id, key]) => [id, t(key)] as VdpAnchor);
|
|
867
|
+
const go = (id: string) => {
|
|
868
|
+
if (onNavigate) {
|
|
869
|
+
onNavigate(id);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (typeof document === 'undefined') return;
|
|
873
|
+
const el = document.getElementById(id);
|
|
874
|
+
if (!el) return;
|
|
875
|
+
let r = el.getBoundingClientRect();
|
|
876
|
+
if (!r.height && el.firstElementChild) r = el.firstElementChild.getBoundingClientRect();
|
|
877
|
+
window.scrollTo({ top: r.top + window.scrollY - 60, behavior: 'smooth' });
|
|
878
|
+
};
|
|
879
|
+
return (
|
|
880
|
+
<div className="vdp-anchors show-mobile" style={{ background: 'var(--md-sc-low)' }}>
|
|
881
|
+
{anchors.map(([id, l]) => (
|
|
882
|
+
<button key={id} className="chip" onClick={() => go(id)} style={{ flex: 'none' }}>
|
|
883
|
+
{l}
|
|
884
|
+
</button>
|
|
885
|
+
))}
|
|
886
|
+
</div>
|
|
887
|
+
);
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
// Desktop sticky summary bar
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
export interface VdpStickyBarProps {
|
|
895
|
+
vehicle: VehicleDetail;
|
|
896
|
+
/** thumbnail image URL (already resolved) for the docked bar. */
|
|
897
|
+
image: string;
|
|
898
|
+
/**
|
|
899
|
+
* Whether the bar is docked/visible. Controlled by the page (W3b owns the
|
|
900
|
+
* scroll position → show toggle), keeping this component pure — no window
|
|
901
|
+
* scroll listener here.
|
|
902
|
+
*/
|
|
903
|
+
show: boolean;
|
|
904
|
+
/** primary CTA click. */
|
|
905
|
+
onSecure?: () => void;
|
|
906
|
+
/** i18n function (drilled from the page). */
|
|
907
|
+
t: TranslationFunction;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
|
|
911
|
+
export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
|
|
912
|
+
<div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
|
|
913
|
+
<div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
|
|
914
|
+
<img
|
|
915
|
+
src={image}
|
|
916
|
+
alt=""
|
|
917
|
+
style={{
|
|
918
|
+
width: 64,
|
|
919
|
+
height: 44,
|
|
920
|
+
objectFit: 'cover',
|
|
921
|
+
borderRadius: 'var(--md-r-sm)',
|
|
922
|
+
background: '#fff',
|
|
923
|
+
border: '1px solid var(--md-outline-variant)',
|
|
924
|
+
flex: 'none',
|
|
925
|
+
}}
|
|
926
|
+
/>
|
|
927
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
928
|
+
<div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
929
|
+
{vehicle.make} {vehicle.model} · {vehicle.trim}
|
|
930
|
+
</div>
|
|
931
|
+
<div className="md-label-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
932
|
+
{fmtKm(vehicle.km)} · {vehicle.reg} · {vehicle.gear}
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
<div style={{ textAlign: 'right', flex: 'none' }}>
|
|
936
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
|
|
937
|
+
<span className="md-label-sm">{t('autobahn:sticky.from')}</span>
|
|
938
|
+
<span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
|
|
939
|
+
{vehicle.monthly} €
|
|
940
|
+
</span>
|
|
941
|
+
<span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
|
|
942
|
+
</div>
|
|
943
|
+
<div className="md-label-sm">{t('autobahn:sticky.purchasePrice', { price: eur(vehicle.price) })}</div>
|
|
944
|
+
</div>
|
|
945
|
+
<Button variant="filled" icon="shield-check" style={{ flex: 'none' }} onClick={onSecure}>
|
|
946
|
+
{t('autobahn:sticky.secureOffer')}
|
|
947
|
+
</Button>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
// Mobile sticky CTA bar
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
export interface VdpBottomBarProps {
|
|
957
|
+
vehicle: VehicleDetail;
|
|
958
|
+
/**
|
|
959
|
+
* Whether the bar is docked/visible — mirrors VdpStickyBar's `show` (same
|
|
960
|
+
* page-owned scroll-position toggle, STICKY_SHOW_AT). Was unconditionally
|
|
961
|
+
* visible before, unlike the desktop VdpStickyBar; the mockup only shows it
|
|
962
|
+
* after scrolling past the main offer card.
|
|
963
|
+
*/
|
|
964
|
+
show: boolean;
|
|
965
|
+
/** active payment mode — drives the tabs + the displayed figure (rate vs price). */
|
|
966
|
+
mode?: FinanceMode;
|
|
967
|
+
/** mode tab tapped; when omitted the tabs row is not rendered. */
|
|
968
|
+
onModeChange?: (mode: FinanceMode) => void;
|
|
969
|
+
/** monthly rate for the CURRENT mode (fin/leasing differ); falls back to vehicle.monthly. */
|
|
970
|
+
monthly?: number;
|
|
971
|
+
/** price-rating label — drives the eval pill + gradient bar (same as the offer card). */
|
|
972
|
+
ratingLabel?: string;
|
|
973
|
+
/** primary CTA click. */
|
|
974
|
+
onSecure?: () => void;
|
|
975
|
+
/** phone CTA click. */
|
|
976
|
+
onCall?: () => void;
|
|
977
|
+
/** opens the "Rate anpassen" sheet; when omitted the button is not rendered. */
|
|
978
|
+
onOpenAdjust?: () => void;
|
|
979
|
+
/** i18n function (drilled from the page). */
|
|
980
|
+
t: TranslationFunction;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Mobile sticky bottom bar (Figma "Sticky Footer"): mode tabs, rate + price rating,
|
|
984
|
+
* then Angebot anfragen · phone · Rate anpassen. No struck old rate — there is no
|
|
985
|
+
* real previous-rate source (needs-data discipline), so nothing is fabricated. */
|
|
986
|
+
export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({
|
|
987
|
+
vehicle,
|
|
988
|
+
show,
|
|
989
|
+
mode = 'fin',
|
|
990
|
+
onModeChange,
|
|
991
|
+
monthly,
|
|
992
|
+
ratingLabel,
|
|
993
|
+
onSecure,
|
|
994
|
+
onCall,
|
|
995
|
+
onOpenAdjust,
|
|
996
|
+
t,
|
|
997
|
+
}) => {
|
|
998
|
+
const isCash = mode === 'cash';
|
|
999
|
+
const rate = monthly != null ? monthly : vehicle.monthly;
|
|
1000
|
+
const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
|
|
1001
|
+
const evalTier = tierIdx >= 0 ? tierIdx : 1;
|
|
1002
|
+
return (
|
|
1003
|
+
<div className={'vdp-bottom-bar show-mobile' + (show ? ' vdp-bottom-bar--on' : '')}>
|
|
1004
|
+
{onModeChange && (
|
|
1005
|
+
<div className="fin-tabs bb-tabs" role="tablist">
|
|
1006
|
+
{(
|
|
1007
|
+
[
|
|
1008
|
+
['fin', 'autobahn:finance.mode.financing'],
|
|
1009
|
+
['leasing', 'autobahn:finance.mode.leasing'],
|
|
1010
|
+
['cash', 'autobahn:card.price.buy'],
|
|
1011
|
+
] as Array<[FinanceMode, string]>
|
|
1012
|
+
).map(([k, labelKey]) => (
|
|
1013
|
+
<button
|
|
1014
|
+
key={k}
|
|
1015
|
+
type="button"
|
|
1016
|
+
role="tab"
|
|
1017
|
+
aria-selected={mode === k}
|
|
1018
|
+
className={'fin-tab' + (mode === k ? ' on' : '')}
|
|
1019
|
+
onClick={() => onModeChange(k)}
|
|
1020
|
+
>
|
|
1021
|
+
{t(labelKey)}
|
|
1022
|
+
</button>
|
|
1023
|
+
))}
|
|
1024
|
+
</div>
|
|
1025
|
+
)}
|
|
1026
|
+
<div className="bb-price-row">
|
|
1027
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 5, whiteSpace: 'nowrap', minWidth: 0 }}>
|
|
1028
|
+
<span className="num" style={{ fontWeight: 800, fontSize: 30, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
|
|
1029
|
+
{isCash ? eur(vehicle.price) : eur(Math.round(rate))} €
|
|
1030
|
+
</span>
|
|
1031
|
+
<span className="md-label-sm" style={{ fontWeight: 600 }}>
|
|
1032
|
+
{isCash ? t('autobahn:finance.fixedPrice') : `${t('autobahn:finance.exampleLabel')} ${t('autobahn:bottom.perMonth')}`}
|
|
1033
|
+
</span>
|
|
1034
|
+
</div>
|
|
1035
|
+
{ratingLabel && (
|
|
1036
|
+
<div className="bb-eval">
|
|
1037
|
+
<span
|
|
1038
|
+
className="fin-eval-pill"
|
|
1039
|
+
style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
|
|
1040
|
+
>
|
|
1041
|
+
<Icon
|
|
1042
|
+
name={evalTier <= 1 ? 'check' : evalTier === 2 ? 'minus' : 'trending-up'}
|
|
1043
|
+
size={13}
|
|
1044
|
+
stroke={2.6}
|
|
1045
|
+
color={TIER_COLORS[evalTier]}
|
|
1046
|
+
/>
|
|
1047
|
+
{t('autobahn:tier.' + TIER_KEYS[evalTier])}
|
|
1048
|
+
</span>
|
|
1049
|
+
<div className="fin-eval-bar">
|
|
1050
|
+
<div className="fin-eval-thumb" style={{ left: `${(evalTier + 0.5) * 20}%`, borderColor: TIER_COLORS[evalTier] }} />
|
|
1051
|
+
</div>
|
|
1052
|
+
</div>
|
|
1053
|
+
)}
|
|
1054
|
+
</div>
|
|
1055
|
+
<div className="bb-cta-row">
|
|
1056
|
+
<Button variant="filled" icon="file-check" style={{ flex: 1, minWidth: 0 }} onClick={onSecure}>
|
|
1057
|
+
{t('autobahn:bottom.secureOffer')}
|
|
1058
|
+
</Button>
|
|
1059
|
+
{/* padding:0 — icon-only button (same fix as the sheet's phone button) */}
|
|
1060
|
+
{onCall && <Button variant="outlined" icon="phone" style={{ width: 46, flex: 'none', padding: 0 }} onClick={onCall} />}
|
|
1061
|
+
{onOpenAdjust && !isCash && (
|
|
1062
|
+
<Button variant="outlined" icon="sliders-horizontal" className="bb-adjust" style={{ flex: 'none' }} onClick={onOpenAdjust}>
|
|
1063
|
+
{t('autobahn:finance.adjustRate')}
|
|
1064
|
+
</Button>
|
|
1065
|
+
)}
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
);
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
// 130-Punkte-Check block
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
|
|
1075
|
+
export interface InspectionBlockProps {
|
|
1076
|
+
/** pass categories; e.g. ['Karosserie & Lack', 'Motor & Antrieb', …]. */
|
|
1077
|
+
categories: string[];
|
|
1078
|
+
/** i18n function (drilled from the page). */
|
|
1079
|
+
t: TranslationFunction;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** 130-Punkte-Check block — the auto.de inspection promise, all categories pass. */
|
|
1083
|
+
export const InspectionBlock: React.FunctionComponent<InspectionBlockProps> = ({ categories, t }) => (
|
|
1084
|
+
<div className="mcard mcard--filled" id="vdp-check" style={{ padding: 22 }}>
|
|
1085
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
|
1086
|
+
<Icon name="badge-check" size={24} color="var(--md-primary)" />
|
|
1087
|
+
<h2 className="md-headline-sm">{t('autobahn:inspection.passed')}</h2>
|
|
1088
|
+
</div>
|
|
1089
|
+
<div className="eq-cols">
|
|
1090
|
+
{categories.map((c) => (
|
|
1091
|
+
<div
|
|
1092
|
+
key={c}
|
|
1093
|
+
style={{
|
|
1094
|
+
display: 'flex',
|
|
1095
|
+
alignItems: 'center',
|
|
1096
|
+
gap: 9,
|
|
1097
|
+
fontSize: 13.5,
|
|
1098
|
+
color: 'var(--md-on-surface-variant)',
|
|
1099
|
+
marginBottom: 10,
|
|
1100
|
+
}}
|
|
1101
|
+
>
|
|
1102
|
+
<Icon name="check-circle" size={18} color="var(--md-good)" stroke={2} style={{ flex: 'none' }} />
|
|
1103
|
+
{c}
|
|
1104
|
+
</div>
|
|
1105
|
+
))}
|
|
1106
|
+
</div>
|
|
1107
|
+
</div>
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// Finanzierungs-/Leasingdetails popup (desktop modal / mobile bottom-sheet)
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
/** full-width label(+optional trailing price) / Switch row — Switch itself is inline-flex
|
|
1115
|
+
* (sized to its own content), so the space-between has to live on this wrapper, not on
|
|
1116
|
+
* Switch's `label` prop (which renders track-then-label, not a full-width justified row). */
|
|
1117
|
+
const ToggleRow: React.FunctionComponent<{
|
|
1118
|
+
label: string;
|
|
1119
|
+
price?: string;
|
|
1120
|
+
checked: boolean;
|
|
1121
|
+
disabled?: boolean;
|
|
1122
|
+
onChange: (checked: boolean) => void;
|
|
1123
|
+
}> = ({ label, price, checked, disabled, onChange }) => (
|
|
1124
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
1125
|
+
<span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
1126
|
+
{label}
|
|
1127
|
+
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1128
|
+
</span>
|
|
1129
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
1130
|
+
{price && (
|
|
1131
|
+
<span className="num" style={{ fontWeight: 700, fontSize: 13.5 }}>
|
|
1132
|
+
{price}
|
|
1133
|
+
</span>
|
|
1134
|
+
)}
|
|
1135
|
+
<Switch checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} ariaLabel={label} />
|
|
1136
|
+
</div>
|
|
1137
|
+
</div>
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
export interface FinanceDetailsModalProps {
|
|
1141
|
+
/** open flag (controlled by the page). */
|
|
1142
|
+
open: boolean;
|
|
1143
|
+
/** active payment mode — drives labels + which legal example shows. */
|
|
1144
|
+
mode: 'fin' | 'leasing' | 'cash';
|
|
1145
|
+
/** car make+model — mobile sheet subtitle under the title (mockup). */
|
|
1146
|
+
vehicleName?: string;
|
|
1147
|
+
price: number;
|
|
1148
|
+
monthly: number;
|
|
1149
|
+
apr: number;
|
|
1150
|
+
down: number;
|
|
1151
|
+
term: number;
|
|
1152
|
+
/** leasing annual mileage (km/yr), shown only in leasing mode. */
|
|
1153
|
+
annualMileage?: number;
|
|
1154
|
+
/** REAL figures from the financing config (NOT computed — balloon/Schlussrate makes monthly·term wrong). */
|
|
1155
|
+
netLoanAmount?: number;
|
|
1156
|
+
/** Schlussrate (balloon final installment); shown only when > 0. */
|
|
1157
|
+
finalInstallment?: number;
|
|
1158
|
+
/** Gesamt(rückzahlungs)betrag from the config; shown only when > 0 (never the wrong monthly·term). */
|
|
1159
|
+
grossAmount?: number;
|
|
1160
|
+
/** Sollzinssatz p.a. (%); shown only when > 0. */
|
|
1161
|
+
nominalRate?: number;
|
|
1162
|
+
/** slider bounds — when the change handlers are supplied, the modal shows adjustment sliders. */
|
|
1163
|
+
downMin?: number;
|
|
1164
|
+
downMax?: number;
|
|
1165
|
+
downStep?: number;
|
|
1166
|
+
termMin?: number;
|
|
1167
|
+
termMax?: number;
|
|
1168
|
+
termStep?: number;
|
|
1169
|
+
/** discrete selectable terms (months) for the Laufzeit chips. */
|
|
1170
|
+
termOptions?: number[];
|
|
1171
|
+
annualMileageMin?: number;
|
|
1172
|
+
annualMileageMax?: number;
|
|
1173
|
+
annualMileageStep?: number;
|
|
1174
|
+
/** slider change handlers (same as the calculator); they recompute the rate via the page. */
|
|
1175
|
+
onDownChange?: (down: number) => void;
|
|
1176
|
+
onTermChange?: (term: number) => void;
|
|
1177
|
+
onAnnualMileageChange?: (km: number) => void;
|
|
1178
|
+
onClose: () => void;
|
|
1179
|
+
/** mobile only — start with the Finanzierungs-/Leasingdetails breakdown already expanded
|
|
1180
|
+
* (opened directly via that link, skipping the "Rate anpassen" collapsed default view). */
|
|
1181
|
+
initialShowBreakdown?: boolean;
|
|
1182
|
+
/** mobile only — price-rating label, drives the eval pill/bar (same as the collapsed card). */
|
|
1183
|
+
ratingLabel?: string;
|
|
1184
|
+
/** mobile only — mode tabs inside the sheet (mirrors the collapsed card's tabs). */
|
|
1185
|
+
onModeChange?: (mode: FinanceMode) => void;
|
|
1186
|
+
/** mobile only — primary "Rate anfragen"/"Angebot anfragen" CTA. */
|
|
1187
|
+
onSecure?: () => void;
|
|
1188
|
+
/** mobile only — secondary call CTA. */
|
|
1189
|
+
onCall?: () => void;
|
|
1190
|
+
/** mobile only — "Mit Schlussrate" toggle (financing only; leasing has no balloon concept). */
|
|
1191
|
+
withFinalInstallment?: boolean;
|
|
1192
|
+
onToggleFinalInstallment?: (checked: boolean) => void;
|
|
1193
|
+
/** mobile only — "Rundum-Sorglos-Paket" toggle (all modes; forced-on + disabled for cash). */
|
|
1194
|
+
withAddServices?: boolean;
|
|
1195
|
+
addServicesCost?: number;
|
|
1196
|
+
addServicesDisabled?: boolean;
|
|
1197
|
+
onToggleAddServices?: (checked: boolean) => void;
|
|
1198
|
+
/** i18n function (drilled from the page). */
|
|
1199
|
+
t: TranslationFunction;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Full payment breakdown + legal example, opened from the calculator's
|
|
1204
|
+
* "Finanzierungsdetails" / "Leasingdetails" link (matches production, which uses a popup).
|
|
1205
|
+
* Desktop = centered modal, mobile = bottom-sheet (CSS). All figures are derived from the
|
|
1206
|
+
* live calculator props (no fabricated Sollzins/Schlussrate — only what we can compute).
|
|
1207
|
+
*/
|
|
1208
|
+
export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalProps> = ({
|
|
1209
|
+
open,
|
|
1210
|
+
mode,
|
|
1211
|
+
vehicleName,
|
|
1212
|
+
price,
|
|
1213
|
+
monthly,
|
|
1214
|
+
apr,
|
|
1215
|
+
down,
|
|
1216
|
+
term,
|
|
1217
|
+
annualMileage,
|
|
1218
|
+
netLoanAmount,
|
|
1219
|
+
finalInstallment,
|
|
1220
|
+
grossAmount,
|
|
1221
|
+
nominalRate,
|
|
1222
|
+
downMin,
|
|
1223
|
+
downMax,
|
|
1224
|
+
downStep = 100,
|
|
1225
|
+
termMin,
|
|
1226
|
+
termMax,
|
|
1227
|
+
termStep = 6,
|
|
1228
|
+
termOptions,
|
|
1229
|
+
annualMileageMin = 5000,
|
|
1230
|
+
annualMileageMax = 60000,
|
|
1231
|
+
annualMileageStep = 5000,
|
|
1232
|
+
onDownChange,
|
|
1233
|
+
onTermChange,
|
|
1234
|
+
onAnnualMileageChange,
|
|
1235
|
+
onClose,
|
|
1236
|
+
initialShowBreakdown,
|
|
1237
|
+
ratingLabel,
|
|
1238
|
+
onModeChange,
|
|
1239
|
+
onSecure,
|
|
1240
|
+
onCall,
|
|
1241
|
+
withFinalInstallment,
|
|
1242
|
+
onToggleFinalInstallment,
|
|
1243
|
+
withAddServices,
|
|
1244
|
+
addServicesCost,
|
|
1245
|
+
addServicesDisabled,
|
|
1246
|
+
onToggleAddServices,
|
|
1247
|
+
t,
|
|
1248
|
+
}) => {
|
|
1249
|
+
// mobile-only: the Finanzierungs-/Leasingdetails breakdown starts collapsed (opened via "Rate
|
|
1250
|
+
// anpassen") or expanded (opened directly via the details link) — reset on every open, since
|
|
1251
|
+
// this component stays mounted (the page always renders it, gated by `open`, not unmounted).
|
|
1252
|
+
const [showBreakdown, setShowBreakdown] = React.useState(!!initialShowBreakdown);
|
|
1253
|
+
React.useEffect(() => {
|
|
1254
|
+
if (open) setShowBreakdown(!!initialShowBreakdown);
|
|
1255
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1256
|
+
}, [open, initialShowBreakdown]);
|
|
1257
|
+
|
|
1258
|
+
if (!open) return null;
|
|
1259
|
+
const isLease = mode === 'leasing';
|
|
1260
|
+
const isCash = mode === 'cash';
|
|
1261
|
+
const title = isLease ? t('autobahn:modal.leasingDetails') : t('autobahn:modal.financingDetails');
|
|
1262
|
+
const mobileTitle = isCash
|
|
1263
|
+
? t('autobahn:modal.sheetTitleCash')
|
|
1264
|
+
: isLease
|
|
1265
|
+
? t('autobahn:modal.sheetTitleLeasing')
|
|
1266
|
+
: t('autobahn:modal.sheetTitleFinancing');
|
|
1267
|
+
// price-evaluation tier — identical derivation to the collapsed card (FinanceCalculator).
|
|
1268
|
+
const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
|
|
1269
|
+
const evalTier = tierIdx >= 0 ? tierIdx : 1;
|
|
1270
|
+
const evalTierLabel = t('autobahn:tier.' + TIER_KEYS[evalTier]);
|
|
1271
|
+
// adjustable only for fin/leasing, and only when the page supplies the change handlers + bounds.
|
|
1272
|
+
const adjustable = mode !== 'cash' && !!onDownChange && downMin != null && downMax != null && termMin != null && termMax != null;
|
|
1273
|
+
// discrete term chip values (supplied periods, else derived from the bounds).
|
|
1274
|
+
const termVals =
|
|
1275
|
+
termOptions && termOptions.length
|
|
1276
|
+
? termOptions
|
|
1277
|
+
: (() => {
|
|
1278
|
+
const out: number[] = [];
|
|
1279
|
+
for (let v = termMin as number; v <= (termMax as number); v += termStep || 12) out.push(v);
|
|
1280
|
+
return out.length ? out : [term];
|
|
1281
|
+
})();
|
|
1282
|
+
const pct = (n: number) => `${n.toFixed(2).replace('.', ',')} %`;
|
|
1283
|
+
// Only REAL figures (from the financing config). Balloon financing means monthly·term is NOT the
|
|
1284
|
+
// total, so Schlussrate + Gesamtbetrag come from the config and are omitted when unavailable —
|
|
1285
|
+
// never a fabricated/misleading total.
|
|
1286
|
+
const netto = netLoanAmount != null ? netLoanAmount : Math.max(0, price - down);
|
|
1287
|
+
const rows: Array<[string, string]> = [
|
|
1288
|
+
[t('autobahn:modal.vehiclePrice'), `${eur(price)} €`],
|
|
1289
|
+
[isLease ? t('autobahn:modal.leasingAmount') : t('autobahn:modal.netLoanAmount'), `${eur(netto)} €`],
|
|
1290
|
+
[t('autobahn:modal.downPayment'), `${eur(down)} €`],
|
|
1291
|
+
...(isLease && annualMileage != null ? ([[t('autobahn:modal.annualMileage'), `${eur(annualMileage)} km`]] as Array<[string, string]>) : []),
|
|
1292
|
+
[t('autobahn:modal.term'), t('autobahn:modal.months', { months: term, count: term })],
|
|
1293
|
+
...(nominalRate != null && nominalRate > 0 ? ([[t('autobahn:modal.nominalRate'), pct(nominalRate)]] as Array<[string, string]>) : []),
|
|
1294
|
+
[t('autobahn:modal.effectiveRate'), pct(apr)],
|
|
1295
|
+
...(finalInstallment != null && finalInstallment > 0 ? ([[t('autobahn:modal.finalInstallment'), `${eur(finalInstallment)} €`]] as Array<[string, string]>) : []),
|
|
1296
|
+
[isLease ? t('autobahn:modal.leasingRate') : t('autobahn:modal.monthlyRate'), t('autobahn:modal.ratePerMonth', { amount: eur(monthly) })],
|
|
1297
|
+
...(grossAmount != null && grossAmount > 0 ? ([[isLease ? t('autobahn:modal.totalAmount') : t('autobahn:modal.totalRepayment'), `${eur(grossAmount)} €`]] as Array<[string, string]>) : []),
|
|
1298
|
+
];
|
|
1299
|
+
const breakdownRows = rows.map(([l, v], i) => (
|
|
1300
|
+
<div
|
|
1301
|
+
key={l}
|
|
1302
|
+
style={{
|
|
1303
|
+
display: 'flex',
|
|
1304
|
+
justifyContent: 'space-between',
|
|
1305
|
+
gap: 12,
|
|
1306
|
+
padding: '9px 0',
|
|
1307
|
+
borderBottom: i < rows.length - 1 ? '1px solid var(--md-outline-variant)' : 0,
|
|
1308
|
+
}}
|
|
1309
|
+
>
|
|
1310
|
+
<span className="md-label-sm">{l}</span>
|
|
1311
|
+
<span className="num" style={{ fontSize: 13.5, fontWeight: 600 }}>
|
|
1312
|
+
{v}
|
|
1313
|
+
</span>
|
|
1314
|
+
</div>
|
|
1315
|
+
));
|
|
1316
|
+
|
|
1317
|
+
return (
|
|
1318
|
+
<div className="fin-modal-scrim" onClick={onClose}>
|
|
1319
|
+
<div className="fin-modal" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
|
1320
|
+
<div className="fin-modal-head">
|
|
1321
|
+
<h2 className="md-headline-sm hide-mobile" style={{ margin: 0 }}>
|
|
1322
|
+
{title}
|
|
1323
|
+
</h2>
|
|
1324
|
+
<h2 className="md-headline-sm show-mobile" style={{ margin: 0, flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
|
|
1325
|
+
{mobileTitle}
|
|
1326
|
+
{vehicleName && (
|
|
1327
|
+
<span className="md-label-sm" style={{ fontWeight: 400 }}>
|
|
1328
|
+
{vehicleName}
|
|
1329
|
+
</span>
|
|
1330
|
+
)}
|
|
1331
|
+
</h2>
|
|
1332
|
+
<button className="icon-btn" onClick={onClose} aria-label={t('autobahn:modal.close')}>
|
|
1333
|
+
<Icon name="x" size={20} />
|
|
1334
|
+
</button>
|
|
1335
|
+
</div>
|
|
1336
|
+
<div className="fin-modal-body">
|
|
1337
|
+
{/* ============================== DESKTOP (≥601px) — unchanged ============================== */}
|
|
1338
|
+
<div className="hide-mobile">
|
|
1339
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 14 }}>
|
|
1340
|
+
<span className="num" style={{ fontSize: 34, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
|
|
1341
|
+
{eur(monthly)} €
|
|
1342
|
+
</span>
|
|
1343
|
+
<span className="md-label-sm">{t('autobahn:finance.perMonth')}</span>
|
|
1344
|
+
</div>
|
|
1345
|
+
{/* adjustment sliders — change a value and every figure below recomputes live (via the page) */}
|
|
1346
|
+
{adjustable && (
|
|
1347
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginBottom: 18 }}>
|
|
1348
|
+
<div>
|
|
1349
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
|
1350
|
+
<span className="md-title-sm">{t('autobahn:finance.downPaymentSliderLabel')}</span>
|
|
1351
|
+
<span className="num" style={{ fontWeight: 700 }}>
|
|
1352
|
+
{eur(down)} €
|
|
1353
|
+
</span>
|
|
1354
|
+
</div>
|
|
1355
|
+
<Slider
|
|
1356
|
+
min={downMin as number}
|
|
1357
|
+
max={downMax as number}
|
|
1358
|
+
step={downStep}
|
|
1359
|
+
value={down}
|
|
1360
|
+
onChange={onDownChange as (n: number) => void}
|
|
1361
|
+
fmt={(v) => `${eur(Number(v))} €`}
|
|
1362
|
+
/>
|
|
1363
|
+
</div>
|
|
1364
|
+
{isLease && annualMileage != null && onAnnualMileageChange && (
|
|
1365
|
+
<div>
|
|
1366
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
|
1367
|
+
<span className="md-title-sm">{t('autobahn:finance.annualMileageLabel')}</span>
|
|
1368
|
+
<span className="num" style={{ fontWeight: 700 }}>
|
|
1369
|
+
{eur(annualMileage)} km
|
|
1370
|
+
</span>
|
|
1371
|
+
</div>
|
|
1372
|
+
<Slider
|
|
1373
|
+
min={annualMileageMin}
|
|
1374
|
+
max={annualMileageMax}
|
|
1375
|
+
step={annualMileageStep}
|
|
1376
|
+
value={annualMileage}
|
|
1377
|
+
onChange={onAnnualMileageChange}
|
|
1378
|
+
fmt={(v) => `${eur(Number(v))} km`}
|
|
1379
|
+
/>
|
|
1380
|
+
</div>
|
|
1381
|
+
)}
|
|
1382
|
+
<div>
|
|
1383
|
+
<div className="md-title-sm" style={{ marginBottom: 8 }}>
|
|
1384
|
+
{t('autobahn:finance.termSelection')}
|
|
1385
|
+
</div>
|
|
1386
|
+
<TermChips options={termVals} value={term} onChange={onTermChange as (n: number) => void} t={t} />
|
|
1387
|
+
</div>
|
|
1388
|
+
</div>
|
|
1389
|
+
)}
|
|
1390
|
+
{breakdownRows}
|
|
1391
|
+
<p className="md-label-sm" style={{ marginTop: 16, lineHeight: 1.55 }}>
|
|
1392
|
+
{isLease ? t('autobahn:modal.legalLeasing') : t('autobahn:modal.legalFinancing')}
|
|
1393
|
+
</p>
|
|
1394
|
+
</div>
|
|
1395
|
+
|
|
1396
|
+
{/* ============================== MOBILE (≤600px) — full "Rate anpassen" sheet ============================== */}
|
|
1397
|
+
<div className="show-mobile" style={{ flexDirection: 'column', gap: 18 }}>
|
|
1398
|
+
{onModeChange && (
|
|
1399
|
+
<div className="fin-tabs" role="tablist">
|
|
1400
|
+
{(
|
|
1401
|
+
[
|
|
1402
|
+
['fin', 'autobahn:finance.mode.financing'],
|
|
1403
|
+
['leasing', 'autobahn:finance.mode.leasing'],
|
|
1404
|
+
// this sheet is mobile-only, so the cash tab is "Kaufen" here (desktop card says "Barzahlung")
|
|
1405
|
+
['cash', 'autobahn:card.price.buy'],
|
|
1406
|
+
] as Array<[FinanceMode, string]>
|
|
1407
|
+
).map(([k, labelKey]) => (
|
|
1408
|
+
<button
|
|
1409
|
+
key={k}
|
|
1410
|
+
type="button"
|
|
1411
|
+
role="tab"
|
|
1412
|
+
aria-selected={mode === k}
|
|
1413
|
+
className={'fin-tab' + (mode === k ? ' on' : '')}
|
|
1414
|
+
onClick={() => onModeChange(k)}
|
|
1415
|
+
>
|
|
1416
|
+
{t(labelKey)}
|
|
1417
|
+
</button>
|
|
1418
|
+
))}
|
|
1419
|
+
</div>
|
|
1420
|
+
)}
|
|
1421
|
+
|
|
1422
|
+
<div className="fin-price-grid">
|
|
1423
|
+
<div className="fin-rate-main">
|
|
1424
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 5 }}>
|
|
1425
|
+
<span className="num" style={{ fontSize: 34, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
|
|
1426
|
+
{isCash ? eur(price) : eur(Math.round(monthly))}
|
|
1427
|
+
</span>
|
|
1428
|
+
<span className="md-body-sm" style={{ fontWeight: 600 }}>
|
|
1429
|
+
{isCash ? '€' : `€ ${t('autobahn:finance.perMonth')}`}
|
|
1430
|
+
</span>
|
|
1431
|
+
</div>
|
|
1432
|
+
</div>
|
|
1433
|
+
<div>
|
|
1434
|
+
{ratingLabel && (
|
|
1435
|
+
<span
|
|
1436
|
+
className="fin-eval-pill"
|
|
1437
|
+
style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
|
|
1438
|
+
>
|
|
1439
|
+
<Icon
|
|
1440
|
+
name={evalTier <= 1 ? 'check' : evalTier === 2 ? 'minus' : 'trending-up'}
|
|
1441
|
+
size={14}
|
|
1442
|
+
stroke={2.6}
|
|
1443
|
+
color={TIER_COLORS[evalTier]}
|
|
1444
|
+
/>
|
|
1445
|
+
{evalTierLabel}
|
|
1446
|
+
</span>
|
|
1447
|
+
)}
|
|
1448
|
+
</div>
|
|
1449
|
+
<div />
|
|
1450
|
+
<div>
|
|
1451
|
+
{ratingLabel && (
|
|
1452
|
+
<div className="fin-eval-bar">
|
|
1453
|
+
<div className="fin-eval-thumb" style={{ left: `${(evalTier + 0.5) * 20}%`, borderColor: TIER_COLORS[evalTier] }} />
|
|
1454
|
+
</div>
|
|
1455
|
+
)}
|
|
1456
|
+
</div>
|
|
1457
|
+
</div>
|
|
1458
|
+
|
|
1459
|
+
{isCash ? (
|
|
1460
|
+
<>
|
|
1461
|
+
<div>
|
|
1462
|
+
<CalcRow label={t('autobahn:finance.paymentLabel')}>{t('autobahn:finance.paymentValue')}</CalcRow>
|
|
1463
|
+
<CalcRow label={t('autobahn:finance.warrantyLabel')}>{t('autobahn:finance.warrantyValue')}</CalcRow>
|
|
1464
|
+
<CalcRow label={t('autobahn:finance.deliveryLabel')} last>
|
|
1465
|
+
{t('autobahn:finance.deliveryValue')}
|
|
1466
|
+
</CalcRow>
|
|
1467
|
+
</div>
|
|
1468
|
+
{addServicesCost != null && (
|
|
1469
|
+
<ToggleRow
|
|
1470
|
+
label={t('autobahn:finance.addServicesToggle')}
|
|
1471
|
+
price={t('autobahn:finance.addServicesOnce', { value: eur(addServicesCost) })}
|
|
1472
|
+
checked={!!withAddServices}
|
|
1473
|
+
disabled={addServicesDisabled}
|
|
1474
|
+
onChange={(checked) => onToggleAddServices && onToggleAddServices(checked)}
|
|
1475
|
+
/>
|
|
1476
|
+
)}
|
|
1477
|
+
<div style={{ display: 'flex', gap: 10 }}>
|
|
1478
|
+
<Button variant="filled" size="lg" icon="shield-check" style={{ flex: 1 }} onClick={onSecure}>
|
|
1479
|
+
{t('autobahn:finance.secureOffer')}
|
|
1480
|
+
</Button>
|
|
1481
|
+
{/* padding:0 — mbtn--lg's 30px side padding left no room for the icon at width 52 */}
|
|
1482
|
+
<Button variant="outlined" size="lg" icon="phone" style={{ width: 52, flex: 'none', padding: 0, borderRadius: 10 }} onClick={onCall} />
|
|
1483
|
+
</div>
|
|
1484
|
+
</>
|
|
1485
|
+
) : (
|
|
1486
|
+
<>
|
|
1487
|
+
{adjustable && (
|
|
1488
|
+
<div className="fin-sheet-adjust" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
1489
|
+
<div>
|
|
1490
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
1491
|
+
<span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
1492
|
+
{t('autobahn:finance.downPaymentSliderLabel')}
|
|
1493
|
+
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1494
|
+
</span>
|
|
1495
|
+
<span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
|
|
1496
|
+
{eur(down)} €
|
|
1497
|
+
</span>
|
|
1498
|
+
</div>
|
|
1499
|
+
<span className="fin-val-box num">{eur(down)} €</span>
|
|
1500
|
+
<Slider
|
|
1501
|
+
min={downMin as number}
|
|
1502
|
+
max={downMax as number}
|
|
1503
|
+
step={downStep}
|
|
1504
|
+
value={down}
|
|
1505
|
+
onChange={onDownChange as (n: number) => void}
|
|
1506
|
+
fmt={(v) => `${eur(Number(v))} €`}
|
|
1507
|
+
/>
|
|
1508
|
+
</div>
|
|
1509
|
+
{isLease && annualMileage != null && onAnnualMileageChange && (
|
|
1510
|
+
<div>
|
|
1511
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
|
1512
|
+
<span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
1513
|
+
{t('autobahn:finance.annualMileageLabel')}
|
|
1514
|
+
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1515
|
+
</span>
|
|
1516
|
+
<span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
|
|
1517
|
+
{eur(annualMileage)} km
|
|
1518
|
+
</span>
|
|
1519
|
+
</div>
|
|
1520
|
+
<span className="fin-val-box num">{eur(annualMileage)} km</span>
|
|
1521
|
+
<Slider
|
|
1522
|
+
min={annualMileageMin}
|
|
1523
|
+
max={annualMileageMax}
|
|
1524
|
+
step={annualMileageStep}
|
|
1525
|
+
value={annualMileage}
|
|
1526
|
+
onChange={onAnnualMileageChange}
|
|
1527
|
+
fmt={(v) => `${eur(Number(v))} km`}
|
|
1528
|
+
/>
|
|
1529
|
+
</div>
|
|
1530
|
+
)}
|
|
1531
|
+
<div className="fin-sheet-terms">
|
|
1532
|
+
<div className="md-title-sm" style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 8 }}>
|
|
1533
|
+
{t('autobahn:finance.termSelectionMonths')}
|
|
1534
|
+
<Icon name="info" size={13} color="var(--md-on-surface-variant)" />
|
|
1535
|
+
</div>
|
|
1536
|
+
<TermChips options={termVals} value={term} onChange={onTermChange as (n: number) => void} t={t} />
|
|
1537
|
+
</div>
|
|
1538
|
+
</div>
|
|
1539
|
+
)}
|
|
1540
|
+
|
|
1541
|
+
{!isLease && onToggleFinalInstallment && (
|
|
1542
|
+
<ToggleRow
|
|
1543
|
+
label={t('autobahn:finance.finalInstallmentToggle')}
|
|
1544
|
+
checked={!!withFinalInstallment}
|
|
1545
|
+
onChange={onToggleFinalInstallment}
|
|
1546
|
+
/>
|
|
1547
|
+
)}
|
|
1548
|
+
{addServicesCost != null && onToggleAddServices && (
|
|
1549
|
+
<ToggleRow
|
|
1550
|
+
label={t('autobahn:finance.addServicesToggle')}
|
|
1551
|
+
price={t('autobahn:finance.addServicesPerMonth', { value: eur(addServicesCost) })}
|
|
1552
|
+
checked={!!withAddServices}
|
|
1553
|
+
disabled={addServicesDisabled}
|
|
1554
|
+
onChange={onToggleAddServices}
|
|
1555
|
+
/>
|
|
1556
|
+
)}
|
|
1557
|
+
|
|
1558
|
+
<div style={{ display: 'flex', gap: 10 }}>
|
|
1559
|
+
<Button variant="filled" size="lg" icon="shield-check" style={{ flex: 1 }} onClick={onSecure}>
|
|
1560
|
+
{t('autobahn:finance.requestRate')}
|
|
1561
|
+
</Button>
|
|
1562
|
+
{/* padding:0 — mbtn--lg's 30px side padding left no room for the icon at width 52 */}
|
|
1563
|
+
<Button variant="outlined" size="lg" icon="phone" style={{ width: 52, flex: 'none', padding: 0, borderRadius: 10 }} onClick={onCall} />
|
|
1564
|
+
</div>
|
|
1565
|
+
|
|
1566
|
+
{/* centered ⓘ details toggle (mockup shows no chevron) */}
|
|
1567
|
+
<button type="button" onClick={() => setShowBreakdown((v) => !v)} style={{ ...expanderBtnStyle, justifyContent: 'center', padding: '2px 0' }}>
|
|
1568
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
|
1569
|
+
<Icon name="info" size={16} color="var(--md-primary)" />
|
|
1570
|
+
{isLease ? t('autobahn:modal.leasingDetails') : t('autobahn:modal.financingDetails')}
|
|
1571
|
+
</span>
|
|
1572
|
+
</button>
|
|
1573
|
+
{/* mobile mockup shows NO row-by-row breakdown table (round 9 review) — only the
|
|
1574
|
+
legally required representative example; the table stays desktop-only. */}
|
|
1575
|
+
{showBreakdown && (
|
|
1576
|
+
<div>
|
|
1577
|
+
<p className="md-label-sm" style={{ margin: 0, lineHeight: 1.55 }}>
|
|
1578
|
+
{isLease ? t('autobahn:modal.legalLeasing') : t('autobahn:modal.legalFinancing')}
|
|
1579
|
+
</p>
|
|
1580
|
+
</div>
|
|
1581
|
+
)}
|
|
1582
|
+
</>
|
|
1583
|
+
)}
|
|
1584
|
+
</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
</div>
|
|
1587
|
+
</div>
|
|
1588
|
+
);
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
// ---------------------------------------------------------------------------
|
|
1592
|
+
// Customer ratings (auto.de Google reviews) — REAL data, loaded by the page
|
|
1593
|
+
// ---------------------------------------------------------------------------
|
|
1594
|
+
|
|
1595
|
+
export interface CustomerRatingsProps {
|
|
1596
|
+
/** the loaded reviews (REAL — from the WP json-quotes source). */
|
|
1597
|
+
reviews: VdpReview[];
|
|
1598
|
+
/**
|
|
1599
|
+
* Overall auto.de Google rating to display instead of the average of the few
|
|
1600
|
+
* loaded reviews (which skews to 5,0 — the review feed only carries 5-star
|
|
1601
|
+
* quotes). The page passes the real aggregate (4,6 per Google, review round 9).
|
|
1602
|
+
*/
|
|
1603
|
+
average?: number;
|
|
1604
|
+
/** i18n function (drilled from the page). */
|
|
1605
|
+
t: TranslationFunction;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Customer-rating section (the legacy VDP's "Kundenmeinungen", rebuilt on the
|
|
1610
|
+
* Autobahn card). Shows the auto.de average (DERIVED from the loaded reviews — not
|
|
1611
|
+
* invented) + a grid of review cards (avatar, name, stars, quote). The reviews are
|
|
1612
|
+
* the SAME real source the legacy CustomerQuotes uses (auto.de Google reviews via
|
|
1613
|
+
* the WordPress json-quotes feed); the page loads them and passes them in. When no
|
|
1614
|
+
* reviews load, the page renders nothing (no fabricated content).
|
|
1615
|
+
*/
|
|
1616
|
+
export const CustomerRatings: React.FunctionComponent<CustomerRatingsProps> = ({ reviews, average, t }) => {
|
|
1617
|
+
const [showAll, setShowAll] = useState(false);
|
|
1618
|
+
if (!reviews || !reviews.length) return null;
|
|
1619
|
+
const count = reviews.length;
|
|
1620
|
+
const avg = average != null ? average : reviews.reduce((s, r) => s + (r.rating || 0), 0) / count;
|
|
1621
|
+
const shown = showAll ? reviews : reviews.slice(0, 4);
|
|
1622
|
+
return (
|
|
1623
|
+
<section className="mcard" id="vdp-bewertungen" style={{ padding: 22, marginTop: 44 }}>
|
|
1624
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
|
|
1625
|
+
<h2 className="md-headline-sm" style={{ margin: 0 }}>
|
|
1626
|
+
{t('autobahn:reviews.title')}
|
|
1627
|
+
</h2>
|
|
1628
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' }}>
|
|
1629
|
+
<span className="num" style={{ fontWeight: 800, fontSize: 26, color: 'var(--md-on-surface)', letterSpacing: '-.5px' }}>
|
|
1630
|
+
{avg.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
|
|
1631
|
+
</span>
|
|
1632
|
+
<span>
|
|
1633
|
+
<Stars value={avg} size={16} />
|
|
1634
|
+
<span className="md-label-sm" style={{ display: 'block' }}>
|
|
1635
|
+
{t('autobahn:reviews.ratingCount', { count })} · via Google
|
|
1636
|
+
</span>
|
|
1637
|
+
</span>
|
|
1638
|
+
</div>
|
|
1639
|
+
</div>
|
|
1640
|
+
<div className="eq-cols">
|
|
1641
|
+
{shown.map((r) => (
|
|
1642
|
+
<div
|
|
1643
|
+
key={r.id}
|
|
1644
|
+
className="mcard mcard--filled"
|
|
1645
|
+
style={{ breakInside: 'avoid', marginBottom: 16, padding: 16 }}
|
|
1646
|
+
>
|
|
1647
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
|
1648
|
+
{r.image ? (
|
|
1649
|
+
<img
|
|
1650
|
+
src={r.image}
|
|
1651
|
+
alt=""
|
|
1652
|
+
loading="lazy"
|
|
1653
|
+
style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', flex: 'none', background: 'var(--md-sc)' }}
|
|
1654
|
+
/>
|
|
1655
|
+
) : (
|
|
1656
|
+
<span
|
|
1657
|
+
style={{
|
|
1658
|
+
width: 40,
|
|
1659
|
+
height: 40,
|
|
1660
|
+
borderRadius: '50%',
|
|
1661
|
+
flex: 'none',
|
|
1662
|
+
background: 'var(--md-primary-container)',
|
|
1663
|
+
color: 'var(--md-on-primary-container)',
|
|
1664
|
+
display: 'flex',
|
|
1665
|
+
alignItems: 'center',
|
|
1666
|
+
justifyContent: 'center',
|
|
1667
|
+
fontWeight: 700,
|
|
1668
|
+
}}
|
|
1669
|
+
>
|
|
1670
|
+
{(r.name || '?').charAt(0).toUpperCase()}
|
|
1671
|
+
</span>
|
|
1672
|
+
)}
|
|
1673
|
+
<div style={{ minWidth: 0 }}>
|
|
1674
|
+
<div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
1675
|
+
{r.name}
|
|
1676
|
+
</div>
|
|
1677
|
+
<Stars value={r.rating} />
|
|
1678
|
+
</div>
|
|
1679
|
+
</div>
|
|
1680
|
+
<p className="md-body-sm" style={{ margin: 0, color: 'var(--md-on-surface-variant)' }}>
|
|
1681
|
+
{r.review}
|
|
1682
|
+
</p>
|
|
1683
|
+
</div>
|
|
1684
|
+
))}
|
|
1685
|
+
</div>
|
|
1686
|
+
{reviews.length > 4 && (
|
|
1687
|
+
<button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setShowAll((a) => !a)}>
|
|
1688
|
+
{showAll ? t('autobahn:ratings.showLess') : t('autobahn:ratings.showMore', { count })}
|
|
1689
|
+
<Icon name={showAll ? 'chevron-up' : 'chevron-down'} size={16} />
|
|
1690
|
+
</button>
|
|
1691
|
+
)}
|
|
1692
|
+
</section>
|
|
1693
|
+
);
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
// ---------------------------------------------------------------------------
|
|
1697
|
+
// Enquiry block — low-friction "Frage zum Fahrzeug" lead form
|
|
1698
|
+
// ---------------------------------------------------------------------------
|
|
1699
|
+
|
|
1700
|
+
export interface VdpEnquiryProps {
|
|
1701
|
+
/** car title for the prefilled message + heading context. */
|
|
1702
|
+
carTitle: string;
|
|
1703
|
+
/** auto.de-ID reference, woven into the prefilled message when present. */
|
|
1704
|
+
autoDeId?: string;
|
|
1705
|
+
/** sales hotline shown in the aside (tap-to-call). */
|
|
1706
|
+
salesPhone?: string;
|
|
1707
|
+
/**
|
|
1708
|
+
* Submit handler — the page wires this to the REAL contact channel
|
|
1709
|
+
* (carStore.submitContactForm). Returns a promise; the form shows a thank-you on
|
|
1710
|
+
* resolve and an inline error on reject.
|
|
1711
|
+
*/
|
|
1712
|
+
onSubmit: (data: { name: string; email: string; phone: string; message: string }) => Promise<void> | void;
|
|
1713
|
+
/** i18n function (drilled from the page). */
|
|
1714
|
+
t: TranslationFunction;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const enquiryInput: React.CSSProperties = {
|
|
1718
|
+
width: '100%',
|
|
1719
|
+
height: 46,
|
|
1720
|
+
padding: '0 14px',
|
|
1721
|
+
borderRadius: 'var(--md-r-sm)',
|
|
1722
|
+
border: '1px solid var(--md-outline-variant)',
|
|
1723
|
+
background: 'var(--md-sc-lowest)',
|
|
1724
|
+
color: 'var(--md-on-surface)',
|
|
1725
|
+
font: 'inherit',
|
|
1726
|
+
fontSize: 14,
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
/** outlined field with the label floating ON the top border (Figma phone/message fields). */
|
|
1730
|
+
const FloatField: React.FunctionComponent<{
|
|
1731
|
+
label: string;
|
|
1732
|
+
children: React.ReactNode;
|
|
1733
|
+
}> = ({ label, children }) => (
|
|
1734
|
+
<label className="enq-float">
|
|
1735
|
+
<span className="enq-float-label">{label}</span>
|
|
1736
|
+
{children}
|
|
1737
|
+
</label>
|
|
1738
|
+
);
|
|
1739
|
+
|
|
1740
|
+
/** [icon, i18n label key] — resolved against the drilled t in the component. */
|
|
1741
|
+
const ENQUIRY_BENEFITS: ReadonlyArray<[string, string]> = [
|
|
1742
|
+
['clock', 'autobahn:enquiry.benefits.fastResponse'],
|
|
1743
|
+
['shield-check', 'autobahn:enquiry.benefits.freeAndNonBinding'],
|
|
1744
|
+
['hand-coins', 'autobahn:enquiry.benefits.financingAdvice'],
|
|
1745
|
+
];
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Vehicle enquiry — an enriched two-pane lead block: a left trust/benefits aside (persönliche
|
|
1749
|
+
* Beratung, response time, hotline) and a right structured form (preferred-contact toggle, named
|
|
1750
|
+
* fields, editable prefilled message), wired to the REAL submit (carStore.submitContactForm).
|
|
1751
|
+
*/
|
|
1752
|
+
export const VdpEnquiry: React.FunctionComponent<VdpEnquiryProps> = ({
|
|
1753
|
+
carTitle,
|
|
1754
|
+
autoDeId,
|
|
1755
|
+
salesPhone = '0800 40 30 182',
|
|
1756
|
+
onSubmit,
|
|
1757
|
+
t,
|
|
1758
|
+
}) => {
|
|
1759
|
+
const [firstName, setFirstName] = useState('');
|
|
1760
|
+
const [lastName, setLastName] = useState('');
|
|
1761
|
+
const [email, setEmail] = useState('');
|
|
1762
|
+
const [areaCode, setAreaCode] = useState('');
|
|
1763
|
+
const [phoneNum, setPhoneNum] = useState('');
|
|
1764
|
+
const [consent, setConsent] = useState(false);
|
|
1765
|
+
const [pref, setPref] = useState<'email' | 'phone'>('email');
|
|
1766
|
+
const [message, setMessage] = useState(
|
|
1767
|
+
autoDeId
|
|
1768
|
+
? t('autobahn:enquiry.prefilledMessageWithId', { carTitle, autoDeId })
|
|
1769
|
+
: t('autobahn:enquiry.prefilledMessage', { carTitle })
|
|
1770
|
+
);
|
|
1771
|
+
const [sending, setSending] = useState(false);
|
|
1772
|
+
const [sent, setSent] = useState(false);
|
|
1773
|
+
const [error, setError] = useState('');
|
|
1774
|
+
|
|
1775
|
+
const emailOk = /.+@.+\..+/.test(email);
|
|
1776
|
+
const phoneDigits = (areaCode + phoneNum).replace(/\D/g, '');
|
|
1777
|
+
const phoneOk = phoneDigits.length >= 6;
|
|
1778
|
+
// Figma round 9: Vorname/Nachname/E-Mail are always required (*), phone additionally
|
|
1779
|
+
// when it's the preferred channel; the consent checkbox gates the send.
|
|
1780
|
+
const valid =
|
|
1781
|
+
firstName.trim().length > 0 &&
|
|
1782
|
+
lastName.trim().length > 0 &&
|
|
1783
|
+
emailOk &&
|
|
1784
|
+
consent &&
|
|
1785
|
+
(pref !== 'phone' || phoneOk);
|
|
1786
|
+
|
|
1787
|
+
const submit = async (): Promise<void> => {
|
|
1788
|
+
if (!valid || sending) return;
|
|
1789
|
+
setSending(true);
|
|
1790
|
+
setError('');
|
|
1791
|
+
const note = t('autobahn:enquiry.preferredContact', {
|
|
1792
|
+
channel: pref === 'email' ? t('autobahn:enquiry.contactMethod.email') : t('autobahn:enquiry.contactMethod.phone'),
|
|
1793
|
+
});
|
|
1794
|
+
const phone = phoneDigits ? `+49 ${areaCode.trim()} ${phoneNum.trim()}`.replace(/\s+/g, ' ').trim() : '';
|
|
1795
|
+
try {
|
|
1796
|
+
await onSubmit({
|
|
1797
|
+
name: `${firstName.trim()} ${lastName.trim()}`,
|
|
1798
|
+
email: email.trim(),
|
|
1799
|
+
phone,
|
|
1800
|
+
message: message.trim() + note,
|
|
1801
|
+
});
|
|
1802
|
+
setSent(true);
|
|
1803
|
+
} catch (e) {
|
|
1804
|
+
setError(t('autobahn:enquiry.form.sendError'));
|
|
1805
|
+
} finally {
|
|
1806
|
+
setSending(false);
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
if (sent) {
|
|
1811
|
+
return (
|
|
1812
|
+
<section className="mcard mcard--filled" id="vdp-kontakt" style={{ padding: 22, marginTop: 24 }}>
|
|
1813
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
1814
|
+
<Icon name="check-circle" size={24} color="var(--md-good)" />
|
|
1815
|
+
<div>
|
|
1816
|
+
<div className="md-title-sm">{t('autobahn:enquiry.success.title')}</div>
|
|
1817
|
+
<div className="md-body-sm">{t('autobahn:enquiry.success.message')}</div>
|
|
1818
|
+
</div>
|
|
1819
|
+
</div>
|
|
1820
|
+
</section>
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
return (
|
|
1825
|
+
<section className="mcard" id="vdp-kontakt" style={{ padding: 0, overflow: 'hidden', marginTop: 24 }}>
|
|
1826
|
+
<div className="enquiry-grid">
|
|
1827
|
+
{/* trust / benefits aside */}
|
|
1828
|
+
<aside className="enquiry-aside">
|
|
1829
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
|
1830
|
+
<span className="enquiry-ico">
|
|
1831
|
+
<Icon name="headset" size={20} color="var(--md-on-primary-container)" />
|
|
1832
|
+
</span>
|
|
1833
|
+
<h2 className="md-title" style={{ margin: 0, fontSize: 19 }}>
|
|
1834
|
+
{t('autobahn:enquiry.personalConsultation')}
|
|
1835
|
+
</h2>
|
|
1836
|
+
</div>
|
|
1837
|
+
<p className="md-body-sm" style={{ marginBottom: 16 }}>
|
|
1838
|
+
{t('autobahn:enquiry.aside.description')}
|
|
1839
|
+
</p>
|
|
1840
|
+
<ul className="enquiry-benefits">
|
|
1841
|
+
{ENQUIRY_BENEFITS.map(([ic, key]) => (
|
|
1842
|
+
<li key={key}>
|
|
1843
|
+
<Icon name={ic} size={17} color="var(--md-primary)" stroke={2} />
|
|
1844
|
+
{t(key)}
|
|
1845
|
+
</li>
|
|
1846
|
+
))}
|
|
1847
|
+
</ul>
|
|
1848
|
+
<a className="enquiry-phone" href={`tel:${salesPhone.replace(/\s/g, '')}`}>
|
|
1849
|
+
<Icon name="phone" size={16} />
|
|
1850
|
+
{salesPhone}
|
|
1851
|
+
</a>
|
|
1852
|
+
</aside>
|
|
1853
|
+
|
|
1854
|
+
{/* form */}
|
|
1855
|
+
<div className="enquiry-form">
|
|
1856
|
+
<div style={{ marginBottom: 14 }}>
|
|
1857
|
+
<div className="md-label-sm" style={{ fontWeight: 600, marginBottom: 7 }}>
|
|
1858
|
+
{t('autobahn:enquiry.contactMethod.heading')}
|
|
1859
|
+
</div>
|
|
1860
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
1861
|
+
<button
|
|
1862
|
+
type="button"
|
|
1863
|
+
className={'chip' + (pref === 'email' ? ' chip--filter chip--selected' : '')}
|
|
1864
|
+
onClick={() => setPref('email')}
|
|
1865
|
+
>
|
|
1866
|
+
<Icon name="mail" size={15} />
|
|
1867
|
+
{t('autobahn:enquiry.contactMethod.email')}
|
|
1868
|
+
</button>
|
|
1869
|
+
<button
|
|
1870
|
+
type="button"
|
|
1871
|
+
className={'chip' + (pref === 'phone' ? ' chip--filter chip--selected' : '')}
|
|
1872
|
+
onClick={() => setPref('phone')}
|
|
1873
|
+
>
|
|
1874
|
+
<Icon name="phone" size={15} />
|
|
1875
|
+
{t('autobahn:enquiry.contactMethod.phone')}
|
|
1876
|
+
</button>
|
|
1877
|
+
</div>
|
|
1878
|
+
</div>
|
|
1879
|
+
{/* Vorname* / Nachname* — two required fields SIDE BY SIDE (Figma round 9; fixed
|
|
1880
|
+
2-col — auto-fit collapsed to one column at 390px, mockup keeps them paired) */}
|
|
1881
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
|
|
1882
|
+
<input
|
|
1883
|
+
style={enquiryInput}
|
|
1884
|
+
placeholder={t('autobahn:enquiry.form.firstNamePlaceholder')}
|
|
1885
|
+
value={firstName}
|
|
1886
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
1887
|
+
aria-label={t('autobahn:enquiry.form.firstNameAria')}
|
|
1888
|
+
/>
|
|
1889
|
+
<input
|
|
1890
|
+
style={enquiryInput}
|
|
1891
|
+
placeholder={t('autobahn:enquiry.form.lastNamePlaceholder')}
|
|
1892
|
+
value={lastName}
|
|
1893
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
1894
|
+
aria-label={t('autobahn:enquiry.form.lastNameAria')}
|
|
1895
|
+
/>
|
|
1896
|
+
</div>
|
|
1897
|
+
<div style={{ marginTop: 12 }}>
|
|
1898
|
+
<input
|
|
1899
|
+
style={enquiryInput}
|
|
1900
|
+
type="email"
|
|
1901
|
+
placeholder={t('autobahn:enquiry.form.emailPlaceholder')}
|
|
1902
|
+
value={email}
|
|
1903
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
1904
|
+
aria-label={t('autobahn:enquiry.contactMethod.email')}
|
|
1905
|
+
/>
|
|
1906
|
+
</div>
|
|
1907
|
+
{/* Land (+49 fixed) · Vorwahl · Telefonnummer — outlined float-label row (Figma) */}
|
|
1908
|
+
<div className="enq-phone-row" style={{ marginTop: 16 }}>
|
|
1909
|
+
<FloatField label={t('autobahn:enquiry.form.countryLabel')}>
|
|
1910
|
+
<span className="enq-country num">
|
|
1911
|
+
+49
|
|
1912
|
+
<Icon name="chevron-down" size={14} color="var(--md-on-surface-variant)" />
|
|
1913
|
+
</span>
|
|
1914
|
+
</FloatField>
|
|
1915
|
+
<FloatField label={t('autobahn:enquiry.form.areaCodeLabel')}>
|
|
1916
|
+
<input
|
|
1917
|
+
type="tel"
|
|
1918
|
+
value={areaCode}
|
|
1919
|
+
onChange={(e) => setAreaCode(e.target.value)}
|
|
1920
|
+
aria-label={t('autobahn:enquiry.form.areaCodeLabel')}
|
|
1921
|
+
/>
|
|
1922
|
+
</FloatField>
|
|
1923
|
+
<FloatField label={t('autobahn:enquiry.form.phoneNumberLabel')}>
|
|
1924
|
+
<input
|
|
1925
|
+
type="tel"
|
|
1926
|
+
value={phoneNum}
|
|
1927
|
+
onChange={(e) => setPhoneNum(e.target.value)}
|
|
1928
|
+
aria-label={t('autobahn:enquiry.form.phoneNumberLabel')}
|
|
1929
|
+
/>
|
|
1930
|
+
</FloatField>
|
|
1931
|
+
</div>
|
|
1932
|
+
<div style={{ marginTop: 16 }}>
|
|
1933
|
+
<FloatField label={t('autobahn:enquiry.form.messageLabel')}>
|
|
1934
|
+
<textarea
|
|
1935
|
+
value={message}
|
|
1936
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
1937
|
+
aria-label={t('autobahn:enquiry.form.messageAria')}
|
|
1938
|
+
/>
|
|
1939
|
+
</FloatField>
|
|
1940
|
+
</div>
|
|
1941
|
+
{/* Nutzungsbedingungen sentence + revocable-consent checkbox (Figma round 9) */}
|
|
1942
|
+
<p className="md-body-sm" style={{ margin: '14px 0 0' }}>
|
|
1943
|
+
{t('autobahn:enquiry.form.termsPrefix')}{' '}
|
|
1944
|
+
<a href="/nutzungsbedingungen/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>
|
|
1945
|
+
{t('autobahn:enquiry.form.termsLink')}
|
|
1946
|
+
</a>{' '}
|
|
1947
|
+
{t('autobahn:enquiry.form.termsSuffix')}
|
|
1948
|
+
</p>
|
|
1949
|
+
<label className="enq-consent">
|
|
1950
|
+
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
|
|
1951
|
+
<span>
|
|
1952
|
+
{t('autobahn:enquiry.form.consentPart1')}{' '}
|
|
1953
|
+
<a href="/datenschutz/" target="_blank" rel="noopener noreferrer">
|
|
1954
|
+
{t('autobahn:enquiry.form.consentLinkHere')}
|
|
1955
|
+
</a>{' '}
|
|
1956
|
+
{t('autobahn:enquiry.form.consentPart2')}{' '}
|
|
1957
|
+
<a href="/datenschutz/" target="_blank" rel="noopener noreferrer">
|
|
1958
|
+
{t('autobahn:enquiry.form.consentLinkPrivacy')}
|
|
1959
|
+
</a>
|
|
1960
|
+
{t('autobahn:enquiry.form.consentPart3')}
|
|
1961
|
+
</span>
|
|
1962
|
+
</label>
|
|
1963
|
+
{error && (
|
|
1964
|
+
<div className="md-label-sm" style={{ color: 'var(--md-error)', marginTop: 8 }}>
|
|
1965
|
+
{error}
|
|
1966
|
+
</div>
|
|
1967
|
+
)}
|
|
1968
|
+
<div style={{ marginTop: 16 }}>
|
|
1969
|
+
<Button variant="filled" block size="lg" icon="send" disabled={!valid || sending} onClick={submit}>
|
|
1970
|
+
{sending ? t('autobahn:enquiry.form.sending') : t('autobahn:enquiry.sendButton')}
|
|
1971
|
+
</Button>
|
|
1972
|
+
<span
|
|
1973
|
+
className="md-label-sm"
|
|
1974
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, marginTop: 10, color: 'var(--md-good)' }}
|
|
1975
|
+
>
|
|
1976
|
+
<Icon name="lock" size={13} color="var(--md-good)" />
|
|
1977
|
+
{t('autobahn:enquiry.security')}
|
|
1978
|
+
</span>
|
|
1979
|
+
</div>
|
|
1980
|
+
</div>
|
|
1981
|
+
</div>
|
|
1982
|
+
</section>
|
|
1983
|
+
);
|
|
1984
|
+
};
|
|
1985
|
+
|
|
1986
|
+
// ---------------------------------------------------------------------------
|
|
1987
|
+
// "So einfach geht's" steps
|
|
1988
|
+
// ---------------------------------------------------------------------------
|
|
1989
|
+
|
|
1990
|
+
/** [title, description]. */
|
|
1991
|
+
export type HowItWorksStep = readonly [title: string, desc: string];
|
|
1992
|
+
|
|
1993
|
+
export interface HowItWorksProps {
|
|
1994
|
+
/** the numbered steps; defaults to the design's four-step flow. */
|
|
1995
|
+
steps?: ReadonlyArray<HowItWorksStep>;
|
|
1996
|
+
/** i18n function (drilled from the page). */
|
|
1997
|
+
t: TranslationFunction;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/** Lucide icon per step index: Angebot / Beratung / Bezahlung / Übergabe. */
|
|
2001
|
+
const STEP_ICONS = ['bookmark', 'message-circle', 'credit-card', 'truck'] as const;
|
|
2002
|
+
|
|
2003
|
+
/** the design's four-step flow — [title key, description key]. */
|
|
2004
|
+
const DEFAULT_STEPS: ReadonlyArray<HowItWorksStep> = [
|
|
2005
|
+
['autobahn:steps.step1.title', 'autobahn:steps.step1.description'],
|
|
2006
|
+
['autobahn:steps.step2.title', 'autobahn:steps.step2.description'],
|
|
2007
|
+
['autobahn:steps.step3.title', 'autobahn:steps.step3.description'],
|
|
2008
|
+
['autobahn:steps.step4.title', 'autobahn:steps.step4.description'],
|
|
2009
|
+
];
|
|
2010
|
+
|
|
2011
|
+
/** "So einfach geht's" — icon-step cards with connector line (professional redesign). */
|
|
2012
|
+
export const HowItWorks: React.FunctionComponent<HowItWorksProps> = ({ steps, t }) => {
|
|
2013
|
+
const items: ReadonlyArray<HowItWorksStep> =
|
|
2014
|
+
steps ?? DEFAULT_STEPS.map(([titleKey, descKey]) => [t(titleKey), t(descKey)] as HowItWorksStep);
|
|
2015
|
+
return (
|
|
2016
|
+
<section className="hiw-section" style={{ marginTop: 44 }}>
|
|
2017
|
+
{/* alignment is breakpoint-owned: centered on desktop, left on the mobile card (CSS) */}
|
|
2018
|
+
<h2 className="md-headline-sm hiw-heading" style={{ marginBottom: 28 }}>
|
|
2019
|
+
{t('autobahn:howItWorks.title')}
|
|
2020
|
+
</h2>
|
|
2021
|
+
<div className="steps-4 hiw-grid">
|
|
2022
|
+
{items.map(([title, desc], i) => (
|
|
2023
|
+
<div key={title} className="hiw-step">
|
|
2024
|
+
<div className="hiw-icon-wrap">
|
|
2025
|
+
<Icon name={STEP_ICONS[i] || 'star'} size={24} color="#fff" stroke={1.8} />
|
|
2026
|
+
<span className="hiw-num">{i + 1}</span>
|
|
2027
|
+
</div>
|
|
2028
|
+
<div className="hiw-title">{title}</div>
|
|
2029
|
+
<p className="hiw-desc">{desc}</p>
|
|
2030
|
+
</div>
|
|
2031
|
+
))}
|
|
2032
|
+
</div>
|
|
2033
|
+
</section>
|
|
2034
|
+
);
|
|
2035
|
+
};
|