@bytebrand/fe-ui-core-autobahn 1.0.80 → 1.0.82

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,62 +1,62 @@
1
- // Autobahn (auto.de redesign) — shared financing-number formatting.
2
- //
3
- // Extracted from vdp/VdpModules.tsx's FinanceDetailsModal so non-React consumers (e.g. the
4
- // Meta AIA marketing-image renderer) format the SAME real numbers the same way, instead of a
5
- // second copy drifting from the VDP's. Pure TS, no React/JSX — safe to import from a plain
6
- // Node service.
7
- //
8
- // The "only real figures, never a fabricated total" rule is load-bearing (legal: balloon
9
- // financing means monthly×term is NOT the total) and lives here once, not per-caller.
10
-
11
- /** Monetary figure → de-DE grouped string (e.g. 47400 → "47.400"). Feed rounded integers —
12
- * toLocaleString on a fractional value prints a bogus 3-decimal grouping. */
13
- export function formatEur(n: number): string {
14
- return n.toLocaleString('de-DE');
15
- }
16
-
17
- /** Rate → de-DE percent string (e.g. 6.49 → "6,49 %"). */
18
- export function formatPct(n: number): string {
19
- return `${n.toFixed(2).replace('.', ',')} %`;
20
- }
21
-
22
- export interface FinanceExampleFields {
23
- price: number;
24
- apr: number;
25
- down: number;
26
- /** Real config figure — falls back to price-down ONLY when genuinely absent. */
27
- netLoanAmount?: number;
28
- nominalRate?: number;
29
- finalInstallment?: number;
30
- grossAmount?: number;
31
- }
32
-
33
- export interface FinanceExampleLabels {
34
- vehiclePrice: string;
35
- priceSuffix: string;
36
- apr: string;
37
- nominal: string;
38
- net: string;
39
- down: string;
40
- final: string;
41
- gross: string;
42
- }
43
-
44
- /**
45
- * The "Repräsentatives Beispiel" disclaimer line (PAngV). Only fields with a genuine value are
46
- * included — nominalRate/finalInstallment/grossAmount are optional real config figures, never
47
- * derived (see fields doc). Matches production wording/format exactly (VdpModules.tsx's
48
- * `exampleLine`, byte-for-byte).
49
- */
50
- export function buildFinanceExampleLine(fields: FinanceExampleFields, labels: FinanceExampleLabels, footnoteMarker = '³'): string {
51
- const netto = fields.netLoanAmount != null ? fields.netLoanAmount : Math.max(0, fields.price - fields.down);
52
- const parts: string[] = [
53
- `${labels.vehiclePrice}: ${formatEur(fields.price)} € ${labels.priceSuffix}`,
54
- ...(fields.apr > 0 ? [`${labels.apr}: ${formatPct(fields.apr)}`] : []),
55
- ...(fields.nominalRate != null && fields.nominalRate > 0 ? [`${labels.nominal}: ${formatPct(fields.nominalRate)}`] : []),
56
- `${labels.net}: ${formatEur(netto)} €`,
57
- `${labels.down}: ${formatEur(fields.down)} €`,
58
- ...(fields.finalInstallment != null && fields.finalInstallment > 0 ? [`${labels.final}: ${formatEur(fields.finalInstallment)} €`] : []),
59
- ...(fields.grossAmount != null && fields.grossAmount > 0 ? [`${labels.gross}: ${formatEur(fields.grossAmount)} €`] : []),
60
- ];
61
- return `${footnoteMarker} ${parts.join('; ')};`;
62
- }
1
+ // Autobahn (auto.de redesign) — shared financing-number formatting.
2
+ //
3
+ // Extracted from vdp/VdpModules.tsx's FinanceDetailsModal so non-React consumers (e.g. the
4
+ // Meta AIA marketing-image renderer) format the SAME real numbers the same way, instead of a
5
+ // second copy drifting from the VDP's. Pure TS, no React/JSX — safe to import from a plain
6
+ // Node service.
7
+ //
8
+ // The "only real figures, never a fabricated total" rule is load-bearing (legal: balloon
9
+ // financing means monthly×term is NOT the total) and lives here once, not per-caller.
10
+
11
+ /** Monetary figure → de-DE grouped string (e.g. 47400 → "47.400"). Feed rounded integers —
12
+ * toLocaleString on a fractional value prints a bogus 3-decimal grouping. */
13
+ export function formatEur(n: number): string {
14
+ return n.toLocaleString('de-DE');
15
+ }
16
+
17
+ /** Rate → de-DE percent string (e.g. 6.49 → "6,49 %"). */
18
+ export function formatPct(n: number): string {
19
+ return `${n.toFixed(2).replace('.', ',')} %`;
20
+ }
21
+
22
+ export interface FinanceExampleFields {
23
+ price: number;
24
+ apr: number;
25
+ down: number;
26
+ /** Real config figure — falls back to price-down ONLY when genuinely absent. */
27
+ netLoanAmount?: number;
28
+ nominalRate?: number;
29
+ finalInstallment?: number;
30
+ grossAmount?: number;
31
+ }
32
+
33
+ export interface FinanceExampleLabels {
34
+ vehiclePrice: string;
35
+ priceSuffix: string;
36
+ apr: string;
37
+ nominal: string;
38
+ net: string;
39
+ down: string;
40
+ final: string;
41
+ gross: string;
42
+ }
43
+
44
+ /**
45
+ * The "Repräsentatives Beispiel" disclaimer line (PAngV). Only fields with a genuine value are
46
+ * included — nominalRate/finalInstallment/grossAmount are optional real config figures, never
47
+ * derived (see fields doc). Matches production wording/format exactly (VdpModules.tsx's
48
+ * `exampleLine`, byte-for-byte).
49
+ */
50
+ export function buildFinanceExampleLine(fields: FinanceExampleFields, labels: FinanceExampleLabels, footnoteMarker = '³'): string {
51
+ const netto = fields.netLoanAmount != null ? fields.netLoanAmount : Math.max(0, fields.price - fields.down);
52
+ const parts: string[] = [
53
+ `${labels.vehiclePrice}: ${formatEur(fields.price)} € ${labels.priceSuffix}`,
54
+ ...(fields.apr > 0 ? [`${labels.apr}: ${formatPct(fields.apr)}`] : []),
55
+ ...(fields.nominalRate != null && fields.nominalRate > 0 ? [`${labels.nominal}: ${formatPct(fields.nominalRate)}`] : []),
56
+ `${labels.net}: ${formatEur(netto)} €`,
57
+ `${labels.down}: ${formatEur(fields.down)} €`,
58
+ ...(fields.finalInstallment != null && fields.finalInstallment > 0 ? [`${labels.final}: ${formatEur(fields.finalInstallment)} €`] : []),
59
+ ...(fields.grossAmount != null && fields.grossAmount > 0 ? [`${labels.gross}: ${formatEur(fields.grossAmount)} €`] : []),
60
+ ];
61
+ return `${footnoteMarker} ${parts.join('; ')};`;
62
+ }
@@ -1,58 +1,58 @@
1
- // Autobahn (auto.de redesign) — Preisbewertung rating math, extracted from CommonUtils.ts.
2
- //
3
- // Pulled out on purpose: CommonUtils.ts transitively imports `../constants/Search`, which
4
- // `require()`s image assets (webpack-only — breaks any plain Node `require`/`tsc` consumer).
5
- // This file only imports `../constants/price` (zero further imports) — a pure, testable leaf
6
- // module. CommonUtils.ts re-exports these for existing consumers — one implementation, not a fork.
7
- // (image-generator's Meta AIA renderer vendors a copy of this math directly instead of depending
8
- // on this package — see services/image-generator/src/services/metaAia/vendoredAutobahnMath.ts —
9
- // so a plain-Node backend doesn't need to npm-install this whole design system's dependency tree.)
10
- import { priceRatings, priceRatingConfig } from '../constants/price';
11
- import { PREDICTABLE_PRICE_INDEX } from '../constants/core';
12
-
13
- export const getPriceRatingConfig = (currentPrice: number, pricePredicted: number) => { // tslint:disable-line
14
- const round = (value: number) => {
15
- return Number.isFinite(pricePredicted)
16
- ? Math.round((pricePredicted * (1 + (value / 100))) / 100) * 100 // tslint:disable-line
17
- : '???';
18
- };
19
-
20
- if (currentPrice === 0) {
21
- console.log('Empty current price');
22
- }
23
-
24
- return Object.keys(priceRatingConfig).reduce((acc: any, key: any) => {
25
- const { min, max } = priceRatingConfig[key];
26
- return { ...acc, [key]: { min: round(min), max: round(max) } };
27
- }, {}); // tslint:disable-line
28
- };
29
-
30
- export const getPredictablePrice = (currentSalesPrice: number, predictablePrice: number) => {
31
- if (Number.isFinite(Number.parseFloat(`${predictablePrice}`))) {
32
- return predictablePrice;
33
- }
34
-
35
- return currentSalesPrice * PREDICTABLE_PRICE_INDEX;
36
- }
37
-
38
- export const getPriceRating = (currentSalesPrice: number, predictablePrice: number) => {
39
- const isCorrectCurrentSalesPrice = Number.isFinite(Number.parseFloat(`${currentSalesPrice}`));
40
- // const isCorrectPredictablePrice = Number.isFinite(Number.parseFloat(`${predictablePrice}`));
41
-
42
- if (!isCorrectCurrentSalesPrice) return 0;
43
-
44
-
45
- const validatedPredictablePrice = getPredictablePrice(currentSalesPrice, predictablePrice);
46
-
47
- const ratingConfig = getPriceRatingConfig(currentSalesPrice, validatedPredictablePrice);
48
-
49
- for (const key of Object.keys(ratingConfig)) {
50
- const { min, max } = ratingConfig[key];
51
- const TOP_PRICE = 5;
52
- if (key === 'highPrice' && currentSalesPrice === max) return 1;
53
- if (key === 'topPrice' && currentSalesPrice <= min) return TOP_PRICE;
54
- if (currentSalesPrice >= min && currentSalesPrice < max) return priceRatings.indexOf(key);
55
- }
56
-
57
- return 0;
58
- };
1
+ // Autobahn (auto.de redesign) — Preisbewertung rating math, extracted from CommonUtils.ts.
2
+ //
3
+ // Pulled out on purpose: CommonUtils.ts transitively imports `../constants/Search`, which
4
+ // `require()`s image assets (webpack-only — breaks any plain Node `require`/`tsc` consumer).
5
+ // This file only imports `../constants/price` (zero further imports) — a pure, testable leaf
6
+ // module. CommonUtils.ts re-exports these for existing consumers — one implementation, not a fork.
7
+ // (image-generator's Meta AIA renderer vendors a copy of this math directly instead of depending
8
+ // on this package — see services/image-generator/src/services/metaAia/vendoredAutobahnMath.ts —
9
+ // so a plain-Node backend doesn't need to npm-install this whole design system's dependency tree.)
10
+ import { priceRatings, priceRatingConfig } from '../constants/price';
11
+ import { PREDICTABLE_PRICE_INDEX } from '../constants/core';
12
+
13
+ export const getPriceRatingConfig = (currentPrice: number, pricePredicted: number) => { // tslint:disable-line
14
+ const round = (value: number) => {
15
+ return Number.isFinite(pricePredicted)
16
+ ? Math.round((pricePredicted * (1 + (value / 100))) / 100) * 100 // tslint:disable-line
17
+ : '???';
18
+ };
19
+
20
+ if (currentPrice === 0) {
21
+ console.log('Empty current price');
22
+ }
23
+
24
+ return Object.keys(priceRatingConfig).reduce((acc: any, key: any) => {
25
+ const { min, max } = priceRatingConfig[key];
26
+ return { ...acc, [key]: { min: round(min), max: round(max) } };
27
+ }, {}); // tslint:disable-line
28
+ };
29
+
30
+ export const getPredictablePrice = (currentSalesPrice: number, predictablePrice: number) => {
31
+ if (Number.isFinite(Number.parseFloat(`${predictablePrice}`))) {
32
+ return predictablePrice;
33
+ }
34
+
35
+ return currentSalesPrice * PREDICTABLE_PRICE_INDEX;
36
+ }
37
+
38
+ export const getPriceRating = (currentSalesPrice: number, predictablePrice: number) => {
39
+ const isCorrectCurrentSalesPrice = Number.isFinite(Number.parseFloat(`${currentSalesPrice}`));
40
+ // const isCorrectPredictablePrice = Number.isFinite(Number.parseFloat(`${predictablePrice}`));
41
+
42
+ if (!isCorrectCurrentSalesPrice) return 0;
43
+
44
+
45
+ const validatedPredictablePrice = getPredictablePrice(currentSalesPrice, predictablePrice);
46
+
47
+ const ratingConfig = getPriceRatingConfig(currentSalesPrice, validatedPredictablePrice);
48
+
49
+ for (const key of Object.keys(ratingConfig)) {
50
+ const { min, max } = ratingConfig[key];
51
+ const TOP_PRICE = 5;
52
+ if (key === 'highPrice' && currentSalesPrice === max) return 1;
53
+ if (key === 'topPrice' && currentSalesPrice <= min) return TOP_PRICE;
54
+ if (currentSalesPrice >= min && currentSalesPrice < max) return priceRatings.indexOf(key);
55
+ }
56
+
57
+ return 0;
58
+ };
@@ -196,27 +196,100 @@ export const ToggleRow: React.FunctionComponent<{
196
196
  className?: string;
197
197
  /** cash mode: the package is always included — shows a static "inklusive" note instead of the switch. */
198
198
  staticNote?: string;
199
- }> = ({ label, price, checked, disabled, onChange, className, staticNote }) => (
200
- <div className={className} style={{ display: 'flex', alignItems: 'center', justifyContent: staticNote ? undefined : 'space-between', gap: 10 }}>
201
- <span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
202
- {label}
203
- <Icon name="info" size={13} color="var(--md-on-surface-variant)" />
204
- </span>
205
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: staticNote ? 'auto' : undefined }}>
206
- {price && (
207
- <span className="num" style={{ fontWeight: 700, fontSize: 13.5, marginRight: staticNote ? 'auto' : undefined }}>
208
- {price}
209
- </span>
210
- )}
211
- {staticNote ? (
212
- <span style={{ fontFamily: 'Arial', fontWeight: 400, fontSize: 15, lineHeight: '17px', color: '#0E2238' }}>
213
- {staticNote}
214
- </span>
215
- ) : (
216
- <Switch checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} ariaLabel={label} />
199
+ /** when set, the info icon becomes clickable and opens a popover with this body (e.g. the
200
+ * Rundum-Sorglos-Paket contents) — "Mit Schlussrate" rows omit this, keeping their icon inert. */
201
+ tooltip?: React.ReactNode;
202
+ /** aria-label for the popover's close button — required together with `tooltip`. */
203
+ tooltipCloseLabel?: string;
204
+ }> = ({ label, price, checked, disabled, onChange, className, staticNote, tooltip, tooltipCloseLabel }) => {
205
+ const [tooltipOpen, setTooltipOpen] = useState(false);
206
+ return (
207
+ <div
208
+ className={className}
209
+ style={{
210
+ display: 'flex',
211
+ alignItems: 'center',
212
+ justifyContent: staticNote ? undefined : 'space-between',
213
+ gap: 10,
214
+ position: tooltip ? 'relative' : undefined,
215
+ }}
216
+ >
217
+ <span
218
+ className="md-title-sm"
219
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 5, cursor: tooltip ? 'pointer' : undefined }}
220
+ role={tooltip ? 'button' : undefined}
221
+ tabIndex={tooltip ? 0 : undefined}
222
+ onClick={tooltip ? (e) => { e.stopPropagation(); setTooltipOpen((v) => !v); } : undefined}
223
+ >
224
+ {label}
225
+ {tooltip && <Icon name="info" size={13} color="var(--md-on-surface-variant)" />}
226
+ </span>
227
+ {tooltip && tooltipOpen && (
228
+ <div className="pp-uvp-tooltip fin-toggle-tooltip" onClick={(e) => e.stopPropagation()}>
229
+ <button
230
+ type="button"
231
+ className="pp-uvp-tooltip-close fin-toggle-tooltip-close"
232
+ aria-label={tooltipCloseLabel}
233
+ onClick={(e) => {
234
+ e.stopPropagation();
235
+ setTooltipOpen(false);
236
+ }}
237
+ >
238
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
239
+ <path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
240
+ </svg>
241
+ </button>
242
+ <div className="pp-uvp-tooltip-body fin-toggle-tooltip-body">{tooltip}</div>
243
+ </div>
217
244
  )}
