@bytebrand/fe-ui-core-autobahn 1.0.51 → 1.0.53

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 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 { SpecLine, EcoLine, DropChip, PercentChip, ZeroDownChip, cardSpec, fmtRate } from './cardParts';
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> = ({ car, isFavorite, onToggleFavorite, onOpen, onMediaEnter, t, adminAction }) => {
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 className="cc-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
54
- <CardCarousel images={car.gallery} height="100%" fit="contain" />
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
- DropChip takes precedence over SaveChip; each only renders when its real data exists. */}
64
- {(car.isNew || car.drop != null || showSave || car.dropPct != null || car.zeroDown) && (
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={{ position: 'absolute', top: 10, left: 10, zIndex: 3, display: 'flex', flexWrap: 'nowrap', gap: 5, alignItems: 'center', maxWidth: 'calc(100% - 52px)' }}
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
- {car.isNew && <span className="neu-badge">NEU</span>}
69
- {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
70
- <ZeroDownChip t={t} show={car.zeroDown} />
71
- <PercentChip pct={car.dropPct} />
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
- {/* rate block: payment-path eyebrow (cheaper of financing/leasing) ABOVE the big
88
- monthly rate; Preisbewertung chip kept on the right, centered on the block. */}
89
- {(() => {
90
- const useLease = car.lease != null && (car.lease as number) < car.monthly;
91
- const rate = useLease ? (car.lease as number) : car.monthly;
92
- const rateLabel = t(useLease ? 'autobahn:card.price.leasingFrom' : 'autobahn:card.price.financingFrom');
93
- return (
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
- {car.listPrice.toLocaleString('de-DE')} €
150
- </span>
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 { EcoLine, PricePaths, PriceKauf, PriceMeter, PpActivity, GalleryBadges, DropChip, PercentChip, ZeroDownChip, fmtKm } from './cardParts';
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. · −%. The price RATING
71
- ('Guter Preis') is NOT here — it lives in the price column (PricePaths meter). */}
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: 'row',
81
- flexWrap: 'nowrap',
82
- alignItems: 'center',
99
+ flexDirection: 'column',
100
+ alignItems: 'flex-start',
83
101
  gap: 5,
84
102
  }}
85
103
  >
86
- {car.isNew && <span className="neu-badge">NEU</span>}
87
- {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
88
- <ZeroDownChip t={t} show={car.zeroDown} compact />
89
- <PercentChip pct={car.dropPct} />
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
- <div style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
92
- <span className="imglabel">
93
- <Icon name="image" size={13} />
94
- {car.photos}
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, two groups: monthly rates on top (Finanzierung is the big-blue focus,
144
- Leasing quiet), then a divider and the purchase group (Kaufen + meter + CTA) anchored to
145
- the bottom (margin-top:auto) — so only ONE price stands out and the CTA sits flush at the
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
- <PricePaths t={t} car={car} />
167
+ <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
149
168
  <div className="pp-purchase">
150
- <PriceKauf t={t} car={car} />
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
@@ -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 { CO2_CLASSES, CO2_COLORS, TIER_COLORS, TIER_KEYS } from '../constants';
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
- * Segmented Preisbewertung meter + label. Extracted from PricePaths so the SRP price
332
- * column (Option A layout) can pin it to the BOTTOM of the column, separate from the
333
- * grouped price figures at the top. Renders nothing when there is no rating/band
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, t }) => {
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 seg = (tier: number) => (
340
- <div className="pp-meter">
341
- {[0, 1, 2, 3, 4].map((s) => (
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 segmented position meter. Real data, not faked.
364
- const tier = TONE_TO_TIER[car.rating];
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';