@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/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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
|
|
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=
|
|
127
|
+
stroke={color}
|
|
99
128
|
strokeWidth={1.5}
|
|
100
129
|
strokeLinecap="round"
|
|
101
130
|
/>
|
|
102
|
-
<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" />
|
|
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=
|
|
134
|
+
stroke={color}
|
|
106
135
|
strokeWidth={1.5}
|
|
107
136
|
strokeLinecap="round"
|
|
108
137
|
/>
|
|
109
|
-
<path d="M5.72461 16V13H8.72461" stroke=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1056
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
|
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
|
)}
|