@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/vdp/VdpViewer.tsx CHANGED
@@ -64,6 +64,27 @@ const MOBILE_BREAKPOINT = 860;
64
64
  /** Landscape thumbnails (Figma node 424:7566 etc.) are 68px, not portrait's 64. */
65
65
  const LS_THUMB_W = 68;
66
66
 
67
+ /** Safe-area helpers — keep overlay controls (especially the close button) clear of the notch /
68
+ * Dynamic Island / status bar (top), home indicator (bottom) and, in landscape, whichever side the
69
+ * notch rotates onto. `env(safe-area-inset-*)` only resolves to anything but 0px when the page's
70
+ * `<meta name="viewport">` declares `viewport-fit=cover` (see services/fe-main/app.html) — the
71
+ * 0px fallback inside every `env()` call here means both of these are complete no-ops (identical
72
+ * to the previous hardcoded numbers) on any browser/device that has no safe area to begin with, so
73
+ * there's no behavioural change for the common case, only where a notch/indicator is actually
74
+ * present. Two shapes, used in different structural contexts below:
75
+ * - `withInset` ADDS the inset to a design offset — for absolute-positioned controls (landscape's
76
+ * floating close/logo/counter/prev-next/zoom/etc.) whose `top`/`right`/… is a gap from the edge
77
+ * of the *safe* content area, which needs to grow by however much the notch/indicator eats into
78
+ * the raw viewport edge.
79
+ * - `atLeastInset` guarantees a MINIMUM gap instead — for padding on a bar that already paints its
80
+ * own edge-to-edge background (portrait's header/bottom-CTA bar, the photosheet), where a small
81
+ * or absent safe area should still leave the existing 12px breathing room, not shrink it.
82
+ */
83
+ const withInset = (px: number, side: 'top' | 'right' | 'bottom' | 'left'): string =>
84
+ `calc(${px}px + env(safe-area-inset-${side}, 0px))`;
85
+ const atLeastInset = (px: number, side: 'top' | 'right' | 'bottom' | 'left'): string =>
86
+ `max(${px}px, env(safe-area-inset-${side}, 0px))`;
87
+
67
88
  type Layout = 'portrait' | 'landscape' | 'desktop';
68
89
  const layoutForWidth = (w: number): Layout => (w <= PORTRAIT_MAX ? 'portrait' : w <= MOBILE_BREAKPOINT ? 'landscape' : 'desktop');
69
90
 
@@ -83,30 +104,38 @@ export interface VdpViewerProps {
83
104
  /** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide the
84
105
  * "Jetzt Anfragen" CTA (mobile) / envelope button (desktop). */
85
106
  onEmail?: () => void;
86
- /** 360°-view toggle (mobile layout only) — presentational only for now (no vehicle in the
87
- * current DTO ships a 360 image set), wired here so a real spin viewer can hang off it once
88
- * 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. */
89
115
  on360?: () => void;
90
116
  }
91
117
 
92
118
  /** Figma "360 / Icon" (node 424:8093) — two counter-rotating arcs; not part of the lucide set
93
- * used elsewhere in this file, so it's inlined verbatim from the design's exported SVG. */
94
- 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' }) => (
95
124
  <svg width={18} height={18} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
96
125
  <path
97
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"
98
- stroke="white"
127
+ stroke={color}
99
128
  strokeWidth={1.5}
100
129
  strokeLinecap="round"
101
130
  />
102
- <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" />
103
132
  <path
104
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"
105
- stroke="white"
134
+ stroke={color}
106
135
  strokeWidth={1.5}
107
136
  strokeLinecap="round"
108
137
  />
109
- <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" />
110
139
  </svg>
111
140
  );
112
141
 
@@ -506,7 +535,7 @@ const PhotoSheet: React.FunctionComponent<{
506
535
  );
507
536
  };
508
537
 
