sunpeak 0.6.4 → 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 +8 -4
- package/dist/style.css +440 -0
- package/package.json +1 -1
- package/template/dist/chatgpt/albums.js +1 -1
- 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 +11 -11
- package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js +3 -3
- package/template/node_modules/.vite/deps/_metadata.json +46 -34
- package/template/node_modules/.vite/deps/{chunk-EVJ3DVH5.js → chunk-LR7NKCX5.js} +7 -7
- 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/index.ts +1 -0
- 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/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/node_modules/.vite/deps/{chunk-EVJ3DVH5.js.map → chunk-LR7NKCX5.js.map} +0 -0
package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.0.13","results":[[":src/resources/carousel-resource.test.tsx",{"duration":
|
|
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,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';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Star } from '@openai/apps-sdk-ui/components/Icon';
|
|
3
|
+
import { cn } from '../../lib/index';
|
|
4
|
+
import type { Place } from '../../simulations/pizzaz-simulation';
|
|
5
|
+
|
|
6
|
+
export type PlaceCardProps = {
|
|
7
|
+
place: Place;
|
|
8
|
+
isSelected?: boolean;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const PlaceCard = React.forwardRef<HTMLDivElement, PlaceCardProps>(
|
|
14
|
+
({ place, isSelected, onClick, className }, ref) => {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
ref={ref}
|
|
18
|
+
className={cn(
|
|
19
|
+
'rounded-2xl px-3 select-none hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer',
|
|
20
|
+
isSelected && 'bg-black/5 dark:bg-white/5',
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
<div
|
|
25
|
+
className={cn(
|
|
26
|
+
'border-b hover:border-transparent',
|
|
27
|
+
isSelected ? 'border-transparent' : 'border-black/5 dark:border-white/5'
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
<button
|
|
31
|
+
className="w-full text-left py-3 transition flex gap-3 items-center"
|
|
32
|
+
onClick={onClick}
|
|
33
|
+
>
|
|
34
|
+
<img
|
|
35
|
+
src={place.thumbnail}
|
|
36
|
+
alt={place.name}
|
|
37
|
+
className="h-16 w-16 rounded-lg object-cover flex-none"
|
|
38
|
+
loading="lazy"
|
|
39
|
+
/>
|
|
40
|
+
<div className="min-w-0">
|
|
41
|
+
<div className="font-medium truncate text-primary">{place.name}</div>
|
|
42
|
+
<div className="text-xs text-secondary truncate">{place.description}</div>
|
|
43
|
+
<div className="text-xs mt-1 text-secondary flex items-center gap-1">
|
|
44
|
+
<Star className="h-3 w-3" aria-hidden="true" />
|
|
45
|
+
{place.rating.toFixed(1)}
|
|
46
|
+
{place.price && <span>· {place.price}</span>}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
PlaceCard.displayName = 'PlaceCard';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import useEmblaCarousel from 'embla-carousel-react';
|
|
3
|
+
import { cn } from '../../lib/index';
|
|
4
|
+
import { PlaceCard } from './place-card';
|
|
5
|
+
import type { Place } from '../../simulations/pizzaz-simulation';
|
|
6
|
+
|
|
7
|
+
export type PlaceCarouselProps = {
|
|
8
|
+
places: Place[];
|
|
9
|
+
selectedId: string | null;
|
|
10
|
+
onSelect: (place: Place) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const PlaceCarousel = React.forwardRef<HTMLDivElement, PlaceCarouselProps>(
|
|
15
|
+
({ places, selectedId, onSelect, className }, ref) => {
|
|
16
|
+
const [emblaRef] = useEmblaCarousel({ dragFree: true, loop: false });
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
ref={ref}
|
|
21
|
+
className={cn('absolute inset-x-0 bottom-0 z-20 pointer-events-auto', className)}
|
|
22
|
+
>
|
|
23
|
+
<div className="pt-2">
|
|
24
|
+
<div className="overflow-hidden" ref={emblaRef}>
|
|
25
|
+
<div className="px-3 py-3 flex gap-3">
|
|
26
|
+
{places.map((place) => (
|
|
27
|
+
<div
|
|
28
|
+
key={place.id}
|
|
29
|
+
className="ring ring-black/10 dark:ring-white/10 max-w-[330px] w-full shadow-xl rounded-2xl bg-surface flex-shrink-0"
|
|
30
|
+
>
|
|
31
|
+
<PlaceCard
|
|
32
|
+
place={place}
|
|
33
|
+
isSelected={selectedId === place.id}
|
|
34
|
+
onClick={() => onSelect(place)}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
PlaceCarousel.displayName = 'PlaceCarousel';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Button } from '@openai/apps-sdk-ui/components/Button';
|
|
3
|
+
import { Avatar } from '@openai/apps-sdk-ui/components/Avatar';
|
|
4
|
+
import { X, Star } from '@openai/apps-sdk-ui/components/Icon';
|
|
5
|
+
import { cn } from '../../lib/index';
|
|
6
|
+
import type { Place } from '../../simulations/pizzaz-simulation';
|
|
7
|
+
|
|
8
|
+
export type PlaceInspectorProps = {
|
|
9
|
+
place: Place;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const REVIEWS = [
|
|
15
|
+
{
|
|
16
|
+
user: 'Leo M.',
|
|
17
|
+
avatar: 'https://persistent.oaistatic.com/pizzaz/user1.png',
|
|
18
|
+
text: 'Fantastic crust and balanced toppings. The marinara is spot on!',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
user: 'Priya S.',
|
|
22
|
+
avatar: 'https://persistent.oaistatic.com/pizzaz/user2.png',
|
|
23
|
+
text: 'Cozy vibe and friendly staff. Quick service on a Friday night.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
user: 'Maya R.',
|
|
27
|
+
avatar: 'https://persistent.oaistatic.com/pizzaz/user3.png',
|
|
28
|
+
text: 'Great for sharing. Will definitely come back with friends.',
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const PlaceInspector = React.forwardRef<HTMLDivElement, PlaceInspectorProps>(
|
|
33
|
+
({ place, onClose, className }, ref) => {
|
|
34
|
+
if (!place) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={cn(
|
|
40
|
+
'pizzaz-inspector absolute z-30 top-0 bottom-4 left-0 right-auto xl:left-auto xl:right-6 md:z-20 w-[340px] xl:w-[360px] xl:top-6 xl:bottom-8 pointer-events-auto',
|
|
41
|
+
'animate-in fade-in slide-in-from-left-2 xl:slide-in-from-right-2 duration-200',
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{/* Close button */}
|
|
46
|
+
<Button
|
|
47
|
+
variant="solid"
|
|
48
|
+
color="secondary"
|
|
49
|
+
size="sm"
|
|
50
|
+
className="absolute z-10 top-4 left-4 xl:top-4 xl:left-4 shadow-xl rounded-full"
|
|
51
|
+
onClick={onClose}
|
|
52
|
+
aria-label="Close details"
|
|
53
|
+
>
|
|
54
|
+
<X className="h-[18px] w-[18px]" aria-hidden="true" />
|
|
55
|
+
</Button>
|
|
56
|
+
|
|
57
|
+
<div className="relative h-full overflow-y-auto rounded-none xl:rounded-3xl bg-surface xl:shadow-xl xl:ring ring-black/10 dark:ring-white/10">
|
|
58
|
+
{/* Thumbnail */}
|
|
59
|
+
<div className="relative mt-2 xl:mt-0 px-2 xl:px-0">
|
|
60
|
+
<img
|
|
61
|
+
src={place.thumbnail}
|
|
62
|
+
alt={place.name}
|
|
63
|
+
className="w-full rounded-3xl xl:rounded-none h-80 object-cover xl:rounded-t-2xl"
|
|
64
|
+
loading="lazy"
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div className="h-[calc(100%-11rem)] sm:h-[calc(100%-14rem)]">
|
|
69
|
+
{/* Place info */}
|
|
70
|
+
<div className="p-4 sm:p-5">
|
|
71
|
+
<div className="text-2xl font-medium truncate text-primary">{place.name}</div>
|
|
72
|
+
<div className="text-sm mt-1 text-secondary flex items-center gap-1">
|
|
73
|
+
<Star className="h-3.5 w-3.5" aria-hidden="true" />
|
|
74
|
+
{place.rating.toFixed(1)}
|
|
75
|
+
{place.price && <span>· {place.price}</span>}
|
|
76
|
+
<span>· {place.city}</span>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Action buttons */}
|
|
80
|
+
<div className="mt-3 flex flex-row items-center gap-3 font-medium">
|
|
81
|
+
<Button
|
|
82
|
+
variant="solid"
|
|
83
|
+
color="warning"
|
|
84
|
+
size="sm"
|
|
85
|
+
className="rounded-full"
|
|
86
|
+
onClick={() => console.log('Add to favorites:', place.id)}
|
|
87
|
+
>
|
|
88
|
+
Add to favorites
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
color="primary"
|
|
93
|
+
size="sm"
|
|
94
|
+
className="rounded-full"
|
|
95
|
+
style={{ color: '#F46C21' }}
|
|
96
|
+
onClick={() => console.log('Contact:', place.id)}
|
|
97
|
+
>
|
|
98
|
+
Contact
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Description */}
|
|
103
|
+
<div className="text-sm mt-5 text-primary">
|
|
104
|
+
{place.description} Enjoy a slice at one of SF's favorites. Fresh ingredients,
|
|
105
|
+
great crust, and cozy vibes.
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Reviews */}
|
|
110
|
+
<div className="px-4 sm:px-5 pb-4">
|
|
111
|
+
<div className="text-lg font-medium mb-2 text-primary">Reviews</div>
|
|
112
|
+
<ul className="space-y-3 divide-y divide-black/5 dark:divide-white/5">
|
|
113
|
+
{REVIEWS.map((review, idx) => (
|
|
114
|
+
<li key={idx} className="py-3">
|
|
115
|
+
<div className="flex items-start gap-3">
|
|
116
|
+
<Avatar imageUrl={review.avatar} name={review.user} size={32} />
|
|
117
|
+
<div className="min-w-0 gap-1 flex flex-col">
|
|
118
|
+
<div className="text-xs font-medium text-secondary">{review.user}</div>
|
|
119
|
+
<div className="text-sm text-primary">{review.text}</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
</ul>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
PlaceInspector.displayName = 'PlaceInspector';
|