@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.
@@ -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: 12 }} 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: 12 }} 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
- {showBreakdown && (
1574
- <div>
1575
- {breakdownRows}
1576
- <p className="md-label-sm" style={{ marginTop: 16, lineHeight: 1.55 }}>
1577
- {isLease ? t('autobahn:modal.legalLeasing') : t('autobahn:modal.legalFinancing')}
1578
- </p>
1579
- </div>
1580
- )}
1581
- </>
1582
- )}
1583
- </div>
1584
- </div>
1585
- </div>
1586
- </div>
1587
- );
1588
- };
1589
-
1590
- // ---------------------------------------------------------------------------
1591
- // Customer ratings (auto.de Google reviews) — REAL data, loaded by the page
1592
- // ---------------------------------------------------------------------------
1593
-
1594
- export interface CustomerRatingsProps {
1595
- /** the loaded reviews (REAL — from the WP json-quotes source). */
1596
- reviews: VdpReview[];
1597
- /**
1598
- * Overall auto.de Google rating to display instead of the average of the few
1599
- * loaded reviews (which skews to 5,0 — the review feed only carries 5-star
1600
- * quotes). The page passes the real aggregate (4,6 per Google, review round 9).
1601
- */
1602
- average?: number;
1603
- /** i18n function (drilled from the page). */
1604
- t: TranslationFunction;
1605
- }
1606
-
1607
- /**
1608
- * Customer-rating section (the legacy VDP's "Kundenmeinungen", rebuilt on the
1609
- * Autobahn card). Shows the auto.de average (DERIVED from the loaded reviews — not
1610
- * invented) + a grid of review cards (avatar, name, stars, quote). The reviews are
1611
- * the SAME real source the legacy CustomerQuotes uses (auto.de Google reviews via
1612
- * the WordPress json-quotes feed); the page loads them and passes them in. When no
1613
- * reviews load, the page renders nothing (no fabricated content).
1614
- */
1615
- export const CustomerRatings: React.FunctionComponent<CustomerRatingsProps> = ({ reviews, average, t }) => {
1616
- const [showAll, setShowAll] = useState(false);
1617
- if (!reviews || !reviews.length) return null;
1618
- const count = reviews.length;
1619
- const avg = average != null ? average : reviews.reduce((s, r) => s + (r.rating || 0), 0) / count;
1620
- const shown = showAll ? reviews : reviews.slice(0, 4);
1621
- return (
1622
- <section className="mcard" id="vdp-bewertungen" style={{ padding: 22, marginTop: 44 }}>
1623
- <div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
1624
- <h2 className="md-headline-sm" style={{ margin: 0 }}>
1625
- {t('autobahn:reviews.title')}
1626
- </h2>
1627
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' }}>
1628
- <span className="num" style={{ fontWeight: 800, fontSize: 26, color: 'var(--md-on-surface)', letterSpacing: '-.5px' }}>
1629
- {avg.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
1630
- </span>
1631
- <span>
1632
- <Stars value={avg} size={16} />
1633
- <span className="md-label-sm" style={{ display: 'block' }}>
1634
- {t('autobahn:reviews.ratingCount', { count })} · via Google
1635
- </span>
1636
- </span>
1637
- </div>
1638
- </div>
1639
- <div className="eq-cols">
1640
- {shown.map((r) => (
1641
- <div
1642
- key={r.id}
1643
- className="mcard mcard--filled"
1644
- style={{ breakInside: 'avoid', marginBottom: 16, padding: 16 }}
1645
- >
1646
- <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
1647
- {r.image ? (
1648
- <img
1649
- src={r.image}
1650
- alt=""
1651
- loading="lazy"
1652
- style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', flex: 'none', background: 'var(--md-sc)' }}
1653
- />
1654
- ) : (
1655
- <span
1656
- style={{
1657
- width: 40,
1658
- height: 40,
1659
- borderRadius: '50%',
1660
- flex: 'none',
1661
- background: 'var(--md-primary-container)',
1662
- color: 'var(--md-on-primary-container)',
1663
- display: 'flex',
1664
- alignItems: 'center',
1665
- justifyContent: 'center',
1666
- fontWeight: 700,
1667
- }}
1668
- >
1669
- {(r.name || '?').charAt(0).toUpperCase()}
1670
- </span>
1671
- )}
1672
- <div style={{ minWidth: 0 }}>
1673
- <div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
1674
- {r.name}
1675
- </div>
1676
- <Stars value={r.rating} />
1677
- </div>
1678
- </div>
1679
- <p className="md-body-sm" style={{ margin: 0, color: 'var(--md-on-surface-variant)' }}>
1680
- {r.review}
1681
- </p>
1682
- </div>
1683
- ))}
1684
- </div>
1685
- {reviews.length > 4 && (
1686
- <button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setShowAll((a) => !a)}>
1687
- {showAll ? t('autobahn:ratings.showLess') : t('autobahn:ratings.showMore', { count })}
1688
- <Icon name={showAll ? 'chevron-up' : 'chevron-down'} size={16} />
1689
- </button>
1690
- )}
1691
- </section>
1692
- );
1693
- };
1694
-
1695
- // ---------------------------------------------------------------------------
1696
- // Enquiry block — low-friction "Frage zum Fahrzeug" lead form
1697
- // ---------------------------------------------------------------------------
1698
-
1699
- export interface VdpEnquiryProps {
1700
- /** car title for the prefilled message + heading context. */
1701
- carTitle: string;
1702
- /** auto.de-ID reference, woven into the prefilled message when present. */
1703
- autoDeId?: string;
1704
- /** sales hotline shown in the aside (tap-to-call). */
1705
- salesPhone?: string;
1706
- /**
1707
- * Submit handler — the page wires this to the REAL contact channel
1708
- * (carStore.submitContactForm). Returns a promise; the form shows a thank-you on
1709
- * resolve and an inline error on reject.
1710
- */
1711
- onSubmit: (data: { name: string; email: string; phone: string; message: string }) => Promise<void> | void;
1712
- /** i18n function (drilled from the page). */
1713
- t: TranslationFunction;
1714
- }
1715
-
1716
- const enquiryInput: React.CSSProperties = {
1717
- width: '100%',
1718
- height: 46,
1719
- padding: '0 14px',
1720
- borderRadius: 'var(--md-r-sm)',
1721
- border: '1px solid var(--md-outline-variant)',
1722
- background: 'var(--md-sc-lowest)',
1723
- color: 'var(--md-on-surface)',
1724
- font: 'inherit',
1725
- fontSize: 14,
1726
- };
1727
-
1728
- /** outlined field with the label floating ON the top border (Figma phone/message fields). */
1729
- const FloatField: React.FunctionComponent<{
1730
- label: string;
1731
- children: React.ReactNode;
1732
- }> = ({ label, children }) => (
1733
- <label className="enq-float">
1734
- <span className="enq-float-label">{label}</span>
1735
- {children}
1736
- </label>
1737
- );
1738
-
1739
- /** [icon, i18n label key] — resolved against the drilled t in the component. */
1740
- const ENQUIRY_BENEFITS: ReadonlyArray<[string, string]> = [
1741
- ['clock', 'autobahn:enquiry.benefits.fastResponse'],
1742
- ['shield-check', 'autobahn:enquiry.benefits.freeAndNonBinding'],
1743
- ['hand-coins', 'autobahn:enquiry.benefits.financingAdvice'],
1744
- ];
1745
-
1746
- /**
1747
- * Vehicle enquiry — an enriched two-pane lead block: a left trust/benefits aside (persönliche
1748
- * Beratung, response time, hotline) and a right structured form (preferred-contact toggle, named
1749
- * fields, editable prefilled message), wired to the REAL submit (carStore.submitContactForm).
1750
- */
1751
- export const VdpEnquiry: React.FunctionComponent<VdpEnquiryProps> = ({
1752
- carTitle,
1753
- autoDeId,
1754
- salesPhone = '0800 40 30 182',
1755
- onSubmit,
1756
- t,
1757
- }) => {
1758
- const [firstName, setFirstName] = useState('');
1759
- const [lastName, setLastName] = useState('');
1760
- const [email, setEmail] = useState('');
1761
- const [areaCode, setAreaCode] = useState('');
1762
- const [phoneNum, setPhoneNum] = useState('');
1763
- const [consent, setConsent] = useState(false);
1764
- const [pref, setPref] = useState<'email' | 'phone'>('email');
1765
- const [message, setMessage] = useState(
1766
- autoDeId
1767
- ? t('autobahn:enquiry.prefilledMessageWithId', { carTitle, autoDeId })
1768
- : t('autobahn:enquiry.prefilledMessage', { carTitle })
1769
- );
1770
- const [sending, setSending] = useState(false);
1771
- const [sent, setSent] = useState(false);
1772
- const [error, setError] = useState('');
1773
-
1774
- const emailOk = /.+@.+\..+/.test(email);
1775
- const phoneDigits = (areaCode + phoneNum).replace(/\D/g, '');
1776
- const phoneOk = phoneDigits.length >= 6;
1777
- // Figma round 9: Vorname/Nachname/E-Mail are always required (*), phone additionally
1778
- // when it's the preferred channel; the consent checkbox gates the send.
1779
- const valid =
1780
- firstName.trim().length > 0 &&
1781
- lastName.trim().length > 0 &&
1782
- emailOk &&
1783
- consent &&
1784
- (pref !== 'phone' || phoneOk);
1785
-
1786
- const submit = async (): Promise<void> => {
1787
- if (!valid || sending) return;
1788
- setSending(true);
1789
- setError('');
1790
- const note = t('autobahn:enquiry.preferredContact', {
1791
- channel: pref === 'email' ? t('autobahn:enquiry.contactMethod.email') : t('autobahn:enquiry.contactMethod.phone'),
1792
- });
1793
- const phone = phoneDigits ? `+49 ${areaCode.trim()} ${phoneNum.trim()}`.replace(/\s+/g, ' ').trim() : '';
1794
- try {
1795
- await onSubmit({
1796
- name: `${firstName.trim()} ${lastName.trim()}`,
1797
- email: email.trim(),
1798
- phone,
1799
- message: message.trim() + note,
1800
- });
1801
- setSent(true);
1802
- } catch (e) {
1803
- setError(t('autobahn:enquiry.form.sendError'));
1804
- } finally {
1805
- setSending(false);
1806
- }
1807
- };
1808
-
1809
- if (sent) {
1810
- return (
1811
- <section className="mcard mcard--filled" id="vdp-kontakt" style={{ padding: 22, marginTop: 24 }}>
1812
- <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1813
- <Icon name="check-circle" size={24} color="var(--md-good)" />
1814
- <div>
1815
- <div className="md-title-sm">{t('autobahn:enquiry.success.title')}</div>
1816
- <div className="md-body-sm">{t('autobahn:enquiry.success.message')}</div>
1817
- </div>
1818
- </div>
1819
- </section>
1820
- );
1821
- }
1822
-
1823
- return (
1824
- <section className="mcard" id="vdp-kontakt" style={{ padding: 0, overflow: 'hidden', marginTop: 24 }}>
1825
- <div className="enquiry-grid">
1826
- {/* trust / benefits aside */}
1827
- <aside className="enquiry-aside">
1828
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
1829
- <span className="enquiry-ico">
1830
- <Icon name="headset" size={20} color="var(--md-on-primary-container)" />
1831
- </span>
1832
- <h2 className="md-title" style={{ margin: 0, fontSize: 19 }}>
1833
- {t('autobahn:enquiry.personalConsultation')}
1834
- </h2>
1835
- </div>
1836
- <p className="md-body-sm" style={{ marginBottom: 16 }}>
1837
- {t('autobahn:enquiry.aside.description')}
1838
- </p>
1839
- <ul className="enquiry-benefits">
1840
- {ENQUIRY_BENEFITS.map(([ic, key]) => (
1841
- <li key={key}>
1842
- <Icon name={ic} size={17} color="var(--md-primary)" stroke={2} />
1843
- {t(key)}
1844
- </li>
1845
- ))}
1846
- </ul>
1847
- <a className="enquiry-phone" href={`tel:${salesPhone.replace(/\s/g, '')}`}>
1848
- <Icon name="phone" size={16} />
1849
- {salesPhone}
1850
- </a>
1851
- </aside>
1852
-
1853
- {/* form */}
1854
- <div className="enquiry-form">
1855
- <div style={{ marginBottom: 14 }}>
1856
- <div className="md-label-sm" style={{ fontWeight: 600, marginBottom: 7 }}>
1857
- {t('autobahn:enquiry.contactMethod.heading')}
1858
- </div>
1859
- <div style={{ display: 'flex', gap: 8 }}>
1860
- <button
1861
- type="button"
1862
- className={'chip' + (pref === 'email' ? ' chip--filter chip--selected' : '')}
1863
- onClick={() => setPref('email')}
1864
- >
1865
- <Icon name="mail" size={15} />
1866
- {t('autobahn:enquiry.contactMethod.email')}
1867
- </button>
1868
- <button
1869
- type="button"
1870
- className={'chip' + (pref === 'phone' ? ' chip--filter chip--selected' : '')}
1871
- onClick={() => setPref('phone')}
1872
- >
1873
- <Icon name="phone" size={15} />
1874
- {t('autobahn:enquiry.contactMethod.phone')}
1875
- </button>
1876
- </div>
1877
- </div>
1878
- {/* Vorname* / Nachname* — two required fields SIDE BY SIDE (Figma round 9; fixed
1879
- 2-col — auto-fit collapsed to one column at 390px, mockup keeps them paired) */}
1880
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
1881
- <input
1882
- style={enquiryInput}
1883
- placeholder={t('autobahn:enquiry.form.firstNamePlaceholder')}
1884
- value={firstName}
1885
- onChange={(e) => setFirstName(e.target.value)}
1886
- aria-label={t('autobahn:enquiry.form.firstNameAria')}
1887
- />
1888
- <input
1889
- style={enquiryInput}
1890
- placeholder={t('autobahn:enquiry.form.lastNamePlaceholder')}
1891
- value={lastName}
1892
- onChange={(e) => setLastName(e.target.value)}
1893
- aria-label={t('autobahn:enquiry.form.lastNameAria')}
1894
- />
1895
- </div>
1896
- <div style={{ marginTop: 12 }}>
1897
- <input
1898
- style={enquiryInput}
1899
- type="email"
1900
- placeholder={t('autobahn:enquiry.form.emailPlaceholder')}
1901
- value={email}
1902
- onChange={(e) => setEmail(e.target.value)}
1903
- aria-label={t('autobahn:enquiry.contactMethod.email')}
1904
- />
1905
- </div>
1906
- {/* Land (+49 fixed) · Vorwahl · Telefonnummer — outlined float-label row (Figma) */}
1907
- <div className="enq-phone-row" style={{ marginTop: 16 }}>
1908
- <FloatField label={t('autobahn:enquiry.form.countryLabel')}>
1909
- <span className="enq-country num">
1910
- +49
1911
- <Icon name="chevron-down" size={14} color="var(--md-on-surface-variant)" />
1912
- </span>
1913
- </FloatField>
1914
- <FloatField label={t('autobahn:enquiry.form.areaCodeLabel')}>
1915
- <input
1916
- type="tel"
1917
- value={areaCode}
1918
- onChange={(e) => setAreaCode(e.target.value)}
1919
- aria-label={t('autobahn:enquiry.form.areaCodeLabel')}
1920
- />
1921
- </FloatField>
1922
- <FloatField label={t('autobahn:enquiry.form.phoneNumberLabel')}>
1923
- <input
1924
- type="tel"
1925
- value={phoneNum}
1926
- onChange={(e) => setPhoneNum(e.target.value)}
1927
- aria-label={t('autobahn:enquiry.form.phoneNumberLabel')}
1928
- />
1929
- </FloatField>
1930
- </div>
1931
- <div style={{ marginTop: 16 }}>
1932
- <FloatField label={t('autobahn:enquiry.form.messageLabel')}>
1933
- <textarea
1934
- value={message}
1935
- onChange={(e) => setMessage(e.target.value)}
1936
- aria-label={t('autobahn:enquiry.form.messageAria')}
1937
- />
1938
- </FloatField>
1939
- </div>
1940
- {/* Nutzungsbedingungen sentence + revocable-consent checkbox (Figma round 9) */}
1941
- <p className="md-body-sm" style={{ margin: '14px 0 0' }}>
1942
- {t('autobahn:enquiry.form.termsPrefix')}{' '}
1943
- <a href="/nutzungsbedingungen/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>
1944
- {t('autobahn:enquiry.form.termsLink')}
1945
- </a>{' '}
1946
- {t('autobahn:enquiry.form.termsSuffix')}
1947
- </p>
1948
- <label className="enq-consent">
1949
- <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
1950
- <span>
1951
- {t('autobahn:enquiry.form.consentPart1')}{' '}
1952
- <a href="/datenschutz/" target="_blank" rel="noopener noreferrer">
1953
- {t('autobahn:enquiry.form.consentLinkHere')}
1954
- </a>{' '}
1955
- {t('autobahn:enquiry.form.consentPart2')}{' '}
1956
- <a href="/datenschutz/" target="_blank" rel="noopener noreferrer">
1957
- {t('autobahn:enquiry.form.consentLinkPrivacy')}
1958
- </a>
1959
- {t('autobahn:enquiry.form.consentPart3')}
1960
- </span>
1961
- </label>
1962
- {error && (
1963
- <div className="md-label-sm" style={{ color: 'var(--md-error)', marginTop: 8 }}>
1964
- {error}
1965
- </div>
1966
- )}
1967
- <div style={{ marginTop: 16 }}>
1968
- <Button variant="filled" block size="lg" icon="send" disabled={!valid || sending} onClick={submit}>
1969
- {sending ? t('autobahn:enquiry.form.sending') : t('autobahn:enquiry.sendButton')}
1970
- </Button>
1971
- <span
1972
- className="md-label-sm"
1973
- style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, marginTop: 10, color: 'var(--md-good)' }}
1974
- >
1975
- <Icon name="lock" size={13} color="var(--md-good)" />
1976
- {t('autobahn:enquiry.security')}
1977
- </span>
1978
- </div>
1979
- </div>
1980
- </div>
1981
- </section>
1982
- );
1983
- };
1984
-
1985
- // ---------------------------------------------------------------------------
1986
- // "So einfach geht's" steps
1987
- // ---------------------------------------------------------------------------
1988
-
1989
- /** [title, description]. */
1990
- export type HowItWorksStep = readonly [title: string, desc: string];
1991
-
1992
- export interface HowItWorksProps {
1993
- /** the numbered steps; defaults to the design's four-step flow. */
1994
- steps?: ReadonlyArray<HowItWorksStep>;
1995
- /** i18n function (drilled from the page). */
1996
- t: TranslationFunction;
1997
- }
1998
-
1999
- /** Lucide icon per step index: Angebot / Beratung / Bezahlung / Übergabe. */
2000
- const STEP_ICONS = ['bookmark', 'message-circle', 'credit-card', 'truck'] as const;
2001
-
2002
- /** the design's four-step flow — [title key, description key]. */
2003
- const DEFAULT_STEPS: ReadonlyArray<HowItWorksStep> = [
2004
- ['autobahn:steps.step1.title', 'autobahn:steps.step1.description'],
2005
- ['autobahn:steps.step2.title', 'autobahn:steps.step2.description'],
2006
- ['autobahn:steps.step3.title', 'autobahn:steps.step3.description'],
2007
- ['autobahn:steps.step4.title', 'autobahn:steps.step4.description'],
2008
- ];
2009
-
2010
- /** "So einfach geht's" — icon-step cards with connector line (professional redesign). */
2011
- export const HowItWorks: React.FunctionComponent<HowItWorksProps> = ({ steps, t }) => {
2012
- const items: ReadonlyArray<HowItWorksStep> =
2013
- steps ?? DEFAULT_STEPS.map(([titleKey, descKey]) => [t(titleKey), t(descKey)] as HowItWorksStep);
2014
- return (
2015
- <section className="hiw-section" style={{ marginTop: 44 }}>
2016
- {/* alignment is breakpoint-owned: centered on desktop, left on the mobile card (CSS) */}
2017
- <h2 className="md-headline-sm hiw-heading" style={{ marginBottom: 28 }}>
2018
- {t('autobahn:howItWorks.title')}
2019
- </h2>
2020
- <div className="steps-4 hiw-grid">
2021
- {items.map(([title, desc], i) => (
2022
- <div key={title} className="hiw-step">
2023
- <div className="hiw-icon-wrap">
2024
- <Icon name={STEP_ICONS[i] || 'star'} size={24} color="#fff" stroke={1.8} />
2025
- <span className="hiw-num">{i + 1}</span>
2026
- </div>
2027
- <div className="hiw-title">{title}</div>
2028
- <p className="hiw-desc">{desc}</p>
2029
- </div>
2030
- ))}
2031
- </div>
2032
- </section>
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
+ };