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.
Files changed (59) hide show
  1. package/bin/sunpeak.js +133 -6
  2. package/dist/chatgpt/conversation.d.ts +2 -1
  3. package/dist/index.cjs +24 -4
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +24 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/mcp/entry.cjs +2 -2
  8. package/dist/mcp/entry.cjs.map +1 -1
  9. package/dist/mcp/entry.js +2 -2
  10. package/dist/mcp/entry.js.map +1 -1
  11. package/dist/mcp/index.cjs +1 -1
  12. package/dist/mcp/index.js +1 -1
  13. package/dist/{server-DpriZ4jT.cjs → server-CQGbJWbk.cjs} +17 -8
  14. package/dist/{server-DpriZ4jT.cjs.map → server-CQGbJWbk.cjs.map} +1 -1
  15. package/dist/{server-SBlanUcf.js → server-DGCvp1RA.js} +17 -8
  16. package/dist/{server-SBlanUcf.js.map → server-DGCvp1RA.js.map} +1 -1
  17. package/dist/style.css +444 -0
  18. package/package.json +1 -1
  19. package/template/.sunpeak/dev.tsx +1 -1
  20. package/template/dist/chatgpt/albums.js +2 -2
  21. package/template/dist/chatgpt/carousel.js +1 -1
  22. package/template/dist/chatgpt/counter.js +1 -1
  23. package/template/dist/chatgpt/pizzaz.js +3034 -0
  24. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Avatar.js +97 -0
  25. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Avatar.js.map +7 -0
  26. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Button.js +3 -3
  27. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js +1 -1
  28. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js +16 -16
  29. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js +3 -3
  30. package/template/node_modules/.vite/deps/_metadata.json +45 -33
  31. package/template/node_modules/.vite/deps/{chunk-DQAZDQU3.js → chunk-LR7NKCX5.js} +8 -8
  32. package/template/node_modules/.vite/deps/mapbox-gl.js +32835 -0
  33. package/template/node_modules/.vite/deps/mapbox-gl.js.map +7 -0
  34. package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  35. package/template/package.json +1 -0
  36. package/template/src/components/album/album-carousel.test.tsx +84 -0
  37. package/template/src/components/album/album-carousel.tsx +168 -0
  38. package/template/src/components/album/albums.test.tsx +2 -2
  39. package/template/src/components/album/albums.tsx +3 -3
  40. package/template/src/components/album/index.ts +1 -0
  41. package/template/src/components/carousel/index.ts +1 -0
  42. package/template/src/components/index.ts +1 -1
  43. package/template/src/components/pizzaz/index.ts +6 -0
  44. package/template/src/components/pizzaz/map-view.tsx +212 -0
  45. package/template/src/components/pizzaz/pizzaz.tsx +145 -0
  46. package/template/src/components/pizzaz/place-card.tsx +55 -0
  47. package/template/src/components/pizzaz/place-carousel.tsx +45 -0
  48. package/template/src/components/pizzaz/place-inspector.tsx +132 -0
  49. package/template/src/components/pizzaz/place-list.tsx +90 -0
  50. package/template/src/resources/carousel-resource.test.tsx +1 -4
  51. package/template/src/resources/carousel-resource.tsx +1 -2
  52. package/template/src/resources/index.ts +1 -0
  53. package/template/src/resources/pizzaz-resource.tsx +32 -0
  54. package/template/src/simulations/index.ts +2 -0
  55. package/template/src/simulations/pizzaz-simulation.ts +177 -0
  56. package/template/src/components/card/index.ts +0 -1
  57. /package/template/node_modules/.vite/deps/{chunk-DQAZDQU3.js.map → chunk-LR7NKCX5.js.map} +0 -0
  58. /package/template/src/components/{card → carousel}/card.test.tsx +0 -0
  59. /package/template/src/components/{card → carousel}/card.tsx +0 -0
@@ -1 +1 @@
1
- {"version":"4.0.13","results":[[":src/resources/counter-resource.test.tsx",{"duration":341.562457,"failed":false}],[":src/components/album/albums.test.tsx",{"duration":347.8453920000002,"failed":false}],[":src/resources/carousel-resource.test.tsx",{"duration":281.398232,"failed":false}],[":src/components/album/fullscreen-viewer.test.tsx",{"duration":245.63889299999983,"failed":false}],[":src/components/carousel/carousel.test.tsx",{"duration":64.06780200000003,"failed":false}],[":src/resources/albums-resource.test.tsx",{"duration":269.48677499999985,"failed":false}],[":src/components/album/film-strip.test.tsx",{"duration":490.057513,"failed":false}],[":src/components/album/album-card.test.tsx",{"duration":302.1487770000001,"failed":false}],[":src/components/card/card.test.tsx",{"duration":55.60354700000016,"failed":false}]]}
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}]]}
@@ -14,6 +14,7 @@
14
14
  "clsx": "^2.1.1",
15
15
  "embla-carousel-react": "^8.6.0",
16
16
  "embla-carousel-wheel-gestures": "^8.1.0",
17
+ "mapbox-gl": "^3.9.4",
17
18
  "sunpeak": "workspace:*",
18
19
  "tailwind-merge": "^3.4.0"
19
20
  },
@@ -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('../carousel', () => ({
33
- Carousel: ({ children }: { children: React.ReactNode }) => (
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 { Carousel } from '../carousel';
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
- <Carousel gap={20} showArrows={false} showEdgeGradients={false} cardWidth={272}>
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
- </Carousel>
72
+ </AlbumCarousel>
73
73
  </div>
74
74
  );
75
75
  });
@@ -1,4 +1,5 @@
1
1
  export * from './album-card';
2
+ export * from './album-carousel';
2
3
  export * from './albums';
3
4
  export * from './fullscreen-viewer';
4
5
  export * from './film-strip';
@@ -1 +1,2 @@
1
1
  export * from './carousel';
2
+ export * from './card';
@@ -1,3 +1,3 @@
1
- export * from './card';
2
1
  export * from './carousel';
3
2
  export * from './album';
3
+ export * from './pizzaz';
@@ -0,0 +1,6 @@
1
+ export * from './pizzaz';
2
+ export * from './place-card';
3
+ export * from './place-list';
4
+ export * from './place-carousel';
5
+ export * from './place-inspector';
6
+ export * from './map-view';
@@ -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';