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.
Files changed (32) hide show
  1. package/bin/sunpeak.js +8 -4
  2. package/dist/style.css +440 -0
  3. package/package.json +1 -1
  4. package/template/dist/chatgpt/albums.js +1 -1
  5. package/template/dist/chatgpt/carousel.js +1 -1
  6. package/template/dist/chatgpt/counter.js +1 -1
  7. package/template/dist/chatgpt/pizzaz.js +3034 -0
  8. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Avatar.js +97 -0
  9. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Avatar.js.map +7 -0
  10. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Button.js +3 -3
  11. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_SegmentedControl.js +1 -1
  12. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Select.js +11 -11
  13. package/template/node_modules/.vite/deps/@openai_apps-sdk-ui_components_Textarea.js +3 -3
  14. package/template/node_modules/.vite/deps/_metadata.json +46 -34
  15. package/template/node_modules/.vite/deps/{chunk-EVJ3DVH5.js → chunk-LR7NKCX5.js} +7 -7
  16. package/template/node_modules/.vite/deps/mapbox-gl.js +32835 -0
  17. package/template/node_modules/.vite/deps/mapbox-gl.js.map +7 -0
  18. package/template/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/template/package.json +1 -0
  20. package/template/src/components/index.ts +1 -0
  21. package/template/src/components/pizzaz/index.ts +6 -0
  22. package/template/src/components/pizzaz/map-view.tsx +212 -0
  23. package/template/src/components/pizzaz/pizzaz.tsx +145 -0
  24. package/template/src/components/pizzaz/place-card.tsx +55 -0
  25. package/template/src/components/pizzaz/place-carousel.tsx +45 -0
  26. package/template/src/components/pizzaz/place-inspector.tsx +132 -0
  27. package/template/src/components/pizzaz/place-list.tsx +90 -0
  28. package/template/src/resources/index.ts +1 -0
  29. package/template/src/resources/pizzaz-resource.tsx +32 -0
  30. package/template/src/simulations/index.ts +2 -0
  31. package/template/src/simulations/pizzaz-simulation.ts +177 -0
  32. /package/template/node_modules/.vite/deps/{chunk-EVJ3DVH5.js.map → chunk-LR7NKCX5.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":"4.0.13","results":[[":src/resources/carousel-resource.test.tsx",{"duration":272.55451200000016,"failed":false}],[":src/resources/counter-resource.test.tsx",{"duration":403.50324999999975,"failed":false}],[":src/components/album/albums.test.tsx",{"duration":332.906375,"failed":false}],[":src/components/album/fullscreen-viewer.test.tsx",{"duration":238.23859200000015,"failed":false}],[":src/components/album/album-carousel.test.tsx",{"duration":64.19374000000016,"failed":false}],[":src/components/carousel/carousel.test.tsx",{"duration":96.49479299999985,"failed":false}],[":src/resources/albums-resource.test.tsx",{"duration":257.8042250000001,"failed":false}],[":src/components/album/film-strip.test.tsx",{"duration":407.23125600000003,"failed":false}],[":src/components/album/album-card.test.tsx",{"duration":254.80172800000014,"failed":false}],[":src/components/carousel/card.test.tsx",{"duration":54.36003299999993,"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
  },
@@ -1,2 +1,3 @@
1
1
  export * from './carousel';
2
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';
@@ -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&apos;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';