pizzaz-mcp 1.0.0

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.
@@ -0,0 +1,165 @@
1
+ import React from "react";
2
+ import useEmblaCarousel from "embla-carousel-react";
3
+ import { useOpenAiGlobal } from "../use-openai-global";
4
+ import { Settings2, Star } from "lucide-react";
5
+ import { AnimatePresence, motion } from "framer-motion";
6
+ import { Button } from "@openai/apps-sdk-ui/components/Button";
7
+ import { Image } from "@openai/apps-sdk-ui/components/Image";
8
+
9
+ function PlaceListItem({ place, isSelected, onClick }) {
10
+ return (
11
+ <div
12
+ className={
13
+ "rounded-2xl px-3 select-none hover:bg-black/5 cursor-pointer" +
14
+ (isSelected ? " bg-black/5" : "")
15
+ }
16
+ >
17
+ <div
18
+ className={`border-b ${
19
+ isSelected ? "border-black/0" : "border-black/5"
20
+ } hover:border-black/0`}
21
+ >
22
+ <div
23
+ className="w-full text-left py-3 transition flex gap-3 items-center cursor-pointer"
24
+ onClick={onClick}
25
+ >
26
+ <Image
27
+ src={place.thumbnail}
28
+ alt={place.name}
29
+ className="h-16 w-16 rounded-lg object-cover flex-none"
30
+ />
31
+ <div className="min-w-0 text-left">
32
+ <div className="font-medium truncate">{place.name}</div>
33
+ <div className="text-xs text-black/50 truncate">
34
+ {place.description}
35
+ </div>
36
+ <div className="text-xs mt-1 text-black/50 flex items-center gap-1">
37
+ <Star className="h-3 w-3" aria-hidden="true" />
38
+ {place.rating.toFixed(1)}
39
+ {place.price ? <span className="">· {place.price}</span> : null}
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export default function Sidebar({ places, selectedId, onSelect }) {
49
+ const [emblaRef] = useEmblaCarousel({ dragFree: true, loop: false });
50
+ const displayMode = useOpenAiGlobal("displayMode");
51
+ const forceMobile = displayMode !== "fullscreen";
52
+ const scrollRef = React.useRef(null);
53
+ const [showBottomFade, setShowBottomFade] = React.useState(false);
54
+
55
+ const updateBottomFadeVisibility = React.useCallback(() => {
56
+ const el = scrollRef.current;
57
+ if (!el) return;
58
+ const atBottom =
59
+ Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight;
60
+ setShowBottomFade(!atBottom);
61
+ }, []);
62
+
63
+ React.useEffect(() => {
64
+ updateBottomFadeVisibility();
65
+ const el = scrollRef.current;
66
+ if (!el) return;
67
+ const onScroll = () => updateBottomFadeVisibility();
68
+ el.addEventListener("scroll", onScroll, { passive: true });
69
+ window.addEventListener("resize", updateBottomFadeVisibility);
70
+ return () => {
71
+ el.removeEventListener("scroll", onScroll);
72
+ window.removeEventListener("resize", updateBottomFadeVisibility);
73
+ };
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
75
+ }, [places]);
76
+
77
+ return (
78
+ <>
79
+ {/* Desktop/Tablet sidebar */}
80
+ <div
81
+ className={`${
82
+ forceMobile ? "hidden" : ""
83
+ } absolute inset-y-0 bottom-4 left-0 z-20 w-[340px] max-w-[75%] pointer-events-auto`}
84
+ >
85
+ <div
86
+ ref={scrollRef}
87
+ className="relative px-2 h-full overflow-y-auto bg-white text-black"
88
+ >
89
+ <div className="flex justify-between flex-row items-center px-3 sticky bg-white top-0 py-4 text-md font-medium">
90
+ {places.length} results
91
+ <Button
92
+ variant="ghost"
93
+ color="secondary"
94
+ size="sm"
95
+ uniform
96
+ aria-label="Filter"
97
+ >
98
+ <Settings2 className="h-5 w-5" aria-hidden="true" />
99
+ </Button>
100
+ </div>
101
+ <div>
102
+ {places.map((place) => (
103
+ <PlaceListItem
104
+ key={place.id}
105
+ place={place}
106
+ isSelected={
107
+ displayMode === "fullscreen" && selectedId === place.id
108
+ }
109
+ onClick={() => onSelect(place)}
110
+ />
111
+ ))}
112
+ </div>
113
+ </div>
114
+ <AnimatePresence>
115
+ {showBottomFade && (
116
+ <motion.div
117
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-9 z-10"
118
+ initial={{ opacity: 0 }}
119
+ animate={{ opacity: 1 }}
120
+ exit={{ opacity: 0 }}
121
+ transition={{ duration: 0.2 }}
122
+ >
123
+ <div
124
+ className="w-full h-full bg-gradient-to-t border-b border-black/50 from-black/15 to-black/0"
125
+ style={{
126
+ WebkitMaskImage:
127
+ "linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.25) 25%, rgba(0,0,0,0.25) 75%, rgba(0,0,0,0) 100%)",
128
+ maskImage:
129
+ "linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.25) 25%, rgba(0,0,0,0.25) 75%, rgba(0,0,0,0) 100%)",
130
+ }}
131
+ aria-hidden
132
+ />
133
+ </motion.div>
134
+ )}
135
+ </AnimatePresence>
136
+ </div>
137
+
138
+ {/* Mobile bottom carousel */}
139
+ <div
140
+ className={`${
141
+ forceMobile ? "" : "hidden"
142
+ } absolute inset-x-0 bottom-0 z-20 pointer-events-auto`}
143
+ >
144
+ <div className="pt-2 text-black">
145
+ <div className="overflow-hidden" ref={emblaRef}>
146
+ <div className="px-3 py-3 flex gap-3">
147
+ {places.map((place) => (
148
+ <div className="ring ring-black/10 max-w-[330px] w-full shadow-xl rounded-2xl bg-white">
149
+ <PlaceListItem
150
+ key={place.id}
151
+ place={place}
152
+ isSelected={
153
+ displayMode === "fullscreen" && selectedId === place.id
154
+ }
155
+ onClick={() => onSelect(place)}
156
+ />
157
+ </div>
158
+ ))}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </>
164
+ );
165
+ }
@@ -0,0 +1,295 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import mapboxgl from "mapbox-gl";
3
+ import "mapbox-gl/dist/mapbox-gl.css";
4
+ import { createRoot } from "react-dom/client";
5
+ import markers from "./markers.json";
6
+ import { AnimatePresence } from "framer-motion";
7
+ import Inspector from "./Inspector";
8
+ import Sidebar from "./Sidebar";
9
+ import { useOpenAiGlobal } from "../use-openai-global";
10
+ import { useMaxHeight } from "../use-max-height";
11
+ import { Maximize2 } from "lucide-react";
12
+ import {
13
+ useNavigate,
14
+ useLocation,
15
+ Routes,
16
+ Route,
17
+ BrowserRouter,
18
+ Outlet,
19
+ } from "react-router-dom";
20
+ import { Button } from "@openai/apps-sdk-ui/components/Button";
21
+
22
+ mapboxgl.accessToken =
23
+ "pk.eyJ1IjoiZXJpY25pbmciLCJhIjoiY21icXlubWM1MDRiczJvb2xwM2p0amNyayJ9.n-3O6JI5nOp_Lw96ZO5vJQ";
24
+
25
+ function fitMapToMarkers(map, coords) {
26
+ if (!map || !coords.length) return;
27
+ if (coords.length === 1) {
28
+ map.flyTo({ center: coords[0], zoom: 12 });
29
+ return;
30
+ }
31
+ const bounds = coords.reduce(
32
+ (b, c) => b.extend(c),
33
+ new mapboxgl.LngLatBounds(coords[0], coords[0])
34
+ );
35
+ map.fitBounds(bounds, { padding: 60, animate: true });
36
+ }
37
+
38
+ export default function App() {
39
+ const mapRef = useRef(null);
40
+ const mapObj = useRef(null);
41
+ const markerObjs = useRef([]);
42
+ const places = markers?.places || [];
43
+ const markerCoords = places.map((p) => p.coords);
44
+ const navigate = useNavigate();
45
+ const location = useLocation();
46
+ const selectedId = React.useMemo(() => {
47
+ const match = location?.pathname?.match(/(?:^|\/)place\/([^/]+)/);
48
+ return match && match[1] ? match[1] : null;
49
+ }, [location?.pathname]);
50
+ const selectedPlace = places.find((p) => p.id === selectedId) || null;
51
+ const [viewState, setViewState] = useState(() => ({
52
+ center: markerCoords.length > 0 ? markerCoords[0] : [0, 0],
53
+ zoom: markerCoords.length > 0 ? 12 : 2,
54
+ }));
55
+ const displayMode = useOpenAiGlobal("displayMode");
56
+ const allowInspector = displayMode === "fullscreen";
57
+ const maxHeight = useMaxHeight() ?? undefined;
58
+
59
+ useEffect(() => {
60
+ if (mapObj.current) return;
61
+ mapObj.current = new mapboxgl.Map({
62
+ container: mapRef.current,
63
+ style: "mapbox://styles/mapbox/streets-v12",
64
+ center: markerCoords.length > 0 ? markerCoords[0] : [0, 0],
65
+ zoom: markerCoords.length > 0 ? 12 : 2,
66
+ attributionControl: false,
67
+ });
68
+ addAllMarkers(places);
69
+ setTimeout(() => {
70
+ fitMapToMarkers(mapObj.current, markerCoords);
71
+ }, 0);
72
+ // after first paint
73
+ requestAnimationFrame(() => mapObj.current.resize());
74
+
75
+ // or keep it in sync with window resizes
76
+ window.addEventListener("resize", mapObj.current.resize);
77
+
78
+ return () => {
79
+ window.removeEventListener("resize", mapObj.current.resize);
80
+ mapObj.current.remove();
81
+ };
82
+ // eslint-disable-next-line
83
+ }, []);
84
+
85
+ useEffect(() => {
86
+ if (!mapObj.current) return;
87
+ const handler = () => {
88
+ const c = mapObj.current.getCenter();
89
+ setViewState({ center: [c.lng, c.lat], zoom: mapObj.current.getZoom() });
90
+ };
91
+ mapObj.current.on("moveend", handler);
92
+ return () => {
93
+ mapObj.current.off("moveend", handler);
94
+ };
95
+ }, []);
96
+
97
+ function addAllMarkers(placesList) {
98
+ markerObjs.current.forEach((m) => m.remove());
99
+ markerObjs.current = [];
100
+ placesList.forEach((place) => {
101
+ const marker = new mapboxgl.Marker({
102
+ color: "#F46C21",
103
+ })
104
+ .setLngLat(place.coords)
105
+ .addTo(mapObj.current);
106
+ const el = marker.getElement();
107
+ if (el) {
108
+ el.style.cursor = "pointer";
109
+ el.addEventListener("click", () => {
110
+ navigate(`/place/${place.id}`);
111
+ panTo(place.coords, { offsetForInspector: true });
112
+ });
113
+ }
114
+ markerObjs.current.push(marker);
115
+ });
116
+ }
117
+
118
+ function getInspectorOffsetPx() {
119
+ if (displayMode !== "fullscreen") return 0;
120
+ if (typeof window === "undefined") return 0;
121
+ const isXlUp =
122
+ window.matchMedia && window.matchMedia("(min-width: 1280px)").matches;
123
+ const el = document.querySelector(".pizzaz-inspector");
124
+ const w = el ? el.getBoundingClientRect().width : 360;
125
+ const half = Math.round(w / 2);
126
+ // xl: inspector on right → negative x offset; lg: inspector on left → positive x offset
127
+ return isXlUp ? -half : half;
128
+ }
129
+
130
+ function panTo(
131
+ coord,
132
+ { offsetForInspector } = { offsetForInspector: false }
133
+ ) {
134
+ if (!mapObj.current) return;
135
+ const inspectorOffset = offsetForInspector ? getInspectorOffsetPx() : 0;
136
+ const flyOpts = {
137
+ center: coord,
138
+ zoom: 14,
139
+ speed: 1.2,
140
+ curve: 1.6,
141
+ };
142
+ if (inspectorOffset) {
143
+ flyOpts.offset = [inspectorOffset, 0];
144
+ }
145
+ mapObj.current.flyTo(flyOpts);
146
+ }
147
+
148
+ useEffect(() => {
149
+ if (!mapObj.current) return;
150
+ addAllMarkers(places);
151
+ }, [places]);
152
+
153
+ // Pan the map when the selected place changes via routing
154
+ useEffect(() => {
155
+ if (!mapObj.current || !selectedPlace) return;
156
+ panTo(selectedPlace.coords, { offsetForInspector: true });
157
+ }, [selectedId]);
158
+
159
+ // Ensure Mapbox resizes when container maxHeight/display mode changes
160
+ useEffect(() => {
161
+ if (!mapObj.current) return;
162
+ mapObj.current.resize();
163
+ }, [maxHeight, displayMode]);
164
+
165
+ useEffect(() => {
166
+ if (
167
+ typeof window !== "undefined" &&
168
+ window.oai &&
169
+ typeof window.oai.widget.setState === "function"
170
+ ) {
171
+ window.oai.widget.setState({
172
+ center: viewState.center,
173
+ zoom: viewState.zoom,
174
+ markers: markerCoords,
175
+ });
176
+ }
177
+ }, [viewState, markerCoords]);
178
+
179
+ return (
180
+ <>
181
+ <div
182
+ style={{
183
+ maxHeight,
184
+ height: displayMode === "fullscreen" ? maxHeight - 40 : 480,
185
+ }}
186
+ className={
187
+ "relative antialiased w-full min-h-[480px] overflow-hidden " +
188
+ (displayMode === "fullscreen"
189
+ ? "rounded-none border-0"
190
+ : "border border-black/10 dark:border-white/10 rounded-2xl sm:rounded-3xl")
191
+ }
192
+ >
193
+ <Outlet />
194
+ {displayMode !== "fullscreen" && (
195
+ <Button
196
+ aria-label="Enter fullscreen"
197
+ className="absolute top-4 right-4 z-30 shadow-lg pointer-events-auto bg-white text-black"
198
+ color="secondary"
199
+ size="sm"
200
+ variant="soft"
201
+ uniform
202
+ onClick={() => {
203
+ if (selectedId) {
204
+ navigate("..", { replace: true });
205
+ }
206
+ if (window?.webplus?.requestDisplayMode) {
207
+ window.webplus.requestDisplayMode({ mode: "fullscreen" });
208
+ }
209
+ }}
210
+ >
211
+ <Maximize2
212
+ strokeWidth={1.5}
213
+ className="h-4.5 w-4.5"
214
+ aria-hidden="true"
215
+ />
216
+ </Button>
217
+ )}
218
+ {/* Sidebar */}
219
+ <Sidebar
220
+ places={places}
221
+ selectedId={selectedId}
222
+ onSelect={(place) => {
223
+ navigate(`/place/${place.id}`);
224
+ panTo(place.coords, { offsetForInspector: true });
225
+ }}
226
+ />
227
+
228
+ {/* Inspector (right) */}
229
+ <AnimatePresence>
230
+ {allowInspector && selectedPlace && (
231
+ <Inspector
232
+ key={selectedPlace.id}
233
+ place={selectedPlace}
234
+ onClose={() => navigate("..")}
235
+ />
236
+ )}
237
+ </AnimatePresence>
238
+
239
+ {/* Map */}
240
+ <div
241
+ className={
242
+ "absolute inset-0 overflow-hidden" +
243
+ (displayMode === "fullscreen"
244
+ ? " left-[340px] right-2 top-2 bottom-4 border border-black/10 rounded-3xl"
245
+ : "")
246
+ }
247
+ >
248
+ <div
249
+ ref={mapRef}
250
+ className="w-full h-full absolute bottom-0 left-0 right-0"
251
+ style={{
252
+ maxHeight,
253
+ height: displayMode === "fullscreen" ? maxHeight : undefined,
254
+ }}
255
+ />
256
+ </div>
257
+ </div>
258
+
259
+ {/* Suggestion chips (bottom, fullscreen) */}
260
+ {displayMode === "fullscreen" && (
261
+ <div className="hidden antialiased md:flex absolute inset-x-0 bottom-2 z-30 justify-center pointer-events-none">
262
+ <div className="flex gap-3 pointer-events-auto">
263
+ {["Open now", "Top rated", "Vegetarian friendly"].map((label) => (
264
+ <Button
265
+ key={label}
266
+ color="secondary"
267
+ variant="soft"
268
+ size="sm"
269
+ className="font-base"
270
+ >
271
+ {label}
272
+ </Button>
273
+ ))}
274
+ </div>
275
+ </div>
276
+ )}
277
+ </>
278
+ );
279
+ }
280
+
281
+ function RouterRoot() {
282
+ return (
283
+ <Routes>
284
+ <Route path="*" element={<App />}>
285
+ <Route path="place/:placeId" element={<></>} />
286
+ </Route>
287
+ </Routes>
288
+ );
289
+ }
290
+
291
+ createRoot(document.getElementById("pizzaz-root")).render(
292
+ <BrowserRouter>
293
+ <RouterRoot />
294
+ </BrowserRouter>
295
+ );