@bytebrand/fe-ui-core-autobahn 1.0.114 → 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/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°-view toggle (mobile layout only) — presentational only for now (no vehicle in the
108
- * current DTO ships a 360 image set), wired here so a real spin viewer can hang off it once
109
- * that data exists. */
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
- const Icon360: React.FunctionComponent = () => (
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="white"
127
+ stroke={color}
120
128
  strokeWidth={1.5}
121
129
  strokeLinecap="round"
122
130
  />
123
- <path d="M14.2754 2.17499V5.17499H11.2754" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
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="white"
134
+ stroke={color}
127
135
  strokeWidth={1.5}
128
136
  strokeLinecap="round"
129
137
  />
130
- <path d="M5.72461 16V13H8.72461" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
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
- dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
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 drag alive if the pointer strays outside 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 drag still works via normal bubbling as long as the pointer stays over
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
- transition: dragRef.current ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
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
- <button
807
- type="button"
808
- onClick={() => on360 && on360()}
809
- aria-label={t('autobahn:viewer.threeSixtyLabel')}
810
- style={{ ...pillStyle, right: 12, border: '1px solid rgba(255,255,255,0.14)', cursor: 'pointer', gap: 3 }}
811
- >
812
- <Icon360 />
813
- 360°
814
- </button>
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
- transition: dragRef.current ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
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
- height: 150,
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
- <div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '8px 0' }} />
1170
- <button
1171
- type="button"
1172
- onClick={() => on360 && on360()}
1173
- aria-label={t('autobahn:viewer.threeSixtyLabel')}
1174
- style={{ position: 'relative', width: 30, height: 30, border: 0, background: 'none', padding: 0, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
1175
- >
1176
- <Ls360Circle />
1177
- <span
1178
- style={{
1179
- position: 'absolute',
1180
- top: '50%',
1181
- left: '50%',
1182
- transform: 'translate(-50%, -50%)',
1183
- fontFamily: "'Inter', var(--md-font)",
1184
- fontWeight: 700,
1185
- fontSize: 8,
1186
- lineHeight: '8px',
1187
- color: '#fff',
1188
- pointerEvents: 'none',
1189
- }}
1190
- >
1191
- 360°
1192
- </span>
1193
- </button>
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
  )}