@bytebrand/fe-ui-core-autobahn 1.0.114 → 1.0.116
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 +2 -2
- package/cards/cardParts.tsx +116 -9
- package/locales/de/autobahn.json +2 -1
- package/locales/en/autobahn.json +1 -0
- package/package.json +1 -1
- package/theme/md3.css +151 -23
- package/ui/CardCarousel.tsx +8 -4
- package/ui/Dropdown.tsx +201 -45
- package/ui/FormDropdown.tsx +7 -1
- package/vdp/FinanceCalculator.tsx +62 -13
- package/vdp/VdpModules.tsx +13 -2
- package/vdp/VdpViewer.tsx +141 -50
package/vdp/VdpViewer.tsx
CHANGED
|
@@ -104,30 +104,38 @@ export interface VdpViewerProps {
|
|
|
104
104
|
/** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide the
|
|
105
105
|
* "Jetzt Anfragen" CTA (mobile) / envelope button (desktop). */
|
|
106
106
|
onEmail?: () => void;
|
|
107
|
-
/** 360
|
|
108
|
-
*
|
|
109
|
-
*
|
|
107
|
+
/** whether this vehicle has a valid 360° image set to view — the ONLY thing that gates showing
|
|
108
|
+
* the 360° control at all (mobile layouts only). Default false: no vehicle in the current DTO
|
|
109
|
+
* ships 360 media, so there is no reliable metadata to show it FOR today — this must come from
|
|
110
|
+
* real backend media metadata once that exists, never inferred (e.g. "gallery has N+ photos"
|
|
111
|
+
* is not evidence of a 360 set). */
|
|
112
|
+
has360?: boolean;
|
|
113
|
+
/** 360°-view toggle (mobile layout only, only rendered when `has360`) — wired here so a real
|
|
114
|
+
* spin viewer can hang off it once 360 data exists; presentational only until then. */
|
|
110
115
|
on360?: () => void;
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
/** Figma "360 / Icon" (node 424:8093) — two counter-rotating arcs; not part of the lucide set
|
|
114
|
-
* used elsewhere in this file, so it's inlined verbatim from the design's exported SVG.
|
|
115
|
-
|
|
119
|
+
* used elsewhere in this file, so it's inlined verbatim from the design's exported SVG. `color`
|
|
120
|
+
* defaults to the original hardcoded white (portrait/landscape's pill sits on a dark translucent
|
|
121
|
+
* background) — the desktop header's `.icon-btn` is light, so that usage passes `currentColor`
|
|
122
|
+
* to inherit the same `--md-on-surface-variant` grey the zoom/close lucide icons already use. */
|
|
123
|
+
const Icon360: React.FunctionComponent<{ color?: string }> = ({ color = 'white' }) => (
|
|
116
124
|
<svg width={18} height={18} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
117
125
|
<path
|
|
118
126
|
d="M5.25 6.3579C5.5593 5.55736 6.06014 4.84485 6.70866 4.28275C7.35719 3.72066 8.13362 3.32612 8.96998 3.13368C9.80634 2.94125 10.6771 2.95678 11.5061 3.17892C12.3351 3.40106 13.0969 3.82303 13.725 4.4079"
|
|
119
|
-
stroke=
|
|
127
|
+
stroke={color}
|
|
120
128
|
strokeWidth={1.5}
|
|
121
129
|
strokeLinecap="round"
|
|
122
130
|
/>
|
|
123
|
-
<path d="M14.2754 2.17499V5.17499H11.2754" stroke=
|
|
131
|
+
<path d="M14.2754 2.17499V5.17499H11.2754" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
124
132
|
<path
|
|
125
133
|
d="M14.7504 11.75C14.4411 12.5505 13.9403 13.2631 13.2917 13.8251C12.6432 14.3872 11.8668 14.7818 11.0304 14.9742C10.194 15.1666 9.32326 15.1511 8.49429 14.929C7.66533 14.7068 6.90346 14.2849 6.27539 13.7"
|
|
126
|
-
stroke=
|
|
134
|
+
stroke={color}
|
|
127
135
|
strokeWidth={1.5}
|
|
128
136
|
strokeLinecap="round"
|
|
129
137
|
/>
|
|
130
|
-
<path d="M5.72461 16V13H8.72461" stroke=
|
|
138
|
+
<path d="M5.72461 16V13H8.72461" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
131
139
|
</svg>
|
|
132
140
|
);
|
|
133
141
|
|
|
@@ -527,7 +535,7 @@ const PhotoSheet: React.FunctionComponent<{
|
|
|
527
535
|
);
|
|
528
536
|
};
|
|
529
537
|
|
|
530
|
-
const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail, on360 }) => {
|
|
538
|
+
const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail, has360 = false, on360 }) => {
|
|
531
539
|
const [i, setI] = useState(start);
|
|
532
540
|
const [zoom, setZoom] = useState(ZOOM_MIN);
|
|
533
541
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
@@ -635,20 +643,65 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
635
643
|
// around instead (that's the whole point of zooming in — being able to look at every part of
|
|
636
644
|
// it, not just the centered crop). Pointer Events cover mouse + touch + pen in one handler set.
|
|
637
645
|
const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number } } | null>(null);
|
|
646
|
+
// Pinch-to-zoom: every active pointer is tracked by id so a genuine 2nd finger landing can be
|
|
647
|
+
// told apart from the single-pointer pan/swipe drag above and switch into pinch mode instead.
|
|
648
|
+
// The ratio between the CURRENT and STARTING distance between the two touch points scales zoom
|
|
649
|
+
// directly — no attempt to keep the pinch midpoint visually fixed, matching the +/- buttons' own
|
|
650
|
+
// un-anchored `scale(zoom)` model; pan re-clamps itself automatically (the zoom-driven clampPan
|
|
651
|
+
// effect above already runs on every zoom change). The stage already has touch-action:none (see
|
|
652
|
+
// render below), so the browser's own native pinch/pan never competes with this.
|
|
653
|
+
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
|
654
|
+
const pinchRef = useRef<{ startDist: number; startZoom: number } | null>(null);
|
|
638
655
|
const onStagePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
639
656
|
// Don't hijack clicks on the buttons layered over the stage (arrows, counter, 360°, zoom).
|
|
640
657
|
if ((e.target as HTMLElement).closest('button')) return;
|
|
641
|
-
|
|
658
|
+
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
642
659
|
try {
|
|
643
660
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
644
661
|
} catch {
|
|
645
|
-
// Capture is a "nice to have" (keeps the
|
|
662
|
+
// Capture is a "nice to have" (keeps the gesture alive if a pointer strays outside the
|
|
646
663
|
// stage) — some browser/input combinations reject it for a pointerId that isn't tracked
|
|
647
|
-
// as active. The
|
|
664
|
+
// as active. The gesture still works via normal bubbling as long as the pointer stays over
|
|
648
665
|
// the stage, so this is safe to swallow rather than let it surface as an uncaught error.
|
|
649
666
|
}
|
|
667
|
+
if (pointersRef.current.size === 2) {
|
|
668
|
+
// 2nd finger just landed — hand off from any single-pointer pan/swipe in progress to pinch.
|
|
669
|
+
dragRef.current = null;
|
|
670
|
+
const [a, b] = [...pointersRef.current.values()];
|
|
671
|
+
pinchRef.current = { startDist: Math.hypot(a.x - b.x, a.y - b.y), startZoom: zoom };
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (pointersRef.current.size === 1) {
|
|
675
|
+
dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
|
|
676
|
+
}
|
|
650
677
|
};
|
|
651
678
|
const onStagePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
679
|
+
if (pointersRef.current.has(e.pointerId)) pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
680
|
+
if (pointersRef.current.size === 2 && pinchRef.current) {
|
|
681
|
+
const [a, b] = [...pointersRef.current.values()];
|
|
682
|
+
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
683
|
+
const el = stageRef.current;
|
|
684
|
+
if (pinchRef.current.startDist > 0 && el) {
|
|
685
|
+
const nextZoom = Math.min(
|
|
686
|
+
ZOOM_MAX,
|
|
687
|
+
Math.max(ZOOM_MIN, +((pinchRef.current.startZoom * dist) / pinchRef.current.startDist).toFixed(2)),
|
|
688
|
+
);
|
|
689
|
+
// Zoom around the pinch MIDPOINT, not the stage's fixed center: solve for the pan that
|
|
690
|
+
// keeps whatever image content is currently under the midpoint visually fixed there as
|
|
691
|
+
// zoom changes — the standard "zoom toward a point" transform math (an un-anchored
|
|
692
|
+
// scale(zoom) always re-centers, leaving the user to manually pan back to what they were
|
|
693
|
+
// actually pinching). rect.width/2, rect.height/2 is the origin scale() already uses
|
|
694
|
+
// (CSS default transform-origin, untouched), so dx/dy is the midpoint's offset from it.
|
|
695
|
+
const rect = el.getBoundingClientRect();
|
|
696
|
+
const dx = (a.x + b.x) / 2 - rect.left - rect.width / 2;
|
|
697
|
+
const dy = (a.y + b.y) / 2 - rect.top - rect.height / 2;
|
|
698
|
+
const ratio = nextZoom / zoom;
|
|
699
|
+
const nextPan = { x: dx * (1 - ratio) + ratio * pan.x, y: dy * (1 - ratio) + ratio * pan.y };
|
|
700
|
+
setZoom(nextZoom);
|
|
701
|
+
setPan(clampPan(nextPan, nextZoom));
|
|
702
|
+
}
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
652
705
|
const start = dragRef.current;
|
|
653
706
|
if (!start || zoom <= ZOOM_MIN) return;
|
|
654
707
|
const dx = e.clientX - start.x;
|
|
@@ -656,8 +709,13 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
656
709
|
setPan(clampPan({ x: start.panStart.x + dx, y: start.panStart.y + dy }, zoom));
|
|
657
710
|
};
|
|
658
711
|
const onStagePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
712
|
+
pointersRef.current.delete(e.pointerId);
|
|
713
|
+
if (pointersRef.current.size < 2) pinchRef.current = null;
|
|
659
714
|
const start = dragRef.current;
|
|
660
715
|
dragRef.current = null;
|
|
716
|
+
// a finger is still down (pinch ending back down to 1, not a full release) — don't read this
|
|
717
|
+
// as a tap/swipe.
|
|
718
|
+
if (pointersRef.current.size > 0) return;
|
|
661
719
|
if (!start) return;
|
|
662
720
|
if (zoom <= ZOOM_MIN) {
|
|
663
721
|
const dx = e.clientX - start.x;
|
|
@@ -792,7 +850,11 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
792
850
|
height: '100%',
|
|
793
851
|
objectFit: 'contain',
|
|
794
852
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
795
|
-
|
|
853
|
+
// pinchRef too, not just dragRef — without it every pinch pointermove still
|
|
854
|
+
// kicked off a 200ms eased transition toward a target that moves again before
|
|
855
|
+
// it finishes, which reads as the image flickering/jittering bigger-smaller
|
|
856
|
+
// instead of tracking the fingers 1:1 (drag already had this right).
|
|
857
|
+
transition: (dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
|
|
796
858
|
cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
|
|
797
859
|
}}
|
|
798
860
|
/>
|
|
@@ -803,15 +865,17 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
803
865
|
</span>
|
|
804
866
|
</div>
|
|
805
867
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
868
|
+
{has360 && (
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
onClick={() => on360 && on360()}
|
|
872
|
+
aria-label={t('autobahn:viewer.threeSixtyLabel')}
|
|
873
|
+
style={{ ...pillStyle, right: 12, border: '1px solid rgba(255,255,255,0.14)', cursor: 'pointer', gap: 3 }}
|
|
874
|
+
>
|
|
875
|
+
<Icon360 />
|
|
876
|
+
360°
|
|
877
|
+
</button>
|
|
878
|
+
)}
|
|
815
879
|
|
|
816
880
|
{n > 1 && (
|
|
817
881
|
<>
|
|
@@ -1068,7 +1132,11 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
1068
1132
|
height: '100%',
|
|
1069
1133
|
objectFit: 'cover',
|
|
1070
1134
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
1071
|
-
|
|
1135
|
+
// pinchRef too, not just dragRef — without it every pinch pointermove still
|
|
1136
|
+
// kicked off a 200ms eased transition toward a target that moves again before
|
|
1137
|
+
// it finishes, which reads as the image flickering/jittering bigger-smaller
|
|
1138
|
+
// instead of tracking the fingers 1:1 (drag already had this right).
|
|
1139
|
+
transition: (dragRef.current || pinchRef.current) ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
|
|
1072
1140
|
cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
|
|
1073
1141
|
}}
|
|
1074
1142
|
/>
|
|
@@ -1137,7 +1205,12 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
1137
1205
|
right: withInset(12, 'right'),
|
|
1138
1206
|
bottom: withInset(12, 'bottom'),
|
|
1139
1207
|
width: 48,
|
|
1140
|
-
|
|
1208
|
+
// 150 fits zoom (in/out + divider) AND the 360 toggle below it; without 360 the
|
|
1209
|
+
// column only needs the zoom pair, so it collapses to the plain zoom-only height
|
|
1210
|
+
// (matches portrait's own zoom-only pill, see the Figma node comment above) —
|
|
1211
|
+
// otherwise hiding just the button/divider below would leave dead space at the
|
|
1212
|
+
// bottom of a still-150px-tall pill.
|
|
1213
|
+
height: has360 ? 150 : 110,
|
|
1141
1214
|
borderRadius: 'var(--md-r-full)',
|
|
1142
1215
|
background: 'rgba(0,0,0,0.5)',
|
|
1143
1216
|
border: '1.2px solid rgba(255,255,255,0.18)',
|
|
@@ -1166,31 +1239,35 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
1166
1239
|
>
|
|
1167
1240
|
<IconZoomOut />
|
|
1168
1241
|
</button>
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1242
|
+
{has360 && (
|
|
1243
|
+
<>
|
|
1244
|
+
<div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '8px 0' }} />
|
|
1245
|
+
<button
|
|
1246
|
+
type="button"
|
|
1247
|
+
onClick={() => on360 && on360()}
|
|
1248
|
+
aria-label={t('autobahn:viewer.threeSixtyLabel')}
|
|
1249
|
+
style={{ position: 'relative', width: 30, height: 30, border: 0, background: 'none', padding: 0, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
1250
|
+
>
|
|
1251
|
+
<Ls360Circle />
|
|
1252
|
+
<span
|
|
1253
|
+
style={{
|
|
1254
|
+
position: 'absolute',
|
|
1255
|
+
top: '50%',
|
|
1256
|
+
left: '50%',
|
|
1257
|
+
transform: 'translate(-50%, -50%)',
|
|
1258
|
+
fontFamily: "'Inter', var(--md-font)",
|
|
1259
|
+
fontWeight: 700,
|
|
1260
|
+
fontSize: 8,
|
|
1261
|
+
lineHeight: '8px',
|
|
1262
|
+
color: '#fff',
|
|
1263
|
+
pointerEvents: 'none',
|
|
1264
|
+
}}
|
|
1265
|
+
>
|
|
1266
|
+
360°
|
|
1267
|
+
</span>
|
|
1268
|
+
</button>
|
|
1269
|
+
</>
|
|
1270
|
+
)}
|
|
1194
1271
|
</div>
|
|
1195
1272
|
|
|
1196
1273
|
{/* bottom band: thumbnails (left, flexible width) + CTA buttons (right, natural width)
|
|
@@ -1404,6 +1481,20 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
1404
1481
|
onClick={zoomIn}
|
|
1405
1482
|
style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
|
|
1406
1483
|
/>
|
|
1484
|
+
{has360 && (
|
|
1485
|
+
// IconButton only takes a lucide `name` — Icon360 is a one-off inlined SVG (see
|
|
1486
|
+
// its own comment above), so this is IconButton's own markup (.icon-btn + the
|
|
1487
|
+
// same iconBtnStyle) built by hand instead of a name it can't render.
|
|
1488
|
+
<button
|
|
1489
|
+
type="button"
|
|
1490
|
+
className="icon-btn"
|
|
1491
|
+
aria-label={t('autobahn:viewer.threeSixtyLabel')}
|
|
1492
|
+
onClick={() => on360 && on360()}
|
|
1493
|
+
style={iconBtnStyle}
|
|
1494
|
+
>
|
|
1495
|
+
<Icon360 color="currentColor" />
|
|
1496
|
+
</button>
|
|
1497
|
+
)}
|
|
1407
1498
|
{onEmail && (
|
|
1408
1499
|
<IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
|
|
1409
1500
|
)}
|