509
- 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 }) => {
510
539
  const [i, setI] = useState(start);
511
540
  const [zoom, setZoom] = useState(ZOOM_MIN);
512
541
  const [pan, setPan] = useState({ x: 0, y: 0 });
@@ -614,20 +643,65 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
614
643
  // around instead (that's the whole point of zooming in — being able to look at every part of
615
644
  // it, not just the centered crop). Pointer Events cover mouse + touch + pen in one handler set.
616
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);
617
655
  const onStagePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
618
656
  // Don't hijack clicks on the buttons layered over the stage (arrows, counter, 360°, zoom).
619
657
  if ((e.target as HTMLElement).closest('button')) return;
620
- dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
658
+ pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
621
659
  try {
622
660
  e.currentTarget.setPointerCapture(e.pointerId);
623
661
  } catch {
624
- // 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
625
663
  // stage) — some browser/input combinations reject it for a pointerId that isn't tracked
626
- // 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
627
665
  // the stage, so this is safe to swallow rather than let it surface as an uncaught error.
628
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
+ }
629
677
  };
630
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
+ }
631
705
  const start = dragRef.current;
632
706
  if (!start || zoom <= ZOOM_MIN) return;
633
707
  const dx = e.clientX - start.x;
@@ -635,8 +709,13 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
635
709
  setPan(clampPan({ x: start.panStart.x + dx, y: start.panStart.y + dy }, zoom));
636
710
  };
637
711
  const onStagePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
712
+ pointersRef.current.delete(e.pointerId);
713
+ if (pointersRef.current.size < 2) pinchRef.current = null;
638
714
  const start = dragRef.current;
639
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;
640
719
  if (!start) return;
641
720
  if (zoom <= ZOOM_MIN) {
642
721
  const dx = e.clientX - start.x;
@@ -719,7 +798,10 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
719
798
  display: 'flex',
720
799
  alignItems: 'flex-start',
721
800
  justifyContent: 'space-between',
722
- padding: '12px 12px 10px',
801
+ // safe-area-aware: this bar sits flush against the top of the fullscreen overlay
802
+ // (the notch/Dynamic Island/status bar's exact territory), and its close button is
803
+ // the primary control the "always tappable" acceptance criterion is about.
804
+ padding: `${atLeastInset(12, 'top')} ${atLeastInset(12, 'right')} 10px ${atLeastInset(12, 'left')}`,
723
805
  background: 'rgba(6,11,18,0.8)',
724
806
  borderBottom: '1px solid rgba(255,255,255,0.12)',
725
807
  }}
@@ -730,7 +812,10 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
730
812
  ariaLabel={t('autobahn:viewer.closeLabel')}
731
813
  onClick={onClose}
732
814
  />
733
- <div style={{ position: 'absolute', left: '50%', top: 10, transform: 'translateX(-50%)', width: 160, height: 71 }}>
815
+ {/* absolutely positioned (unlike its close/call siblings), so it does NOT
816
+ automatically shift down with the header's safe-area padding above — needs its
817
+ own top offset. */}
818
+ <div style={{ position: 'absolute', left: '50%', top: withInset(10, 'top'), transform: 'translateX(-50%)', width: 160, height: 71 }}>
734
819
  <LogoMark />
735
820
  </div>
736
821
  {onCall ? (
@@ -765,7 +850,11 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
765
850
  height: '100%',
766
851
  objectFit: 'contain',
767
852
  transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
768
- 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)',
769
858
  cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
770
859
  }}
771
860
  />
@@ -776,15 +865,17 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
776
865
  </span>
777
866
  </div>
778
867
 
779
- <button
780
- type="button"
781
- onClick={() => on360 && on360()}
782
- aria-label={t('autobahn:viewer.threeSixtyLabel')}
783
- style={{ ...pillStyle, right: 12, border: '1px solid rgba(255,255,255,0.14)', cursor: 'pointer', gap: 3 }}
784
- >
785
- <Icon360 />
786
- 360°
787
- </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
+ )}
788
879
 
