@bytebrand/fe-ui-core-autobahn 1.0.54 → 1.0.56

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