@bytebrand/fe-ui-core-autobahn 1.0.113 → 1.0.115
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 +52 -5
- package/cards/CarRow.tsx +53 -4
- package/cards/cardParts.tsx +139 -13
- package/chrome/TopBar.tsx +8 -3
- package/locales/de/autobahn.json +8 -2
- package/locales/en/autobahn.json +7 -1
- package/package.json +1 -1
- package/theme/md3.css +243 -58
- package/ui/CardCarousel.tsx +20 -5
- package/ui/Dropdown.tsx +192 -45
- package/ui/FormDropdown.tsx +7 -1
- package/vdp/FinanceCalculator.tsx +62 -13
- package/vdp/VdpModules.tsx +4080 -4050
- package/vdp/VdpViewer.tsx +238 -62
package/cards/CarCard.tsx
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// page, landing pages and "similar cars" grids.
|
|
7
7
|
//
|
|
8
8
|
// Pure / presentational: all data comes via the `AutobahnCar` prop, all behaviour
|
|
9
|
-
// via injected callbacks. No store
|
|
10
|
-
//
|
|
9
|
+
// via injected callbacks. No store/API access — the one guarded `window`-adjacent
|
|
10
|
+
// read is IntersectionObserver (see `eagerMedia` below), feature-checked so a
|
|
11
|
+
// non-browser render (tests without a polyfill) degrades to a no-op, not a crash.
|
|
12
|
+
// Renders correctly only inside a `<div className="md3">` subtree.
|
|
11
13
|
//
|
|
12
14
|
// Differs from the design kit on purpose:
|
|
13
15
|
// - favorite state is INJECTED (isFavorite + onToggleFavorite) instead of the
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
// - the needs-data deal signals (DropChip / SaveChip) only appear when real
|
|
16
18
|
// `drop` / `savings` data is present.
|
|
17
19
|
|
|
18
|
-
import React, { useState } from 'react';
|
|
20
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
19
21
|
import { TranslationFunction } from '../types';
|
|
20
22
|
|
|
21
23
|
import { CardCarousel, IconButton, SaveChip, Button } from '../ui';
|
|
@@ -53,6 +55,20 @@ export interface CarCardProps {
|
|
|
53
55
|
onOpen: (car: AutobahnCar) => void;
|
|
54
56
|
/** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
|
|
55
57
|
onMediaEnter?: () => void;
|
|
58
|
+
/**
|
|
59
|
+
* Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
|
|
60
|
+
* hover/touch. Off by default — the SRP's grid is effectively unbounded (pagination/infinite
|
|
61
|
+
* scroll), and firing one getImages() request per card the instant it enters the viewport
|
|
62
|
+
* reintroduced exactly the failure mode `loadCarImages` was deliberately scoped away from
|
|
63
|
+
* (SearchNextPage.tsx: "eager per-card getImages() ... raised an error toast PER failed
|
|
64
|
+
* request, stacked toasts on mobile scroll" whenever the image service is degraded).
|
|
65
|
+
* Opt into this only for small, BOUNDED grids (e.g. the homepage's ≤6-card "Top-Angebote"),
|
|
66
|
+
* where without it the gallery's prev/next arrows and image-count dots stay hidden until the
|
|
67
|
+
* user's first hover/touch on THAT card resolves (async) — on touch devices this reads as
|
|
68
|
+
* "controls need a preliminary tap", because the very same tap that reveals them is usually
|
|
69
|
+
* also the tap the user meant as their real interaction with the card.
|
|
70
|
+
*/
|
|
71
|
+
eagerMedia?: boolean;
|
|
56
72
|
/** i18n translate fn (drilled from the page). */
|
|
57
73
|
t: TranslationFunction;
|
|
58
74
|
/**
|
|
@@ -90,6 +106,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
90
106
|
onToggleFavorite,
|
|
91
107
|
onOpen,
|
|
92
108
|
onMediaEnter,
|
|
109
|
+
eagerMedia,
|
|
93
110
|
t,
|
|
94
111
|
adminAction,
|
|
95
112
|
onRequestOffer,
|
|
@@ -100,6 +117,35 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
100
117
|
const [mode, setMode] = useState<PaymentMode>('financing');
|
|
101
118
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
102
119
|
const [viewerOpen, setViewerOpen] = useState(false);
|
|
120
|
+
const mediaRef = useRef<HTMLDivElement | null>(null);
|
|
121
|
+
// always call the LATEST onMediaEnter without re-arming the observer on every render (the
|
|
122
|
+
// page callers below construct a fresh closure each render) — the effect itself only needs
|
|
123
|
+
// to run once per mount.
|
|
124
|
+
const onMediaEnterRef = useRef(onMediaEnter);
|
|
125
|
+
onMediaEnterRef.current = onMediaEnter;
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
|
|
128
|
+
const el = mediaRef.current;
|
|
129
|
+
if (!el) return undefined;
|
|
130
|
+
let fired = false;
|
|
131
|
+
// rootMargin starts the fetch a bit before the card is actually on-screen, so by the time
|
|
132
|
+
// the user's eye (or finger, on touch) reaches it the gallery has usually already resolved
|
|
133
|
+
// — the whole point being no tap is required to make prev/next + the counter "real".
|
|
134
|
+
const observer = new IntersectionObserver(
|
|
135
|
+
(entries) => {
|
|
136
|
+
entries.forEach((entry) => {
|
|
137
|
+
if (entry.isIntersecting && !fired) {
|
|
138
|
+
fired = true;
|
|
139
|
+
onMediaEnterRef.current && onMediaEnterRef.current();
|
|
140
|
+
observer.disconnect();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
{ rootMargin: '200px 0px', threshold: 0 },
|
|
145
|
+
);
|
|
146
|
+
observer.observe(el);
|
|
147
|
+
return () => observer.disconnect();
|
|
148
|
+
}, [eagerMedia]);
|
|
103
149
|
// as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
|
|
104
150
|
// undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
|
|
105
151
|
const asGoodAsNew = car.asGoodAsNew !== false;
|
|
@@ -119,6 +165,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
119
165
|
>
|
|
120
166
|
<div
|
|
121
167
|
className="cc-media"
|
|
168
|
+
ref={mediaRef}
|
|
122
169
|
style={{ position: 'relative' }}
|
|
123
170
|
onMouseEnter={onMediaEnter}
|
|
124
171
|
onTouchStart={onMediaEnter}
|
|
@@ -193,7 +240,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
193
240
|
<GalleryActivityPill viewsCount={car.viewsCount} favoritesCount={car.favoritesCount} />
|
|
194
241
|
</div>
|
|
195
242
|
</div>
|
|
196
|
-
<div style={{ padding: '
|
|
243
|
+
<div style={{ padding: '8px 18px', display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
|
|
197
244
|
<div>
|
|
198
245
|
<div className="md-title" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
199
246
|
{car.make} {car.model}
|
|
@@ -247,7 +294,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
247
294
|
/>
|
|
248
295
|
{/* legacy VehicleSmallCard's "SeoText" footer line (full spec + equipment list) —
|
|
249
296
|
renders nothing when the caller hasn't populated car.seoText. */}
|
|
250
|
-
<SeoLine car={car} style={{ marginTop:
|
|
297
|
+
<SeoLine car={car} style={{ marginTop: 0 }} />
|
|
251
298
|
{adminAction && (
|
|
252
299
|
<div style={{ marginTop: 8 }}>
|
|
253
300
|
<Button
|
package/cards/CarRow.tsx
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// CTA stop propagation so they don't also trigger the card open.
|
|
7
7
|
//
|
|
8
8
|
// Pure / presentational: all data via the `AutobahnCar` prop, all behaviour via
|
|
9
|
-
// injected callbacks. No store
|
|
9
|
+
// injected callbacks. No store/API access — the one guarded `window`-adjacent read is
|
|
10
|
+
// IntersectionObserver (see `eagerMedia` below), feature-checked so a non-browser render
|
|
11
|
+
// (tests without a polyfill) degrades to a no-op, not a crash. Renders correctly only
|
|
10
12
|
// inside a `<div className="md3">` subtree.
|
|
11
13
|
//
|
|
12
14
|
// Differs from the design kit on purpose:
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
// no className prop) so the scoped `.pp-cta` margin / responsive rules and the
|
|
19
21
|
// `.pp-cta + .pp-activity` adjacency in md3.css still apply.
|
|
20
22
|
|
|
21
|
-
import React, { useState } from 'react';
|
|
23
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
22
24
|
import { TranslationFunction } from '../types';
|
|
23
25
|
|
|
24
26
|
import { CardCarousel, IconButton, SaveChip, Button, Icon, StatChip } from '../ui';
|
|
@@ -53,6 +55,16 @@ export interface CarRowProps {
|
|
|
53
55
|
onOpen: (car: AutobahnCar) => void;
|
|
54
56
|
/** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
|
|
55
57
|
onMediaEnter?: () => void;
|
|
58
|
+
/**
|
|
59
|
+
* Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
|
|
60
|
+
* hover/touch. Off by default. See CarCard's identically-named prop for the full rationale —
|
|
61
|
+
* same trade-off applies here (SRP's list view is the other consumer of this same concern).
|
|
62
|
+
* Safe to enable even on an effectively-unbounded list AS LONG AS the caller's `onMediaEnter`
|
|
63
|
+
* itself caps concurrent fetches (e.g. SearchNextPage.tsx's loadCarImages queues through
|
|
64
|
+
* runQueuedImageLoad) — this component has no opinion on that, it only decides WHEN to call
|
|
65
|
+
* onMediaEnter, never how many may run at once.
|
|
66
|
+
*/
|
|
67
|
+
eagerMedia?: boolean;
|
|
56
68
|
/** i18n translate fn (drilled from the page). */
|
|
57
69
|
t: TranslationFunction;
|
|
58
70
|
/**
|
|
@@ -75,12 +87,39 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
75
87
|
onToggleFavorite,
|
|
76
88
|
onOpen,
|
|
77
89
|
onMediaEnter,
|
|
90
|
+
eagerMedia,
|
|
78
91
|
t,
|
|
79
92
|
adminAction,
|
|
80
93
|
onShowFinancingDetails,
|
|
81
94
|
}) => {
|
|
82
95
|
const [mode, setMode] = useState<PaymentMode>('financing');
|
|
83
96
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
97
|
+
const mediaRef = useRef<HTMLDivElement | null>(null);
|
|
98
|
+
// always call the LATEST onMediaEnter without re-arming the observer on every render (the
|
|
99
|
+
// page callers construct a fresh closure each render) — the effect itself only needs to run
|
|
100
|
+
// once per mount. Identical pattern to CarCard.tsx — see its own comment for the rationale.
|
|
101
|
+
const onMediaEnterRef = useRef(onMediaEnter);
|
|
102
|
+
onMediaEnterRef.current = onMediaEnter;
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
|
|
105
|
+
const el = mediaRef.current;
|
|
106
|
+
if (!el) return undefined;
|
|
107
|
+
let fired = false;
|
|
108
|
+
const observer = new IntersectionObserver(
|
|
109
|
+
(entries) => {
|
|
110
|
+
entries.forEach((entry) => {
|
|
111
|
+
if (entry.isIntersecting && !fired) {
|
|
112
|
+
fired = true;
|
|
113
|
+
onMediaEnterRef.current && onMediaEnterRef.current();
|
|
114
|
+
observer.disconnect();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
{ rootMargin: '200px 0px', threshold: 0 },
|
|
119
|
+
);
|
|
120
|
+
observer.observe(el);
|
|
121
|
+
return () => observer.disconnect();
|
|
122
|
+
}, [eagerMedia]);
|
|
84
123
|
// as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
|
|
85
124
|
// undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
|
|
86
125
|
const asGoodAsNew = car.asGoodAsNew !== false;
|
|
@@ -109,7 +148,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
109
148
|
onClick={() => onOpen(car)}
|
|
110
149
|
style={{ background: 'var(--md-sc-lowest)' }}
|
|
111
150
|
>
|
|
112
|
-
<div className="listcard-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
|
|
151
|
+
<div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
|
|
113
152
|
{/* 'auto': desktop picks cover/contain per photo (crops only when it wouldn't lose too
|
|
114
153
|
much of the shot); below 601px this is identical to 'contain', so mobile is unchanged. */}
|
|
115
154
|
<CardCarousel images={car.gallery} height="100%" fit="auto" onChange={setPhotoIndex} />
|
|
@@ -173,7 +212,17 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
173
212
|
<GalleryBadges car={car} t={t} />
|
|
174
213
|
</div>
|
|
175
214
|
{/* desktop: single row — light "1|N" photo counter + a combined eye/heart chip, no
|
|
176
|
-
availability/Geprüft pill (not present in the Figma "mcard" export).
|
|
215
|
+
availability/Geprüft pill (not present in the Figma "mcard" export).
|
|
216
|
+
Same 3-state policy as the mobile pills above (GalleryCounter/GalleryBadges,
|
|
217
|
+
cardParts.tsx) and CarCard's GalleryCounter/GalleryActivityPill, just inlined here
|
|
218
|
+
instead of reusing those components (different chip markup — one shared "N|M" +
|
|
219
|
+
eye+heart pill instead of three separate ones):
|
|
220
|
+
- single-image: car.photos is the real, eagerly-known count (not the lazily-loaded
|
|
221
|
+
gallery array length) — accurate even before the extra photos have loaded.
|
|
222
|
+
- loading: viewsCount/favoritesCount are undefined until stats resolve for this car
|
|
223
|
+
— hidden, no skeleton, same as everywhere else.
|
|
224
|
+
- zero-value: a real 0 is intentionally treated the same as "not loaded" — hidden,
|
|
225
|
+
not shown as "eye 0" / "heart 0" (noise on a dense results list otherwise). */}
|
|
177
226
|
<div
|
|
178
227
|
className="hide-mobile"
|
|
179
228
|
style={{ position: 'absolute', bottom: 20, left: 12, zIndex: 3, display: 'flex', alignItems: 'center', gap: 6 }}
|
package/cards/cardParts.tsx
CHANGED
|
@@ -19,7 +19,7 @@ import { TranslationFunction } from '../types';
|
|
|
19
19
|
import { defaultConfig } from '@bytebrand/fe-financing';
|
|
20
20
|
|
|
21
21
|
import { Icon, Button } from '../ui';
|
|
22
|
-
import {
|
|
22
|
+
import { EvalBar } from '../vdp/FinanceCalculator';
|
|
23
23
|
import { CO2_CLASSES, CO2_COLORS, TIER_KEYS, TIER_COLORS } from '../constants';
|
|
24
24
|
import { AutobahnCar } from './types';
|
|
25
25
|
// pure, non-React — shared with the Meta AIA marketing-image renderer (a plain Node service).
|
|
@@ -203,6 +203,7 @@ export const DropChip: React.FunctionComponent<DropChipProps> = ({ amount, t })
|
|
|
203
203
|
color: 'var(--md-good)',
|
|
204
204
|
fontSize: 10.5,
|
|
205
205
|
fontWeight: 700,
|
|
206
|
+
lineHeight: '14px',
|
|
206
207
|
boxShadow: 'var(--md-e1)',
|
|
207
208
|
whiteSpace: 'nowrap',
|
|
208
209
|
}}
|
|
@@ -230,7 +231,7 @@ const dealPill: React.CSSProperties = {
|
|
|
230
231
|
export const PercentChip: React.FunctionComponent<{ pct?: number }> = ({ pct }) => {
|
|
231
232
|
if (pct == null || pct <= 0) return null;
|
|
232
233
|
return (
|
|
233
|
-
<span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff' }}>
|
|
234
|
+
<span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff', lineHeight: '14px' }}>
|
|
234
235
|
−{pct}%
|
|
235
236
|
</span>
|
|
236
237
|
);
|
|
@@ -783,37 +784,144 @@ export interface CardCtaRowProps {
|
|
|
783
784
|
export const CardCtaRow: React.FunctionComponent<CardCtaRowProps> = ({ car, mode, onRequestOffer, onCall, onOpenRateAdjust, t }) => (
|
|
784
785
|
<div
|
|
785
786
|
className="card-cta-row show-mobile"
|
|
786
|
-
style={{ display: 'flex', gap:
|
|
787
|
+
style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--md-outline-variant)', paddingTop: 8 }}
|
|
787
788
|
>
|
|
788
789
|
<Button
|
|
789
790
|
variant="filled"
|
|
790
|
-
style={{
|
|
791
|
+
style={{
|
|
792
|
+
// Figma width (157px) as a STARTING size, not a hard fixed one — flex-grow:1 so this
|
|
793
|
+
// button (and "Adjust rate" below) still fill extra row width on wider cards instead of
|
|
794
|
+
// leaving dead space next to the fixed 40px phone button; flex-shrink:1 lets it narrow
|
|
795
|
+
// gracefully (text ellipsizes via the existing .card-cta-row .mbtn overflow rule) rather
|
|
796
|
+
// than overflow on very narrow cards.
|
|
797
|
+
flex: '1 1 157px',
|
|
798
|
+
height: 40,
|
|
799
|
+
background: '#005cff',
|
|
800
|
+
borderRadius: 16,
|
|
801
|
+
padding: '0 8px',
|
|
802
|
+
fontFamily: 'Arial',
|
|
803
|
+
fontStyle: 'normal',
|
|
804
|
+
fontWeight: 700,
|
|
805
|
+
fontSize: 13,
|
|
806
|
+
lineHeight: '15px',
|
|
807
|
+
color: '#ffffff',
|
|
808
|
+
}}
|
|
791
809
|
onClick={(e) => {
|
|
792
810
|
e.stopPropagation();
|
|
793
811
|
onRequestOffer();
|
|
794
812
|
}}
|
|
795
813
|
>
|
|
796
|
-
|
|
814
|
+
{/* exact design icon (single self-contained SVG) — replaces the composed lucide
|
|
815
|
+
file-text + check-circle badge (DocCheckIcon), which is still used as-is on VDP's own
|
|
816
|
+
"Angebot anfragen" buttons; this swap is scoped to the search-card CTA only.
|
|
817
|
+
flex:none for the same shrink reason as the "Adjust rate" icon above. */}
|
|
818
|
+
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flex: 'none' }}>
|
|
819
|
+
<path
|
|
820
|
+
d="M6.90625 3.65625H15.275L19.9062 8.2875V20.7188C19.9062 21.6162 19.1787 22.3438 18.2812 22.3438H6.90625C6.00879 22.3438 5.28125 21.6162 5.28125 20.7188V5.28125C5.28125 4.38379 6.00879 3.65625 6.90625 3.65625Z"
|
|
821
|
+
stroke="white"
|
|
822
|
+
strokeLinecap="round"
|
|
823
|
+
strokeLinejoin="round"
|
|
824
|
+
/>
|
|
825
|
+
<path d="M15.0312 3.85938V8.53125H19.7031" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
|
|
826
|
+
<path d="M8.9375 11.375H14.625" stroke="white" strokeLinecap="round" />
|
|
827
|
+
<path d="M8.9375 14.625H13.4062" stroke="white" strokeLinecap="round" />
|
|
828
|
+
<path d="M8.9375 17.875H12.5938" stroke="white" strokeLinecap="round" />
|
|
829
|
+
<path
|
|
830
|
+
d="M18.4844 23.1562C20.8402 23.1562 22.75 21.2465 22.75 18.8906C22.75 16.5348 20.8402 14.625 18.4844 14.625C16.1285 14.625 14.2188 16.5348 14.2188 18.8906C14.2188 21.2465 16.1285 23.1562 18.4844 23.1562Z"
|
|
831
|
+
fill="#005CFF"
|
|
832
|
+
stroke="white"
|
|
833
|
+
/>
|
|
834
|
+
<path d="M17.0625 18.3219L18.5656 19.825L23.7656 14.625" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
|
|
835
|
+
<path d="M21.6533 17.875L23.2783 16.25" stroke="#005CFF" strokeLinecap="round" strokeLinejoin="round" />
|
|
836
|
+
<path d="M20.1904 17.0625L21.8154 15.4375" stroke="#005CFF" strokeLinecap="round" strokeLinejoin="round" />
|
|
837
|
+
</svg>
|
|
797
838
|
{t('autobahn:finance.secureOffer')}
|
|
798
839
|
</Button>
|
|
799
840
|
<Button
|
|
800
841
|
variant="outlined"
|
|
801
|
-
|
|
802
|
-
|
|
842
|
+
style={{
|
|
843
|
+
flex: '0 0 auto',
|
|
844
|
+
width: 40,
|
|
845
|
+
height: 40,
|
|
846
|
+
background: '#ffffff',
|
|
847
|
+
border: '1.6px solid #005cff',
|
|
848
|
+
borderRadius: 16,
|
|
849
|
+
padding: 0,
|
|
850
|
+
}}
|
|
803
851
|
onClick={(e) => {
|
|
804
852
|
e.stopPropagation();
|
|
805
853
|
onCall();
|
|
806
854
|
}}
|
|
807
|
-
|
|
855
|
+
>
|
|
856
|
+
{/* exact design icon (not lucide's "phone") — no class="lucide", so the
|
|
857
|
+
.card-cta-row .mbtn .lucide 15px override doesn't touch it; the rect-clip from the
|
|
858
|
+
source SVG was a full-viewBox no-op (already inside 0-20/0-20) and dropped, since a
|
|
859
|
+
static clipPath id would collide across the many CarCard instances on one search page. */}
|
|
860
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
861
|
+
<path
|
|
862
|
+
d="M18.3332 14.1004V16.6004C18.3341 16.8325 18.2866 17.0622 18.1936 17.2749C18.1006 17.4875 17.9643 17.6784 17.7933 17.8353C17.6222 17.9922 17.4203 18.1116 17.2005 18.186C16.9806 18.2603 16.7477 18.288 16.5165 18.2671C13.9523 17.9881 11.4892 17.1119 9.32486 15.7087C7.31139 14.4293 5.60431 12.7222 4.32486 10.7087C2.91679 8.5346 2.04037 6.0595 1.76653 3.48374C1.7457 3.2533 1.77309 3.02104 1.84695 2.80176C1.9208 2.58248 2.03951 2.38098 2.1955 2.21009C2.3515 2.0392 2.54137 1.90266 2.75302 1.80917C2.96468 1.71569 3.19348 1.66729 3.42486 1.66707H5.92486C6.32928 1.66309 6.72136 1.80631 7.028 2.07002C7.33464 2.33373 7.53493 2.69995 7.59153 3.10041C7.69153 3.85041 7.86653 4.57541 8.11653 5.26707C8.22865 5.56534 8.25291 5.8895 8.18645 6.20114C8.11999 6.51278 7.96558 6.79883 7.74153 7.02541L6.74153 8.03374C7.95617 10.2329 9.76739 12.0441 11.9665 13.2587L12.9749 12.2587C13.2014 12.0347 13.4875 11.8803 13.7991 11.8138C14.1108 11.7474 14.4349 11.7716 14.7332 11.8837C15.4249 12.1337 16.1499 12.3087 16.8999 12.4087C17.3047 12.4658 17.6744 12.6697 17.9386 12.9817C18.2029 13.2936 18.3433 13.6917 18.3332 14.1004Z"
|
|
863
|
+
stroke="#005cff"
|
|
864
|
+
strokeWidth={1.66667}
|
|
865
|
+
strokeLinecap="round"
|
|
866
|
+
strokeLinejoin="round"
|
|
867
|
+
/>
|
|
868
|
+
</svg>
|
|
869
|
+
</Button>
|
|
808
870
|
<Button
|
|
809
871
|
variant="outlined"
|
|
810
|
-
|
|
811
|
-
|
|
872
|
+
style={{
|
|
873
|
+
// same "starting size that can still grow/shrink" reasoning as "Request offer" above.
|
|
874
|
+
flex: '1 1 125px',
|
|
875
|
+
height: 40,
|
|
876
|
+
background: '#ffffff',
|
|
877
|
+
border: '1.6px solid #5b6b7f',
|
|
878
|
+
borderRadius: 16,
|
|
879
|
+
padding: '0 6px',
|
|
880
|
+
fontFamily: 'Arial',
|
|
881
|
+
fontStyle: 'normal',
|
|
882
|
+
fontWeight: 700,
|
|
883
|
+
fontSize: 13,
|
|
884
|
+
lineHeight: '15px',
|
|
885
|
+
color: '#5b6b7f',
|
|
886
|
+
}}
|
|
812
887
|
onClick={(e) => {
|
|
813
888
|
e.stopPropagation();
|
|
814
889
|
onOpenRateAdjust(mode);
|
|
815
890
|
}}
|
|
816
891
|
>
|
|
892
|
+
{/* exact design icon (not lucide's "sliders-horizontal") — colors are baked into the SVG
|
|
893
|
+
itself (matches the button's #5b6b7f border/text), not currentColor. flex:none is
|
|
894
|
+
load-bearing: this button is a flex row (icon + the "Adjust rate" label) inside a fixed
|
|
895
|
+
125px width, and without it the icon was a shrinkable flex item — flexbox squeezed its
|
|
896
|
+
WIDTH down to fit the text (rendering ~12.94x18, squashed) while its literal height
|
|
897
|
+
attribute stayed 18. Lucide icons here dodge this via the unrelated .card-cta-row .mbtn
|
|
898
|
+
.lucide CSS rule (flex: none), which doesn't match a plain <svg> with no lucide class. */}
|
|
899
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flex: 'none' }}>
|
|
900
|
+
<path d="M3 5.25H15" stroke="#5B6B7F" strokeWidth={1.91667} strokeLinecap="round" strokeLinejoin="round" />
|
|
901
|
+
<path
|
|
902
|
+
d="M6 6.75C6.82843 6.75 7.5 6.07843 7.5 5.25C7.5 4.42157 6.82843 3.75 6 3.75C5.17157 3.75 4.5 4.42157 4.5 5.25C4.5 6.07843 5.17157 6.75 6 6.75Z"
|
|
903
|
+
fill="#FFFEFE"
|
|
904
|
+
stroke="#5B6B7F"
|
|
905
|
+
strokeLinecap="round"
|
|
906
|
+
strokeLinejoin="round"
|
|
907
|
+
/>
|
|
908
|
+
<path d="M3 9H15" stroke="#5B6B7F" strokeWidth={1.91667} strokeLinecap="round" strokeLinejoin="round" />
|
|
909
|
+
<path
|
|
910
|
+
d="M12.4565 10.5C13.285 10.5 13.9565 9.82843 13.9565 9C13.9565 8.17157 13.285 7.5 12.4565 7.5C11.6281 7.5 10.9565 8.17157 10.9565 9C10.9565 9.82843 11.6281 10.5 12.4565 10.5Z"
|
|
911
|
+
fill="white"
|
|
912
|
+
stroke="#5B6B7F"
|
|
913
|
+
strokeLinecap="round"
|
|
914
|
+
strokeLinejoin="round"
|
|
915
|
+
/>
|
|
916
|
+
<path d="M3 12.75H15" stroke="#5B6B7F" strokeWidth={1.91667} strokeLinecap="round" strokeLinejoin="round" />
|
|
917
|
+
<path
|
|
918
|
+
d="M9.81543 14.25C10.6439 14.25 11.3154 13.5784 11.3154 12.75C11.3154 11.9216 10.6439 11.25 9.81543 11.25C8.987 11.25 8.31543 11.9216 8.31543 12.75C8.31543 13.5784 8.987 14.25 9.81543 14.25Z"
|
|
919
|
+
fill="white"
|
|
920
|
+
stroke="#5B6B7F"
|
|
921
|
+
strokeLinecap="round"
|
|
922
|
+
strokeLinejoin="round"
|
|
923
|
+
/>
|
|
924
|
+
</svg>
|
|
817
925
|
{t('autobahn:finance.adjustRate')}
|
|
818
926
|
</Button>
|
|
819
927
|
</div>
|
|
@@ -835,7 +943,15 @@ export interface GalleryCounterProps {
|
|
|
835
943
|
* Bottom-left gallery overlay: expand icon + "current|total" (Figma). Reuses the existing
|
|
836
944
|
* `.imglabel` pill style (already used for the list-card's photo-count badge) rather than a new
|
|
837
945
|
* class — same dark translucent pill, just with the maximize icon instead of the image icon.
|
|
838
|
-
*
|
|
946
|
+
*
|
|
947
|
+
* States (shared policy across GalleryCounter, the carousel's own arrows/dots in CardCarousel,
|
|
948
|
+
* and GalleryActivityPill below — see its own doc comment for the other two):
|
|
949
|
+
* - single-image: `total` is the car's REAL, eagerly-known photo count (car.photos, from the
|
|
950
|
+
* search payload — not the lazily-loaded gallery array length), so this is accurate even
|
|
951
|
+
* before any image beyond the first has actually loaded. total<=1 → hidden: nothing to expand
|
|
952
|
+
* into. Never a "1|1" placeholder.
|
|
953
|
+
* - loading: while `total` itself is still unknown (car not yet resolved) the caller simply
|
|
954
|
+
* hasn't rendered this component yet — there is no separate in-between skeleton state here.
|
|
839
955
|
*/
|
|
840
956
|
export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
|
|
841
957
|
if (total <= 1) return null;
|
|
@@ -864,8 +980,18 @@ export interface GalleryActivityPillProps {
|
|
|
864
980
|
|
|
865
981
|
/**
|
|
866
982
|
* Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
|
|
867
|
-
* Likes", node 297:1037). Each half renders independently
|
|
868
|
-
*
|
|
983
|
+
* Likes", node 297:1037). Each half renders independently.
|
|
984
|
+
*
|
|
985
|
+
* States (chosen ONE policy, applied identically to both halves):
|
|
986
|
+
* - loading: `viewsCount`/`favoritesCount` are `undefined` until the async stats call
|
|
987
|
+
* (UserActionsAPI 'stats/viewers') resolves for this car. No skeleton — the half (or the
|
|
988
|
+
* whole pill, if both are still pending) simply doesn't render yet, same "hidden, not a
|
|
989
|
+
* placeholder" discipline as GalleryCounter/CardCarousel's arrows above.
|
|
990
|
+
* - zero-value: a REAL `0` is treated the same as "not there" — hidden, not shown as "👁 0".
|
|
991
|
+
* Deliberate choice over spelling out "0" (the other option the ticket allows): on a dense
|
|
992
|
+
* grid of fresh listings a "👁 0 ❤ 0" pill on every card is noise, not signal. `!viewsCount`/
|
|
993
|
+
* `!favoritesCount` below intentionally don't distinguish real-0 from not-yet-loaded — both
|
|
994
|
+
* read as "nothing worth showing (yet)", which is the same outcome either way.
|
|
869
995
|
*/
|
|
870
996
|
export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
|
|
871
997
|
if (!viewsCount && !favoritesCount) return null;
|
package/chrome/TopBar.tsx
CHANGED
|
@@ -110,9 +110,14 @@ export interface TopBarProps {
|
|
|
110
110
|
* number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
|
|
111
111
|
* href here once one is confirmed. */
|
|
112
112
|
phoneHref?: string;
|
|
113
|
-
/** mobile-only
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
/** mobile-only (the CSS that actually moves the header, `.topbar--hidden`, only exists inside
|
|
114
|
+
* the `@media (max-width: 600px)` block — desktop keeps this prop's `hidden` state but never
|
|
115
|
+
* renders it): slide the header out of view on scroll-down, back in on scroll-up. On the
|
|
116
|
+
* car-detail page this is additionally coordinated with the VDP sticky offer bottom bar (hides
|
|
117
|
+
* well before that bar would ever dock — see STICKY_SHOW_AT in VehicleDetailedNextPage.tsx).
|
|
118
|
+
* Off by default — the car-detail page (topBarVariant 'conversion'), the homepage ('/') and
|
|
119
|
+
* the SRP list page ('/search') opt in (see App.tsx); other pages keep the header always
|
|
120
|
+
* docked. */
|
|
116
121
|
autoHideOnScroll?: boolean;
|
|
117
122
|
}
|
|
118
123
|
|
package/locales/de/autobahn.json
CHANGED
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"priceRatingAria": "Preisbewertung: {{rating}}",
|
|
87
87
|
"detailsShow": "Details anzeigen",
|
|
88
88
|
"paymentLabel": "Zahlung",
|
|
89
|
-
"paymentValue": "
|
|
89
|
+
"paymentValue": "Überweisung",
|
|
90
90
|
"warrantyLabel": "Garantie",
|
|
91
91
|
"warrantyValue": "1 Jahr inklusive",
|
|
92
92
|
"deliveryLabel": "Lieferung",
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"callCta": "Anrufen",
|
|
106
106
|
"finalInstallmentToggle": "Mit Schlussrate",
|
|
107
107
|
"addServicesToggle": "Rundum-Sorglos-Paket",
|
|
108
|
+
"moreInfoAria": "Weitere Informationen zu {{label}}",
|
|
108
109
|
"addServicesPerMonth": "+{{value}} € / Monat",
|
|
109
110
|
"addServicesOnce": "+{{value}} €",
|
|
110
111
|
"addServicesIncluded": "inklusive",
|
|
@@ -382,6 +383,7 @@
|
|
|
382
383
|
},
|
|
383
384
|
"rate": {
|
|
384
385
|
"upTo": "Rate bis",
|
|
386
|
+
"upToPlaceholder": "Rate bis, z. B. 350 €/mtl.",
|
|
385
387
|
"perMonth": "/mtl.",
|
|
386
388
|
"downPayment": "Anzahlung",
|
|
387
389
|
"none": "keine",
|
|
@@ -389,6 +391,10 @@
|
|
|
389
391
|
"months": "{{months}} Monate",
|
|
390
392
|
"months_plural": "{{months}} Monate"
|
|
391
393
|
},
|
|
394
|
+
"mileage": {
|
|
395
|
+
"upTo": "km bis",
|
|
396
|
+
"upToPlaceholder": "km bis, z. B. 50000 km"
|
|
397
|
+
},
|
|
392
398
|
"consumption": {
|
|
393
399
|
"unitLabel": "l / 100 km"
|
|
394
400
|
},
|
|
@@ -723,7 +729,7 @@
|
|
|
723
729
|
"leaseVehiclePrice": "Anschaffungspreis¹",
|
|
724
730
|
"leaseDownPayment": "Einmalige Sonderzahlung",
|
|
725
731
|
"finalPaymentDue": "Schlusszahlung (fällig am Vertragsende)",
|
|
726
|
-
"residualValue": "
|
|
732
|
+
"residualValue": "Verpflichtender Kaufpreis am Vertragsende",
|
|
727
733
|
"rateSumLabel": "Summe der Leasingraten ({{term}} × {{amount}} €)",
|
|
728
734
|
"addServicesRowLease": "Rundum-Sorglos-Paket (Einmalzahlung)",
|
|
729
735
|
"subtotalWithPackage": "Gesamt inkl. Sonderzahl. & Paket",
|
package/locales/en/autobahn.json
CHANGED
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"callCta": "Call",
|
|
106
106
|
"finalInstallmentToggle": "With final installment",
|
|
107
107
|
"addServicesToggle": "All-round care package",
|
|
108
|
+
"moreInfoAria": "More information about {{label}}",
|
|
108
109
|
"addServicesPerMonth": "+€{{value}} / month",
|
|
109
110
|
"addServicesOnce": "+€{{value}}",
|
|
110
111
|
"addServicesIncluded": "included",
|
|
@@ -382,6 +383,7 @@
|
|
|
382
383
|
},
|
|
383
384
|
"rate": {
|
|
384
385
|
"upTo": "Rate up to",
|
|
386
|
+
"upToPlaceholder": "Rate up to, e.g. 350 €/month",
|
|
385
387
|
"perMonth": "/month",
|
|
386
388
|
"downPayment": "Down payment",
|
|
387
389
|
"none": "none",
|
|
@@ -389,6 +391,10 @@
|
|
|
389
391
|
"months": "{{months}} months",
|
|
390
392
|
"months_plural": "{{months}} months"
|
|
391
393
|
},
|
|
394
|
+
"mileage": {
|
|
395
|
+
"upTo": "Mileage up to",
|
|
396
|
+
"upToPlaceholder": "Mileage up to, e.g. 50000 km"
|
|
397
|
+
},
|
|
392
398
|
"consumption": {
|
|
393
399
|
"unitLabel": "l / 100 km"
|
|
394
400
|
},
|
|
@@ -723,7 +729,7 @@
|
|
|
723
729
|
"leaseVehiclePrice": "Acquisition price¹",
|
|
724
730
|
"leaseDownPayment": "One-time special payment",
|
|
725
731
|
"finalPaymentDue": "Final payment (due at contract end)",
|
|
726
|
-
"residualValue": "
|
|
732
|
+
"residualValue": "Mandatory purchase price at contract end",
|
|
727
733
|
"rateSumLabel": "Sum of leasing installments ({{term}} × {{amount}} €)",
|
|
728
734
|
"addServicesRowLease": "Complete care package (one-time payment)",
|
|
729
735
|
"subtotalWithPackage": "Total incl. special payment & package",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/fe-ui-core-autobahn",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.115",
|
|
4
4
|
"description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"license": "UNLICENSED",
|