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.
- package/.vscode/settings.json +3 -0
- package/README.md +139 -0
- package/build-all.mts +188 -0
- package/docs/DEPLOYMENT_GUIDE.md +226 -0
- package/package.json +41 -0
- package/render.yaml +12 -0
- package/server/server.ts +400 -0
- package/src/index.css +39 -0
- package/src/media-queries.ts +15 -0
- package/src/pizzaz/Inspector.jsx +109 -0
- package/src/pizzaz/Sidebar.jsx +165 -0
- package/src/pizzaz/index.jsx +295 -0
- package/src/pizzaz/map.css +707 -0
- package/src/pizzaz/markers.json +104 -0
- package/src/pizzaz-albums/AlbumCard.jsx +45 -0
- package/src/pizzaz-albums/FilmStrip.jsx +30 -0
- package/src/pizzaz-albums/FullscreenViewer.jsx +43 -0
- package/src/pizzaz-albums/albums.json +112 -0
- package/src/pizzaz-albums/index.jsx +153 -0
- package/src/pizzaz-carousel/PlaceCard.jsx +40 -0
- package/src/pizzaz-carousel/index.jsx +121 -0
- package/src/pizzaz-list/index.jsx +115 -0
- package/src/pizzaz-shop/index.tsx +1482 -0
- package/src/types.ts +103 -0
- package/src/use-display-mode.ts +6 -0
- package/src/use-max-height.ts +5 -0
- package/src/use-openai-global.ts +37 -0
- package/src/use-widget-props.ts +14 -0
- package/src/use-widget-state.ts +46 -0
- package/tailwind.config.ts +7 -0
- package/tsconfig.app.json +24 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite-env.d.ts +1 -0
- package/vite.config.mts +232 -0
package/server/server.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer,
|
|
3
|
+
type IncomingMessage,
|
|
4
|
+
type ServerResponse,
|
|
5
|
+
} from "node:http";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { URL, fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
12
|
+
import {
|
|
13
|
+
CallToolRequestSchema,
|
|
14
|
+
ListResourceTemplatesRequestSchema,
|
|
15
|
+
ListResourcesRequestSchema,
|
|
16
|
+
ListToolsRequestSchema,
|
|
17
|
+
ReadResourceRequestSchema,
|
|
18
|
+
type CallToolRequest,
|
|
19
|
+
type ListResourceTemplatesRequest,
|
|
20
|
+
type ListResourcesRequest,
|
|
21
|
+
type ListToolsRequest,
|
|
22
|
+
type ReadResourceRequest,
|
|
23
|
+
type Resource,
|
|
24
|
+
type ResourceTemplate,
|
|
25
|
+
type Tool,
|
|
26
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
|
|
29
|
+
type PizzazWidget = {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
templateUri: string;
|
|
33
|
+
invoking: string;
|
|
34
|
+
invoked: string;
|
|
35
|
+
html: string;
|
|
36
|
+
responseText: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const ROOT_DIR = path.resolve(__dirname, "..");
|
|
41
|
+
const ASSETS_DIR = path.resolve(ROOT_DIR, "assets");
|
|
42
|
+
|
|
43
|
+
function readWidgetHtml(componentName: string): string {
|
|
44
|
+
if (!fs.existsSync(ASSETS_DIR)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Widget assets not found. Expected directory ${ASSETS_DIR}. Run "pnpm run build" before starting the server.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const directPath = path.join(ASSETS_DIR, `${componentName}.html`);
|
|
51
|
+
let htmlContents: string | null = null;
|
|
52
|
+
|
|
53
|
+
if (fs.existsSync(directPath)) {
|
|
54
|
+
htmlContents = fs.readFileSync(directPath, "utf8");
|
|
55
|
+
} else {
|
|
56
|
+
const candidates = fs
|
|
57
|
+
.readdirSync(ASSETS_DIR)
|
|
58
|
+
.filter(
|
|
59
|
+
(file) => file.startsWith(`${componentName}-`) && file.endsWith(".html")
|
|
60
|
+
)
|
|
61
|
+
.sort();
|
|
62
|
+
const fallback = candidates[candidates.length - 1];
|
|
63
|
+
if (fallback) {
|
|
64
|
+
htmlContents = fs.readFileSync(path.join(ASSETS_DIR, fallback), "utf8");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!htmlContents) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Widget HTML for "${componentName}" not found in ${ASSETS_DIR}. Run "pnpm run build" to generate the assets.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return htmlContents;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function widgetDescriptorMeta(widget: PizzazWidget) {
|
|
78
|
+
return {
|
|
79
|
+
"openai/outputTemplate": widget.templateUri,
|
|
80
|
+
"openai/toolInvocation/invoking": widget.invoking,
|
|
81
|
+
"openai/toolInvocation/invoked": widget.invoked,
|
|
82
|
+
"openai/widgetAccessible": true,
|
|
83
|
+
} as const;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function widgetInvocationMeta(widget: PizzazWidget) {
|
|
87
|
+
return {
|
|
88
|
+
"openai/toolInvocation/invoking": widget.invoking,
|
|
89
|
+
"openai/toolInvocation/invoked": widget.invoked,
|
|
90
|
+
} as const;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const widgets: PizzazWidget[] = [
|
|
94
|
+
{
|
|
95
|
+
id: "pizza-map",
|
|
96
|
+
title: "Show Pizza Map",
|
|
97
|
+
templateUri: "ui://widget/pizza-map.html",
|
|
98
|
+
invoking: "Hand-tossing a map",
|
|
99
|
+
invoked: "Served a fresh map",
|
|
100
|
+
html: readWidgetHtml("pizzaz"),
|
|
101
|
+
responseText: "Rendered a pizza map!",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "pizza-carousel",
|
|
105
|
+
title: "Show Pizza Carousel",
|
|
106
|
+
templateUri: "ui://widget/pizza-carousel.html",
|
|
107
|
+
invoking: "Carousel some spots",
|
|
108
|
+
invoked: "Served a fresh carousel",
|
|
109
|
+
html: readWidgetHtml("pizzaz-carousel"),
|
|
110
|
+
responseText: "Rendered a pizza carousel!",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "pizza-albums",
|
|
114
|
+
title: "Show Pizza Album",
|
|
115
|
+
templateUri: "ui://widget/pizza-albums.html",
|
|
116
|
+
invoking: "Hand-tossing an album",
|
|
117
|
+
invoked: "Served a fresh album",
|
|
118
|
+
html: readWidgetHtml("pizzaz-albums"),
|
|
119
|
+
responseText: "Rendered a pizza album!",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "pizza-list",
|
|
123
|
+
title: "Show Pizza List",
|
|
124
|
+
templateUri: "ui://widget/pizza-list.html",
|
|
125
|
+
invoking: "Hand-tossing a list",
|
|
126
|
+
invoked: "Served a fresh list",
|
|
127
|
+
html: readWidgetHtml("pizzaz-list"),
|
|
128
|
+
responseText: "Rendered a pizza list!",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "pizza-shop",
|
|
132
|
+
title: "Open Pizzaz Shop",
|
|
133
|
+
templateUri: "ui://widget/pizza-shop.html",
|
|
134
|
+
invoking: "Opening the shop",
|
|
135
|
+
invoked: "Shop opened",
|
|
136
|
+
html: readWidgetHtml("pizzaz-shop"),
|
|
137
|
+
responseText: "Rendered the Pizzaz shop!",
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const widgetsById = new Map<string, PizzazWidget>();
|
|
142
|
+
const widgetsByUri = new Map<string, PizzazWidget>();
|
|
143
|
+
|
|
144
|
+
widgets.forEach((widget) => {
|
|
145
|
+
widgetsById.set(widget.id, widget);
|
|
146
|
+
widgetsByUri.set(widget.templateUri, widget);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const toolInputSchema = {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: {
|
|
152
|
+
pizzaTopping: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Topping to mention when rendering the widget.",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
required: ["pizzaTopping"],
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
} as const;
|
|
160
|
+
|
|
161
|
+
const toolInputParser = z.object({
|
|
162
|
+
pizzaTopping: z.string(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const tools: Tool[] = widgets.map((widget) => ({
|
|
166
|
+
name: widget.id,
|
|
167
|
+
description: widget.title,
|
|
168
|
+
inputSchema: toolInputSchema,
|
|
169
|
+
title: widget.title,
|
|
170
|
+
_meta: widgetDescriptorMeta(widget),
|
|
171
|
+
// To disable the approval prompt for the widgets
|
|
172
|
+
annotations: {
|
|
173
|
+
destructiveHint: false,
|
|
174
|
+
openWorldHint: false,
|
|
175
|
+
readOnlyHint: true,
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
const resources: Resource[] = widgets.map((widget) => ({
|
|
180
|
+
uri: widget.templateUri,
|
|
181
|
+
name: widget.title,
|
|
182
|
+
description: `${widget.title} widget markup`,
|
|
183
|
+
mimeType: "text/html+skybridge",
|
|
184
|
+
_meta: widgetDescriptorMeta(widget),
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const resourceTemplates: ResourceTemplate[] = widgets.map((widget) => ({
|
|
188
|
+
uriTemplate: widget.templateUri,
|
|
189
|
+
name: widget.title,
|
|
190
|
+
description: `${widget.title} widget markup`,
|
|
191
|
+
mimeType: "text/html+skybridge",
|
|
192
|
+
_meta: widgetDescriptorMeta(widget),
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
function createPizzazServer(): Server {
|
|
196
|
+
const server = new Server(
|
|
197
|
+
{
|
|
198
|
+
name: "pizzaz-node",
|
|
199
|
+
version: "0.1.0",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
capabilities: {
|
|
203
|
+
resources: {},
|
|
204
|
+
tools: {},
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
server.setRequestHandler(
|
|
210
|
+
ListResourcesRequestSchema,
|
|
211
|
+
async (_request: ListResourcesRequest) => ({
|
|
212
|
+
resources,
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
server.setRequestHandler(
|
|
217
|
+
ReadResourceRequestSchema,
|
|
218
|
+
async (request: ReadResourceRequest) => {
|
|
219
|
+
const widget = widgetsByUri.get(request.params.uri);
|
|
220
|
+
|
|
221
|
+
if (!widget) {
|
|
222
|
+
throw new Error(`Unknown resource: ${request.params.uri}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
contents: [
|
|
227
|
+
{
|
|
228
|
+
uri: widget.templateUri,
|
|
229
|
+
mimeType: "text/html+skybridge",
|
|
230
|
+
text: widget.html,
|
|
231
|
+
_meta: widgetDescriptorMeta(widget),
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
server.setRequestHandler(
|
|
239
|
+
ListResourceTemplatesRequestSchema,
|
|
240
|
+
async (_request: ListResourceTemplatesRequest) => ({
|
|
241
|
+
resourceTemplates,
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
server.setRequestHandler(
|
|
246
|
+
ListToolsRequestSchema,
|
|
247
|
+
async (_request: ListToolsRequest) => ({
|
|
248
|
+
tools,
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
server.setRequestHandler(
|
|
253
|
+
CallToolRequestSchema,
|
|
254
|
+
async (request: CallToolRequest) => {
|
|
255
|
+
const widget = widgetsById.get(request.params.name);
|
|
256
|
+
|
|
257
|
+
if (!widget) {
|
|
258
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const args = toolInputParser.parse(request.params.arguments ?? {});
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: widget.responseText,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
structuredContent: {
|
|
271
|
+
pizzaTopping: args.pizzaTopping,
|
|
272
|
+
},
|
|
273
|
+
_meta: widgetInvocationMeta(widget),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return server;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
type SessionRecord = {
|
|
282
|
+
server: Server;
|
|
283
|
+
transport: SSEServerTransport;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const sessions = new Map<string, SessionRecord>();
|
|
287
|
+
|
|
288
|
+
const ssePath = "/mcp";
|
|
289
|
+
const postPath = "/mcp/messages";
|
|
290
|
+
|
|
291
|
+
async function handleSseRequest(res: ServerResponse) {
|
|
292
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
293
|
+
const server = createPizzazServer();
|
|
294
|
+
const transport = new SSEServerTransport(postPath, res);
|
|
295
|
+
const sessionId = transport.sessionId;
|
|
296
|
+
|
|
297
|
+
sessions.set(sessionId, { server, transport });
|
|
298
|
+
|
|
299
|
+
transport.onclose = async () => {
|
|
300
|
+
sessions.delete(sessionId);
|
|
301
|
+
await server.close();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
transport.onerror = (error) => {
|
|
305
|
+
console.error("SSE transport error", error);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await server.connect(transport);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
sessions.delete(sessionId);
|
|
312
|
+
console.error("Failed to start SSE session", error);
|
|
313
|
+
if (!res.headersSent) {
|
|
314
|
+
res.writeHead(500).end("Failed to establish SSE connection");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function handlePostMessage(
|
|
320
|
+
req: IncomingMessage,
|
|
321
|
+
res: ServerResponse,
|
|
322
|
+
url: URL
|
|
323
|
+
) {
|
|
324
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
325
|
+
res.setHeader("Access-Control-Allow-Headers", "content-type");
|
|
326
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
327
|
+
|
|
328
|
+
if (!sessionId) {
|
|
329
|
+
res.writeHead(400).end("Missing sessionId query parameter");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const session = sessions.get(sessionId);
|
|
334
|
+
|
|
335
|
+
if (!session) {
|
|
336
|
+
res.writeHead(404).end("Unknown session");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await session.transport.handlePostMessage(req, res);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error("Failed to process message", error);
|
|
344
|
+
if (!res.headersSent) {
|
|
345
|
+
res.writeHead(500).end("Failed to process message");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const portEnv = Number(process.env.PORT ?? 8000);
|
|
351
|
+
const port = Number.isFinite(portEnv) ? portEnv : 8000;
|
|
352
|
+
|
|
353
|
+
const httpServer = createServer(
|
|
354
|
+
async (req: IncomingMessage, res: ServerResponse) => {
|
|
355
|
+
if (!req.url) {
|
|
356
|
+
res.writeHead(400).end("Missing URL");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
req.method === "OPTIONS" &&
|
|
364
|
+
(url.pathname === ssePath || url.pathname === postPath)
|
|
365
|
+
) {
|
|
366
|
+
res.writeHead(204, {
|
|
367
|
+
"Access-Control-Allow-Origin": "*",
|
|
368
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
369
|
+
"Access-Control-Allow-Headers": "content-type",
|
|
370
|
+
});
|
|
371
|
+
res.end();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (req.method === "GET" && url.pathname === ssePath) {
|
|
376
|
+
await handleSseRequest(res);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (req.method === "POST" && url.pathname === postPath) {
|
|
381
|
+
await handlePostMessage(req, res, url);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
res.writeHead(404).end("Not Found");
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
httpServer.on("clientError", (err: Error, socket) => {
|
|
390
|
+
console.error("HTTP client error", err);
|
|
391
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
httpServer.listen(port, () => {
|
|
395
|
+
console.log(`Pizzaz MCP server listening on http://localhost:${port}`);
|
|
396
|
+
console.log(` SSE stream: GET http://localhost:${port}${ssePath}`);
|
|
397
|
+
console.log(
|
|
398
|
+
` Message post endpoint: POST http://localhost:${port}${postPath}?sessionId=...`
|
|
399
|
+
);
|
|
400
|
+
});
|
package/src/index.css
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "@openai/apps-sdk-ui/css";
|
|
3
|
+
@source "../node_modules/@openai/apps-sdk-ui";
|
|
4
|
+
@source ".";
|
|
5
|
+
|
|
6
|
+
@layer utilities {
|
|
7
|
+
.overflow-auto > *,
|
|
8
|
+
.overflow-scroll > *,
|
|
9
|
+
.overflow-x-auto > *,
|
|
10
|
+
.overflow-y-auto > * {
|
|
11
|
+
scrollbar-color: auto;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Base style for scrollable elements */
|
|
15
|
+
.overflow-auto,
|
|
16
|
+
.overflow-scroll,
|
|
17
|
+
.overflow-x-auto,
|
|
18
|
+
.overflow-y-auto,
|
|
19
|
+
.overflow-x-scroll,
|
|
20
|
+
.overflow-y-scroll {
|
|
21
|
+
scrollbar-color: rgb(0, 0, 0, 0.1) transparent;
|
|
22
|
+
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
scrollbar-color: rgb(255, 255, 255, 0.1) transparent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Hover state directly on the scrollable element */
|
|
29
|
+
.overflow-auto:hover,
|
|
30
|
+
.overflow-scroll:hover,
|
|
31
|
+
.overflow-x-auto:hover,
|
|
32
|
+
.overflow-y-auto:hover {
|
|
33
|
+
scrollbar-color: rgb(0, 0, 0, 0.2) transparent;
|
|
34
|
+
|
|
35
|
+
@media (prefers-color-scheme: dark) {
|
|
36
|
+
scrollbar-color: rgb(255, 255, 255, 0.2) transparent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function matchMediaQuery(query: string) {
|
|
2
|
+
return window.matchMedia(query).matches;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function createMediaQueryFn(query: string) {
|
|
6
|
+
return () => matchMediaQuery(query);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const prefersReducedMotion = createMediaQueryFn(
|
|
10
|
+
"(prefers-reduced-motion: reduce)"
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const isPrimarilyTouchDevice = createMediaQueryFn("(pointer: coarse)");
|
|
14
|
+
|
|
15
|
+
export const isHoverAvailable = createMediaQueryFn("(hover: hover)");
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { motion } from "framer-motion";
|
|
3
|
+
import { Star, X } from "lucide-react";
|
|
4
|
+
import { Button } from "@openai/apps-sdk-ui/components/Button";
|
|
5
|
+
import { Image } from "@openai/apps-sdk-ui/components/Image";
|
|
6
|
+
|
|
7
|
+
export default function Inspector({ place, onClose }) {
|
|
8
|
+
if (!place) return null;
|
|
9
|
+
return (
|
|
10
|
+
<motion.div
|
|
11
|
+
key={place.id}
|
|
12
|
+
initial={{ opacity: 0, scale: 0.98 }}
|
|
13
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
14
|
+
exit={{ opacity: 0, scale: 0.98 }}
|
|
15
|
+
transition={{ type: "spring", bounce: 0, duration: 0.25 }}
|
|
16
|
+
className="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"
|
|
17
|
+
>
|
|
18
|
+
<Button
|
|
19
|
+
aria-label="Close details"
|
|
20
|
+
className="inline-flex absolute z-10 top-4 left-4 xl:top-4 xl:left-4 shadow-xl rounded-full p-2 bg-white ring ring-black/10 xl:shadow-2xl hover:bg-white"
|
|
21
|
+
variant="soft"
|
|
22
|
+
color="secondary"
|
|
23
|
+
size="sm"
|
|
24
|
+
uniform
|
|
25
|
+
onClick={onClose}
|
|
26
|
+
>
|
|
27
|
+
<X className="h-[18px] w-[18px]" aria-hidden="true" />
|
|
28
|
+
</Button>
|
|
29
|
+
<div className="relative h-full overflow-y-auto rounded-none xl:rounded-3xl bg-white text-black xl:shadow-xl xl:ring ring-black/10">
|
|
30
|
+
<div className="relative mt-2 xl:mt-0 px-2 xl:px-0">
|
|
31
|
+
<Image
|
|
32
|
+
src={place.thumbnail}
|
|
33
|
+
alt={place.name}
|
|
34
|
+
className="w-full rounded-3xl xl:rounded-none h-80 object-cover xl:rounded-t-2xl"
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="h-[calc(100%-11rem)] sm:h-[calc(100%-14rem)]">
|
|
39
|
+
<div className="p-4 sm:p-5">
|
|
40
|
+
<div className="text-2xl font-medium truncate">{place.name}</div>
|
|
41
|
+
<div className="text-sm mt-1 opacity-70 flex items-center gap-1">
|
|
42
|
+
<Star className="h-3.5 w-3.5" aria-hidden="true" />
|
|
43
|
+
{place.rating.toFixed(1)}
|
|
44
|
+
{place.price ? <span>· {place.price}</span> : null}
|
|
45
|
+
<span>· San Francisco</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="mt-3 flex flex-row items-center gap-3 font-medium">
|
|
48
|
+
<Button color="primary" variant="solid" size="sm">
|
|
49
|
+
{" "}
|
|
50
|
+
Add to favorites
|
|
51
|
+
</Button>
|
|
52
|
+
<Button
|
|
53
|
+
color="primary"
|
|
54
|
+
variant="outline"
|
|
55
|
+
size="sm"
|
|
56
|
+
className="border-[#F46C21]/50 text-[#F46C21]"
|
|
57
|
+
>
|
|
58
|
+
Contact
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="text-sm mt-5">
|
|
62
|
+
{place.description} Enjoy a slice at one of SF's favorites. Fresh
|
|
63
|
+
ingredients, great crust, and cozy vibes.
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="px-4 sm:px-5 pb-4">
|
|
68
|
+
<div className="text-lg font-medium mb-2">Reviews</div>
|
|
69
|
+
<ul className="space-y-3 divide-y divide-black/5">
|
|
70
|
+
{[
|
|
71
|
+
{
|
|
72
|
+
user: "Leo M.",
|
|
73
|
+
avatar: "https://persistent.oaistatic.com/pizzaz/user1.png",
|
|
74
|
+
text: "Fantastic crust and balanced toppings. The marinara is spot on!",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
user: "Priya S.",
|
|
78
|
+
avatar: "https://persistent.oaistatic.com/pizzaz/user2.png",
|
|
79
|
+
text: "Cozy vibe and friendly staff. Quick service on a Friday night.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
user: "Maya R.",
|
|
83
|
+
avatar: "https://persistent.oaistatic.com/pizzaz/user3.png",
|
|
84
|
+
text: "Great for sharing. Will definitely come back with friends.",
|
|
85
|
+
},
|
|
86
|
+
].map((review, idx) => (
|
|
87
|
+
<li key={idx} className="py-3">
|
|
88
|
+
<div className="flex items-start gap-3">
|
|
89
|
+
<Image
|
|
90
|
+
src={review.avatar}
|
|
91
|
+
alt={`${review.user} avatar`}
|
|
92
|
+
className="h-8 w-8 ring ring-black/5 rounded-full object-cover flex-none"
|
|
93
|
+
/>
|
|
94
|
+
<div className="min-w-0 gap-1 flex flex-col">
|
|
95
|
+
<div className="text-xs font-medium text-black/70">
|
|
96
|
+
{review.user}
|
|
97
|
+
</div>
|
|
98
|
+
<div className="text-sm">{review.text}</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</li>
|
|
102
|
+
))}
|
|
103
|
+
</ul>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</motion.div>
|
|
108
|
+
);
|
|
109
|
+
}
|