@bytebrand/fe-ui-core-autobahn 1.0.59 → 1.0.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cards/CarCard.tsx +55 -19
- package/cards/CarRow.tsx +66 -16
- package/cards/cardParts.tsx +303 -31
- package/cards/index.ts +4 -0
- package/cards/types.ts +7 -0
- package/constants.ts +31 -31
- package/data/toAutobahnCar.ts +13 -3
- package/locales/de/autobahn.json +34 -20
- package/locales/en/autobahn.json +33 -19
- package/package.json +1 -1
- package/theme/md3.css +557 -51
- package/utils/financeFormat.ts +62 -62
- package/utils/priceRating.ts +58 -58
- package/vdp/FinanceCalculator.tsx +127 -96
- package/vdp/VdpModules.tsx +219 -44
package/utils/financeFormat.ts
CHANGED
|
@@ -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
|
+
}
|
package/utils/priceRating.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -329,24 +329,35 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
329
329
|
}) => {
|
|
330
330
|
// Pure view state only — whether the adjust/details expander is open.
|
|
331
331
|
const [adjust, setAdjust] = useState(false);
|
|
332
|
+
// whether the "UVP-Rate" footnote popover (fin/leasing) is open — same widget as CarCard's.
|
|
333
|
+
const [uvpTooltipOpen, setUvpTooltipOpen] = useState(false);
|
|
332
334
|
const isCash = mode === 'cash';
|
|
333
335
|
const terms = termOptions && termOptions.length ? termOptions : range(termMin, termMax, termStep);
|
|
334
336
|
const showStrike = listPrice != null && listPrice > price;
|
|
335
337
|
// price-evaluation tier from the rating label (defaults to 'Guter Preis' tier when unmapped).
|
|
336
338
|
const tierIdx = TIER_LABELS.indexOf(ratingLabel as (typeof TIER_LABELS)[number]);
|
|
337
339
|
const evalTier = tierIdx >= 0 ? tierIdx : 1;
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
+
// both green tiers ('top' and 'good') collapse onto one "Top-Preis" claim (same convention as
|
|
341
|
+
// CarCard's PriceEvalCol) — footnote 4 carries the disclaimer text.
|
|
342
|
+
const isGoodTier = TIER_KEYS[evalTier] === 'good';
|
|
343
|
+
const evalTierLabel = isGoodTier ? t('autobahn:tier.top') : t('autobahn:tier.' + TIER_KEYS[evalTier]);
|
|
344
|
+
const evalPillColor = isGoodTier ? TIER_COLORS[0] : TIER_COLORS[evalTier];
|
|
345
|
+
// condition summary (current selection) + its own details link — financing: "0 € Anzahlung ·
|
|
346
|
+
// 60 Monate · Finanzierungsdetails"; leasing: "25.000 € Sonderzahlung · 60 Mon. · 10.000
|
|
347
|
+
// km/Jahr · Details" (same wording/order as CarCard's own PriceTerms leaseTerms).
|
|
340
348
|
const condParts: string[] = [];
|
|
341
|
-
if (
|
|
349
|
+
if (mode === 'leasing') {
|
|
342
350
|
condParts.push(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
:
|
|
351
|
+
...t('autobahn:card.price.leaseTerms', {
|
|
352
|
+
firstInstallment: eur(down),
|
|
353
|
+
months: term,
|
|
354
|
+
mileage: annualMileage != null ? eur(annualMileage) : '',
|
|
355
|
+
}).split(' · '),
|
|
346
356
|
);
|
|
357
|
+
} else if (!isCash) {
|
|
358
|
+
condParts.push(t('autobahn:finance.downPaymentLabel', { value: eur(down) }));
|
|
347
359
|
condParts.push(t('autobahn:finance.monthsLabel', { months: term, count: term }));
|
|
348
360
|
}
|
|
349
|
-
const condLine = condParts.length ? `${t('autobahn:finance.exampleLabel')} ${condParts.join(' · ')}` : '';
|
|
350
361
|
|
|
351
362
|
return (
|
|
352
363
|
<div className="mcard mcard--elevated fin-calc-card" style={{ padding: 20 }}>
|
|
@@ -410,10 +421,48 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
410
421
|
{/* mobile Kaufen (Figma round 14): struck list price ABOVE the price, right-aligned,
|
|
411
422
|
tight to the figure — replaces the Festpreis + UVP lines, which stay desktop-only */}
|
|
412
423
|
{isCash && showStrike && (
|
|
413
|
-
<div className="show-mobile" style={{ justifyContent: 'flex-end', lineHeight: 1, marginTop: 7 }}>
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
424
|
+
<div className="show-mobile" style={{ justifyContent: 'flex-end', alignItems: 'baseline', gap: 4, lineHeight: 1, marginTop: 7 }}>
|
|
425
|
+
<span style={{ fontSize: 13, color: 'var(--md-on-surface-variant)' }}>{t('autobahn:finance.uvp')}</span>
|
|
426
|
+
<span
|
|
427
|
+
className={`num pp-hero-old${
|
|
428
|
+
String(Math.round(listPrice as number)).length === 5
|
|
429
|
+
? ' pp-hero-old--len5'
|
|
430
|
+
: String(Math.round(listPrice as number)).length === 4
|
|
431
|
+
? ' pp-hero-old--len4'
|
|
432
|
+
: ''
|
|
433
|
+
}`}
|
|
434
|
+
>
|
|
435
|
+
{eur(Math.round(listPrice as number))} €
|
|
436
|
+
<span
|
|
437
|
+
className="pp-eyebrow-badge"
|
|
438
|
+
role="button"
|
|
439
|
+
tabIndex={0}
|
|
440
|
+
onClick={(e) => {
|
|
441
|
+
e.stopPropagation();
|
|
442
|
+
setUvpTooltipOpen((v) => !v);
|
|
443
|
+
}}
|
|
444
|
+
>
|
|
445
|
+
2
|
|
446
|
+
</span>
|
|
447
|
+
{uvpTooltipOpen && (
|
|
448
|
+
<div className="pp-uvp-tooltip" onClick={(e) => e.stopPropagation()}>
|
|
449
|
+
<button
|
|
450
|
+
type="button"
|
|
451
|
+
className="pp-uvp-tooltip-close"
|
|
452
|
+
aria-label={t('autobahn:modal.close')}
|
|
453
|
+
onClick={(e) => {
|
|
454
|
+
e.stopPropagation();
|
|
455
|
+
setUvpTooltipOpen(false);
|
|
456
|
+
}}
|
|
457
|
+
>
|
|
458
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
459
|
+
<path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
|
|
460
|
+
</svg>
|
|
461
|
+
</button>
|
|
462
|
+
<div className="pp-uvp-tooltip-body">{t('autobahn:card.price.uvpListTooltipBody')}</div>
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
</span>
|
|
417
466
|
</div>
|
|
418
467
|
)}
|
|
419
468
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
|
@@ -463,7 +512,44 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
463
512
|
{/* struck old rate (REAL oldMonthlyInstallment; SRP strikes the same source) */}
|
|
464
513
|
{oldMonthly != null && (
|
|
465
514
|
<>
|
|
466
|
-
<span className="
|
|
515
|
+
<span className="pp-eyebrow">
|
|
516
|
+
{t('autobahn:card.price.uvpRateLabel')}
|
|
517
|
+
<span
|
|
518
|
+
className="pp-eyebrow-badge"
|
|
519
|
+
role="button"
|
|
520
|
+
tabIndex={0}
|
|
521
|
+
onClick={(e) => {
|
|
522
|
+
e.stopPropagation();
|
|
523
|
+
setUvpTooltipOpen((v) => !v);
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
3
|
|
527
|
+
</span>
|
|
528
|
+
{uvpTooltipOpen && (
|
|
529
|
+
<div className="pp-uvp-tooltip" onClick={(e) => e.stopPropagation()}>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
className="pp-uvp-tooltip-close"
|
|
533
|
+
aria-label={t('autobahn:modal.close')}
|
|
534
|
+
onClick={(e) => {
|
|
535
|
+
e.stopPropagation();
|
|
536
|
+
setUvpTooltipOpen(false);
|
|
537
|
+
}}
|
|
538
|
+
>
|
|
539
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
540
|
+
<path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
|
|
541
|
+
</svg>
|
|
542
|
+
</button>
|
|
543
|
+
<div className="pp-uvp-tooltip-body">
|
|
544
|
+
{t(
|
|
545
|
+
mode === 'leasing'
|
|
546
|
+
? 'autobahn:card.price.uvpRateTooltipBodyLeasing'
|
|
547
|
+
: 'autobahn:card.price.uvpRateTooltipBody',
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
</span>
|
|
467
553
|
<s
|
|
468
554
|
className={`num fin-rate-old fin-rate-example-price${
|
|
469
555
|
String(Math.round(oldMonthly)).length >= 5
|
|
@@ -473,12 +559,12 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
473
559
|
: ''
|
|
474
560
|
}`}
|
|
475
561
|
>
|
|
476
|
-
{eur(Math.round(oldMonthly))}
|
|
562
|
+
{eur(Math.round(oldMonthly))} €
|
|
477
563
|
</s>
|
|
478
564
|
</>
|
|
479
565
|
)}
|
|
480
566
|
<span className="fin-rate-example-suffix">
|
|
481
|
-
{t('autobahn:finance.perMonth')}
|
|
567
|
+
{t('autobahn:finance.perMonth')}
|
|
482
568
|
</span>
|
|
483
569
|
</span>
|
|
484
570
|
</>
|
|
@@ -499,19 +585,38 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
499
585
|
<div className="fin-eval-pill-cell">
|
|
500
586
|
{ratingLabel && (
|
|
501
587
|
<span
|
|
502
|
-
className={`fin-eval-pill${
|
|
503
|
-
style={{ color:
|
|
588
|
+
className={`fin-eval-pill${isGoodTier ? ' fin-eval-pill--good' : ''}`}
|
|
589
|
+
style={{ color: evalPillColor, background: `color-mix(in srgb, ${evalPillColor} 16%, transparent)` }}
|
|
504
590
|
aria-label={t('autobahn:finance.priceRatingAria', { rating: evalTierLabel })}
|
|
505
591
|
>
|
|
506
592
|
{evalTierLabel}
|
|
593
|
+
{isGoodTier && <sup>4</sup>}
|
|
507
594
|
</span>
|
|
508
595
|
)}
|
|
509
596
|
</div>
|
|
510
597
|
{/* segmented evaluation bar (Figma: discrete tiers, not a gradient) */}
|
|
511
598
|
<div className="fin-eval-bar-cell">{ratingLabel && <EvalBar tier={evalTier} />}</div>
|
|
512
599
|
</div>
|
|
513
|
-
{/* col 1 row 2: condition line
|
|
514
|
-
|
|
600
|
+
{/* col 1 row 2: condition line — financing "0 € Anzahlung · 60 Monate · Finanzierungsdetails",
|
|
601
|
+
leasing "25.000 € Sonderzahlung · 60 Mon. · 10.000 km/Jahr · Details" */}
|
|
602
|
+
<div className="fin-cond">
|
|
603
|
+
{!isCash &&
|
|
604
|
+
condParts.map((part, i) => (
|
|
605
|
+
<React.Fragment key={i}>
|
|
606
|
+
{i > 0 && <span className="cc-terms-dot"> · </span>}
|
|
607
|
+
{part}
|
|
608
|
+
</React.Fragment>
|
|
609
|
+
))}
|
|
610
|
+
{!isCash && onShowDetails && (
|
|
611
|
+
<>
|
|
612
|
+
<span className="cc-terms-dot"> · </span>
|
|
613
|
+
{/* plain underlined text link, same onShowDetails handler as .fin-info used to open. */}
|
|
614
|
+
<button type="button" className="cc-terms-link" onClick={onShowDetails}>
|
|
615
|
+
{mode === 'leasing' ? t('autobahn:card.price.detailsLink') : t('autobahn:finance.financingDetails')}
|
|
616
|
+
</button>
|
|
617
|
+
</>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
515
620
|
</div>
|
|
516
621
|
|
|
517
622
|
{/* ============================== DESKTOP (≥601px) — unchanged ============================== */}
|
|
@@ -642,84 +747,10 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
642
747
|
</div>
|
|
643
748
|
</div>
|
|
644
749
|
|
|
645
|
-
{/* Persönliche Beratung (
|
|
646
|
-
<div className="fin-info">
|
|
647
|
-
<
|
|
648
|
-
|
|
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
|
-
)}
|
|
750
|
+
{/* Persönliche Beratung — icon + one line (the details link now lives in .fin-cond above) */}
|
|
751
|
+
<div className="fin-info" style={{ justifyContent: 'flex-start', paddingLeft: 15 }}>
|
|
752
|
+
<Icon name="user" size={26} color="var(--md-on-surface-variant)" style={{ flex: 'none', marginRight: 20 }} />
|
|
753
|
+
<span className="fin-info-line">{t('autobahn:finance.personalConsultationLine')}</span>
|
|
723
754
|
</div>
|
|
724
755
|
|
|
725
756
|
{/* "Rate anpassen" / "Details anzeigen" — opens the full bottom sheet (not an inline expander) */}
|