@bytebrand/fe-ui-core-autobahn 1.0.51 → 1.0.54
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 +101 -77
- package/cards/CarRow.tsx +50 -24
- package/cards/cardParts.tsx +204 -33
- package/cards/index.ts +13 -0
- package/cards/types.d.ts +112 -0
- package/cards/types.js +14 -0
- package/cards/types.ts +8 -0
- package/constants/autobahn.d.ts +22 -0
- package/constants/autobahn.js +57 -0
- package/constants/autobahn.ts +68 -0
- package/constants/core.d.ts +152 -0
- package/constants/core.js +125 -0
- package/constants/core.ts +1 -1
- package/constants/price.d.ts +2 -0
- package/constants/price.js +11 -0
- package/constants/selectors.d.ts +11 -0
- package/constants/selectors.js +32 -0
- package/constants.ts +31 -91
- package/data/toAutobahnCar.d.ts +89 -0
- package/data/toAutobahnCar.js +421 -0
- package/data/toAutobahnCar.ts +27 -6
- package/locales/de/autobahn.json +3 -0
- package/locales/en/autobahn.json +3 -0
- package/package.json +5 -2
- package/theme/md3.css +25 -1
- package/tsconfig.node-safe.json +25 -0
- package/utils/CommonUtils.ts +5 -48
- package/utils/DateUtils.d.ts +21 -0
- package/utils/DateUtils.js +97 -0
- package/utils/DateUtils.ts +1 -1
- package/utils/financeFormat.d.ts +32 -0
- package/utils/financeFormat.js +42 -0
- package/utils/financeFormat.ts +62 -0
- package/utils/index.ts +11 -1
- package/utils/power.d.ts +1 -0
- package/utils/power.js +14 -0
- package/utils/priceRating.d.ts +3 -0
- package/utils/priceRating.js +55 -0
- package/utils/priceRating.ts +57 -0
- package/vdp/VdpModules.tsx +16 -14
package/cards/CarCard.tsx
CHANGED
|
@@ -15,11 +15,27 @@
|
|
|
15
15
|
// - the needs-data deal signals (DropChip / SaveChip) only appear when real
|
|
16
16
|
// `drop` / `savings` data is present.
|
|
17
17
|
|
|
18
|
-
import React from 'react';
|
|
18
|
+
import React, { useState } from 'react';
|
|
19
19
|
import { TranslationFunction } from '../types';
|
|
20
20
|
|
|
21
21
|
import { CardCarousel, IconButton, StatChip, SaveChip, Button } from '../ui';
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
SpecLine,
|
|
24
|
+
EcoLine,
|
|
25
|
+
DropChip,
|
|
26
|
+
PercentChip,
|
|
27
|
+
ZeroDownChip,
|
|
28
|
+
OfferBadge,
|
|
29
|
+
PriceTabs,
|
|
30
|
+
PriceHero,
|
|
31
|
+
PriceMeter,
|
|
32
|
+
CardCtaRow,
|
|
33
|
+
GalleryCounter,
|
|
34
|
+
cardSpec,
|
|
35
|
+
PaymentMode,
|
|
36
|
+
ratingTone,
|
|
37
|
+
callDealerSalesPhone,
|
|
38
|
+
} from './cardParts';
|
|
23
39
|
import { AutobahnCar } from './types';
|
|
24
40
|
|
|
25
41
|
export interface CarCardProps {
|
|
@@ -40,18 +56,50 @@ export interface CarCardProps {
|
|
|
40
56
|
* Rendered as a quiet button at the card foot; the page decides visibility (JWT claim).
|
|
41
57
|
*/
|
|
42
58
|
adminAction?: { label: string; onClick: () => void };
|
|
59
|
+
/**
|
|
60
|
+
* Mobile CTA row (Figma: Angebot anfragen / phone / Rate anpassen) — omitted entirely when
|
|
61
|
+
* the page hasn't wired these yet (same optional pattern as adminAction), so pages migrate
|
|
62
|
+
* one at a time instead of all breaking at once.
|
|
63
|
+
*/
|
|
64
|
+
onRequestOffer?: () => void;
|
|
65
|
+
onCall?: () => void;
|
|
66
|
+
/**
|
|
67
|
+
* opens the shared FinanceDetailsModal "Rate anpassen" sheet for this car (page-owned) —
|
|
68
|
+
* receives the tab the card was showing (Finanzierung/Leasing/Kaufen) so the sheet opens on
|
|
69
|
+
* the same one.
|
|
70
|
+
*/
|
|
71
|
+
onOpenRateAdjust?: (mode: PaymentMode) => void;
|
|
43
72
|
}
|
|
44
73
|
|
|
45
|
-
const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
74
|
+
const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
75
|
+
car,
|
|
76
|
+
isFavorite,
|
|
77
|
+
onToggleFavorite,
|
|
78
|
+
onOpen,
|
|
79
|
+
onMediaEnter,
|
|
80
|
+
t,
|
|
81
|
+
adminAction,
|
|
82
|
+
onRequestOffer,
|
|
83
|
+
onCall,
|
|
84
|
+
onOpenRateAdjust,
|
|
85
|
+
}) => {
|
|
86
|
+
const [mode, setMode] = useState<PaymentMode>('financing');
|
|
87
|
+
const [photoIndex, setPhotoIndex] = useState(0);
|
|
46
88
|
const showSave = car.savings != null && car.savings > 600;
|
|
89
|
+
const showOfferBadge = !!(car.isNew || car.drop != null || showSave || car.dropPct != null || car.zeroDown);
|
|
47
90
|
return (
|
|
48
91
|
<article
|
|
49
92
|
className="mcard mcard--interactive cc-card"
|
|
50
93
|
onClick={() => onOpen(car)}
|
|
51
94
|
style={{ display: 'flex', flexDirection: 'column' }}
|
|
52
95
|
>
|
|
53
|
-
<div
|
|
54
|
-
|
|
96
|
+
<div
|
|
97
|
+
className="cc-media"
|
|
98
|
+
style={{ position: 'relative' }}
|
|
99
|
+
onMouseEnter={onMediaEnter}
|
|
100
|
+
onTouchStart={onMediaEnter}
|
|
101
|
+
>
|
|
102
|
+
<CardCarousel images={car.gallery} height="100%" fit="contain" onChange={setPhotoIndex} />
|
|
55
103
|
{/* max two overlays: heart + ONE deal signal — rating moved next to the price */}
|
|
56
104
|
<div
|
|
57
105
|
style={{ position: 'absolute', top: 6, right: 6, zIndex: 3 }}
|
|
@@ -59,18 +107,36 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({ car, isFavorite, onTog
|
|
|
59
107
|
>
|
|
60
108
|
<IconButton name="heart" filled active={isFavorite} onClick={onToggleFavorite} label={t('autobahn:card.action.save')} />
|
|
61
109
|
</div>
|
|
62
|
-
{/* top-left signal stack (heart is top-right): NEU badge + ONE deal signal
|
|
63
|
-
|
|
64
|
-
|
|
110
|
+
{/* top-left signal stack (heart is top-right): NEU badge + ONE deal signal + the
|
|
111
|
+
"⚡ Angebot" badge (own line, Figma) below it. DropChip takes precedence over
|
|
112
|
+
SaveChip; each only renders when its real data exists. */}
|
|
113
|
+
{(showOfferBadge || car.dropPct != null) && (
|
|
65
114
|
<div
|
|
66
|
-
style={{
|
|
115
|
+
style={{
|
|
116
|
+
position: 'absolute',
|
|
117
|
+
top: 10,
|
|
118
|
+
left: 10,
|
|
119
|
+
zIndex: 3,
|
|
120
|
+
display: 'flex',
|
|
121
|
+
flexDirection: 'column',
|
|
122
|
+
alignItems: 'flex-start',
|
|
123
|
+
gap: 5,
|
|
124
|
+
maxWidth: 'calc(100% - 52px)',
|
|
125
|
+
}}
|
|
67
126
|
>
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
127
|
+
<div style={{ display: 'flex', flexWrap: 'nowrap', gap: 5, alignItems: 'center' }}>
|
|
128
|
+
{car.isNew && <span className="neu-badge">NEU</span>}
|
|
129
|
+
{car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
|
|
130
|
+
<ZeroDownChip t={t} show={car.zeroDown} />
|
|
131
|
+
<PercentChip pct={car.dropPct} />
|
|
132
|
+
</div>
|
|
133
|
+
<OfferBadge show={showOfferBadge} t={t} />
|
|
72
134
|
</div>
|
|
73
135
|
)}
|
|
136
|
+
{/* bottom-left: expand icon + "current|total" photo counter (Figma) */}
|
|
137
|
+
<div style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}>
|
|
138
|
+
<GalleryCounter current={photoIndex + 1} total={car.photos} />
|
|
139
|
+
</div>
|
|
74
140
|
</div>
|
|
75
141
|
<div style={{ padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 9, flex: 1 }}>
|
|
76
142
|
<div>
|
|
@@ -83,75 +149,33 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({ car, isFavorite, onTog
|
|
|
83
149
|
</div>
|
|
84
150
|
<SpecLine items={cardSpec(car)} oneLine />
|
|
85
151
|
<div style={{ flex: 1 }} />
|
|
86
|
-
<div style={{ marginTop: 4 }}>
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
95
|
-
<div style={{ minWidth: 0 }}>
|
|
96
|
-
<div
|
|
97
|
-
className="md-label-sm"
|
|
98
|
-
style={{
|
|
99
|
-
textTransform: 'uppercase',
|
|
100
|
-
letterSpacing: '.5px',
|
|
101
|
-
fontWeight: 700,
|
|
102
|
-
fontSize: 10.5,
|
|
103
|
-
color: 'var(--md-on-surface-variant)',
|
|
104
|
-
lineHeight: 1.4,
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
107
|
-
{rateLabel}
|
|
108
|
-
</div>
|
|
109
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 5 }}>
|
|
110
|
-
<span
|
|
111
|
-
className="num"
|
|
112
|
-
style={{
|
|
113
|
-
fontWeight: 700,
|
|
114
|
-
fontSize: 26,
|
|
115
|
-
color: 'var(--md-primary)',
|
|
116
|
-
letterSpacing: '-.6px',
|
|
117
|
-
whiteSpace: 'nowrap',
|
|
118
|
-
}}
|
|
119
|
-
>
|
|
120
|
-
{fmtRate(rate)} €
|
|
121
|
-
</span>
|
|
122
|
-
<span className="md-label-sm">{t('autobahn:card.price.perMonth')}</span>
|
|
123
|
-
</div>
|
|
124
|
-
</div>
|
|
125
|
-
{car.rating && (
|
|
126
|
-
<StatChip
|
|
127
|
-
tone={car.rating === 'fair' ? 'warn' : 'good'}
|
|
128
|
-
icon={car.rating === 'fair' ? 'minus' : 'check'}
|
|
129
|
-
>
|
|
130
|
-
{t('autobahn:tier.' + car.rating)}
|
|
131
|
-
</StatChip>
|
|
132
|
-
)}
|
|
133
|
-
</div>
|
|
134
|
-
);
|
|
135
|
-
})()}
|
|
136
|
-
<div className="md-label-sm" style={{ marginTop: 2, display: 'flex', flexWrap: 'wrap', gap: '0 6px' }}>
|
|
137
|
-
<span style={{ whiteSpace: 'nowrap' }}>
|
|
138
|
-
{t('autobahn:card.price.purchasePrice')}{' '}
|
|
139
|
-
<b style={{ color: 'var(--md-on-surface)' }}>{car.price.toLocaleString('de-DE')} €</b>
|
|
140
|
-
</span>
|
|
141
|
-
{showSave && car.listPrice != null && (
|
|
142
|
-
<span
|
|
143
|
-
style={{
|
|
144
|
-
color: 'var(--md-on-surface-variant)',
|
|
145
|
-
textDecoration: 'line-through',
|
|
146
|
-
whiteSpace: 'nowrap',
|
|
147
|
-
}}
|
|
152
|
+
<div className="cc-price" style={{ marginTop: 4, borderTop: '1px solid var(--md-outline-variant)', paddingTop: 10 }}>
|
|
153
|
+
<PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
|
|
154
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
|
|
155
|
+
<PriceHero car={car} mode={mode} t={t} />
|
|
156
|
+
{car.rating && (
|
|
157
|
+
<StatChip
|
|
158
|
+
tone={ratingTone(car.rating)}
|
|
159
|
+
icon={ratingTone(car.rating) === 'good' ? 'check' : 'minus'}
|
|
148
160
|
>
|
|
149
|
-
{
|
|
150
|
-
</
|
|
161
|
+
{t('autobahn:tier.' + car.rating)}
|
|
162
|
+
</StatChip>
|
|
151
163
|
)}
|
|
152
164
|
</div>
|
|
165
|
+
<PriceMeter car={car} t={t} />
|
|
153
166
|
</div>
|
|
154
167
|
<EcoLine t={t} car={car} style={{ marginTop: 8 }} />
|
|
168
|
+
{/* Mobile-only row (CardCtaRow itself carries the show-mobile class) — always
|
|
169
|
+
rendered, with real fallbacks the page can override once it wires its own
|
|
170
|
+
handlers (e.g. the "Rate anpassen" FinanceDetailsModal sheet). Never hidden. */}
|
|
171
|
+
<CardCtaRow
|
|
172
|
+
car={car}
|
|
173
|
+
mode={mode}
|
|
174
|
+
onRequestOffer={onRequestOffer || (() => onOpen(car))}
|
|
175
|
+
onCall={onCall || callDealerSalesPhone}
|
|
176
|
+
onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
|
|
177
|
+
t={t}
|
|
178
|
+
/>
|
|
155
179
|
{adminAction && (
|
|
156
180
|
<div style={{ marginTop: 8 }}>
|
|
157
181
|
<Button
|
package/cards/CarRow.tsx
CHANGED
|
@@ -18,11 +18,26 @@
|
|
|
18
18
|
// no className prop) so the scoped `.pp-cta` margin / responsive rules and the
|
|
19
19
|
// `.pp-cta + .pp-activity` adjacency in md3.css still apply.
|
|
20
20
|
|
|
21
|
-
import React from 'react';
|
|
21
|
+
import React, { useState } from 'react';
|
|
22
22
|
import { TranslationFunction } from '../types';
|
|
23
23
|
|
|
24
|
-
import { CardCarousel, IconButton, SaveChip, Button, Icon } from '../ui';
|
|
25
|
-
import {
|
|
24
|
+
import { CardCarousel, IconButton, SaveChip, Button, Icon, StatChip } from '../ui';
|
|
25
|
+
import {
|
|
26
|
+
EcoLine,
|
|
27
|
+
PriceMeter,
|
|
28
|
+
PpActivity,
|
|
29
|
+
GalleryBadges,
|
|
30
|
+
GalleryCounter,
|
|
31
|
+
OfferBadge,
|
|
32
|
+
PriceTabs,
|
|
33
|
+
PriceHero,
|
|
34
|
+
DropChip,
|
|
35
|
+
PercentChip,
|
|
36
|
+
ZeroDownChip,
|
|
37
|
+
fmtKm,
|
|
38
|
+
PaymentMode,
|
|
39
|
+
ratingTone,
|
|
40
|
+
} from './cardParts';
|
|
26
41
|
import { AutobahnCar } from './types';
|
|
27
42
|
|
|
28
43
|
export interface CarRowProps {
|
|
@@ -46,7 +61,10 @@ export interface CarRowProps {
|
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggleFavorite, onOpen, onMediaEnter, t, adminAction }) => {
|
|
64
|
+
const [mode, setMode] = useState<PaymentMode>('financing');
|
|
65
|
+
const [photoIndex, setPhotoIndex] = useState(0);
|
|
49
66
|
const showSave = car.savings != null && car.savings > 600;
|
|
67
|
+
const showOfferBadge = !!(car.isNew || car.drop != null || showSave || car.dropPct != null || car.zeroDown);
|
|
50
68
|
const facts: Array<[icon: string, value: string]> = [
|
|
51
69
|
['gauge', fmtKm(car.km)],
|
|
52
70
|
['calendar', t('autobahn:card.specs.firstRegistration', { reg: car.reg })],
|
|
@@ -66,9 +84,10 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggl
|
|
|
66
84
|
style={{ background: 'var(--md-sc-lowest)' }}
|
|
67
85
|
>
|
|
68
86
|
<div className="listcard-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
|
|
69
|
-
<CardCarousel images={car.gallery} height="100%" fit="contain" />
|
|
70
|
-
{/* deal badges — ONE compact line: NEU · −€ · 0 € Anz. ·
|
|
71
|
-
('Guter Preis') is NOT here — it
|
|
87
|
+
<CardCarousel images={car.gallery} height="100%" fit="contain" onChange={setPhotoIndex} />
|
|
88
|
+
{/* deal badges — ONE compact line: NEU · −€ · 0 € Anz. · −%, plus the "⚡ Angebot" badge
|
|
89
|
+
(Figma) on its own line below. The price RATING ('Guter Preis') is NOT here — it
|
|
90
|
+
lives in the price column (PriceMeter). */}
|
|
72
91
|
<div
|
|
73
92
|
style={{
|
|
74
93
|
position: 'absolute',
|
|
@@ -77,22 +96,23 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggl
|
|
|
77
96
|
right: 10,
|
|
78
97
|
zIndex: 3,
|
|
79
98
|
display: 'flex',
|
|
80
|
-
flexDirection: '
|
|
81
|
-
|
|
82
|
-
alignItems: 'center',
|
|
99
|
+
flexDirection: 'column',
|
|
100
|
+
alignItems: 'flex-start',
|
|
83
101
|
gap: 5,
|
|
84
102
|
}}
|
|
85
103
|
>
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
<div style={{ display: 'flex', flexWrap: 'nowrap', alignItems: 'center', gap: 5 }}>
|
|
105
|
+
{car.isNew && <span className="neu-badge">NEU</span>}
|
|
106
|
+
{car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
|
|
107
|
+
<ZeroDownChip t={t} show={car.zeroDown} compact />
|
|
108
|
+
<PercentChip pct={car.dropPct} />
|
|
109
|
+
</div>
|
|
110
|
+
<OfferBadge show={showOfferBadge} t={t} />
|
|
90
111
|
</div>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
</span>
|
|
112
|
+
{/* bottom-left: expand icon + "current|total" (Figma) — replaces the old plain photo-count
|
|
113
|
+
pill; GalleryBadges (eye/heart/trust) keeps its own spot bottom-right, unchanged. */}
|
|
114
|
+
<div style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}>
|
|
115
|
+
<GalleryCounter current={photoIndex + 1} total={car.photos} />
|
|
96
116
|
</div>
|
|
97
117
|
<div
|
|
98
118
|
onClick={(e) => e.stopPropagation()}
|
|
@@ -140,14 +160,20 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggl
|
|
|
140
160
|
<EcoLine t={t} car={car} />
|
|
141
161
|
</div>
|
|
142
162
|
|
|
143
|
-
{/* price column
|
|
144
|
-
|
|
145
|
-
the bottom (margin-top:auto)
|
|
146
|
-
foot with no trailing gap. */}
|
|
163
|
+
{/* price column: Finanzierung/Leasing/Kaufen tabs pick ONE hero price (same tabbed
|
|
164
|
+
component the grid CarCard uses), then the fixed Preisbewertung meter and the CTA,
|
|
165
|
+
anchored to the bottom (margin-top:auto) so the button sits flush at the foot. */}
|
|
147
166
|
<div className="listcard-price">
|
|
148
|
-
<
|
|
167
|
+
<PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
|
|
149
168
|
<div className="pp-purchase">
|
|
150
|
-
<
|
|
169
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
170
|
+
<PriceHero car={car} mode={mode} t={t} />
|
|
171
|
+
{car.rating && (
|
|
172
|
+
<StatChip tone={ratingTone(car.rating)} icon={ratingTone(car.rating) === 'good' ? 'check' : 'minus'}>
|
|
173
|
+
{t('autobahn:tier.' + car.rating)}
|
|
174
|
+
</StatChip>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
151
177
|
<PriceMeter t={t} car={car} />
|
|
152
178
|
<div className="pp-cta">
|
|
153
179
|
<Button
|
package/cards/cardParts.tsx
CHANGED
|
@@ -18,9 +18,12 @@ import React from 'react';
|
|
|
18
18
|
import { TranslationFunction } from '../types';
|
|
19
19
|
import { defaultConfig } from '@bytebrand/fe-financing';
|
|
20
20
|
|
|
21
|
-
import { Icon } from '../ui';
|
|
22
|
-
import {
|
|
21
|
+
import { Icon, Button } from '../ui';
|
|
22
|
+
import { DocCheckIcon } from '../vdp/FinanceCalculator';
|
|
23
|
+
import { CO2_CLASSES, CO2_COLORS } from '../constants';
|
|
23
24
|
import { AutobahnCar } from './types';
|
|
25
|
+
// pure, non-React — shared with the Meta AIA marketing-image renderer (a plain Node service).
|
|
26
|
+
export { ratingTone } from '../data/toAutobahnCar';
|
|
24
27
|
|
|
25
28
|
// Default financing/leasing terms shown under the rate (the SRP card has no per-car selector, so
|
|
26
29
|
// these are the same default config the displayed rate is computed with — consistent, not invented).
|
|
@@ -244,6 +247,22 @@ export const ZeroDownChip: React.FunctionComponent<{ show?: boolean; compact?: b
|
|
|
244
247
|
);
|
|
245
248
|
};
|
|
246
249
|
|
|
250
|
+
/**
|
|
251
|
+
* "⚡ Angebot" gray badge — NOT a separate backend flag (none exists yet). Shown whenever the
|
|
252
|
+
* card already carries a real deal signal (NEU / price drop / meaningful savings / 0€ Anzahlung
|
|
253
|
+
* / percent drop) — i.e. it labels the same real discount the other badges point at, rather
|
|
254
|
+
* than inventing a new "is this an offer" field. Revisit if/when a dedicated flag exists.
|
|
255
|
+
*/
|
|
256
|
+
export const OfferBadge: React.FunctionComponent<{ show: boolean; t: TranslationFunction }> = ({ show, t }) => {
|
|
257
|
+
if (!show) return null;
|
|
258
|
+
return (
|
|
259
|
+
<span className="offer-badge">
|
|
260
|
+
<Icon name="zap" size={13} stroke={2.2} />
|
|
261
|
+
{t('autobahn:card.badge.offer')}
|
|
262
|
+
</span>
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
247
266
|
interface InlineClsProps {
|
|
248
267
|
/** CO₂ class letter, e.g. 'B'. */
|
|
249
268
|
c: string;
|
|
@@ -328,52 +347,204 @@ export interface PricePathsProps {
|
|
|
328
347
|
const TONE_TO_TIER: Record<string, number> = { top: 0, good: 1, fair: 2, raised: 3, high: 4 };
|
|
329
348
|
|
|
330
349
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
350
|
+
* Continuous Preisbewertung gradient bar + knob (Figma: green→yellow→orange→red rail with a
|
|
351
|
+
* small circular marker at the car's position — no text label, since the tier is already named
|
|
352
|
+
* by the StatChip pill next to the price). Renders nothing when there is no rating/band
|
|
334
353
|
* (needs-data: we never draw a fake market position).
|
|
335
354
|
*/
|
|
336
|
-
export const PriceMeter: React.FunctionComponent<{ car: AutobahnCar; t: TranslationFunction }> = ({ car
|
|
355
|
+
export const PriceMeter: React.FunctionComponent<{ car: AutobahnCar; t: TranslationFunction }> = ({ car }) => {
|
|
337
356
|
const hasMarket =
|
|
338
357
|
car.marketLow != null && car.marketAvg != null && car.marketHigh != null && car.marketHigh > car.marketLow;
|
|
339
|
-
const
|
|
340
|
-
<div className="pp-
|
|
341
|
-
{
|
|
342
|
-
<span key={s} className="pp-seg" style={{ background: TIER_COLORS[s], opacity: s === tier ? 1 : 0.26 }} />
|
|
343
|
-
))}
|
|
358
|
+
const knob = (tier: number) => (
|
|
359
|
+
<div className="pp-bar">
|
|
360
|
+
<span className="pp-bar-dot" style={{ left: `${Math.max(4, Math.min(96, (tier / 4) * 100))}%` }} />
|
|
344
361
|
</div>
|
|
345
362
|
);
|
|
346
363
|
if (hasMarket) {
|
|
347
|
-
const delta = car.marketAvg! - car.price;
|
|
348
|
-
const good = delta > 150;
|
|
349
364
|
const mpos = Math.max(0, Math.min(1, (car.price - car.marketLow!) / (car.marketHigh! - car.marketLow!)));
|
|
350
|
-
return (
|
|
351
|
-
<div className="pp-meter-block">
|
|
352
|
-
{seg(Math.round(mpos * 4))}
|
|
353
|
-
<span className={'pp-mlabel' + (good ? ' pp-mlabel--good' : '')}>
|
|
354
|
-
{good
|
|
355
|
-
? t('autobahn:card.pricing.belowMarket', { delta: delta.toLocaleString('de-DE') })
|
|
356
|
-
: t('autobahn:card.pricing.marketPrice')}
|
|
357
|
-
</span>
|
|
358
|
-
</div>
|
|
359
|
-
);
|
|
365
|
+
return knob(mpos * 4);
|
|
360
366
|
}
|
|
361
367
|
if (car.rating && TONE_TO_TIER[car.rating] != null) {
|
|
362
368
|
// No market BAND yet, but the real Preisbewertung tier (same signal as the rating chip)
|
|
363
|
-
// drives the
|
|
364
|
-
|
|
365
|
-
return (
|
|
366
|
-
<div className="pp-meter-block">
|
|
367
|
-
{seg(tier)}
|
|
368
|
-
<span className={'pp-mlabel' + (tier <= 1 ? ' pp-mlabel--good' : '')}>
|
|
369
|
-
{TIER_KEYS[tier] ? t('autobahn:tier.' + TIER_KEYS[tier]) : t('autobahn:card.pricing.marketPrice')}
|
|
370
|
-
</span>
|
|
371
|
-
</div>
|
|
372
|
-
);
|
|
369
|
+
// drives the gradient knob position. Real data, not faked.
|
|
370
|
+
return knob(TONE_TO_TIER[car.rating]);
|
|
373
371
|
}
|
|
374
372
|
return null;
|
|
375
373
|
};
|
|
376
374
|
|
|
375
|
+
export type PaymentMode = 'financing' | 'leasing' | 'buy';
|
|
376
|
+
|
|
377
|
+
export interface PriceTabsProps {
|
|
378
|
+
car: AutobahnCar;
|
|
379
|
+
mode: PaymentMode;
|
|
380
|
+
onModeChange: (mode: PaymentMode) => void;
|
|
381
|
+
t: TranslationFunction;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Finanzierung / Leasing / Kaufen tab row — reuses the exact `.fin-tabs`/`.fin-tab` classes
|
|
386
|
+
* the VDP's FinanceCalculator already renders (md3.css), so the card's tabs are visually
|
|
387
|
+
* identical to the VDP's, not a second tab style. Leasing tab omitted when the car isn't
|
|
388
|
+
* leasing-eligible (car.lease undefined) — same rule PricePaths already used.
|
|
389
|
+
*/
|
|
390
|
+
export const PriceTabs: React.FunctionComponent<PriceTabsProps> = ({ car, mode, onModeChange, t }) => {
|
|
391
|
+
const tabs: Array<[PaymentMode, string]> = [
|
|
392
|
+
['financing', t('autobahn:finance.mode.financing')],
|
|
393
|
+
...(car.lease != null ? ([['leasing', t('autobahn:finance.mode.leasing')]] as Array<[PaymentMode, string]>) : []),
|
|
394
|
+
['buy', t('autobahn:card.price.buy')],
|
|
395
|
+
];
|
|
396
|
+
return (
|
|
397
|
+
<div className="fin-tabs" role="tablist" onClick={(e) => e.stopPropagation()}>
|
|
398
|
+
{tabs.map(([m, label]) => (
|
|
399
|
+
<button
|
|
400
|
+
key={m}
|
|
401
|
+
type="button"
|
|
402
|
+
role="tab"
|
|
403
|
+
aria-selected={mode === m}
|
|
404
|
+
className={'fin-tab' + (mode === m ? ' on' : '')}
|
|
405
|
+
onClick={() => onModeChange(m)}
|
|
406
|
+
>
|
|
407
|
+
{label}
|
|
408
|
+
</button>
|
|
409
|
+
))}
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export interface PriceHeroProps {
|
|
415
|
+
car: AutobahnCar;
|
|
416
|
+
mode: PaymentMode;
|
|
417
|
+
t: TranslationFunction;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Hero price for the active tab — Finanzierung/Leasing show a monthly rate ("z.B. … €/mtl.")
|
|
422
|
+
* with its own real struck-through old rate (getFinancing/getLeasing `oldMonthlyInstallment`,
|
|
423
|
+
* same isStrikeShown gate as everywhere else); Kaufen shows the flat purchase price struck
|
|
424
|
+
* against the real list price (car.listPrice, already used elsewhere on the card). The
|
|
425
|
+
* superscript footnote markers (¹ price, ² struck reference, ³ "/mtl.") match the Figma card —
|
|
426
|
+
* their legal text lives in the page's own footnote block, not duplicated per-card.
|
|
427
|
+
*/
|
|
428
|
+
export const PriceHero: React.FunctionComponent<PriceHeroProps> = ({ car, mode, t }) => {
|
|
429
|
+
const isMoney = mode === 'buy';
|
|
430
|
+
const value = mode === 'financing' ? car.monthly : mode === 'leasing' ? (car.lease as number) : car.price;
|
|
431
|
+
const oldValue = mode === 'financing' ? car.oldMonthly : mode === 'leasing' ? car.oldLease : car.listPrice;
|
|
432
|
+
const showOld = oldValue != null && oldValue > value;
|
|
433
|
+
return (
|
|
434
|
+
<div className="pp-hero">
|
|
435
|
+
{!isMoney && <div className="pp-eyebrow">{t('autobahn:finance.exampleLabel')}</div>}
|
|
436
|
+
<div className="pp-hero-rate">
|
|
437
|
+
{showOld && (
|
|
438
|
+
<span className="pp-hero-old">
|
|
439
|
+
{(oldValue as number).toLocaleString('de-DE')} €<sup>2</sup>
|
|
440
|
+
</span>
|
|
441
|
+
)}
|
|
442
|
+
<b className="num pp-hero-val">
|
|
443
|
+
{isMoney ? value.toLocaleString('de-DE') : fmtRate(value)} €<sup>1</sup>
|
|
444
|
+
</b>
|
|
445
|
+
{!isMoney && (
|
|
446
|
+
<span className="pp-hero-unit">
|
|
447
|
+
{t('autobahn:card.price.perMonth')}
|
|
448
|
+
<sup>3</sup>
|
|
449
|
+
</span>
|
|
450
|
+
)}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Same real sales number VehicleDetailedNextPage.tsx (VDP) already dials via its own onCall —
|
|
457
|
+
// duplicated here (not imported) because that page doesn't export it; both should eventually
|
|
458
|
+
// read from one shared constant.
|
|
459
|
+
export const DEALER_SALES_PHONE = '0800 40 30 182';
|
|
460
|
+
|
|
461
|
+
/** default onCall — dials the real sales number directly, same tel: pattern VDP uses. */
|
|
462
|
+
export function callDealerSalesPhone(): void {
|
|
463
|
+
if (typeof window !== 'undefined') window.location.href = `tel:${DEALER_SALES_PHONE.replace(/\s/g, '')}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export interface CardCtaRowProps {
|
|
467
|
+
car: AutobahnCar;
|
|
468
|
+
/** which payment tab is currently active on the card — forwarded to onOpenRateAdjust so the
|
|
469
|
+
* sheet opens on the SAME tab the user was looking at (the tab itself is CarCard-internal
|
|
470
|
+
* state, so this is the only way the page learns it). */
|
|
471
|
+
mode: PaymentMode;
|
|
472
|
+
/** primary CTA — mirrors FinanceCalculator's onSecure ("Angebot anfragen"). */
|
|
473
|
+
onRequestOffer: () => void;
|
|
474
|
+
/** phone CTA — mirrors FinanceCalculator's onCall. */
|
|
475
|
+
onCall: () => void;
|
|
476
|
+
/**
|
|
477
|
+
* opens the SAME "Rate anpassen" bottom-sheet FinanceDetailsModal already used on VDP mobile
|
|
478
|
+
* (VdpModules.tsx) — the page mounts one shared modal instance and passes this car's data,
|
|
479
|
+
* this button never builds its own sheet.
|
|
480
|
+
*/
|
|
481
|
+
onOpenRateAdjust: (mode: PaymentMode) => void;
|
|
482
|
+
t: TranslationFunction;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Mobile-only 3-button CTA row (Figma: Angebot anfragen / phone / Rate anpassen). Desktop card
|
|
487
|
+
* has no CTA row at all (confirmed: mobile-only, per explicit user correction). "Rate anpassen"
|
|
488
|
+
* keeps its text label — only the phone button is icon-only, matching the mockup.
|
|
489
|
+
*/
|
|
490
|
+
export const CardCtaRow: React.FunctionComponent<CardCtaRowProps> = ({ car, mode, onRequestOffer, onCall, onOpenRateAdjust, t }) => (
|
|
491
|
+
<div className="card-cta-row show-mobile" style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
|
492
|
+
<Button
|
|
493
|
+
variant="filled"
|
|
494
|
+
style={{ flex: 2 }}
|
|
495
|
+
onClick={(e) => {
|
|
496
|
+
e.stopPropagation();
|
|
497
|
+
onRequestOffer();
|
|
498
|
+
}}
|
|
499
|
+
>
|
|
500
|
+
<DocCheckIcon size={16} />
|
|
501
|
+
{t('autobahn:finance.secureOffer')}
|
|
502
|
+
</Button>
|
|
503
|
+
<Button
|
|
504
|
+
variant="outlined"
|
|
505
|
+
icon="phone"
|
|
506
|
+
style={{ flex: 'none', width: 44, padding: 0 }}
|
|
507
|
+
onClick={(e) => {
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
onCall();
|
|
510
|
+
}}
|
|
511
|
+
/>
|
|
512
|
+
<Button
|
|
513
|
+
variant="outlined"
|
|
514
|
+
icon="sliders-horizontal"
|
|
515
|
+
style={{ flex: 1.5 }}
|
|
516
|
+
onClick={(e) => {
|
|
517
|
+
e.stopPropagation();
|
|
518
|
+
onOpenRateAdjust(mode);
|
|
519
|
+
}}
|
|
520
|
+
>
|
|
521
|
+
{t('autobahn:finance.adjustRate')}
|
|
522
|
+
</Button>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
export interface GalleryCounterProps {
|
|
527
|
+
/** 1-based index of the currently shown photo. */
|
|
528
|
+
current: number;
|
|
529
|
+
total: number;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Bottom-left gallery overlay: expand icon + "current|total" (Figma). Reuses the existing
|
|
534
|
+
* `.imglabel` pill style (already used for the list-card's photo-count badge) rather than a new
|
|
535
|
+
* class — same dark translucent pill, just with the maximize icon instead of the image icon.
|
|
536
|
+
* Hidden with 0/1 photos (nothing to expand into).
|
|
537
|
+
*/
|
|
538
|
+
export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total }) => {
|
|
539
|
+
if (total <= 1) return null;
|
|
540
|
+
return (
|
|
541
|
+
<span className="imglabel">
|
|
542
|
+
<Icon name="maximize" size={13} stroke={2} />
|
|
543
|
+
{current}|{total}
|
|
544
|
+
</span>
|
|
545
|
+
);
|
|
546
|
+
};
|
|
547
|
+
|
|
377
548
|
interface PpRowProps {
|
|
378
549
|
/** payment-path label (Finanzierung / Leasing / Kaufen). */
|
|
379
550
|
label: string;
|
package/cards/index.ts
CHANGED
|
@@ -23,6 +23,14 @@ export {
|
|
|
23
23
|
PriceMeter,
|
|
24
24
|
PpActivity,
|
|
25
25
|
GalleryBadges,
|
|
26
|
+
OfferBadge,
|
|
27
|
+
PriceTabs,
|
|
28
|
+
PriceHero,
|
|
29
|
+
CardCtaRow,
|
|
30
|
+
GalleryCounter,
|
|
31
|
+
ratingTone,
|
|
32
|
+
DEALER_SALES_PHONE,
|
|
33
|
+
callDealerSalesPhone,
|
|
26
34
|
} from './cardParts';
|
|
27
35
|
export type {
|
|
28
36
|
SpecLineProps,
|
|
@@ -32,6 +40,11 @@ export type {
|
|
|
32
40
|
PricePathsProps,
|
|
33
41
|
PpActivityProps,
|
|
34
42
|
GalleryBadgesProps,
|
|
43
|
+
PaymentMode,
|
|
44
|
+
PriceTabsProps,
|
|
45
|
+
PriceHeroProps,
|
|
46
|
+
CardCtaRowProps,
|
|
47
|
+
GalleryCounterProps,
|
|
35
48
|
} from './cardParts';
|
|
36
49
|
|
|
37
50
|
export type { AutobahnCar, AutobahnPhev } from './types';
|