789
880
  {n > 1 && (
790
881
  <>
@@ -914,7 +1005,23 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
914
1005
  {/* full-width bottom bar — "Jetzt Anfragen" (primary) + "Anrufen" (dark), Figma nodes
915
1006
  424:8128-8134. */}
916
1007
  {(onEmail || onCall) && (
917
- <div style={{ display: 'flex', gap: THUMB_GAP, padding: 12, height: 82, background: '#797979', flex: 'none' }}>
1008
+ // safe-area-aware: last item before the true bottom edge — height is deliberately
1009
+ // NOT fixed anymore (was 82px) so the extra bottom padding on a home-indicator device
1010
+ // grows the bar instead of squeezing its 56px-tall buttons; `alignItems: 'center'`
1011
+ // keeps them centered in that now-variable padding box.
1012
+ <div
1013
+ style={{
1014
+ display: 'flex',
1015
+ alignItems: 'center',
1016
+ gap: THUMB_GAP,
1017
+ paddingTop: 12,
1018
+ paddingRight: atLeastInset(12, 'right'),
1019
+ paddingBottom: atLeastInset(12, 'bottom'),
1020
+ paddingLeft: atLeastInset(12, 'left'),
1021
+ background: '#797979',
1022
+ flex: 'none',
1023
+ }}
1024
+ >
918
1025
  {onEmail && (
919
1026
  <button
920
1027
  type="button"
@@ -1025,7 +1132,11 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1025
1132
  height: '100%',
1026
1133
  objectFit: 'cover',
1027
1134
  transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
1028
- 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)',
1029
1140
  cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
1030
1141
  }}
1031
1142
  />
@@ -1035,25 +1146,54 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1035
1146
  brand-blue/gradient mark (verified against its raw export), i.e. the SAME svg this
1036
1147
  codebase already ships as `assets/autode-logo.svg` — reused directly, not portrait's
1037
1148
  white LogoMark (landscape has no dark bar behind it to need a white variant). */}
1038
- <div style={{ position: 'absolute', left: 4, top: 10, width: 93, height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
1149
+ <div
1150
+ style={{
1151
+ position: 'absolute',
1152
+ left: withInset(4, 'left'),
1153
+ top: withInset(10, 'top'),
1154
+ width: 93,
1155
+ height: 42,
1156
+ display: 'flex',
1157
+ alignItems: 'center',
1158
+ justifyContent: 'center',
1159
+ }}
1160
+ >
1039
1161
  <img src={logoUrl} alt="auto.de" style={{ width: 74, height: 21.46, display: 'block' }} />
1040
1162
  </div>
1041
1163
 
1042
1164
  {/* counter pill — Figma node 424:9277 "Counter / BG" */}
1043
- <div style={lsCounterStyle} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
1165
+ <div style={{ ...lsCounterStyle, top: withInset(12, 'top') }} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
1044
1166
  <span className="num">
1045
1167
  {i + 1} | {n}
1046
1168
  </span>
1047
1169
  </div>
1048
1170
 
1049
- {/* close — Figma nodes 424:7556/7557, no caption text in this layout */}
1050
- <LsCircleButton icon={<IconClose />} ariaLabel={t('autobahn:viewer.closeLabel')} onClick={onClose} style={{ right: 12, top: 12 }} />
1171
+ {/* close — Figma nodes 424:7556/7557, no caption text in this layout. Rotating the
1172
+ phone moves the notch/Dynamic Island to whichever side is now "up" (device-relative
1173
+ left/right, not a fixed screen side) — safe-area-aware on both axes since this sits
1174
+ at the true top-right corner, the corner most likely to overlap it. */}
1175
+ <LsCircleButton
1176
+ icon={<IconClose />}
1177
+ ariaLabel={t('autobahn:viewer.closeLabel')}
1178
+ onClick={onClose}
1179
+ style={{ right: withInset(12, 'right'), top: withInset(12, 'top') }}
1180
+ />
1051
1181
 
1052
1182
  {/* prev/next — Figma nodes 424:7560/7561 and 424:7563/7564 */}
1053
1183
  {n > 1 && (
1054
1184
  <>
1055
- <LsCircleButton icon={<IconChevronLeft />} ariaLabel={t('autobahn:viewer.previousLabel')} onClick={() => go(-1)} style={{ left: 12, top: 126 }} />
1056
- <LsCircleButton icon={<IconChevronRight />} ariaLabel={t('autobahn:viewer.nextLabel')} onClick={() => go(1)} style={{ right: 12, top: 126 }} />
1185
+ <LsCircleButton
1186
+ icon={<IconChevronLeft />}
1187
+ ariaLabel={t('autobahn:viewer.previousLabel')}
1188
+ onClick={() => go(-1)}
1189
+ style={{ left: withInset(12, 'left'), top: 126 }}
1190
+ />
1191
+ <LsCircleButton
1192
+ icon={<IconChevronRight />}
1193
+ ariaLabel={t('autobahn:viewer.nextLabel')}
1194
+ onClick={() => go(1)}
1195
+ style={{ right: withInset(12, 'right'), top: 126 }}
1196
+ />
1057
1197
  </>
1058
1198
  )}
1059
1199
 
@@ -1062,10 +1202,15 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1062
1202
  <div
1063
1203
  style={{
1064
1204
  position: 'absolute',
1065
- right: 12,
1066
- bottom: 12,
1205
+ right: withInset(12, 'right'),
1206
+ bottom: withInset(12, 'bottom'),
1067
1207
  width: 48,
1068
- 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,
1069
1214
  borderRadius: 'var(--md-r-full)',
1070
1215
  background: 'rgba(0,0,0,0.5)',
1071
1216
  border: '1.2px solid rgba(255,255,255,0.18)',
@@ -1094,31 +1239,35 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1094
1239
  >
1095
1240
  <IconZoomOut />
1096
1241
  </button>
1097
- <div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '8px 0' }} />
1098
- <button
1099
- type="button"
1100
- onClick={() => on360 && on360()}
1101
- aria-label={t('autobahn:viewer.threeSixtyLabel')}
1102
- style={{ position: 'relative', width: 30, height: 30, border: 0, background: 'none', padding: 0, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
1103
- >
1104
- <Ls360Circle />
1105
- <span
1106
- style={{
1107
- position: 'absolute',
1108
- top: '50%',
1109
- left: '50%',
1110
- transform: 'translate(-50%, -50%)',
1111
- fontFamily: "'Inter', var(--md-font)",
1112
- fontWeight: 700,
1113
- fontSize: 8,
1114
- lineHeight: '8px',
1115
- color: '#fff',
1116
- pointerEvents: 'none',
1117
- }}
1118
- >
1119
- 360°
1120
- </span>
1121
- </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
+ )}
1122
1271
  </div>
1123
1272
 
1124
1273
  {/* bottom band: thumbnails (left, flexible width) + CTA buttons (right, natural width)
@@ -1126,7 +1275,20 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1126
1275
  align the way the design does, without hardcoding a container width that would break
1127
1276
  across the 693-860 range. `right: 81` keeps this band clear of the zoom/360 column
1128
1277
  (12 right-margin + 48 wide + the 21px gap Figma leaves before it = 81). */}
1129
- <div style={{ position: 'absolute', left: 12, right: 81, bottom: 12, height: 50, display: 'flex', alignItems: 'center' }}>
1278
+ <div
1279
+ style={{
1280
+ position: 'absolute',
1281
+ left: withInset(12, 'left'),
1282
+ // 81 = 12 right-margin + 48 zoom-column width + 21 Figma gap before it; add the
1283
+ // same right inset the zoom/360 column itself got above so this band still clears
1284
+ // it once that column has shifted in from a notched edge.
1285
+ right: withInset(81, 'right'),
1286
+ bottom: withInset(12, 'bottom'),
1287
+ height: 50,
1288
+ display: 'flex',
1289
+ alignItems: 'center',
1290
+ }}
1291
+ >
1130
1292
  {(() => {
1131
1293
  const overflow = images.length > lsFitCount;
1132
1294
  const visibleCount = overflow ? lsFitCount - 1 : images.length;
@@ -1319,6 +1481,20 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
1319
1481
  onClick={zoomIn}
1320
1482
  style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
1321
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
+ )}
1322
1498
  {onEmail && (
1323
1499
  <IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
1324
1500
  )}