sunpeak 0.6.1 → 0.6.5
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/bin/sunpeak.js +133 -6
- package/dist/chatgpt/conversation.d.ts +2 -1
- package/dist/index.cjs +24 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +24 -4
- package/dist/index.js.map +1 -1
- package/dist/mcp/entry.cjs +2 -2
- package/dist/mcp/entry.cjs.map +1 -1
- package/dist/mcp/entry.js +2 -2
- package/dist/mcp/entry.js.map +1 -1
- package/dist/mcp/index.cjs +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/{server-DpriZ4jT.cjs → server-CQGbJWbk.cjs} +17 -8
- package/dist/{server-DpriZ4jT.cjs.map → server-CQGbJWbk.cjs.map} +1 -1
- package/dist/{server-SBlanUcf.js → server-DGCvp1RA.js} +17 -8
- package/dist/{server-SBlanUcf.js.map → server-DGCvp1RA.js.map} +1 -1
- package/dist/style.css +444 -0
- package/package.json +1 -1
- package/template/.sunpeak/dev.tsx +1 -1
- package/template/dist/chatgpt/albums.js +2 -2
- package/template/dist/chatgpt/carousel.js +1 -1
- package/template/dist/chatgpt/counter.js +1 -1
- package/template/dist/chatgpt/pizzaz.js +3034 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Avatar.js +97 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Avatar.js.map +7 -0
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Button.js +3 -3
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js +1 -1
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js +16 -16
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js +3 -3
- package/template/node_modules/.vite/deps/_metadata.json +45 -33
- package/template/node_modules/.vite/deps/{chunk-DQAZDQU3.js → chunk-LR7NKCX5.js} +8 -8
- package/template/node_modules/.vite/deps/mapbox-gl.js +32835 -0
- package/template/node_modules/.vite/deps/mapbox-gl.js.map +7 -0
- package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/template/package.json +1 -0
- package/template/src/components/album/album-carousel.test.tsx +84 -0
- package/template/src/components/album/album-carousel.tsx +168 -0
- package/template/src/components/album/albums.test.tsx +2 -2
- package/template/src/components/album/albums.tsx +3 -3
- package/template/src/components/album/index.ts +1 -0
- package/template/src/components/carousel/index.ts +1 -0
- package/template/src/components/index.ts +1 -1
- package/template/src/components/pizzaz/index.ts +6 -0
- package/template/src/components/pizzaz/map-view.tsx +212 -0
- package/template/src/components/pizzaz/pizzaz.tsx +145 -0
- package/template/src/components/pizzaz/place-card.tsx +55 -0
- package/template/src/components/pizzaz/place-carousel.tsx +45 -0
- package/template/src/components/pizzaz/place-inspector.tsx +132 -0
- package/template/src/components/pizzaz/place-list.tsx +90 -0
- package/template/src/resources/carousel-resource.test.tsx +1 -4
- package/template/src/resources/carousel-resource.tsx +1 -2
- package/template/src/resources/index.ts +1 -0
- package/template/src/resources/pizzaz-resource.tsx +32 -0
- package/template/src/simulations/index.ts +2 -0
- package/template/src/simulations/pizzaz-simulation.ts +177 -0
- package/template/src/components/card/index.ts +0 -1
- /package/template/node_modules/.vite/deps/{chunk-DQAZDQU3.js.map → chunk-LR7NKCX5.js.map} +0 -0
- /package/template/src/components/{card → carousel}/card.test.tsx +0 -0
- /package/template/src/components/{card → carousel}/card.tsx +0 -0
package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.0.13","results":[[":src/resources/
|
|
1
|
+
{"version":"4.0.13","results":[[":src/resources/carousel-resource.test.tsx",{"duration":268.5257530000001,"failed":false}],[":src/components/album/albums.test.tsx",{"duration":360.95308,"failed":false}],[":src/resources/counter-resource.test.tsx",{"duration":304.96090899999945,"failed":false}],[":src/components/album/fullscreen-viewer.test.tsx",{"duration":245.82036500000004,"failed":false}],[":src/components/album/album-carousel.test.tsx",{"duration":80.01594899999964,"failed":false}],[":src/components/carousel/carousel.test.tsx",{"duration":78.85182500000019,"failed":false}],[":src/components/album/film-strip.test.tsx",{"duration":434.28565300000014,"failed":false}],[":src/resources/albums-resource.test.tsx",{"duration":222.29475500000012,"failed":false}],[":src/components/album/album-card.test.tsx",{"duration":281.053044,"failed":false}],[":src/components/carousel/card.test.tsx",{"duration":53.92481799999996,"failed":false}]]}
|
package/template/package.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { AlbumCarousel } from './album-carousel';
|
|
4
|
+
|
|
5
|
+
const mockUseDisplayMode = vi.fn(() => 'inline');
|
|
6
|
+
|
|
7
|
+
// Mock sunpeak hooks
|
|
8
|
+
vi.mock('sunpeak', () => ({
|
|
9
|
+
useWidgetState: vi.fn(() => [{ currentIndex: 0 }, vi.fn()]),
|
|
10
|
+
useDisplayMode: () => mockUseDisplayMode(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock embla-carousel-react
|
|
14
|
+
vi.mock('embla-carousel-react', () => ({
|
|
15
|
+
default: vi.fn(() => [vi.fn(), null]),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock embla-carousel-wheel-gestures
|
|
19
|
+
vi.mock('embla-carousel-wheel-gestures', () => ({
|
|
20
|
+
WheelGesturesPlugin: vi.fn(() => ({})),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('AlbumCarousel', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockUseDisplayMode.mockReturnValue('inline');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders all children with correct card width', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<AlbumCarousel cardWidth={300}>
|
|
31
|
+
<div>Card 1</div>
|
|
32
|
+
<div>Card 2</div>
|
|
33
|
+
<div>Card 3</div>
|
|
34
|
+
</AlbumCarousel>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const cardContainers = container.querySelectorAll('.flex-none');
|
|
38
|
+
expect(cardContainers).toHaveLength(3);
|
|
39
|
+
|
|
40
|
+
cardContainers.forEach((cardContainer) => {
|
|
41
|
+
const element = cardContainer as HTMLElement;
|
|
42
|
+
expect(element.style.minWidth).toBe('300px');
|
|
43
|
+
expect(element.style.maxWidth).toBe('300px');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles cardWidth object with inline/fullscreen modes', () => {
|
|
48
|
+
// Test inline mode
|
|
49
|
+
mockUseDisplayMode.mockReturnValue('inline');
|
|
50
|
+
const { container: inlineContainer } = render(
|
|
51
|
+
<AlbumCarousel cardWidth={{ inline: 250, fullscreen: 400 }}>
|
|
52
|
+
<div>Card 1</div>
|
|
53
|
+
</AlbumCarousel>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
let cardContainer = inlineContainer.querySelector('.flex-none') as HTMLElement;
|
|
57
|
+
expect(cardContainer.style.minWidth).toBe('250px');
|
|
58
|
+
|
|
59
|
+
// Test fullscreen mode
|
|
60
|
+
mockUseDisplayMode.mockReturnValue('fullscreen');
|
|
61
|
+
const { container: fullscreenContainer } = render(
|
|
62
|
+
<AlbumCarousel cardWidth={{ inline: 250, fullscreen: 400 }}>
|
|
63
|
+
<div>Card 1</div>
|
|
64
|
+
</AlbumCarousel>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
cardContainer = fullscreenContainer.querySelector('.flex-none') as HTMLElement;
|
|
68
|
+
expect(cardContainer.style.minWidth).toBe('400px');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('applies custom gap between cards', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<AlbumCarousel gap={24}>
|
|
74
|
+
<div>Card 1</div>
|
|
75
|
+
<div>Card 2</div>
|
|
76
|
+
</AlbumCarousel>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const carouselTrack = container.querySelector('.flex.touch-pan-y') as HTMLElement;
|
|
80
|
+
expect(carouselTrack.style.gap).toBe('24px');
|
|
81
|
+
expect(carouselTrack.style.marginLeft).toBe('-24px');
|
|
82
|
+
expect(carouselTrack.style.paddingLeft).toBe('24px');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import useEmblaCarousel from 'embla-carousel-react';
|
|
3
|
+
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
|
4
|
+
import { ArrowLeft, ArrowRight } from '@openai/apps-sdk-ui/components/Icon';
|
|
5
|
+
import { useWidgetState, useDisplayMode } from 'sunpeak';
|
|
6
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
7
|
+
import { cn } from '../../lib/index';
|
|
8
|
+
|
|
9
|
+
export interface AlbumCarouselState extends Record<string, unknown> {
|
|
10
|
+
currentIndex?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AlbumCarouselProps = {
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
gap?: number;
|
|
16
|
+
showArrows?: boolean;
|
|
17
|
+
showEdgeGradients?: boolean;
|
|
18
|
+
cardWidth?: number | { inline?: number; fullscreen?: number };
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const AlbumCarousel = React.forwardRef<HTMLDivElement, AlbumCarouselProps>(
|
|
23
|
+
(
|
|
24
|
+
{ children, gap = 16, showArrows = true, showEdgeGradients = true, cardWidth, className },
|
|
25
|
+
ref
|
|
26
|
+
) => {
|
|
27
|
+
const [widgetState, setWidgetState] = useWidgetState<AlbumCarouselState>(() => ({
|
|
28
|
+
currentIndex: 0,
|
|
29
|
+
}));
|
|
30
|
+
const displayMode = useDisplayMode();
|
|
31
|
+
|
|
32
|
+
const [emblaRef, emblaApi] = useEmblaCarousel(
|
|
33
|
+
{
|
|
34
|
+
align: 'start',
|
|
35
|
+
dragFree: true,
|
|
36
|
+
containScroll: 'trimSnaps',
|
|
37
|
+
},
|
|
38
|
+
[WheelGesturesPlugin()]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
|
42
|
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
|
43
|
+
|
|
44
|
+
const scrollPrev = React.useCallback(() => {
|
|
45
|
+
if (emblaApi) emblaApi.scrollPrev();
|
|
46
|
+
}, [emblaApi]);
|
|
47
|
+
|
|
48
|
+
const scrollNext = React.useCallback(() => {
|
|
49
|
+
if (emblaApi) emblaApi.scrollNext();
|
|
50
|
+
}, [emblaApi]);
|
|
51
|
+
|
|
52
|
+
const onSelect = React.useCallback(() => {
|
|
53
|
+
if (!emblaApi) return;
|
|
54
|
+
|
|
55
|
+
setCanScrollPrev(emblaApi.canScrollPrev());
|
|
56
|
+
setCanScrollNext(emblaApi.canScrollNext());
|
|
57
|
+
|
|
58
|
+
const currentIndex = emblaApi.selectedScrollSnap();
|
|
59
|
+
if (widgetState?.currentIndex !== currentIndex) {
|
|
60
|
+
setWidgetState((prev) => ({ ...prev, currentIndex }));
|
|
61
|
+
}
|
|
62
|
+
}, [emblaApi, widgetState?.currentIndex, setWidgetState]);
|
|
63
|
+
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
if (!emblaApi) return;
|
|
66
|
+
|
|
67
|
+
onSelect();
|
|
68
|
+
emblaApi.on('select', onSelect);
|
|
69
|
+
emblaApi.on('reInit', onSelect);
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
emblaApi.off('select', onSelect);
|
|
73
|
+
emblaApi.off('reInit', onSelect);
|
|
74
|
+
};
|
|
75
|
+
}, [emblaApi, onSelect]);
|
|
76
|
+
|
|
77
|
+
const childArray = React.Children.toArray(children);
|
|
78
|
+
|
|
79
|
+
const getCardWidth = () => {
|
|
80
|
+
if (typeof cardWidth === 'number') {
|
|
81
|
+
return cardWidth;
|
|
82
|
+
}
|
|
83
|
+
if (cardWidth && typeof cardWidth === 'object') {
|
|
84
|
+
if (displayMode === 'fullscreen' && cardWidth.fullscreen) {
|
|
85
|
+
return cardWidth.fullscreen;
|
|
86
|
+
}
|
|
87
|
+
if (cardWidth.inline) {
|
|
88
|
+
return cardWidth.inline;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return 220;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const cardWidthPx = getCardWidth();
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div ref={ref} className={cn('relative w-full', className)}>
|
|
98
|
+
{/* Left edge gradient */}
|
|
99
|
+
{showEdgeGradients && canScrollPrev && (
|
|
100
|
+
<div
|
|
101
|
+
className="pointer-events-none absolute left-0 top-0 z-10 h-full w-12 bg-gradient-to-r from-surface to-transparent"
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{/* Right edge gradient */}
|
|
107
|
+
{showEdgeGradients && canScrollNext && (
|
|
108
|
+
<div
|
|
109
|
+
className="pointer-events-none absolute right-0 top-0 z-10 h-full w-12 bg-gradient-to-l from-surface to-transparent"
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Carousel viewport */}
|
|
115
|
+
<div ref={emblaRef} className="overflow-hidden w-full">
|
|
116
|
+
<div
|
|
117
|
+
className="flex touch-pan-y"
|
|
118
|
+
style={{
|
|
119
|
+
gap: `${gap}px`,
|
|
120
|
+
marginLeft: `-${gap}px`,
|
|
121
|
+
paddingLeft: `${gap}px`,
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{childArray.map((child, index) => (
|
|
125
|
+
<div
|
|
126
|
+
key={index}
|
|
127
|
+
className="flex-none"
|
|
128
|
+
style={{
|
|
129
|
+
minWidth: `${cardWidthPx}px`,
|
|
130
|
+
maxWidth: `${cardWidthPx}px`,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{child}
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Previous button */}
|
|
140
|
+
{showArrows && canScrollPrev && (
|
|
141
|
+
<Button
|
|
142
|
+
variant="soft"
|
|
143
|
+
color="secondary"
|
|
144
|
+
onClick={scrollPrev}
|
|
145
|
+
className="absolute left-2 top-1/2 -translate-y-1/2 z-20 h-8 w-8 min-w-8 rounded-full p-0 shadow-md"
|
|
146
|
+
aria-label="Previous slide"
|
|
147
|
+
>
|
|
148
|
+
<ArrowLeft className="h-4 w-4" />
|
|
149
|
+
</Button>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Next button */}
|
|
153
|
+
{showArrows && canScrollNext && (
|
|
154
|
+
<Button
|
|
155
|
+
variant="soft"
|
|
156
|
+
color="secondary"
|
|
157
|
+
onClick={scrollNext}
|
|
158
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 z-20 h-8 w-8 min-w-8 rounded-full p-0 shadow-md"
|
|
159
|
+
aria-label="Next slide"
|
|
160
|
+
>
|
|
161
|
+
<ArrowRight className="h-4 w-4" />
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
AlbumCarousel.displayName = 'AlbumCarousel';
|
|
@@ -29,8 +29,8 @@ vi.mock('./fullscreen-viewer', () => ({
|
|
|
29
29
|
),
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
-
vi.mock('
|
|
33
|
-
|
|
32
|
+
vi.mock('./album-carousel', () => ({
|
|
33
|
+
AlbumCarousel: ({ children }: { children: React.ReactNode }) => (
|
|
34
34
|
<div data-testid="carousel">{children}</div>
|
|
35
35
|
),
|
|
36
36
|
}));
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
useWidgetProps,
|
|
7
7
|
useUserAgent,
|
|
8
8
|
} from 'sunpeak';
|
|
9
|
-
import {
|
|
9
|
+
import { AlbumCarousel } from './album-carousel';
|
|
10
10
|
import { AlbumCard } from './album-card';
|
|
11
11
|
import { FullscreenViewer } from './fullscreen-viewer';
|
|
12
12
|
|
|
@@ -60,7 +60,7 @@ export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className
|
|
|
60
60
|
|
|
61
61
|
return (
|
|
62
62
|
<div ref={ref} className={className}>
|
|
63
|
-
<
|
|
63
|
+
<AlbumCarousel gap={20} showArrows={false} showEdgeGradients={false} cardWidth={272}>
|
|
64
64
|
{albums.map((album: Album) => (
|
|
65
65
|
<AlbumCard
|
|
66
66
|
key={album.id}
|
|
@@ -69,7 +69,7 @@ export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className
|
|
|
69
69
|
buttonSize={hasTouch ? 'lg' : 'md'}
|
|
70
70
|
/>
|
|
71
71
|
))}
|
|
72
|
-
</
|
|
72
|
+
</AlbumCarousel>
|
|
73
73
|
</div>
|
|
74
74
|
);
|
|
75
75
|
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import mapboxgl from 'mapbox-gl';
|
|
3
|
+
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
4
|
+
import { useMaxHeight } from 'sunpeak';
|
|
5
|
+
import { cn } from '../../lib/index';
|
|
6
|
+
import type { Place } from '../../simulations/pizzaz-simulation';
|
|
7
|
+
|
|
8
|
+
// Public Mapbox token for demo purposes
|
|
9
|
+
mapboxgl.accessToken =
|
|
10
|
+
'pk.eyJ1IjoiZXJpY25pbmciLCJhIjoiY21icXlubWM1MDRiczJvb2xwM2p0amNyayJ9.n-3O6JI5nOp_Lw96ZO5vJQ';
|
|
11
|
+
|
|
12
|
+
export type MapViewProps = {
|
|
13
|
+
places: Place[];
|
|
14
|
+
selectedPlace: Place | null;
|
|
15
|
+
isFullscreen: boolean;
|
|
16
|
+
onSelectPlace: (place: Place) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fitMapToMarkers(map: mapboxgl.Map, coords: [number, number][]) {
|
|
21
|
+
if (!map || !coords.length) return;
|
|
22
|
+
if (coords.length === 1) {
|
|
23
|
+
map.flyTo({ center: coords[0], zoom: 12 });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const bounds = coords.reduce(
|
|
27
|
+
(b, c) => b.extend(c),
|
|
28
|
+
new mapboxgl.LngLatBounds(coords[0], coords[0])
|
|
29
|
+
);
|
|
30
|
+
map.fitBounds(bounds, { padding: 60, animate: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MapView = React.forwardRef<HTMLDivElement, MapViewProps>(
|
|
34
|
+
({ places, selectedPlace, isFullscreen, onSelectPlace, className }, ref) => {
|
|
35
|
+
const mapRef = React.useRef<HTMLDivElement>(null);
|
|
36
|
+
const mapObj = React.useRef<mapboxgl.Map | null>(null);
|
|
37
|
+
const markerObjs = React.useRef<mapboxgl.Marker[]>([]);
|
|
38
|
+
const maxHeight = useMaxHeight();
|
|
39
|
+
|
|
40
|
+
const markerCoords = React.useMemo(() => places.map((p) => p.coords), [places]);
|
|
41
|
+
|
|
42
|
+
// Track if initial fit has happened
|
|
43
|
+
const hasFittedRef = React.useRef(false);
|
|
44
|
+
|
|
45
|
+
// Initialize map
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
if (mapObj.current || !mapRef.current) return;
|
|
48
|
+
|
|
49
|
+
// Default to San Francisco if no coords yet
|
|
50
|
+
const defaultCenter: [number, number] = [-122.4194, 37.7749];
|
|
51
|
+
|
|
52
|
+
mapObj.current = new mapboxgl.Map({
|
|
53
|
+
container: mapRef.current,
|
|
54
|
+
style: 'mapbox://styles/mapbox/streets-v12',
|
|
55
|
+
center: defaultCenter,
|
|
56
|
+
zoom: 12,
|
|
57
|
+
attributionControl: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Resize after first paint
|
|
61
|
+
requestAnimationFrame(() => {
|
|
62
|
+
mapObj.current?.resize();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Handle window resize
|
|
66
|
+
const handleResize = () => mapObj.current?.resize();
|
|
67
|
+
window.addEventListener('resize', handleResize);
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
window.removeEventListener('resize', handleResize);
|
|
71
|
+
mapObj.current?.remove();
|
|
72
|
+
mapObj.current = null;
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
// Helper function to get inspector offset
|
|
77
|
+
const getInspectorOffsetPx = React.useCallback((): number => {
|
|
78
|
+
if (!isFullscreen) return 0;
|
|
79
|
+
if (typeof window === 'undefined') return 0;
|
|
80
|
+
|
|
81
|
+
const isXlUp = window.matchMedia && window.matchMedia('(min-width: 1280px)').matches;
|
|
82
|
+
const el = document.querySelector('.pizzaz-inspector');
|
|
83
|
+
const w = el ? el.getBoundingClientRect().width : 360;
|
|
84
|
+
const half = Math.round(w / 2);
|
|
85
|
+
|
|
86
|
+
// xl: inspector on right → negative x offset; lg: inspector on left → positive x offset
|
|
87
|
+
return isXlUp ? -half : half;
|
|
88
|
+
}, [isFullscreen]);
|
|
89
|
+
|
|
90
|
+
// Helper function to pan to a place
|
|
91
|
+
const panToPlace = React.useCallback(
|
|
92
|
+
(place: Place, offsetForInspector = false) => {
|
|
93
|
+
if (!mapObj.current) return;
|
|
94
|
+
|
|
95
|
+
// Validate coords before panning
|
|
96
|
+
if (!place.coords || !Array.isArray(place.coords) || place.coords.length !== 2) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const inspectorOffset = offsetForInspector ? getInspectorOffsetPx() : 0;
|
|
101
|
+
const flyOpts: Parameters<typeof mapObj.current.flyTo>[0] = {
|
|
102
|
+
center: place.coords,
|
|
103
|
+
zoom: 14,
|
|
104
|
+
speed: 1.2,
|
|
105
|
+
curve: 1.6,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (inspectorOffset) {
|
|
109
|
+
flyOpts.offset = [inspectorOffset, 0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
mapObj.current.flyTo(flyOpts);
|
|
113
|
+
},
|
|
114
|
+
[getInspectorOffsetPx]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Fit to markers when places data loads
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
if (!mapObj.current || markerCoords.length === 0) return;
|
|
120
|
+
|
|
121
|
+
// Validate all coords are valid
|
|
122
|
+
const allCoordsValid = markerCoords.every(
|
|
123
|
+
(coord) => Array.isArray(coord) && coord.length === 2
|
|
124
|
+
);
|
|
125
|
+
if (!allCoordsValid) return;
|
|
126
|
+
|
|
127
|
+
// Only auto-fit on initial load, not on every places change
|
|
128
|
+
if (!hasFittedRef.current) {
|
|
129
|
+
hasFittedRef.current = true;
|
|
130
|
+
// Wait for map to be ready
|
|
131
|
+
if (mapObj.current.loaded()) {
|
|
132
|
+
fitMapToMarkers(mapObj.current, markerCoords);
|
|
133
|
+
} else {
|
|
134
|
+
mapObj.current.once('load', () => {
|
|
135
|
+
if (mapObj.current) {
|
|
136
|
+
fitMapToMarkers(mapObj.current, markerCoords);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}, [markerCoords]);
|
|
142
|
+
|
|
143
|
+
// Add markers when places change
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
if (!mapObj.current) return;
|
|
146
|
+
|
|
147
|
+
// Remove existing markers
|
|
148
|
+
markerObjs.current.forEach((m) => m.remove());
|
|
149
|
+
markerObjs.current = [];
|
|
150
|
+
|
|
151
|
+
// Add new markers
|
|
152
|
+
places.forEach((place) => {
|
|
153
|
+
// Validate coords before creating marker
|
|
154
|
+
if (!place.coords || !Array.isArray(place.coords) || place.coords.length !== 2) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const marker = new mapboxgl.Marker({
|
|
159
|
+
color: '#F46C21',
|
|
160
|
+
})
|
|
161
|
+
.setLngLat(place.coords)
|
|
162
|
+
.addTo(mapObj.current!);
|
|
163
|
+
|
|
164
|
+
const el = marker.getElement();
|
|
165
|
+
if (el) {
|
|
166
|
+
el.style.cursor = 'pointer';
|
|
167
|
+
el.addEventListener('click', () => {
|
|
168
|
+
onSelectPlace(place);
|
|
169
|
+
panToPlace(place, true);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
markerObjs.current.push(marker);
|
|
173
|
+
});
|
|
174
|
+
}, [places, onSelectPlace, panToPlace]);
|
|
175
|
+
|
|
176
|
+
// Pan to selected place
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
if (!mapObj.current || !selectedPlace) return;
|
|
179
|
+
panToPlace(selectedPlace, true);
|
|
180
|
+
}, [selectedPlace, panToPlace]);
|
|
181
|
+
|
|
182
|
+
// Resize map when display mode or height changes
|
|
183
|
+
React.useEffect(() => {
|
|
184
|
+
if (!mapObj.current) return;
|
|
185
|
+
mapObj.current.resize();
|
|
186
|
+
}, [maxHeight, isFullscreen]);
|
|
187
|
+
|
|
188
|
+
// Combine refs
|
|
189
|
+
React.useImperativeHandle(ref, () => mapRef.current!);
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
className={cn(
|
|
194
|
+
'absolute inset-0 overflow-hidden',
|
|
195
|
+
isFullscreen &&
|
|
196
|
+
'left-[340px] right-2 top-2 bottom-4 border border-black/10 dark:border-white/10 rounded-3xl',
|
|
197
|
+
className
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
<div
|
|
201
|
+
ref={mapRef}
|
|
202
|
+
className="w-full h-full absolute bottom-0 left-0 right-0"
|
|
203
|
+
style={{
|
|
204
|
+
maxHeight: maxHeight ?? undefined,
|
|
205
|
+
height: isFullscreen ? (maxHeight ?? undefined) : undefined,
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
MapView.displayName = 'MapView';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
useWidgetState,
|
|
4
|
+
useDisplayMode,
|
|
5
|
+
useWidgetAPI,
|
|
6
|
+
useWidgetProps,
|
|
7
|
+
useMaxHeight,
|
|
8
|
+
} from 'sunpeak';
|
|
9
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
10
|
+
import { ExpandLg } from '@openai/apps-sdk-ui/components/Icon';
|
|
11
|
+
import { cn } from '../../lib/index';
|
|
12
|
+
import { PlaceList } from './place-list';
|
|
13
|
+
import { PlaceCarousel } from './place-carousel';
|
|
14
|
+
import { PlaceInspector } from './place-inspector';
|
|
15
|
+
import { MapView } from './map-view';
|
|
16
|
+
import type { Place, PizzazData } from '../../simulations/pizzaz-simulation';
|
|
17
|
+
|
|
18
|
+
export interface PizzazState extends Record<string, unknown> {
|
|
19
|
+
selectedPlaceId?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PizzazProps = {
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Pizzaz = React.forwardRef<HTMLDivElement, PizzazProps>(({ className }, ref) => {
|
|
27
|
+
const data = useWidgetProps<PizzazData>(() => ({ places: [] }));
|
|
28
|
+
const [widgetState, setWidgetState] = useWidgetState<PizzazState>(() => ({
|
|
29
|
+
selectedPlaceId: null,
|
|
30
|
+
}));
|
|
31
|
+
const displayMode = useDisplayMode();
|
|
32
|
+
const api = useWidgetAPI();
|
|
33
|
+
const maxHeight = useMaxHeight();
|
|
34
|
+
|
|
35
|
+
const places = data.places || [];
|
|
36
|
+
const selectedPlace = places.find((place: Place) => place.id === widgetState?.selectedPlaceId);
|
|
37
|
+
const isFullscreen = displayMode === 'fullscreen';
|
|
38
|
+
|
|
39
|
+
const handleSelectPlace = React.useCallback(
|
|
40
|
+
(place: Place) => {
|
|
41
|
+
setWidgetState((prev) => ({ ...prev, selectedPlaceId: place.id }));
|
|
42
|
+
},
|
|
43
|
+
[setWidgetState]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const handleCloseInspector = React.useCallback(() => {
|
|
47
|
+
setWidgetState((prev) => ({ ...prev, selectedPlaceId: null }));
|
|
48
|
+
}, [setWidgetState]);
|
|
49
|
+
|
|
50
|
+
const handleRequestFullscreen = React.useCallback(() => {
|
|
51
|
+
// Clear selection when entering fullscreen from embedded mode
|
|
52
|
+
if (widgetState?.selectedPlaceId) {
|
|
53
|
+
setWidgetState((prev) => ({ ...prev, selectedPlaceId: null }));
|
|
54
|
+
}
|
|
55
|
+
api?.requestDisplayMode?.({ mode: 'fullscreen' });
|
|
56
|
+
}, [api, widgetState?.selectedPlaceId, setWidgetState]);
|
|
57
|
+
|
|
58
|
+
const containerHeight = isFullscreen ? (maxHeight ?? 600) - 40 : 480;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
ref={ref}
|
|
63
|
+
className={cn('relative antialiased w-full overflow-hidden', className)}
|
|
64
|
+
style={{
|
|
65
|
+
height: containerHeight,
|
|
66
|
+
minHeight: 480,
|
|
67
|
+
maxHeight: maxHeight ?? undefined,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
'relative w-full h-full',
|
|
73
|
+
isFullscreen
|
|
74
|
+
? 'rounded-none border-0'
|
|
75
|
+
: 'border border-black/10 dark:border-white/10 rounded-2xl sm:rounded-3xl'
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{/* Fullscreen button - only show in embedded mode */}
|
|
79
|
+
{!isFullscreen && (
|
|
80
|
+
<Button
|
|
81
|
+
variant="solid"
|
|
82
|
+
color="secondary"
|
|
83
|
+
size="sm"
|
|
84
|
+
className="absolute top-4 right-4 z-30 rounded-full shadow-lg"
|
|
85
|
+
onClick={handleRequestFullscreen}
|
|
86
|
+
aria-label="Enter fullscreen"
|
|
87
|
+
>
|
|
88
|
+
<ExpandLg className="h-4 w-4" aria-hidden="true" />
|
|
89
|
+
</Button>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Desktop sidebar - only in fullscreen */}
|
|
93
|
+
{isFullscreen && (
|
|
94
|
+
<PlaceList
|
|
95
|
+
places={places}
|
|
96
|
+
selectedId={widgetState?.selectedPlaceId ?? null}
|
|
97
|
+
onSelect={handleSelectPlace}
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{/* Mobile bottom carousel - only in embedded mode */}
|
|
102
|
+
{!isFullscreen && (
|
|
103
|
+
<PlaceCarousel
|
|
104
|
+
places={places}
|
|
105
|
+
selectedId={widgetState?.selectedPlaceId ?? null}
|
|
106
|
+
onSelect={handleSelectPlace}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Inspector (place details) - only in fullscreen */}
|
|
111
|
+
{isFullscreen && selectedPlace && (
|
|
112
|
+
<PlaceInspector place={selectedPlace} onClose={handleCloseInspector} />
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Map */}
|
|
116
|
+
<MapView
|
|
117
|
+
places={places}
|
|
118
|
+
selectedPlace={selectedPlace ?? null}
|
|
119
|
+
isFullscreen={isFullscreen}
|
|
120
|
+
onSelectPlace={handleSelectPlace}
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
{/* Suggestion chips - only in fullscreen */}
|
|
124
|
+
{isFullscreen && (
|
|
125
|
+
<div className="hidden md:flex absolute inset-x-0 bottom-2 z-30 justify-center pointer-events-none">
|
|
126
|
+
<div className="flex gap-3 pointer-events-auto">
|
|
127
|
+
{['Open now', 'Top rated', 'Vegetarian friendly'].map((label) => (
|
|
128
|
+
<Button
|
|
129
|
+
key={label}
|
|
130
|
+
variant="solid"
|
|
131
|
+
color="secondary"
|
|
132
|
+
size="sm"
|
|
133
|
+
className="rounded-full shadow-md"
|
|
134
|
+
>
|
|
135
|
+
{label}
|
|
136
|
+
</Button>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
Pizzaz.displayName = 'Pizzaz';
|