245
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: staticNote ? 'auto' : undefined }}>
246
+ {price && (
247
+ <span className="num" style={{ fontWeight: 700, fontSize: 13.5, marginRight: staticNote ? 'auto' : undefined }}>
248
+ {price}
249
+ </span>
250
+ )}
251
+ {staticNote ? (
252
+ <span style={{ fontFamily: 'Arial', fontWeight: 400, fontSize: 15, lineHeight: '17px', color: '#0E2238' }}>
253
+ {staticNote}
254
+ </span>
255
+ ) : (
256
+ <Switch checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} ariaLabel={label} />
257
+ )}
258
+ </div>
218
259
  </div>
219
- </div>
260
+ );
261
+ };
262
+
263
+ /** body content for the "Rundum-Sorglos-Paket" ToggleRow tooltip — intro sentence, bold heading,
264
+ * the package's contents as a bullet list, then (financing/leasing only) a closing paragraph
265
+ * explaining the one-off-vs-financed payment choice. Shared by every addServicesToggle
266
+ * ToggleRow (FinanceCalculator's own card + VdpModules' modal/mobile-sheet instances); intro/
267
+ * outro vary by mode since cash mode has no "financed into the rate" option. */
268
+ export const AddServicesTooltipBody: React.FunctionComponent<{ t: TranslationFunction; isCash?: boolean; isLease?: boolean }> = ({
269
+ t,
270
+ isCash,
271
+ isLease,
272
+ }) => (
273
+ <>
274
+ <p style={{ margin: '0 0 8px' }}>
275
+ {t(isCash ? 'autobahn:finance.addServicesTooltipIntroCash' : 'autobahn:finance.addServicesTooltipIntroFinanced')}
276
+ </p>
277
+ <p style={{ fontFamily: 'Arial', fontWeight: 700, fontSize: 10, lineHeight: '13px', color: '#0E2238', margin: '0 0 4px' }}>
278
+ {t('autobahn:finance.addServicesTooltipHeading')}
279
+ </p>
280
+ <ul style={{ margin: 0, padding: '0 0 0 14px' }}>
281
+ {t('autobahn:finance.addServicesTooltipItems')
282
+ .split('\n')
283
+ .map((item) => (
284
+ <li key={item}>{item}</li>
285
+ ))}
286
+ </ul>
287
+ {!isCash && (
288
+ <p style={{ margin: '8px 0 0' }}>
289
+ {t(isLease ? 'autobahn:finance.addServicesTooltipOutroLeasing' : 'autobahn:finance.addServicesTooltipOutroFinancing')}
290
+ </p>
291
+ )}
292
+ </>
220
293
  );
