@bytebrand/fe-ui-core-autobahn 1.0.44 → 1.0.45

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,599 +1,622 @@
1
- // Autobahn (auto.de redesign) — VDP offer / finance card (Finanzierung / Leasing /
2
- // Barzahlung).
3
- //
4
- // Structure (desktop rethink, tabs-on-top): payment-type TABS at the top (MD3 underline tabs),
5
- // then the rate (with any discount), then the "Rate anpassen" rate-editor, then the primary
6
- // "Angebot sichern" request CTA, and finally the Finanzierungs-/Leasingdetails info + popup link
7
- // at the foot. Laufzeit is chosen via chips (discrete terms), not a slider.
8
- //
9
- // CONTROLLED / PRESENTATIONAL — computes NOTHING financial: `mode`, `monthly`, `down`, `term` and
10
- // the slider/chip bounds all arrive as props; segment/slider/chip changes are reported back via
11
- // `onModeChange` / `onDownChange` / `onTermChange`. The real numbers come from `financingStore`.
12
- // The only local state is the `adjust` expander (pure view state).
13
- //
14
- // Renders correctly only inside a `<div className="md3">` subtree.
15
-
16
- import React, { useState } from 'react';
17
- import { TranslationFunction } from '../types';
18
-
19
- import { Button, Icon } from '../ui';
20
- import { TIER_LABELS, TIER_KEYS, TIER_COLORS } from '../constants';
21
-
22
- /** Payment mode segment keys. Mirrors the design's ['fin','leasing','cash']. */
23
- export type FinanceMode = 'fin' | 'leasing' | 'cash';
24
-
25
- export interface SliderProps {
26
- min: number;
27
- max: number;
28
- step?: number;
29
- value: number;
30
- onChange: (value: number) => void;
31
- /** formats the min/max end labels (and is reused by callers for the value). */
32
- fmt?: (v: number) => React.ReactNode;
33
- /** render the current value in a floating bubble above the thumb (Figma sheet sliders). */
34
- bubble?: boolean;
35
- }
36
-
37
- /**
38
- * Range slider with a filled track and min/max end labels. The track fill is a
39
- * linear-gradient driven by the value's position; the design's `.mslider` class
40
- * styles the thumb/rail. With `bubble` the current value floats above the thumb
41
- * (mockup "Rate anpassen" sliders) instead of the caller printing it elsewhere.
42
- */
43
- export const Slider: React.FunctionComponent<SliderProps> = ({ min, max, step = 1, value, onChange, fmt = (v) => v, bubble }) => {
44
- const span = max - min;
45
- const pct = span > 0 ? ((value - min) / span) * 100 : 0;
46
- return (
47
- <div>
48
- <div style={{ position: 'relative', paddingTop: bubble ? 38 : 0 }}>
49
- {bubble && (
50
- // thumb is 22px wide: interpolate the bubble anchor between half-thumb insets so it
51
- // tracks the thumb center (a plain pct% drifts at the rail ends)
52
- <span className="slider-bubble num" style={{ left: `calc(11px + (100% - 22px) * ${pct / 100})` }}>
53
- {fmt(value)}
54
- </span>
55
- )}
56
- <input
57
- type="range"
58
- className="mslider"
59
- min={min}
60
- max={max}
61
- step={step}
62
- value={value}
63
- onChange={(e) => onChange(Number(e.target.value))}
64
- style={{ background: `linear-gradient(90deg, var(--md-primary) ${pct}%, var(--md-sc-highest) ${pct}%)` }}
65
- />
66
- </div>
67
- {!bubble && (
68
- <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
69
- <span className="md-label-sm">{fmt(min)}</span>
70
- <span className="md-label-sm">{fmt(max)}</span>
71
- </div>
72
- )}
73
- </div>
74
- );
75
- };
76
-
77
- /**
78
- * Segmented price-evaluation bar (Figma: 5 discrete tier segments, NOT a gradient) with a
79
- * white ring marker. `leftPct` overrides the marker position (the Marktwert gauge positions
80
- * it continuously); default = centered on the tier segment.
81
- */
82
- export const EvalBar: React.FunctionComponent<{ tier: number; leftPct?: number }> = ({ tier, leftPct }) => (
83
- <div className="fin-eval-bar">
84
- {TIER_COLORS.map((c, i) => (
85
- <span key={i} className="fin-eval-seg" style={{ background: c }} />
86
- ))}
87
- <div
88
- className="fin-eval-thumb"
89
- style={{ left: `${leftPct != null ? leftPct : (tier + 0.5) * 20}%`, borderColor: TIER_COLORS[Math.max(0, Math.min(4, tier))] }}
90
- />
91
- </div>
92
- );
93
-
94
- export interface TermChipsProps {
95
- /** the selectable term values (months), e.g. [12,24,36,48,60,72]. */
96
- options: number[];
97
- value: number;
98
- onChange: (term: number) => void;
99
- /** i18n function (drilled from the page). */
100
- t: TranslationFunction;
101
- }
102
-
103
- /** Laufzeit selector — discrete term values as tappable chips (replaces the fiddly slider). */
104
- export const TermChips: React.FunctionComponent<TermChipsProps> = ({ options, value, onChange, t }) => (
105
- <div className="term-chips" role="group" aria-label={t('autobahn:finance.termSelection')}>
106
- {options.map((m) => (
107
- <button
108
- key={m}
109
- type="button"
110
- aria-pressed={m === value}
111
- className={'term-chip' + (m === value ? ' on' : '')}
112
- onClick={() => onChange(m)}
113
- >
114
- {m}
115
- <span className="tc-unit">{t('autobahn:finance.monthUnit')}</span>
116
- </button>
117
- ))}
118
- </div>
119
- );
120
-
121
- export interface CalcRowProps {
122
- label: React.ReactNode;
123
- /** when true, drops the bottom hairline (last row). */
124
- last?: boolean;
125
- children?: React.ReactNode;
126
- }
127
-
128
- /** One label/value row inside the "Rate anpassen" / "Details" expander. */
129
- export const CalcRow: React.FunctionComponent<CalcRowProps> = ({ label, last, children }) => (
130
- <div
131
- className="calcrow"
132
- style={{
133
- display: 'flex',
134
- justifyContent: 'space-between',
135
- alignItems: 'baseline',
136
- gap: 12,
137
- padding: '8px 0',
138
- borderBottom: last ? 0 : '1px solid var(--md-outline-variant)',
139
- }}
140
- >
141
- <span className="md-label-sm calcrow-label">{label}</span>
142
- <span className="num calcrow-value" style={{ fontSize: 13.5, fontWeight: 600 }}>
143
- {children}
144
- </span>
145
- </div>
146
- );
147
-
148
- export interface FinanceCalculatorProps {
149
- /** active payment mode (controlled). */
150
- mode: FinanceMode;
151
- /** purchase price in EUR (the cash mode hero). */
152
- price: number;
153
- /** previous/list price — when > price, shown struck through as the UVP in cash mode (real discount). */
154
- listPrice?: number;
155
- /** auto.de-ID reference code — shown with the financing/leasing condition line. */
156
- autoDeId?: string;
157
- /** price-rating label, e.g. 'Guter Preis' — drives the Preisbewertung indicator. */
158
- ratingLabel?: string;
159
- /** computed monthly rate in EUR for the current mode — supplied, NOT computed here. */
160
- monthly: number;
161
- /** struck old monthly rate (fe-financing oldMonthlyInstallment, isStrikeShown-gated) —
162
- * rendered as the red line-through figure next to the hero rate (fin/leasing only). */
163
- oldMonthly?: number;
164
- /** effective annual rate (%) for the current mode — supplied, NOT computed here. */
165
- apr: number;
166
- /** current down payment in EUR (controlled). */
167
- down: number;
168
- /** current term in months (controlled). */
169
- term: number;
170
- /** down-payment slider bounds. */
171
- downMin: number;
172
- downMax: number;
173
- /** down-payment slider step (EUR). */
174
- downStep?: number;
175
- /** term slider bounds (months) — used to derive chip options when termOptions is absent. */
176
- termMin: number;
177
- termMax: number;
178
- /** term slider step (months). */
179
- termStep?: number;
180
- /** the discrete selectable terms (months) for the Laufzeit chips. */
181
- termOptions?: number[];
182
- /** annual mileage in km (controlled) — leasing/financing Laufleistung/Jahr. Omit to hide the slider. */
183
- annualMileage?: number;
184
- /** annual-mileage slider bounds + step (km). */
185
- annualMileageMin?: number;
186
- annualMileageMax?: number;
187
- annualMileageStep?: number;
188
- /** annual-mileage slider changed. */
189
- onAnnualMileageChange?: (km: number) => void;
190
- /** phone number shown on the secondary CTA (touch only). */
191
- phone?: string;
192
- /** primary CTA click (Angebot sichern). */
193
- onSecure?: () => void;
194
- /** secondary CTA click (phone). */
195
- onCall?: () => void;
196
- /** open the Finanzierungs-/Leasingdetails popup (full breakdown + legal, expanded). Omit to hide the link. */
197
- onShowDetails?: () => void;
198
- /** mobile only: open the "Rate anpassen" / "Details anzeigen" sheet (collapsed/default view).
199
- * Omit to hide the trigger row (falls back to the desktop inline expander). */
200
- onOpenAdjust?: () => void;
201
- /** mode segment changed. */
202
- onModeChange: (mode: FinanceMode) => void;
203
- /** down-payment slider changed. */
204
- onDownChange: (down: number) => void;
205
- /** term changed. */
206
- onTermChange: (term: number) => void;
207
- /** i18n function (drilled from the page). */
208
- t: TranslationFunction;
209
- }
210
-
211
- const eur = (n: number): string => n.toLocaleString('de-DE');
212
-
213
- /** build a [min..max] step range (fallback term options when none are supplied). */
214
- const range = (min: number, max: number, step: number): number[] => {
215
- const out: number[] = [];
216
- for (let v = min; v <= max; v += step || 12) out.push(v);
217
- return out.length ? out : [min];
218
- };
219
-
220
- /** quiet primary-colored text link (Finanzierungsdetails / Details anzeigen). */
221
- const DetailsLink: React.FunctionComponent<{ label: string; onClick: () => void }> = ({ label, onClick }) => (
222
- <button
223
- type="button"
224
- onClick={onClick}
225
- style={{
226
- display: 'inline-flex',
227
- alignItems: 'center',
228
- gap: 5,
229
- background: 'none',
230
- border: 0,
231
- padding: 0,
232
- cursor: 'pointer',
233
- color: 'var(--md-primary)',
234
- font: 'inherit',
235
- fontSize: 13,
236
- fontWeight: 600,
237
- }}
238
- >
239
- <Icon name="info" size={15} color="var(--md-primary)" />
240
- {label}
241
- </button>
242
- );
243
-
244
- /** "Rate anpassen" / "Details anzeigen" expander toggle button. */
245
- export const expanderBtnStyle: React.CSSProperties = {
246
- width: '100%',
247
- background: 'none',
248
- border: 0,
249
- cursor: 'pointer',
250
- display: 'flex',
251
- alignItems: 'center',
252
- justifyContent: 'space-between',
253
- padding: '10px 0 2px',
254
- color: 'var(--md-primary)',
255
- fontWeight: 600,
256
- fontSize: 14,
257
- };
258
-
259
- const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
260
- mode,
261
- price,
262
- listPrice,
263
- autoDeId,
264
- ratingLabel,
265
- monthly,
266
- oldMonthly,
267
- down,
268
- term,
269
- downMin,
270
- downMax,
271
- downStep = 500,
272
- termMin,
273
- termMax,
274
- termStep = 6,
275
- termOptions,
276
- annualMileage,
277
- annualMileageMin = 5000,
278
- annualMileageMax = 60000,
279
- annualMileageStep = 5000,
280
- onAnnualMileageChange,
281
- phone = '0800 40 30 182',
282
- onSecure,
283
- onCall,
284
- onShowDetails,
285
- onOpenAdjust,
286
- onModeChange,
287
- onDownChange,
288
- onTermChange,
289
- t,
290
- }) => {
291
- // Pure view state only — whether the adjust/details expander is open.
292
- const [adjust, setAdjust] = useState(false);
293
- const isCash = mode === 'cash';
294
- const terms = termOptions && termOptions.length ? termOptions : range(termMin, termMax, termStep);
295
- const showStrike = listPrice != null && listPrice > price;
296
- // price-evaluation tier from the rating label (defaults to 'Guter Preis' tier when unmapped).
297
- const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
298
- const evalTier = tierIdx >= 0 ? tierIdx : 1;
299
- const evalTierLabel = t('autobahn:tier.' + TIER_KEYS[evalTier]);
300
- // condition summary (current selection) — the auto.de-ID lives with the details link, not here.
301
- const condParts: string[] = [];
302
- if (!isCash) {
303
- condParts.push(
304
- mode === 'leasing' && annualMileage != null
305
- ? t('autobahn:finance.kmPerYear', { value: eur(annualMileage) })
306
- : t('autobahn:finance.downPaymentLabel', { value: eur(down) }),
307
- );
308
- condParts.push(t('autobahn:finance.monthsLabel', { months: term, count: term }));
309
- }
310
- const condLine = condParts.length ? `${t('autobahn:finance.exampleLabel')} ${condParts.join(' · ')}` : '';
311
-
312
- return (
313
- <div className="mcard mcard--elevated" style={{ padding: 20 }}>
314
- {/* header — desktop only; mobile goes straight into the tabs (see mockup: no card
315
- title above Finanzierung/Leasing/Kaufen on phone). */}
316
- <div className="fin-card-header hide-mobile" style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
317
- <span
318
- style={{
319
- width: 34,
320
- height: 34,
321
- borderRadius: 'var(--md-r-full)',
322
- background: 'var(--md-primary-container)',
323
- display: 'flex',
324
- alignItems: 'center',
325
- justifyContent: 'center',
326
- flex: 'none',
327
- }}
328
- >
329
- <Icon name="tag" size={18} color="var(--md-on-primary-container)" />
330
- </span>
331
- <div style={{ fontFamily: 'var(--md-font-display)', fontSize: 18, fontWeight: 700 }}>{t('autobahn:finance.headerTitle')}</div>
332
- </div>
333
-
334
- {/* payment-type TABS on top */}
335
- <div className="fin-tabs" role="tablist">
336
- {(
337
- [
338
- ['fin', 'autobahn:finance.mode.financing'],
339
- ['leasing', 'autobahn:finance.mode.leasing'],
340
- ['cash', 'autobahn:finance.mode.cash'],
341
- ] as Array<[FinanceMode, string]>
342
- ).map(([k, labelKey]) => (
343
- <button
344
- key={k}
345
- type="button"
346
- role="tab"
347
- aria-selected={mode === k}
348
- className={'fin-tab' + (mode === k ? ' on' : '')}
349
- onClick={() => onModeChange(k)}
350
- >
351
- {k === 'cash' ? (
352
- <>
353
- {/* mockup names the cash tab "Kaufen" on phone; desktop keeps "Barzahlung" */}
354
- <span className="hide-mobile">{t(labelKey)}</span>
355
- <span className="show-mobile" style={{ justifyContent: 'center' }}>
356
- {t('autobahn:card.price.buy')}
357
- </span>
358
- </>
359
- ) : (
360
- t(labelKey)
361
- )}
362
- </button>
363
- ))}
364
- </div>
365
-
366
- {/* 2-col grid: rate|pill (row 1) + condLine|bar (row 2) — fixed right column prevents wrapping */}
367
- <div className="fin-price-grid">
368
- {/* col 1 row 1: hero rate — Figma: "329 €" all-big, then a small stacked column with
369
- the struck old rate ABOVE "/mtl." (round 11; was a single baseline row) */}
370
- <div className="fin-rate-main">
371
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
372
- <span
373
- className="num"
374
- style={{ fontSize: 48, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-2px' }}
375
- >
376
- {/* cash keeps the long price number-only (a big "13.249 €" overflows the grid cell) */}
377
- {isCash ? eur(price) : `${eur(Math.round(monthly))} €`}
378
- </span>
379
- {isCash ? (
380
- <span className="md-body-sm" style={{ fontWeight: 600 }}>€</span>
381
- ) : (
382
- <span className="fin-rate-unit">
383
- {/* struck old rate (REAL oldMonthlyInstallment; SRP strikes the same source) */}
384
- {oldMonthly != null && <s className="num fin-rate-old">{eur(Math.round(oldMonthly))} €</s>}
385
- <span>{t('autobahn:finance.perMonth')}</span>
386
- </span>
387
- )}
388
- </div>
389
- {isCash && <div className="md-label-sm">{t('autobahn:finance.fixedPrice')}</div>}
390
- {isCash && showStrike && (
391
- <div className="fin-kauf">
392
- {t('autobahn:finance.uvp')} <s>{eur(listPrice as number)} €</s>
393
- </div>
394
- )}
395
- </div>
396
- {/* col 2 row 1: price-rating pill (Figma: bold colored label, NO icon) */}
397
- <div>
398
- {ratingLabel && (
399
- <span
400
- className="fin-eval-pill"
401
- style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
402
- aria-label={t('autobahn:finance.priceRatingAria', { rating: evalTierLabel })}
403
- >
404
- {evalTierLabel}
405
- </span>
406
- )}
407
- </div>
408
- {/* col 1 row 2: condition line */}
409
- <div className="fin-cond">{condLine}</div>
410
- {/* col 2 row 2: segmented evaluation bar (Figma: discrete tiers, not a gradient) */}
411
- <div>{ratingLabel && <EvalBar tier={evalTier} />}</div>
412
- </div>
413
-
414
- {/* ============================== DESKTOP (≥601px) — unchanged ============================== */}
415
- <div className="hide-mobile">
416
- {/* RATE EDIT (Rate anpassen) — between the rate and the request CTA */}
417
- <div className="fin-adjust">
418
- <button onClick={() => setAdjust((a) => !a)} style={expanderBtnStyle}>
419
- <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
420
- <Icon name={isCash ? 'list' : 'sliders-horizontal'} size={16} color="var(--md-primary)" />
421
- {isCash ? t('autobahn:finance.detailsShow') : t('autobahn:finance.adjustRate')}
422
- </span>
423
- <Icon name={adjust ? 'chevron-up' : 'chevron-down'} size={18} color="var(--md-primary)" />
424
- </button>
425
- {adjust &&
426
- (isCash ? (
427
- <div style={{ marginTop: 4 }}>
428
- <CalcRow label={t('autobahn:finance.paymentLabel')}>{t('autobahn:finance.paymentValue')}</CalcRow>
429
- <CalcRow label={t('autobahn:finance.warrantyLabel')}>{t('autobahn:finance.warrantyValue')}</CalcRow>
430
- <CalcRow label={t('autobahn:finance.deliveryLabel')} last>
431
- {t('autobahn:finance.deliveryValue')}
432
- </CalcRow>
433
- </div>
434
- ) : (
435
- <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 8 }}>
436
- <div>
437
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
438
- <span className="md-title-sm">{t('autobahn:finance.downPaymentSliderLabel')}</span>
439
- <span className="num" style={{ fontWeight: 700 }}>
440
- {eur(down)} €
441
- </span>
442
- </div>
443
- <Slider
444
- min={downMin}
445
- max={downMax}
446
- step={downStep}
447
- value={down}
448
- onChange={onDownChange}
449
- fmt={(v) => `${eur(Number(v))} €`}
450
- />
451
- </div>
452
- {annualMileage != null && onAnnualMileageChange && (
453
- <div>
454
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
455
- <span className="md-title-sm">{t('autobahn:finance.annualMileageLabel')}</span>
456
- <span className="num" style={{ fontWeight: 700 }}>
457
- {eur(annualMileage)} km
458
- </span>
459
- </div>
460
- <Slider
461
- min={annualMileageMin}
462
- max={annualMileageMax}
463
- step={annualMileageStep}
464
- value={annualMileage}
465
- onChange={onAnnualMileageChange}
466
- fmt={(v) => `${eur(Number(v))} km`}
467
- />
468
- </div>
469
- )}
470
- <div>
471
- <div className="md-title-sm" style={{ marginBottom: 8 }}>
472
- {t('autobahn:finance.termSelection')}
473
- </div>
474
- <TermChips options={terms} value={term} onChange={onTermChange} t={t} />
475
- </div>
476
- <div>
477
- <CalcRow label={t('autobahn:finance.purchasePrice')}>{eur(price)} €</CalcRow>
478
- <CalcRow label={t('autobahn:finance.warrantyLabel')}>{t('autobahn:finance.warrantyValue')}</CalcRow>
479
- <CalcRow label={t('autobahn:finance.deliveryLabel')} last>
480
- {t('autobahn:finance.deliveryValue')}
481
- </CalcRow>
482
- </div>
483
- </div>
484
- ))}
485
- </div>
486
-
487
- {/* REQUEST CTA */}
488
- <Button variant="filled" block size="lg" icon="shield-check" style={{ marginTop: 14 }} onClick={onSecure}>
489
- {t('autobahn:finance.secureOffer')}
490
- </Button>
491
- {/* phone CTA — touch only (hidden on desktop via .fin-call) */}
492
- <div className="fin-call">
493
- <Button variant="outlined" block icon="phone" onClick={onCall}>
494
- {phone}
495
- </Button>
496
- </div>
497
-
498
- {/* auto.de-ID in front of the Finanzierungs-/Leasingdetails link — one row, under everything */}
499
- {(autoDeId || (!isCash && onShowDetails)) && (
500
- <div className="fin-info">
501
- {autoDeId && <span className="fin-id">auto.de-ID {autoDeId}</span>}
502
- {!isCash && onShowDetails && (
503
- <DetailsLink
504
- label={mode === 'leasing' ? t('autobahn:finance.leasingDetails') : t('autobahn:finance.financingDetails')}
505
- onClick={onShowDetails}
506
- />
507
- )}
508
- </div>
509
- )}
510
- </div>
511
-
512
- {/* ============================== MOBILE (≤600px) — new layout ============================== */}
513
- <div className="show-mobile" style={{ flexDirection: 'column' }}>
514
- {/* side-by-side CTAs — mockup ratio ~2:1, with the tiny auto.de-ID sitting
515
- directly under the Anrufen button (not in its own full-width row). */}
516
- <div className="fin-cta-row" style={{ display: 'flex', gap: 10, marginTop: 14, alignItems: 'flex-start' }}>
517
- {/* file-check, not shield — the Figma CTA carries the document-with-check icon */}
518
- <Button variant="filled" size="lg" icon="file-check" style={{ flex: 2 }} onClick={onSecure}>
519
- {t('autobahn:finance.secureOffer')}
520
- </Button>
521
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
522
- <Button variant="outlined" size="lg" icon="phone" block onClick={onCall}>
523
- {t('autobahn:finance.callCta')}
524
- </Button>
525
- {autoDeId && (
526
- <span
527
- className="num"
528
- style={{ fontSize: 10, fontWeight: 700, color: 'var(--md-primary)', textAlign: 'center', whiteSpace: 'nowrap' }}
529
- >
530
- auto.de ID: {autoDeId}
531
- </span>
532
- )}
533
- </div>
534
- </div>
535
-
536
- {/* Persönliche Beratung (left) · divider · Finanzierungs-/Leasingdetails or hint (right) */}
537
- <div className="fin-info">
538
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
539
- <Icon name="user" size={18} color="var(--md-on-surface-variant)" style={{ flex: 'none' }} />
540
- <div style={{ minWidth: 0 }}>
541
- <div className="md-label-sm" style={{ fontWeight: 700, color: 'var(--md-on-surface)' }}>
542
- {t('autobahn:finance.personalConsultation')}
543
- </div>
544
- <div className="md-label-sm">{t('autobahn:finance.personalConsultationSub')}</div>
545
- </div>
546
- </div>
547
- <span style={{ width: 1, alignSelf: 'stretch', background: 'var(--md-outline-variant)', flex: 'none' }} />
548
- {!isCash && onShowDetails ? (
549
- <button
550
- type="button"
551
- onClick={onShowDetails}
552
- style={{
553
- display: 'flex',
554
- alignItems: 'center',
555
- gap: 8,
556
- background: 'none',
557
- border: 0,
558
- padding: 0,
559
- cursor: 'pointer',
560
- font: 'inherit',
561
- color: 'var(--md-on-surface)',
562
- textAlign: 'left',
563
- }}
564
- >
565
- <Icon name="info" size={18} color="var(--md-on-surface-variant)" style={{ flex: 'none' }} />
566
- {/* no max-width: "Finanzierungsdetails" is one unbreakable word — capping the
567
- span clipped it mid-word instead of wrapping */}
568
- <span className="md-label-sm" style={{ lineHeight: 1.5 }}>
569
- {mode === 'leasing' ? t('autobahn:finance.leasingDetails') : t('autobahn:finance.financingDetails')}
570
- </span>
571
- </button>
572
- ) : (
573
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--md-on-surface-variant)' }}>
574
- <Icon name="info" size={15} />
575
- <span className="md-label-sm">{t('autobahn:finance.financingLeasingPossible')}</span>
576
- </div>
577
- )}
578
- </div>
579
-
580
- {/* "Rate anpassen" / "Details anzeigen" — opens the full bottom sheet (not an inline expander) */}
581
- {onOpenAdjust && (
582
- <button
583
- type="button"
584
- onClick={onOpenAdjust}
585
- style={{ ...expanderBtnStyle, borderTop: '1px solid var(--md-outline-variant)', marginTop: 4, padding: '14px 0 2px' }}
586
- >
587
- <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
588
- <Icon name={isCash ? 'list' : 'sliders-horizontal'} size={16} color="var(--md-primary)" />
589
- {isCash ? t('autobahn:finance.detailsShow') : t('autobahn:finance.adjustRate')}
590
- </span>
591
- <Icon name="chevron-right" size={18} color="var(--md-primary)" />
592
- </button>
593
- )}
594
- </div>
595
- </div>
596
- );
597
- };
598
-
599
- export default FinanceCalculator;
1
+ // Autobahn (auto.de redesign) — VDP offer / finance card (Finanzierung / Leasing /
2
+ // Barzahlung).
3
+ //
4
+ // Structure (desktop rethink, tabs-on-top): payment-type TABS at the top (MD3 underline tabs),
5
+ // then the rate (with any discount), then the "Rate anpassen" rate-editor, then the primary
6
+ // "Angebot sichern" request CTA, and finally the Finanzierungs-/Leasingdetails info + popup link
7
+ // at the foot. Laufzeit is chosen via chips (discrete terms), not a slider.
8
+ //
9
+ // CONTROLLED / PRESENTATIONAL — computes NOTHING financial: `mode`, `monthly`, `down`, `term` and
10
+ // the slider/chip bounds all arrive as props; segment/slider/chip changes are reported back via
11
+ // `onModeChange` / `onDownChange` / `onTermChange`. The real numbers come from `financingStore`.
12
+ // The only local state is the `adjust` expander (pure view state).
13
+ //
14
+ // Renders correctly only inside a `<div className="md3">` subtree.
15
+
16
+ import React, { useState } from 'react';
17
+ import { TranslationFunction } from '../types';
18
+
19
+ import { Button, Icon } from '../ui';
20
+ import { TIER_LABELS, TIER_KEYS, TIER_COLORS } from '../constants';
21
+
22
+ /** Payment mode segment keys. Mirrors the design's ['fin','leasing','cash']. */
23
+ export type FinanceMode = 'fin' | 'leasing' | 'cash';
24
+
25
+ export interface SliderProps {
26
+ min: number;
27
+ max: number;
28
+ step?: number;
29
+ value: number;
30
+ onChange: (value: number) => void;
31
+ /** formats the min/max end labels (and is reused by callers for the value). */
32
+ fmt?: (v: number) => React.ReactNode;
33
+ /** render the current value in a floating bubble above the thumb (Figma sheet sliders). */
34
+ bubble?: boolean;
35
+ }
36
+
37
+ /**
38
+ * Range slider with a filled track and min/max end labels. The track fill is a
39
+ * linear-gradient driven by the value's position; the design's `.mslider` class
40
+ * styles the thumb/rail. With `bubble` the current value floats above the thumb
41
+ * (mockup "Rate anpassen" sliders) instead of the caller printing it elsewhere.
42
+ */
43
+ export const Slider: React.FunctionComponent<SliderProps> = ({ min, max, step = 1, value, onChange, fmt = (v) => v, bubble }) => {
44
+ const span = max - min;
45
+ const pct = span > 0 ? ((value - min) / span) * 100 : 0;
46
+ return (
47
+ <div>
48
+ <div style={{ position: 'relative', paddingTop: bubble ? 38 : 0 }}>
49
+ {bubble && (
50
+ // thumb is 22px wide: interpolate the bubble anchor between half-thumb insets so it
51
+ // tracks the thumb center (a plain pct% drifts at the rail ends)
52
+ <span className="slider-bubble num" style={{ left: `calc(11px + (100% - 22px) * ${pct / 100})` }}>
53
+ {fmt(value)}
54
+ </span>
55
+ )}
56
+ <input
57
+ type="range"
58
+ className="mslider"
59
+ min={min}
60
+ max={max}
61
+ step={step}
62
+ value={value}
63
+ onChange={(e) => onChange(Number(e.target.value))}
64
+ style={{ background: `linear-gradient(90deg, var(--md-primary) ${pct}%, var(--md-sc-highest) ${pct}%)` }}
65
+ />
66
+ </div>
67
+ {!bubble && (
68
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
69
+ <span className="md-label-sm">{fmt(min)}</span>
70
+ <span className="md-label-sm">{fmt(max)}</span>
71
+ </div>
72
+ )}
73
+ </div>
74
+ );
75
+ };
76
+
77
+ /**
78
+ * Segmented price-evaluation bar (Figma: 5 discrete tier segments, NOT a gradient) with a
79
+ * white ring marker. `leftPct` overrides the marker position (the Marktwert gauge positions
80
+ * it continuously); default = centered on the tier segment.
81
+ */
82
+ export const EvalBar: React.FunctionComponent<{ tier: number; leftPct?: number }> = ({ tier, leftPct }) => (
83
+ <div className="fin-eval-bar">
84
+ {TIER_COLORS.map((c, i) => (
85
+ <span key={i} className="fin-eval-seg" style={{ background: c }} />
86
+ ))}
87
+ <div
88
+ className="fin-eval-thumb"
89
+ style={{ left: `${leftPct != null ? leftPct : (tier + 0.5) * 20}%`, borderColor: TIER_COLORS[Math.max(0, Math.min(4, tier))] }}
90
+ />
91
+ </div>
92
+ );
93
+
94
+ /** Figma CTA icon — document with a check-in-circle badge over its bottom-right corner
95
+ * (no single lucide glyph matches; composed from file-text + a knocked-out check-circle).
96
+ * The badge backdrop assumes a filled (primary) button. */
97
+ export const DocCheckIcon: React.FunctionComponent<{ size?: number }> = ({ size = 18 }) => (
98
+ <span style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
99
+ <Icon name="file-text" size={size} stroke={2} />
100
+ <span
101
+ style={{
102
+ position: 'absolute',
103
+ right: -4,
104
+ bottom: -3,
105
+ display: 'flex',
106
+ borderRadius: '50%',
107
+ background: 'var(--md-primary)',
108
+ padding: 1,
109
+ }}
110
+ >
111
+ <Icon name="check-circle" size={Math.round(size * 0.62)} stroke={2.6} />
112
+ </span>
113
+ </span>
114
+ );
115
+
116
+ export interface TermChipsProps {
117
+ /** the selectable term values (months), e.g. [12,24,36,48,60,72]. */
118
+ options: number[];
119
+ value: number;
120
+ onChange: (term: number) => void;
121
+ /** i18n function (drilled from the page). */
122
+ t: TranslationFunction;
123
+ }
124
+
125
+ /** Laufzeit selector — discrete term values as tappable chips (replaces the fiddly slider). */
126
+ export const TermChips: React.FunctionComponent<TermChipsProps> = ({ options, value, onChange, t }) => (
127
+ <div className="term-chips" role="group" aria-label={t('autobahn:finance.termSelection')}>
128
+ {options.map((m) => (
129
+ <button
130
+ key={m}
131
+ type="button"
132
+ aria-pressed={m === value}
133
+ className={'term-chip' + (m === value ? ' on' : '')}
134
+ onClick={() => onChange(m)}
135
+ >
136
+ {m}
137
+ <span className="tc-unit">{t('autobahn:finance.monthUnit')}</span>
138
+ </button>
139
+ ))}
140
+ </div>
141
+ );
142
+
143
+ export interface CalcRowProps {
144
+ label: React.ReactNode;
145
+ /** when true, drops the bottom hairline (last row). */
146
+ last?: boolean;
147
+ children?: React.ReactNode;
148
+ }
149
+
150
+ /** One label/value row inside the "Rate anpassen" / "Details" expander. */
151
+ export const CalcRow: React.FunctionComponent<CalcRowProps> = ({ label, last, children }) => (
152
+ <div
153
+ className="calcrow"
154
+ style={{
155
+ display: 'flex',
156
+ justifyContent: 'space-between',
157
+ alignItems: 'baseline',
158
+ gap: 12,
159
+ padding: '8px 0',
160
+ borderBottom: last ? 0 : '1px solid var(--md-outline-variant)',
161
+ }}
162
+ >
163
+ <span className="md-label-sm calcrow-label">{label}</span>
164
+ <span className="num calcrow-value" style={{ fontSize: 13.5, fontWeight: 600 }}>
165
+ {children}
166
+ </span>
167
+ </div>
168
+ );
169
+
170
+ export interface FinanceCalculatorProps {
171
+ /** active payment mode (controlled). */
172
+ mode: FinanceMode;
173
+ /** purchase price in EUR (the cash mode hero). */
174
+ price: number;
175
+ /** previous/list price — when > price, shown struck through as the UVP in cash mode (real discount). */
176
+ listPrice?: number;
177
+ /** auto.de-ID reference code — shown with the financing/leasing condition line. */
178
+ autoDeId?: string;
179
+ /** price-rating label, e.g. 'Guter Preis' — drives the Preisbewertung indicator. */
180
+ ratingLabel?: string;
181
+ /** computed monthly rate in EUR for the current mode — supplied, NOT computed here. */
182
+ monthly: number;
183
+ /** struck old monthly rate (fe-financing oldMonthlyInstallment, isStrikeShown-gated) —
184
+ * rendered as the red line-through figure next to the hero rate (fin/leasing only). */
185
+ oldMonthly?: number;
186
+ /** effective annual rate (%) for the current mode — supplied, NOT computed here. */
187
+ apr: number;
188
+ /** current down payment in EUR (controlled). */
189
+ down: number;
190
+ /** current term in months (controlled). */
191
+ term: number;
192
+ /** down-payment slider bounds. */
193
+ downMin: number;
194
+ downMax: number;
195
+ /** down-payment slider step (EUR). */
196
+ downStep?: number;
197
+ /** term slider bounds (months) — used to derive chip options when termOptions is absent. */
198
+ termMin: number;
199
+ termMax: number;
200
+ /** term slider step (months). */
201
+ termStep?: number;
202
+ /** the discrete selectable terms (months) for the Laufzeit chips. */
203
+ termOptions?: number[];
204
+ /** annual mileage in km (controlled) — leasing/financing Laufleistung/Jahr. Omit to hide the slider. */
205
+ annualMileage?: number;
206
+ /** annual-mileage slider bounds + step (km). */
207
+ annualMileageMin?: number;
208
+ annualMileageMax?: number;
209
+ annualMileageStep?: number;
210
+ /** annual-mileage slider changed. */
211
+ onAnnualMileageChange?: (km: number) => void;
212
+ /** phone number shown on the secondary CTA (touch only). */
213
+ phone?: string;
214
+ /** primary CTA click (Angebot sichern). */
215
+ onSecure?: () => void;
216
+ /** secondary CTA click (phone). */
217
+ onCall?: () => void;
218
+ /** open the Finanzierungs-/Leasingdetails popup (full breakdown + legal, expanded). Omit to hide the link. */
219
+ onShowDetails?: () => void;
220
+ /** mobile only: open the "Rate anpassen" / "Details anzeigen" sheet (collapsed/default view).
221
+ * Omit to hide the trigger row (falls back to the desktop inline expander). */
222
+ onOpenAdjust?: () => void;
223
+ /** mode segment changed. */
224
+ onModeChange: (mode: FinanceMode) => void;
225
+ /** down-payment slider changed. */
226
+ onDownChange: (down: number) => void;
227
+ /** term changed. */
228
+ onTermChange: (term: number) => void;
229
+ /** i18n function (drilled from the page). */
230
+ t: TranslationFunction;
231
+ }
232
+
233
+ const eur = (n: number): string => n.toLocaleString('de-DE');
234
+
235
+ /** build a [min..max] step range (fallback term options when none are supplied). */
236
+ const range = (min: number, max: number, step: number): number[] => {
237
+ const out: number[] = [];
238
+ for (let v = min; v <= max; v += step || 12) out.push(v);
239
+ return out.length ? out : [min];
240
+ };
241
+
242
+ /** quiet primary-colored text link (Finanzierungsdetails / Details anzeigen). */
243
+ const DetailsLink: React.FunctionComponent<{ label: string; onClick: () => void }> = ({ label, onClick }) => (
244
+ <button
245
+ type="button"
246
+ onClick={onClick}
247
+ style={{
248
+ display: 'inline-flex',
249
+ alignItems: 'center',
250
+ gap: 5,
251
+ background: 'none',
252
+ border: 0,
253
+ padding: 0,
254
+ cursor: 'pointer',
255
+ color: 'var(--md-primary)',
256
+ font: 'inherit',
257
+ fontSize: 13,
258
+ fontWeight: 600,
259
+ }}
260
+ >
261
+ <Icon name="info" size={15} color="var(--md-primary)" />
262
+ {label}
263
+ </button>
264
+ );
265
+
266
+ /** "Rate anpassen" / "Details anzeigen" expander toggle button. */
267
+ export const expanderBtnStyle: React.CSSProperties = {
268
+ width: '100%',
269
+ background: 'none',
270
+ border: 0,
271
+ cursor: 'pointer',
272
+ display: 'flex',
273
+ alignItems: 'center',
274
+ justifyContent: 'space-between',
275
+ padding: '10px 0 2px',
276
+ color: 'var(--md-primary)',
277
+ fontWeight: 600,
278
+ fontSize: 14,
279
+ };
280
+
281
+ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
282
+ mode,
283
+ price,
284
+ listPrice,
285
+ autoDeId,
286
+ ratingLabel,
287
+ monthly,
288
+ oldMonthly,
289
+ down,
290
+ term,
291
+ downMin,
292
+ downMax,
293
+ downStep = 500,
294
+ termMin,
295
+ termMax,
296
+ termStep = 6,
297
+ termOptions,
298
+ annualMileage,
299
+ annualMileageMin = 5000,
300
+ annualMileageMax = 60000,
301
+ annualMileageStep = 5000,
302
+ onAnnualMileageChange,
303
+ phone = '0800 40 30 182',
304
+ onSecure,
305
+ onCall,
306
+ onShowDetails,
307
+ onOpenAdjust,
308
+ onModeChange,
309
+ onDownChange,
310
+ onTermChange,
311
+ t,
312
+ }) => {
313
+ // Pure view state only — whether the adjust/details expander is open.
314
+ const [adjust, setAdjust] = useState(false);
315
+ const isCash = mode === 'cash';
316
+ const terms = termOptions && termOptions.length ? termOptions : range(termMin, termMax, termStep);
317
+ const showStrike = listPrice != null && listPrice > price;
318
+ // price-evaluation tier from the rating label (defaults to 'Guter Preis' tier when unmapped).
319
+ const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
320
+ const evalTier = tierIdx >= 0 ? tierIdx : 1;
321
+ const evalTierLabel = t('autobahn:tier.' + TIER_KEYS[evalTier]);
322
+ // condition summary (current selection) — the auto.de-ID lives with the details link, not here.
323
+ const condParts: string[] = [];
324
+ if (!isCash) {
325
+ condParts.push(
326
+ mode === 'leasing' && annualMileage != null
327
+ ? t('autobahn:finance.kmPerYear', { value: eur(annualMileage) })
328
+ : t('autobahn:finance.downPaymentLabel', { value: eur(down) }),
329
+ );
330
+ condParts.push(t('autobahn:finance.monthsLabel', { months: term, count: term }));
331
+ }
332
+ const condLine = condParts.length ? `${t('autobahn:finance.exampleLabel')} ${condParts.join(' · ')}` : '';
333
+
334
+ return (
335
+ <div className="mcard mcard--elevated" style={{ padding: 20 }}>
336
+ {/* header — desktop only; mobile goes straight into the tabs (see mockup: no card
337
+ title above Finanzierung/Leasing/Kaufen on phone). */}
338
+ <div className="fin-card-header hide-mobile" style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
339
+ <span
340
+ style={{
341
+ width: 34,
342
+ height: 34,
343
+ borderRadius: 'var(--md-r-full)',
344
+ background: 'var(--md-primary-container)',
345
+ display: 'flex',
346
+ alignItems: 'center',
347
+ justifyContent: 'center',
348
+ flex: 'none',
349
+ }}
350
+ >
351
+ <Icon name="tag" size={18} color="var(--md-on-primary-container)" />
352
+ </span>
353
+ <div style={{ fontFamily: 'var(--md-font-display)', fontSize: 18, fontWeight: 700 }}>{t('autobahn:finance.headerTitle')}</div>
354
+ </div>
355
+
356
+ {/* payment-type TABS on top */}
357
+ <div className="fin-tabs" role="tablist">
358
+ {(
359
+ [
360
+ ['fin', 'autobahn:finance.mode.financing'],
361
+ ['leasing', 'autobahn:finance.mode.leasing'],
362
+ ['cash', 'autobahn:finance.mode.cash'],
363
+ ] as Array<[FinanceMode, string]>
364
+ ).map(([k, labelKey]) => (
365
+ <button
366
+ key={k}
367
+ type="button"
368
+ role="tab"
369
+ aria-selected={mode === k}
370
+ className={'fin-tab' + (mode === k ? ' on' : '')}
371
+ onClick={() => onModeChange(k)}
372
+ >
373
+ {k === 'cash' ? (
374
+ <>
375
+ {/* mockup names the cash tab "Kaufen" on phone; desktop keeps "Barzahlung" */}
376
+ <span className="hide-mobile">{t(labelKey)}</span>
377
+ <span className="show-mobile" style={{ justifyContent: 'center' }}>
378
+ {t('autobahn:card.price.buy')}
379
+ </span>
380
+ </>
381
+ ) : (
382
+ t(labelKey)
383
+ )}
384
+ </button>
385
+ ))}
386
+ </div>
387
+
388
+ {/* 2-col grid: rate|pill (row 1) + condLine|bar (row 2) — fixed right column prevents wrapping */}
389
+ <div className="fin-price-grid">
390
+ {/* col 1 row 1: hero rate — Figma: "329 €" all-big, then a small stacked column with
391
+ the struck old rate ABOVE "/mtl." (round 11; was a single baseline row) */}
392
+ <div className="fin-rate-main">
393
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
394
+ <span
395
+ className="num"
396
+ style={{ fontSize: 48, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-2px' }}
397
+ >
398
+ {/* cash keeps the long price number-only (a big "13.249 €" overflows the grid cell) */}
399
+ {isCash ? eur(price) : `${eur(Math.round(monthly))} €`}
400
+ </span>
401
+ {isCash ? (
402
+ <span className="md-body-sm" style={{ fontWeight: 600 }}>€</span>
403
+ ) : (
404
+ <span className="fin-rate-unit">
405
+ {/* struck old rate (REAL oldMonthlyInstallment; SRP strikes the same source) */}
406
+ {oldMonthly != null && <s className="num fin-rate-old">{eur(Math.round(oldMonthly))} €</s>}
407
+ <span>{t('autobahn:finance.perMonth')}</span>
408
+ </span>
409
+ )}
410
+ </div>
411
+ {isCash && <div className="md-label-sm">{t('autobahn:finance.fixedPrice')}</div>}
412
+ {isCash && showStrike && (
413
+ <div className="fin-kauf">
414
+ {t('autobahn:finance.uvp')} <s>{eur(listPrice as number)} €</s>
415
+ </div>
416
+ )}
417
+ </div>
418
+ {/* col 2 row 1: price-rating pill (Figma: bold colored label, NO icon) */}
419
+ <div>
420
+ {ratingLabel && (
421
+ <span
422
+ className="fin-eval-pill"
423
+ style={{ color: TIER_COLORS[evalTier], background: `color-mix(in srgb, ${TIER_COLORS[evalTier]} 16%, transparent)` }}
424
+ aria-label={t('autobahn:finance.priceRatingAria', { rating: evalTierLabel })}
425
+ >
426
+ {evalTierLabel}
427
+ </span>
428
+ )}
429
+ </div>
430
+ {/* col 1 row 2: condition line */}
431
+ <div className="fin-cond">{condLine}</div>
432
+ {/* col 2 row 2: segmented evaluation bar (Figma: discrete tiers, not a gradient) */}
433
+ <div>{ratingLabel && <EvalBar tier={evalTier} />}</div>
434
+ </div>
435
+
436
+ {/* ============================== DESKTOP (≥601px) — unchanged ============================== */}
437
+ <div className="hide-mobile">
438
+ {/* RATE EDIT (Rate anpassen) — between the rate and the request CTA */}
439
+ <div className="fin-adjust">
440
+ <button onClick={() => setAdjust((a) => !a)} style={expanderBtnStyle}>
441
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
442
+ <Icon name={isCash ? 'list' : 'sliders-horizontal'} size={16} color="var(--md-primary)" />
443
+ {isCash ? t('autobahn:finance.detailsShow') : t('autobahn:finance.adjustRate')}
444
+ </span>
445
+ <Icon name={adjust ? 'chevron-up' : 'chevron-down'} size={18} color="var(--md-primary)" />
446
+ </button>
447
+ {adjust &&
448
+ (isCash ? (
449
+ <div style={{ marginTop: 4 }}>
450
+ <CalcRow label={t('autobahn:finance.paymentLabel')}>{t('autobahn:finance.paymentValue')}</CalcRow>
451
+ <CalcRow label={t('autobahn:finance.warrantyLabel')}>{t('autobahn:finance.warrantyValue')}</CalcRow>
452
+ <CalcRow label={t('autobahn:finance.deliveryLabel')} last>
453
+ {t('autobahn:finance.deliveryValue')}
454
+ </CalcRow>
455
+ </div>
456
+ ) : (
457
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 8 }}>
458
+ <div>
459
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
460
+ <span className="md-title-sm">{t('autobahn:finance.downPaymentSliderLabel')}</span>
461
+ <span className="num" style={{ fontWeight: 700 }}>
462
+ {eur(down)} €
463
+ </span>
464
+ </div>
465
+ <Slider
466
+ min={downMin}
467
+ max={downMax}
468
+ step={downStep}
469
+ value={down}
470
+ onChange={onDownChange}
471
+ fmt={(v) => `${eur(Number(v))} €`}
472
+ />
473
+ </div>
474
+ {annualMileage != null && onAnnualMileageChange && (
475
+ <div>
476
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
477
+ <span className="md-title-sm">{t('autobahn:finance.annualMileageLabel')}</span>
478
+ <span className="num" style={{ fontWeight: 700 }}>
479
+ {eur(annualMileage)} km
480
+ </span>
481
+ </div>
482
+ <Slider
483
+ min={annualMileageMin}
484
+ max={annualMileageMax}
485
+ step={annualMileageStep}
486
+ value={annualMileage}
487
+ onChange={onAnnualMileageChange}
488
+ fmt={(v) => `${eur(Number(v))} km`}
489
+ />
490
+ </div>
491
+ )}
492
+ <div>
493
+ <div className="md-title-sm" style={{ marginBottom: 8 }}>
494
+ {t('autobahn:finance.termSelection')}
495
+ </div>
496
+ <TermChips options={terms} value={term} onChange={onTermChange} t={t} />
497
+ </div>
498
+ <div>
499
+ <CalcRow label={t('autobahn:finance.purchasePrice')}>{eur(price)} €</CalcRow>
500
+ <CalcRow label={t('autobahn:finance.warrantyLabel')}>{t('autobahn:finance.warrantyValue')}</CalcRow>
501
+ <CalcRow label={t('autobahn:finance.deliveryLabel')} last>
502
+ {t('autobahn:finance.deliveryValue')}
503
+ </CalcRow>
504
+ </div>
505
+ </div>
506
+ ))}
507
+ </div>
508
+
509
+ {/* REQUEST CTA */}
510
+ <Button variant="filled" block size="lg" icon="shield-check" style={{ marginTop: 14 }} onClick={onSecure}>
511
+ {t('autobahn:finance.secureOffer')}
512
+ </Button>
513
+ {/* phone CTA — touch only (hidden on desktop via .fin-call) */}
514
+ <div className="fin-call">
515
+ <Button variant="outlined" block icon="phone" onClick={onCall}>
516
+ {phone}
517
+ </Button>
518
+ </div>
519
+
520
+ {/* auto.de-ID in front of the Finanzierungs-/Leasingdetails link — one row, under everything */}
521
+ {(autoDeId || (!isCash && onShowDetails)) && (
522
+ <div className="fin-info">
523
+ {autoDeId && <span className="fin-id">auto.de-ID {autoDeId}</span>}
524
+ {!isCash && onShowDetails && (
525
+ <DetailsLink
526
+ label={mode === 'leasing' ? t('autobahn:finance.leasingDetails') : t('autobahn:finance.financingDetails')}
527
+ onClick={onShowDetails}
528
+ />
529
+ )}
530
+ </div>
531
+ )}
532
+ </div>
533
+
534
+ {/* ============================== MOBILE (≤600px) — new layout ============================== */}
535
+ <div className="show-mobile" style={{ flexDirection: 'column' }}>
536
+ {/* side-by-side CTAs — mockup ratio ~2:1, with the tiny auto.de-ID sitting
537
+ directly under the Anrufen button (not in its own full-width row). */}
538
+ <div className="fin-cta-row" style={{ display: 'flex', gap: 10, marginTop: 14, alignItems: 'flex-start' }}>
539
+ {/* Figma CTA icon: document + check badge (composed — no single lucide glyph) */}
540
+ <Button variant="filled" size="lg" style={{ flex: 2 }} onClick={onSecure}>
541
+ <DocCheckIcon />
542
+ {t('autobahn:finance.secureOffer')}
543
+ </Button>
544
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
545
+ <Button variant="outlined" size="lg" icon="phone" block onClick={onCall}>
546
+ {t('autobahn:finance.callCta')}
547
+ </Button>
548
+ {autoDeId && (
549
+ <span
550
+ className="num"
551
+ style={{ fontSize: 10, fontWeight: 700, color: 'var(--md-primary)', textAlign: 'center', whiteSpace: 'nowrap' }}
552
+ >
553
+ auto.de ID: {autoDeId}
554
+ </span>
555
+ )}
556
+ </div>
557
+ </div>
558
+
559
+ {/* Persönliche Beratung (left) · divider · Finanzierungs-/Leasingdetails or hint (right) */}
560
+ <div className="fin-info">
561
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
562
+ <Icon name="user" size={18} color="var(--md-on-surface-variant)" style={{ flex: 'none' }} />
563
+ <div style={{ minWidth: 0 }}>
564
+ <div className="md-label-sm" style={{ fontWeight: 700, color: 'var(--md-on-surface)' }}>
565
+ {t('autobahn:finance.personalConsultation')}
566
+ </div>
567
+ <div className="md-label-sm">{t('autobahn:finance.personalConsultationSub')}</div>
568
+ </div>
569
+ </div>
570
+ <span style={{ width: 1, alignSelf: 'stretch', background: 'var(--md-outline-variant)', flex: 'none' }} />
571
+ {!isCash && onShowDetails ? (
572
+ <button
573
+ type="button"
574
+ onClick={onShowDetails}
575
+ style={{
576
+ display: 'flex',
577
+ alignItems: 'center',
578
+ gap: 8,
579
+ background: 'none',
580
+ border: 0,
581
+ padding: 0,
582
+ cursor: 'pointer',
583
+ font: 'inherit',
584
+ color: 'var(--md-on-surface)',
585
+ textAlign: 'left',
586
+ }}
587
+ >
588
+ <Icon name="info" size={18} color="var(--md-on-surface-variant)" style={{ flex: 'none' }} />
589
+ {/* no max-width: "Finanzierungsdetails" is one unbreakable word — capping the
590
+ span clipped it mid-word instead of wrapping */}
591
+ <span className="md-label-sm" style={{ lineHeight: 1.5 }}>
592
+ {mode === 'leasing' ? t('autobahn:finance.leasingDetails') : t('autobahn:finance.financingDetails')}
593
+ </span>
594
+ </button>
595
+ ) : (
596
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--md-on-surface-variant)' }}>
597
+ <Icon name="info" size={15} />
598
+ <span className="md-label-sm">{t('autobahn:finance.financingLeasingPossible')}</span>
599
+ </div>
600
+ )}
601
+ </div>
602
+
603
+ {/* "Rate anpassen" / "Details anzeigen" — opens the full bottom sheet (not an inline expander) */}
604
+ {onOpenAdjust && (
605
+ <button
606
+ type="button"
607
+ onClick={onOpenAdjust}
608
+ style={{ ...expanderBtnStyle, borderTop: '1px solid var(--md-outline-variant)', marginTop: 4, padding: '14px 0 2px' }}
609
+ >
610
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
611
+ <Icon name={isCash ? 'list' : 'sliders-horizontal'} size={16} color="var(--md-primary)" />
612
+ {isCash ? t('autobahn:finance.detailsShow') : t('autobahn:finance.adjustRate')}
613
+ </span>
614
+ <Icon name="chevron-right" size={18} color="var(--md-primary)" />
615
+ </button>
616
+ )}
617
+ </div>
618
+ </div>
619
+ );
620
+ };
621
+
622
+ export default FinanceCalculator;