221
294
 
222
295
  export interface FinanceCalculatorProps {
@@ -278,6 +351,12 @@ export interface FinanceCalculatorProps {
278
351
  phone?: string;
279
352
  /** primary CTA click (Angebot sichern). */
280
353
  onSecure?: () => void;
354
+ /** true once the customer already has an offer request/checkout in progress for this car —
355
+ * turns the primary CTA green (see .fin-cta-secured) instead of the default filled style. */
356
+ secured?: boolean;
357
+ /** overrides the primary CTA label while `secured` is true (e.g. "Zur Bestellung"). Ignored
358
+ * when `secured` is false — the CTA keeps its own default label in that case. */
359
+ secureLabel?: string;
281
360
  /** secondary CTA click (phone). */
282
361
  onCall?: () => void;
283
362
  /** open the Finanzierungs-/Leasingdetails popup (full breakdown + legal, expanded). Omit to hide the link. */
@@ -374,6 +453,8 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
374
453
  available,
375
454
  phone = '0800 40 30 182',
376
455
  onSecure,
456
+ secured,
457
+ secureLabel,
377
458
  onCall,
378
459
  onShowDetails,
379
460
  onOpenAdjust,
@@ -393,10 +474,10 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
393
474
  // price-evaluation tier from the rating label (defaults to 'Guter Preis' tier when unmapped).
394
475
  const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
395
476
  const evalTier = tierIdx >= 0 ? tierIdx : 1;
396
- // both green tiers ('top' and 'good') collapse onto one "Top-Preis" claim (same convention as
397
- // CarCard's PriceEvalCol) — footnote 4 carries the disclaimer text.
398
477
  const isGoodTier = TIER_KEYS[evalTier] === 'good';
399
- const evalTierLabel = isGoodTier ? t('autobahn:tier.top') : t('autobahn:tier.' + TIER_KEYS[evalTier]);
478
+ // each tier shows its own real label now (Top/Guter/Fairer/Erhöht/Hoher Preis) — no more
479
+ // collapsing 'good' onto the "Top-Preis" claim. Pill styling (colour/class/footnote) unchanged.
480
+ const evalTierLabel = t('autobahn:tier.' + TIER_KEYS[evalTier]);
400
481
  const evalPillColor = isGoodTier ? TIER_COLORS[0] : TIER_COLORS[evalTier];
401
482
  // condition summary (current selection) + its own details link — financing: "0 € Anzahlung ·
402
483
  // 60 Monate · Finanzierungsdetails"; leasing: "25.000 € Sonderzahlung · 60 Mon. · 10.000
@@ -456,8 +537,8 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
456
537
  >
457
538
  {k === 'cash' ? (
458
539
  <>
459
- {/* mockup names the cash tab "Kaufen" on phone; desktop keeps "Barzahlung" */}
460
- <span className="hide-mobile">{t(labelKey)}</span>
540
+ {/* cash tab is "Kaufen" on both phone and desktop now — same key both spans. */}
541
+ <span className="hide-mobile">{t('autobahn:card.price.buy')}</span>
461
542
  <span className="show-mobile" style={{ justifyContent: 'center' }}>
462
543
  {t('autobahn:card.price.buy')}
463
544
  </span>
@@ -636,7 +717,10 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
636
717
  3
637
718
  </span>
638
719
  {uvpTooltipOpen && (
639
- <div className="pp-uvp-tooltip" onClick={(e) => e.stopPropagation()}>
720
+ <div
721
+ className={'pp-uvp-tooltip' + (mode === 'leasing' ? ' pp-uvp-tooltip--leasing' : '')}
722
+ onClick={(e) => e.stopPropagation()}
723
+ >
640
724
  <button
641
725
  type="button"
642
726
  className="pp-uvp-tooltip-close"
@@ -737,17 +821,29 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
737
821
  </button>
738
822
  {adjust &&
739
823
  (isCash ? (
740
- <div style={{ marginTop: 4 }}>
741
- <CalcRow label={t('autobahn:finance.paymentLabel')}>{t('autobahn:finance.paymentValue')}</CalcRow>
742
- <CalcRow label={t('autobahn:finance.warrantyLabel')} last={!available}>
743
- {t('autobahn:finance.warrantyValue')}
744
- </CalcRow>
745
- {available && (
746
- <CalcRow label={t('autobahn:finance.availabilityLabel')} last>
747
- {t('autobahn:finance.availabilityValue')}
748
- </CalcRow>
824
+ <>
825
+ <div style={{ marginTop: 4 }}>
826
+ <CalcRow label={t('autobahn:finance.paymentLabel')} last={!available && !(addServicesCost != null && onToggleAddServices)}>{t('autobahn:finance.paymentValue')}</CalcRow>
827
+ {available && (
828
+ <CalcRow label={t('autobahn:finance.availabilityLabel')} last={!(addServicesCost != null && onToggleAddServices)}>
829
+ {t('autobahn:finance.availabilityValue')}
830
+ </CalcRow>
831
+ )}
832
+ </div>
833
+ {addServicesCost != null && onToggleAddServices && (
834
+ <ToggleRow
835
+ className="fin-toggle-addservices fin-toggle-addservices--cash"
836
+ label={t('autobahn:finance.addServicesToggle')}
837
+ price={t('autobahn:finance.addServicesOnce', { value: eur(Math.round(addServicesCost)) })}
838
+ checked={!!withAddServices}
839
+ disabled={addServicesDisabled}
840
+ onChange={onToggleAddServices}
841
+ staticNote={t('autobahn:finance.addServicesIncluded')}
842
+ tooltip={<AddServicesTooltipBody t={t} isCash />}
843
+ tooltipCloseLabel={t('autobahn:modal.close')}
844
+ />
749
845
  )}
750
- </div>
846
+ </>
751
847
  ) : (
752
848
  <div style={{ display: 'flex', flexDirection: 'column', marginTop: 8 }}>
753
849
  <div style={{ marginBottom: 15 }}>
@@ -808,6 +904,8 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
808
904
  checked={!!withAddServices}
809
905
  disabled={addServicesDisabled}
810
906
  onChange={onToggleAddServices}
907
+ tooltip={<AddServicesTooltipBody t={t} isLease={isLease} />}
908
+ tooltipCloseLabel={t('autobahn:modal.close')}
811
909
  />
812
910
  )}
813
911
  </div>
@@ -815,8 +913,16 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
815
913
  </div>
816
914
 
817
915
  {/* REQUEST CTA */}
818
- <Button variant="filled" block size="lg" icon="shield-check" style={{ marginTop: 14 }} onClick={onSecure}>
819
- {t('autobahn:finance.secureOffer')}
916
+ <Button
917
+ variant="filled"
918
+ block
919
+ size="lg"
920
+ icon="shield-check"
921
+ className={secured ? 'fin-cta-secured' : undefined}
922
+ style={{ marginTop: 14 }}
923
+ onClick={onSecure}
924
+ >
925
+ {secured && secureLabel ? secureLabel : t('autobahn:finance.secureOffer')}
820
926
  </Button>
821
927
  {/* phone CTA — touch only (hidden on desktop via .fin-call) */}
822
928
  <div className="fin-call">
@@ -848,11 +954,12 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
848
954
  <Button
849
955
  variant="filled"
850
956
  size="lg"
957
+ className={secured ? 'fin-cta-secured' : undefined}
851
958
  style={{ width: 216, height: 50, paddingLeft: 0, paddingRight: 9, gap: 18 }}
852
959
  onClick={onSecure}
853
960
  >
854
- <DocCheckIcon size={23} />
855
- {t('autobahn:finance.secureOffer')}
961
+ {secured ? <Icon name="shield-check" size={18} /> : <DocCheckIcon size={23} />}
962
+ {secured && secureLabel ? secureLabel : t('autobahn:finance.secureOffer')}
856
963
  </Button>
857
964
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
858
965
  <Button variant="outlined" size="lg" icon="phone" style={{ width: 112, height: 50 }} onClick={onCall}>