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,1482 @@
1
+ import clsx from "clsx";
2
+ import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
3
+ import { Minus, Plus, ShoppingCart } from "lucide-react";
4
+ import {
5
+ type MouseEvent as ReactMouseEvent,
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import { createRoot } from "react-dom/client";
14
+ import { BrowserRouter, useLocation, useNavigate } from "react-router-dom";
15
+ import { useDisplayMode } from "../use-display-mode";
16
+ import { useMaxHeight } from "../use-max-height";
17
+ import { useOpenAiGlobal } from "../use-openai-global";
18
+ import { useWidgetProps } from "../use-widget-props";
19
+ import { useWidgetState } from "../use-widget-state";
20
+
21
+ import { Button } from "@openai/apps-sdk-ui/components/Button";
22
+ import { Image } from "@openai/apps-sdk-ui/components/Image";
23
+
24
+ type NutritionFact = {
25
+ label: string;
26
+ value: string;
27
+ };
28
+
29
+ type CartItem = {
30
+ id: string;
31
+ name: string;
32
+ price: number;
33
+ description: string;
34
+ shortDescription?: string;
35
+ detailSummary?: string;
36
+ nutritionFacts?: NutritionFact[];
37
+ highlights?: string[];
38
+ tags?: string[];
39
+ quantity: number;
40
+ image: string;
41
+ };
42
+
43
+ type PizzazCartWidgetState = {
44
+ state?: "checkout" | null;
45
+ cartItems?: CartItem[];
46
+ selectedCartItemId?: string | null;
47
+ };
48
+
49
+ type PizzazCartWidgetProps = {
50
+ cartItems?: CartItem[];
51
+ widgetState?: Partial<PizzazCartWidgetState> | null;
52
+ };
53
+
54
+ const SERVICE_FEE = 3;
55
+ const DELIVERY_FEE = 2.99;
56
+ const TAX_FEE = 3.4;
57
+ const CONTINUE_TO_PAYMENT_EVENT = "pizzaz-shop:continue-to-payment";
58
+
59
+ const FILTERS: Array<{
60
+ id: "all" | "vegetarian" | "vegan" | "size" | "spicy";
61
+ label: string;
62
+ tag?: string;
63
+ }> = [
64
+ { id: "all", label: "All" },
65
+ { id: "vegetarian", label: "Vegetarian", tag: "vegetarian" },
66
+ { id: "vegan", label: "Vegan", tag: "vegan" },
67
+ { id: "size", label: "Size", tag: "size" },
68
+ { id: "spicy", label: "Spicy", tag: "spicy" },
69
+ ];
70
+
71
+ const INITIAL_CART_ITEMS: CartItem[] = [
72
+ {
73
+ id: "marys-chicken",
74
+ name: "Mary's Chicken",
75
+ price: 19.48,
76
+ description:
77
+ "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.",
78
+ shortDescription: "Organic chicken breasts",
79
+ detailSummary: "4 lbs • $3.99/lb",
80
+ nutritionFacts: [
81
+ { label: "Protein", value: "8g" },
82
+ { label: "Fat", value: "9g" },
83
+ { label: "Sugar", value: "12g" },
84
+ { label: "Calories", value: "160" },
85
+ ],
86
+ highlights: [
87
+ "No antibiotics or added hormones.",
88
+ "Air chilled and never frozen for peak flavor.",
89
+ "Raised in the USA on a vegetarian diet.",
90
+ ],
91
+ quantity: 2,
92
+ image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png",
93
+ tags: ["size"],
94
+ },
95
+ {
96
+ id: "avocados",
97
+ name: "Avocados",
98
+ price: 1,
99
+ description:
100
+ "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.",
101
+ shortDescription: "Creamy Hass avocados",
102
+ detailSummary: "3 ct • $1.00/ea",
103
+ nutritionFacts: [
104
+ { label: "Fiber", value: "7g" },
105
+ { label: "Fat", value: "15g" },
106
+ { label: "Potassium", value: "485mg" },
107
+ { label: "Calories", value: "160" },
108
+ ],
109
+ highlights: [
110
+ "Perfectly ripe and ready for slicing.",
111
+ "Rich in healthy fats and naturally creamy.",
112
+ ],
113
+ quantity: 2,
114
+ image: "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png",
115
+ tags: ["vegan"],
116
+ },
117
+ {
118
+ id: "hojicha-pizza",
119
+ name: "Hojicha Pizza",
120
+ price: 15.5,
121
+ description:
122
+ "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.",
123
+ shortDescription: "Smoky hojicha sauce & honey",
124
+ detailSummary: '12" pie • Serves 2',
125
+ nutritionFacts: [
126
+ { label: "Protein", value: "14g" },
127
+ { label: "Fat", value: "18g" },
128
+ { label: "Sugar", value: "9g" },
129
+ { label: "Calories", value: "320" },
130
+ ],
131
+ highlights: [
132
+ "Smoky roasted hojicha glaze with honey drizzle.",
133
+ "Stone-fired crust with a delicate char.",
134
+ ],
135
+ quantity: 2,
136
+ image: "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png",
137
+ tags: ["vegetarian", "size", "spicy"],
138
+ },
139
+ {
140
+ id: "chicken-pizza",
141
+ name: "Chicken Pizza",
142
+ price: 7,
143
+ description:
144
+ "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.",
145
+ shortDescription: "Roasted chicken & pesto",
146
+ detailSummary: '10" personal • Serves 1',
147
+ nutritionFacts: [
148
+ { label: "Protein", value: "20g" },
149
+ { label: "Fat", value: "11g" },
150
+ { label: "Carbs", value: "36g" },
151
+ { label: "Calories", value: "290" },
152
+ ],
153
+ highlights: [
154
+ "Roasted chicken with caramelized onions.",
155
+ "Fresh basil pesto and mozzarella.",
156
+ ],
157
+ quantity: 1,
158
+ image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png",
159
+ tags: ["size"],
160
+ },
161
+ {
162
+ id: "matcha-pizza",
163
+ name: "Matcha Pizza",
164
+ price: 5,
165
+ description:
166
+ "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.",
167
+ shortDescription: "Velvety matcha cream",
168
+ detailSummary: '8" dessert • Serves 2',
169
+ nutritionFacts: [
170
+ { label: "Protein", value: "6g" },
171
+ { label: "Fat", value: "10g" },
172
+ { label: "Sugar", value: "14g" },
173
+ { label: "Calories", value: "240" },
174
+ ],
175
+ highlights: [
176
+ "Stone-baked crust with delicate crunch.",
177
+ "Matcha mascarpone with white chocolate drizzle.",
178
+ ],
179
+ quantity: 1,
180
+ image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png",
181
+ tags: ["vegetarian"],
182
+ },
183
+ {
184
+ id: "pesto-pizza",
185
+ name: "Pesto Pizza",
186
+ price: 12.5,
187
+ description:
188
+ "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.",
189
+ shortDescription: "Basil pesto & tomatoes",
190
+ detailSummary: '12" pie • Serves 2',
191
+ nutritionFacts: [
192
+ { label: "Protein", value: "16g" },
193
+ { label: "Fat", value: "14g" },
194
+ { label: "Carbs", value: "28g" },
195
+ { label: "Calories", value: "310" },
196
+ ],
197
+ highlights: [
198
+ "House-made pesto with sweet basil and pine nuts.",
199
+ "Roasted cherry tomatoes for a pop of acidity.",
200
+ ],
201
+ quantity: 1,
202
+ image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png",
203
+ tags: ["vegetarian", "size"],
204
+ },
205
+ ];
206
+
207
+ const cloneCartItem = (item: CartItem): CartItem => ({
208
+ ...item,
209
+ nutritionFacts: item.nutritionFacts?.map((fact) => ({ ...fact })),
210
+ highlights: item.highlights ? [...item.highlights] : undefined,
211
+ tags: item.tags ? [...item.tags] : undefined,
212
+ });
213
+
214
+ const createDefaultCartItems = (): CartItem[] =>
215
+ INITIAL_CART_ITEMS.map((item) => cloneCartItem(item));
216
+
217
+ const createDefaultWidgetState = (): PizzazCartWidgetState => ({
218
+ state: null,
219
+ cartItems: createDefaultCartItems(),
220
+ selectedCartItemId: null,
221
+ });
222
+
223
+ const nutritionFactsEqual = (
224
+ a?: NutritionFact[],
225
+ b?: NutritionFact[]
226
+ ): boolean => {
227
+ if (!a?.length && !b?.length) {
228
+ return true;
229
+ }
230
+ if (!a || !b || a.length !== b.length) {
231
+ return false;
232
+ }
233
+ return a.every((fact, index) => {
234
+ const other = b[index];
235
+ if (!other) {
236
+ return false;
237
+ }
238
+ return fact.label === other.label && fact.value === other.value;
239
+ });
240
+ };
241
+
242
+ const highlightsEqual = (a?: string[], b?: string[]): boolean => {
243
+ if (!a?.length && !b?.length) {
244
+ return true;
245
+ }
246
+ if (!a || !b || a.length !== b.length) {
247
+ return false;
248
+ }
249
+ return a.every((highlight, index) => highlight === b[index]);
250
+ };
251
+
252
+ const cartItemsEqual = (a: CartItem[], b: CartItem[]): boolean => {
253
+ if (a.length !== b.length) {
254
+ return false;
255
+ }
256
+ for (let i = 0; i < a.length; i += 1) {
257
+ const left = a[i];
258
+ const right = b[i];
259
+ if (!right) {
260
+ return false;
261
+ }
262
+ if (
263
+ left.id !== right.id ||
264
+ left.quantity !== right.quantity ||
265
+ left.name !== right.name ||
266
+ left.price !== right.price ||
267
+ left.description !== right.description ||
268
+ left.shortDescription !== right.shortDescription ||
269
+ left.detailSummary !== right.detailSummary ||
270
+ !nutritionFactsEqual(left.nutritionFacts, right.nutritionFacts) ||
271
+ !highlightsEqual(left.highlights, right.highlights) ||
272
+ !highlightsEqual(left.tags, right.tags) ||
273
+ left.image !== right.image
274
+ ) {
275
+ return false;
276
+ }
277
+ }
278
+ return true;
279
+ };
280
+
281
+ type SelectedCartItemPanelProps = {
282
+ item: CartItem;
283
+ onAdjustQuantity: (id: string, delta: number) => void;
284
+ };
285
+
286
+ function SelectedCartItemPanel({
287
+ item,
288
+ onAdjustQuantity,
289
+ }: SelectedCartItemPanelProps) {
290
+ const nutritionFacts = Array.isArray(item.nutritionFacts)
291
+ ? item.nutritionFacts
292
+ : [];
293
+ const highlights = Array.isArray(item.highlights) ? item.highlights : [];
294
+
295
+ const hasNutritionFacts = nutritionFacts.length > 0;
296
+ const hasHighlights = highlights.length > 0;
297
+
298
+ return (
299
+ <div className="space-y-4">
300
+ <div className="overflow-hidden rounded-none border-b border-black/5 bg-white">
301
+ <div className="relative flex items-center justify-center overflow-hidden">
302
+ <Image
303
+ src={item.image}
304
+ alt={item.name}
305
+ className="max-h-[320px] w-[80%] object-cover"
306
+ />
307
+ <div className="absolute inset-0 bg-black/[0.025]" />
308
+ </div>
309
+ </div>
310
+
311
+ <div className="flex flex-col gap-3 px-5 pb-5">
312
+ <div className="flex items-start justify-between gap-4">
313
+ <div className="space-y-0">
314
+ <p className="text-xl font-medium text-black">
315
+ ${item.price.toFixed(2)}
316
+ </p>
317
+ <h2 className="text-base text-black">{item.name}</h2>
318
+ </div>
319
+ <div className="flex items-center rounded-full bg-black/[0.04] px-1 py-1 text-black">
320
+ <Button
321
+ type="button"
322
+ variant="ghost"
323
+ color="secondary"
324
+ size="xs"
325
+ uniform
326
+ aria-label={`Decrease quantity of ${item.name}`}
327
+ onClick={() => onAdjustQuantity(item.id, -1)}
328
+ >
329
+ <Minus
330
+ strokeWidth={2}
331
+ className="h-3.5 w-3.5"
332
+ aria-hidden="true"
333
+ />
334
+ </Button>
335
+ <span className="mx-2 min-w-[10px] text-center text-base font-medium">
336
+ {item.quantity}
337
+ </span>
338
+ <Button
339
+ type="button"
340
+ variant="ghost"
341
+ color="secondary"
342
+ size="xs"
343
+ uniform
344
+ aria-label={`Increase quantity of ${item.name}`}
345
+ onClick={() => onAdjustQuantity(item.id, 1)}
346
+ >
347
+ <Plus
348
+ strokeWidth={2}
349
+ className="h-3.5 w-3.5"
350
+ aria-hidden="true"
351
+ />
352
+ </Button>
353
+ </div>
354
+ </div>
355
+
356
+ <p className="text-sm text-black/60">{item.description}</p>
357
+
358
+ {item.detailSummary ? (
359
+ <p className="text-sm font-medium text-black">{item.detailSummary}</p>
360
+ ) : null}
361
+
362
+ {hasNutritionFacts ? (
363
+ <div className="grid grid-cols-3 gap-3 rounded-3xl border border-black/[0.05] px-4 py-2 text-center sm:grid-cols-4">
364
+ {nutritionFacts.map((fact) => (
365
+ <div key={`${item.id}-${fact.label}`} className="space-y-0.5">
366
+ <p className="text-base font-medium text-black">{fact.value}</p>
367
+ <p className="text-xs text-black/60">{fact.label}</p>
368
+ </div>
369
+ ))}
370
+ </div>
371
+ ) : null}
372
+
373
+ {hasHighlights ? (
374
+ <div className="space-y-1 text-sm text-black/60">
375
+ {highlights.map((highlight, index) => (
376
+ <p key={`${item.id}-highlight-${index}`}>{highlight}</p>
377
+ ))}
378
+ </div>
379
+ ) : null}
380
+ </div>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ type CheckoutDetailsPanelProps = {
386
+ shouldShowCheckoutOnly: boolean;
387
+ subtotal: number;
388
+ total: number;
389
+ onContinueToPayment?: () => void;
390
+ };
391
+
392
+ function CheckoutDetailsPanel({
393
+ shouldShowCheckoutOnly,
394
+ subtotal,
395
+ total,
396
+ onContinueToPayment,
397
+ }: CheckoutDetailsPanelProps) {
398
+ return (
399
+ <>
400
+ {!shouldShowCheckoutOnly && (
401
+ <header className="hidden space-y-4 sm:block">
402
+ <h2 className="text-xl text-black">Checkout details</h2>
403
+ </header>
404
+ )}
405
+
406
+ <section className="space-y-4 border-t border-black/5 pt-3">
407
+ <div className="space-y-0">
408
+ <h3 className="text-sm font-medium">Delivery address</h3>
409
+ </div>
410
+ <div className="space-y-0">
411
+ <p className="text-base text-sm font-medium text-slate-900">
412
+ 1234 Main St, San Francisco, CA
413
+ </p>
414
+ <p className="text-xs text-black/50">
415
+ Leave at door - Delivery instructions
416
+ </p>
417
+ </div>
418
+
419
+ <div className="mt-1 flex flex-row items-center gap-3">
420
+ <div className="flex flex-1 items-center justify-between rounded-xl border border-black/35 bg-white px-4 py-2.5 shadow-sm">
421
+ <div>
422
+ <p className="text-sm font-medium text-slate-900">Fast</p>
423
+ <p className="line-clamp-1 text-xs text-black/50">
424
+ 50 min - 2 hr 10 min
425
+ </p>
426
+ </div>
427
+ <span className="text-sm font-semibold text-[#047857]">Free</span>
428
+ </div>
429
+ <div className="flex flex-1 items-center justify-between rounded-xl border border-black/10 px-4 py-2.5">
430
+ <div>
431
+ <p className="text-sm font-medium text-slate-900">Priority</p>
432
+ <p className="line-clamp-1 text-xs text-black/50">35 min</p>
433
+ </div>
434
+ <span className="text-sm font-semibold text-[#047857]">Free</span>
435
+ </div>
436
+ </div>
437
+ </section>
438
+
439
+ <section className="space-y-4 border-t border-black/5 pt-3">
440
+ <div>
441
+ <h3 className="text-sm font-medium text-black">Delivery tip</h3>
442
+ <p className="text-xs text-black/50">100% goes to the shopper</p>
443
+ </div>
444
+ <div className="flex items-center gap-3 text-sm">
445
+ <Button
446
+ type="button"
447
+ variant="soft"
448
+ color="secondary"
449
+ size="sm"
450
+ className="flex-1"
451
+ >
452
+ 5%
453
+ </Button>
454
+ <Button
455
+ type="button"
456
+ variant="solid"
457
+ color="primary"
458
+ size="sm"
459
+ className="flex-1"
460
+ >
461
+ 10%
462
+ </Button>
463
+ <Button
464
+ type="button"
465
+ variant="soft"
466
+ color="secondary"
467
+ size="sm"
468
+ className="flex-1"
469
+ >
470
+ 15%
471
+ </Button>
472
+ <Button
473
+ type="button"
474
+ variant="soft"
475
+ color="secondary"
476
+ size="sm"
477
+ className="flex-1"
478
+ >
479
+ Other
480
+ </Button>
481
+ </div>
482
+ </section>
483
+
484
+ <section className="space-y-1 border-t border-black/5 pt-3 text-center">
485
+ <div className="flex items-center justify-between">
486
+ <span className="text-sm text-black/70">Subtotal</span>
487
+ <span className="text-md text-black">${subtotal.toFixed(2)}</span>
488
+ </div>
489
+ <div className="flex items-center justify-between">
490
+ <span className="text-sm text-black/70">Total</span>
491
+ <span className="text-md font-medium text-black">
492
+ ${total.toFixed(2)}
493
+ </span>
494
+ </div>
495
+ <p className="mt-3 mb-4 border-b border-black/5 text-xs text-black/50"></p>
496
+ <Button
497
+ type="button"
498
+ color="primary"
499
+ variant="solid"
500
+ size="md"
501
+ className="mx-auto w-full max-w-xs"
502
+ block
503
+ onClick={onContinueToPayment}
504
+ >
505
+ Continue to payment
506
+ </Button>
507
+ </section>
508
+ </>
509
+ );
510
+ }
511
+
512
+ function App() {
513
+ const maxHeight = useMaxHeight() ?? undefined;
514
+ const displayMode = useDisplayMode();
515
+ const isFullscreen = displayMode === "fullscreen";
516
+ const widgetProps = useWidgetProps<PizzazCartWidgetProps>(() => ({}));
517
+ const [widgetState, setWidgetState] = useWidgetState<PizzazCartWidgetState>(
518
+ createDefaultWidgetState
519
+ );
520
+ const navigate = useNavigate();
521
+ const location = useLocation();
522
+ const isCheckoutRoute = useMemo(() => {
523
+ const pathname = location?.pathname ?? "";
524
+ if (!pathname) {
525
+ return false;
526
+ }
527
+
528
+ return pathname === "/checkout" || pathname.endsWith("/checkout");
529
+ }, [location?.pathname]);
530
+
531
+ const defaultCartItems = useMemo(() => createDefaultCartItems(), []);
532
+ const cartGridRef = useRef<HTMLDivElement | null>(null);
533
+ const [gridColumnCount, setGridColumnCount] = useState(1);
534
+
535
+ const mergeWithDefaultItems = useCallback(
536
+ (items: CartItem[]): CartItem[] => {
537
+ const existingIds = new Set(items.map((item) => item.id));
538
+ const merged = items.map((item) => {
539
+ const defaultItem = defaultCartItems.find(
540
+ (candidate) => candidate.id === item.id
541
+ );
542
+
543
+ if (!defaultItem) {
544
+ return cloneCartItem(item);
545
+ }
546
+
547
+ const enriched: CartItem = {
548
+ ...cloneCartItem(defaultItem),
549
+ ...item,
550
+ tags: item.tags ? [...item.tags] : defaultItem.tags,
551
+ nutritionFacts:
552
+ item.nutritionFacts ??
553
+ defaultItem.nutritionFacts?.map((fact) => ({ ...fact })),
554
+ highlights:
555
+ item.highlights != null
556
+ ? [...item.highlights]
557
+ : defaultItem.highlights
558
+ ? [...defaultItem.highlights]
559
+ : undefined,
560
+ };
561
+
562
+ return cloneCartItem(enriched);
563
+ });
564
+
565
+ defaultCartItems.forEach((defaultItem) => {
566
+ if (!existingIds.has(defaultItem.id)) {
567
+ merged.push(cloneCartItem(defaultItem));
568
+ }
569
+ });
570
+
571
+ return merged;
572
+ },
573
+ [defaultCartItems]
574
+ );
575
+
576
+ const resolvedCartItems = useMemo(() => {
577
+ if (Array.isArray(widgetState?.cartItems) && widgetState.cartItems.length) {
578
+ return mergeWithDefaultItems(widgetState.cartItems);
579
+ }
580
+
581
+ if (
582
+ Array.isArray(widgetProps?.widgetState?.cartItems) &&
583
+ widgetProps.widgetState.cartItems.length
584
+ ) {
585
+ return mergeWithDefaultItems(widgetProps.widgetState.cartItems);
586
+ }
587
+
588
+ if (Array.isArray(widgetProps?.cartItems) && widgetProps.cartItems.length) {
589
+ return mergeWithDefaultItems(widgetProps.cartItems);
590
+ }
591
+
592
+ return mergeWithDefaultItems(defaultCartItems);
593
+ }, [
594
+ defaultCartItems,
595
+ mergeWithDefaultItems,
596
+ widgetProps?.cartItems,
597
+ widgetProps?.widgetState?.cartItems,
598
+ widgetState,
599
+ ]);
600
+
601
+ const [cartItems, setCartItems] = useState<CartItem[]>(resolvedCartItems);
602
+
603
+ useEffect(() => {
604
+ setCartItems((previous) =>
605
+ cartItemsEqual(previous, resolvedCartItems) ? previous : resolvedCartItems
606
+ );
607
+ }, [resolvedCartItems]);
608
+
609
+ const resolvedSelectedCartItemId =
610
+ widgetState?.selectedCartItemId ??
611
+ widgetProps?.widgetState?.selectedCartItemId ??
612
+ null;
613
+
614
+ const [selectedCartItemId, setSelectedCartItemId] = useState<string | null>(
615
+ resolvedSelectedCartItemId
616
+ );
617
+
618
+ useEffect(() => {
619
+ setSelectedCartItemId((prev) =>
620
+ prev === resolvedSelectedCartItemId ? prev : resolvedSelectedCartItemId
621
+ );
622
+ }, [resolvedSelectedCartItemId]);
623
+
624
+ const view = useOpenAiGlobal("view");
625
+ const viewParams = view?.params;
626
+ const isModalView = view?.mode === "modal";
627
+ const checkoutFromState =
628
+ (widgetState?.state ?? widgetProps?.widgetState?.state) === "checkout";
629
+ const modalParams =
630
+ viewParams && typeof viewParams === "object"
631
+ ? (viewParams as {
632
+ state?: unknown;
633
+ cartItems?: unknown;
634
+ subtotal?: unknown;
635
+ total?: unknown;
636
+ totalItems?: unknown;
637
+ })
638
+ : null;
639
+
640
+ const modalState =
641
+ modalParams && typeof modalParams.state === "string"
642
+ ? (modalParams.state as string)
643
+ : null;
644
+
645
+ const isCartModalView = isModalView && modalState === "cart";
646
+ const shouldShowCheckoutOnly =
647
+ isCheckoutRoute || (isModalView && !isCartModalView);
648
+ const wasModalViewRef = useRef(isModalView);
649
+
650
+ useEffect(() => {
651
+ if (!viewParams || typeof viewParams !== "object") {
652
+ return;
653
+ }
654
+
655
+ const paramsWithSelection = viewParams as {
656
+ selectedCartItemId?: unknown;
657
+ };
658
+
659
+ const selectedIdFromParams = paramsWithSelection.selectedCartItemId;
660
+
661
+ if (
662
+ typeof selectedIdFromParams === "string" &&
663
+ selectedIdFromParams !== selectedCartItemId
664
+ ) {
665
+ setSelectedCartItemId(selectedIdFromParams);
666
+ return;
667
+ }
668
+
669
+ if (selectedIdFromParams === null && selectedCartItemId !== null) {
670
+ setSelectedCartItemId(null);
671
+ }
672
+ }, [selectedCartItemId, viewParams]);
673
+
674
+ const [hoveredCartItemId, setHoveredCartItemId] = useState<string | null>(
675
+ null
676
+ );
677
+ const [activeFilters, setActiveFilters] = useState<string[]>([]);
678
+
679
+ const updateWidgetState = useCallback(
680
+ (partial: Partial<PizzazCartWidgetState>) => {
681
+ setWidgetState((previous) => ({
682
+ ...createDefaultWidgetState(),
683
+ ...(previous ?? {}),
684
+ ...partial,
685
+ }));
686
+ },
687
+ [setWidgetState]
688
+ );
689
+
690
+ useEffect(() => {
691
+ if (!Array.isArray(widgetState?.cartItems)) {
692
+ return;
693
+ }
694
+
695
+ const merged = mergeWithDefaultItems(widgetState.cartItems);
696
+
697
+ if (!cartItemsEqual(widgetState.cartItems, merged)) {
698
+ updateWidgetState({ cartItems: merged });
699
+ }
700
+ }, [mergeWithDefaultItems, updateWidgetState, widgetState?.cartItems]);
701
+
702
+ useEffect(() => {
703
+ if (wasModalViewRef.current && !isModalView && checkoutFromState) {
704
+ updateWidgetState({ state: null });
705
+ }
706
+
707
+ wasModalViewRef.current = isModalView;
708
+ }, [checkoutFromState, isModalView, updateWidgetState]);
709
+
710
+ const adjustQuantity = useCallback(
711
+ (id: string, delta: number) => {
712
+ setCartItems((previousItems) => {
713
+ const updatedItems = previousItems.map((item) =>
714
+ item.id === id
715
+ ? { ...item, quantity: Math.max(0, item.quantity + delta) }
716
+ : item
717
+ );
718
+
719
+ if (!cartItemsEqual(previousItems, updatedItems)) {
720
+ updateWidgetState({ cartItems: updatedItems });
721
+ }
722
+
723
+ return updatedItems;
724
+ });
725
+ },
726
+ [updateWidgetState]
727
+ );
728
+
729
+ useEffect(() => {
730
+ if (!shouldShowCheckoutOnly) {
731
+ return;
732
+ }
733
+
734
+ setHoveredCartItemId(null);
735
+ }, [shouldShowCheckoutOnly]);
736
+
737
+ const manualCheckoutTriggerRef = useRef(false);
738
+
739
+ const requestModalWithAnchor = useCallback(
740
+ ({
741
+ title,
742
+ params,
743
+ anchorElement,
744
+ }: {
745
+ title: string;
746
+ params: Record<string, unknown>;
747
+ anchorElement?: HTMLElement | null;
748
+ }) => {
749
+ if (isModalView) {
750
+ return;
751
+ }
752
+
753
+ const anchorRect = anchorElement?.getBoundingClientRect();
754
+ const anchor =
755
+ anchorRect == null
756
+ ? undefined
757
+ : {
758
+ top: anchorRect.top,
759
+ left: anchorRect.left,
760
+ width: anchorRect.width,
761
+ height: anchorRect.height,
762
+ };
763
+
764
+ void (async () => {
765
+ try {
766
+ await window?.openai?.requestModal?.({
767
+ title,
768
+ params,
769
+ ...(anchor ? { anchor } : {}),
770
+ });
771
+ } catch (error) {
772
+ console.error("Failed to open checkout modal", error);
773
+ }
774
+ })();
775
+ },
776
+ [isModalView]
777
+ );
778
+
779
+ const openCheckoutModal = useCallback(
780
+ (anchorElement?: HTMLElement | null) => {
781
+ requestModalWithAnchor({
782
+ title: "Checkout",
783
+ params: { state: "checkout" },
784
+ anchorElement,
785
+ });
786
+ },
787
+ [requestModalWithAnchor]
788
+ );
789
+
790
+ const openCartItemModal = useCallback(
791
+ ({
792
+ selectedId,
793
+ selectedName,
794
+ anchorElement,
795
+ }: {
796
+ selectedId: string;
797
+ selectedName: string | null;
798
+ anchorElement?: HTMLElement | null;
799
+ }) => {
800
+ requestModalWithAnchor({
801
+ title: selectedName ?? selectedId,
802
+ params: { state: "checkout", selectedCartItemId: selectedId },
803
+ anchorElement,
804
+ });
805
+ },
806
+ [requestModalWithAnchor]
807
+ );
808
+
809
+ const handleCartItemSelect = useCallback(
810
+ (id: string, anchorElement?: HTMLElement | null) => {
811
+ const itemName = cartItems.find((item) => item.id === id)?.name ?? null;
812
+ manualCheckoutTriggerRef.current = true;
813
+ setSelectedCartItemId(id);
814
+ updateWidgetState({ selectedCartItemId: id, state: "checkout" });
815
+ openCartItemModal({
816
+ selectedId: id,
817
+ selectedName: itemName,
818
+ anchorElement,
819
+ });
820
+ },
821
+ [cartItems, openCartItemModal, updateWidgetState]
822
+ );
823
+
824
+ const subtotal = useMemo(
825
+ () =>
826
+ cartItems.reduce(
827
+ (total, item) => total + item.price * Math.max(0, item.quantity),
828
+ 0
829
+ ),
830
+ [cartItems]
831
+ );
832
+
833
+ const total = subtotal + SERVICE_FEE + DELIVERY_FEE + TAX_FEE;
834
+
835
+ const totalItems = useMemo(
836
+ () =>
837
+ cartItems.reduce((total, item) => total + Math.max(0, item.quantity), 0),
838
+ [cartItems]
839
+ );
840
+
841
+ const visibleCartItems = useMemo(() => {
842
+ if (!activeFilters.length) {
843
+ return cartItems;
844
+ }
845
+
846
+ return cartItems.filter((item) => {
847
+ const tags = item.tags ?? [];
848
+
849
+ return activeFilters.every((filterId) => {
850
+ const filterMeta = FILTERS.find((filter) => filter.id === filterId);
851
+ if (!filterMeta?.tag) {
852
+ return true;
853
+ }
854
+ return tags.includes(filterMeta.tag);
855
+ });
856
+ });
857
+ }, [activeFilters, cartItems]);
858
+
859
+ const updateItemColumnPlacement = useCallback(() => {
860
+ const gridNode = cartGridRef.current;
861
+
862
+ const width = gridNode?.offsetWidth ?? 0;
863
+
864
+ let baseColumnCount = 1;
865
+ if (width >= 768) {
866
+ baseColumnCount = 3;
867
+ } else if (width >= 640) {
868
+ baseColumnCount = 2;
869
+ }
870
+
871
+ const columnCount = isFullscreen
872
+ ? Math.max(baseColumnCount, 3)
873
+ : baseColumnCount;
874
+
875
+ if (gridNode) {
876
+ gridNode.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`;
877
+ }
878
+
879
+ setGridColumnCount(columnCount);
880
+ }, [isFullscreen]);
881
+
882
+ const handleFilterToggle = useCallback(
883
+ (id: string) => {
884
+ setActiveFilters((previous) => {
885
+ if (id === "all") {
886
+ return [];
887
+ }
888
+
889
+ const isActive = previous.includes(id);
890
+ if (isActive) {
891
+ return [];
892
+ }
893
+
894
+ return [id];
895
+ });
896
+
897
+ requestAnimationFrame(() => {
898
+ updateItemColumnPlacement();
899
+ });
900
+ },
901
+ [updateItemColumnPlacement]
902
+ );
903
+
904
+ useEffect(() => {
905
+ const node = cartGridRef.current;
906
+
907
+ if (!node) {
908
+ return;
909
+ }
910
+
911
+ const observer =
912
+ typeof ResizeObserver !== "undefined"
913
+ ? new ResizeObserver(() => {
914
+ requestAnimationFrame(updateItemColumnPlacement);
915
+ })
916
+ : null;
917
+
918
+ observer?.observe(node);
919
+ window.addEventListener("resize", updateItemColumnPlacement);
920
+
921
+ return () => {
922
+ observer?.disconnect();
923
+ window.removeEventListener("resize", updateItemColumnPlacement);
924
+ };
925
+ }, [updateItemColumnPlacement]);
926
+
927
+ const openCartModal = useCallback(
928
+ (anchorElement?: HTMLElement | null) => {
929
+ if (isModalView || shouldShowCheckoutOnly) {
930
+ return;
931
+ }
932
+
933
+ requestModalWithAnchor({
934
+ title: "Cart",
935
+ params: {
936
+ state: "cart",
937
+ cartItems,
938
+ subtotal,
939
+ total,
940
+ totalItems,
941
+ },
942
+ anchorElement,
943
+ });
944
+ },
945
+ [
946
+ cartItems,
947
+ isModalView,
948
+ requestModalWithAnchor,
949
+ shouldShowCheckoutOnly,
950
+ subtotal,
951
+ total,
952
+ totalItems,
953
+ ]
954
+ );
955
+
956
+ type CartSummaryItem = {
957
+ id: string;
958
+ name: string;
959
+ price: number;
960
+ quantity: number;
961
+ image?: string;
962
+ };
963
+
964
+ const cartSummaryItems: CartSummaryItem[] = useMemo(() => {
965
+ if (!isCartModalView) {
966
+ return [];
967
+ }
968
+
969
+ const items = Array.isArray(modalParams?.cartItems)
970
+ ? modalParams?.cartItems
971
+ : null;
972
+
973
+ if (!items) {
974
+ return cartItems.map((item) => ({
975
+ id: item.id,
976
+ name: item.name,
977
+ price: item.price,
978
+ quantity: Math.max(0, item.quantity),
979
+ image: item.image,
980
+ }));
981
+ }
982
+
983
+ const sanitized = items
984
+ .map((raw, index) => {
985
+ if (!raw || typeof raw !== "object") {
986
+ return null;
987
+ }
988
+ const candidate = raw as Record<string, unknown>;
989
+ const id =
990
+ typeof candidate.id === "string" ? candidate.id : `cart-${index}`;
991
+ const name =
992
+ typeof candidate.name === "string" ? candidate.name : "Item";
993
+ const priceValue = Number(candidate.price);
994
+ const quantityValue = Number(candidate.quantity);
995
+ const price = Number.isFinite(priceValue) ? priceValue : 0;
996
+ const quantity = Number.isFinite(quantityValue)
997
+ ? Math.max(0, quantityValue)
998
+ : 0;
999
+ const image =
1000
+ typeof candidate.image === "string" ? candidate.image : undefined;
1001
+
1002
+ return {
1003
+ id,
1004
+ name,
1005
+ price,
1006
+ quantity,
1007
+ image,
1008
+ } as CartSummaryItem;
1009
+ })
1010
+ .filter(Boolean) as CartSummaryItem[];
1011
+
1012
+ if (sanitized.length === 0) {
1013
+ return cartItems.map((item) => ({
1014
+ id: item.id,
1015
+ name: item.name,
1016
+ price: item.price,
1017
+ quantity: Math.max(0, item.quantity),
1018
+ image: item.image,
1019
+ }));
1020
+ }
1021
+
1022
+ return sanitized;
1023
+ }, [cartItems, isCartModalView, modalParams?.cartItems]);
1024
+
1025
+ const cartSummarySubtotal = useMemo(() => {
1026
+ if (!isCartModalView) {
1027
+ return subtotal;
1028
+ }
1029
+
1030
+ const candidate = Number(modalParams?.subtotal);
1031
+ return Number.isFinite(candidate) ? candidate : subtotal;
1032
+ }, [isCartModalView, modalParams?.subtotal, subtotal]);
1033
+
1034
+ const cartSummaryTotal = useMemo(() => {
1035
+ if (!isCartModalView) {
1036
+ return total;
1037
+ }
1038
+
1039
+ const candidate = Number(modalParams?.total);
1040
+ return Number.isFinite(candidate) ? candidate : total;
1041
+ }, [isCartModalView, modalParams?.total, total]);
1042
+
1043
+ const cartSummaryTotalItems = useMemo(() => {
1044
+ if (!isCartModalView) {
1045
+ return totalItems;
1046
+ }
1047
+
1048
+ const candidate = Number(modalParams?.totalItems);
1049
+ return Number.isFinite(candidate) ? candidate : totalItems;
1050
+ }, [isCartModalView, modalParams?.totalItems, totalItems]);
1051
+
1052
+ const handleContinueToPayment = useCallback(
1053
+ (event?: ReactMouseEvent<HTMLElement>) => {
1054
+ const anchorElement = event?.currentTarget ?? null;
1055
+
1056
+ if (typeof window !== "undefined") {
1057
+ const detail = {
1058
+ subtotal: isCartModalView ? cartSummarySubtotal : subtotal,
1059
+ total: isCartModalView ? cartSummaryTotal : total,
1060
+ totalItems: isCartModalView ? cartSummaryTotalItems : totalItems,
1061
+ };
1062
+
1063
+ try {
1064
+ window.dispatchEvent(
1065
+ new CustomEvent(CONTINUE_TO_PAYMENT_EVENT, { detail })
1066
+ );
1067
+ } catch (error) {
1068
+ console.error("Failed to dispatch checkout navigation event", error);
1069
+ }
1070
+ }
1071
+
1072
+ if (isCartModalView) {
1073
+ return;
1074
+ }
1075
+
1076
+ manualCheckoutTriggerRef.current = true;
1077
+ updateWidgetState({ state: "checkout" });
1078
+ const shouldNavigateToCheckout = isCartModalView || !isCheckoutRoute;
1079
+
1080
+ if (shouldNavigateToCheckout) {
1081
+ navigate("/checkout");
1082
+ return;
1083
+ }
1084
+
1085
+ openCheckoutModal(anchorElement);
1086
+ },
1087
+ [
1088
+ cartSummarySubtotal,
1089
+ cartSummaryTotal,
1090
+ cartSummaryTotalItems,
1091
+ isCartModalView,
1092
+ isCheckoutRoute,
1093
+ navigate,
1094
+ openCheckoutModal,
1095
+ subtotal,
1096
+ total,
1097
+ totalItems,
1098
+ updateWidgetState,
1099
+ ]
1100
+ );
1101
+
1102
+ const handleSeeAll = useCallback(async () => {
1103
+ if (typeof window === "undefined") {
1104
+ return;
1105
+ }
1106
+
1107
+ try {
1108
+ await window?.openai?.requestDisplayMode?.({ mode: "fullscreen" });
1109
+ } catch (error) {
1110
+ console.error("Failed to request fullscreen display mode", error);
1111
+ }
1112
+ }, []);
1113
+
1114
+ useLayoutEffect(() => {
1115
+ const raf = requestAnimationFrame(updateItemColumnPlacement);
1116
+
1117
+ return () => {
1118
+ cancelAnimationFrame(raf);
1119
+ };
1120
+ }, [updateItemColumnPlacement, visibleCartItems]);
1121
+
1122
+ const selectedCartItem = useMemo(() => {
1123
+ if (selectedCartItemId == null) {
1124
+ return null;
1125
+ }
1126
+ return cartItems.find((item) => item.id === selectedCartItemId) ?? null;
1127
+ }, [cartItems, selectedCartItemId]);
1128
+
1129
+ const selectedCartItemName = selectedCartItem?.name ?? null;
1130
+ const shouldShowSelectedCartItemPanel =
1131
+ selectedCartItem != null && !isFullscreen;
1132
+
1133
+ useEffect(() => {
1134
+ if (isCheckoutRoute) {
1135
+ return;
1136
+ }
1137
+
1138
+ if (!checkoutFromState) {
1139
+ return;
1140
+ }
1141
+
1142
+ if (manualCheckoutTriggerRef.current) {
1143
+ manualCheckoutTriggerRef.current = false;
1144
+ return;
1145
+ }
1146
+
1147
+ if (selectedCartItemId) {
1148
+ openCartItemModal({
1149
+ selectedId: selectedCartItemId,
1150
+ selectedName: selectedCartItemName,
1151
+ });
1152
+ return;
1153
+ }
1154
+
1155
+ openCheckoutModal();
1156
+ }, [
1157
+ isCheckoutRoute,
1158
+ checkoutFromState,
1159
+ openCartItemModal,
1160
+ openCheckoutModal,
1161
+ selectedCartItemId,
1162
+ selectedCartItemName,
1163
+ ]);
1164
+
1165
+ const cartPanel = (
1166
+ <section>
1167
+ {!shouldShowCheckoutOnly && (
1168
+ <header className="mb-4 flex flex-col gap-3 border-b border-black/5 px-0 pb-3 sm:flex-row sm:items-center sm:justify-between">
1169
+ {!isFullscreen ? (
1170
+ <div className="flex items-center gap-3">
1171
+ <Button
1172
+ type="button"
1173
+ onClick={(event) =>
1174
+ openCartModal(event.currentTarget as HTMLElement)
1175
+ }
1176
+ aria-haspopup="dialog"
1177
+ variant="outline"
1178
+ color="secondary"
1179
+ size="sm"
1180
+ >
1181
+ <ShoppingCart className="h-4 w-4" aria-hidden="true" />
1182
+ <span>Cart</span>
1183
+ </Button>
1184
+ </div>
1185
+ ) : (
1186
+ <div className="text-lg text-black/70">Results</div>
1187
+ )}
1188
+ <nav className="flex flex-wrap items-center gap-2">
1189
+ {FILTERS.map((filter) => {
1190
+ const isActive =
1191
+ filter.id === "all"
1192
+ ? activeFilters.length === 0
1193
+ : activeFilters.includes(filter.id);
1194
+
1195
+ return (
1196
+ <Button
1197
+ key={filter.id}
1198
+ type="button"
1199
+ onClick={() => handleFilterToggle(filter.id)}
1200
+ aria-pressed={isActive}
1201
+ variant={isActive ? "solid" : "outline"}
1202
+ color="primary"
1203
+ size="sm"
1204
+ >
1205
+ {filter.label}
1206
+ </Button>
1207
+ );
1208
+ })}
1209
+ </nav>
1210
+ </header>
1211
+ )}
1212
+
1213
+ <LayoutGroup id="pizzas-grid">
1214
+ <div
1215
+ ref={cartGridRef}
1216
+ className={clsx(
1217
+ "mt-4 grid gap-[1.5px]",
1218
+ isFullscreen ? "grid-cols-3" : "sm:grid-cols-2 md:grid-cols-3"
1219
+ )}
1220
+ >
1221
+ <AnimatePresence initial={false} mode="popLayout">
1222
+ {visibleCartItems.map((item, index) => {
1223
+ const isHovered = hoveredCartItemId === item.id;
1224
+ const shortDescription =
1225
+ item.shortDescription ?? item.description.split(".")[0];
1226
+ const columnCount = Math.max(gridColumnCount, 1);
1227
+ const rowStartIndex =
1228
+ Math.floor(index / columnCount) * columnCount;
1229
+ const itemsRemaining = visibleCartItems.length - rowStartIndex;
1230
+ const rowSize = Math.min(columnCount, itemsRemaining);
1231
+ const positionInRow = index - rowStartIndex;
1232
+
1233
+ const isSingle = rowSize === 1;
1234
+ const isLeft = positionInRow === 0;
1235
+ const isRight = positionInRow === rowSize - 1;
1236
+
1237
+ return (
1238
+ <motion.article
1239
+ layout
1240
+ layoutId={item.id}
1241
+ key={item.id}
1242
+ initial={{ opacity: 0, scale: 0.98 }}
1243
+ animate={{ opacity: 1, scale: 1 }}
1244
+ exit={{ opacity: 0, scale: 0.98 }}
1245
+ transition={{
1246
+ type: "spring",
1247
+ stiffness: 260,
1248
+ damping: 26,
1249
+ mass: 0.8,
1250
+ }}
1251
+ onClick={(event) =>
1252
+ handleCartItemSelect(
1253
+ item.id,
1254
+ event.currentTarget as HTMLElement
1255
+ )
1256
+ }
1257
+ onMouseEnter={() => setHoveredCartItemId(item.id)}
1258
+ onMouseLeave={() => setHoveredCartItemId(null)}
1259
+ className={clsx(
1260
+ "group mb-4 flex cursor-pointer flex-col overflow-hidden border border-transparent bg-white transition-colors",
1261
+ isHovered && "border-[#0f766e]"
1262
+ )}
1263
+ >
1264
+ <div
1265
+ className={clsx(
1266
+ "relative overflow-hidden",
1267
+ isSingle && "rounded-3xl",
1268
+ !isSingle && isLeft && "rounded-l-3xl",
1269
+ !isSingle && isRight && "rounded-r-3xl",
1270
+ !isSingle && !isLeft && !isRight && "rounded-none"
1271
+ )}
1272
+ >
1273
+ <Image
1274
+ src={item.image}
1275
+ alt={item.name}
1276
+ className="h-60 w-full object-cover transition-transform duration-200"
1277
+ />
1278
+
1279
+ <div className="absolute inset-0 bg-black/[0.05]" />
1280
+ </div>
1281
+ <div className="flex flex-1 flex-col gap-3 pe-6 pt-3 pb-4 text-left">
1282
+ <div className="space-y-0.5">
1283
+ <p className="text-base font-semibold text-slate-900">
1284
+ {item.name}
1285
+ </p>
1286
+ <p className="text-sm text-black/60">
1287
+ ${item.price.toFixed(2)}
1288
+ </p>
1289
+ </div>
1290
+ {shortDescription ? (
1291
+ <p
1292
+ className="text-sm leading-snug text-black/50"
1293
+ title={shortDescription}
1294
+ >
1295
+ {shortDescription}
1296
+ </p>
1297
+ ) : null}
1298
+ <div className="flex items-center justify-between">
1299
+ <div className="flex items-center rounded-full bg-black/[0.04] px-1.5 py-1 text-black">
1300
+ <button
1301
+ type="button"
1302
+ className="flex h-6 w-6 items-center justify-center rounded-full opacity-50 transition-colors hover:bg-slate-200 hover:opacity-100"
1303
+ aria-label={`Decrease quantity of ${item.name}`}
1304
+ onClick={(event) => {
1305
+ event.stopPropagation();
1306
+ adjustQuantity(item.id, -1);
1307
+ }}
1308
+ >
1309
+ <Minus
1310
+ strokeWidth={2.5}
1311
+ className="h-3 w-3"
1312
+ aria-hidden="true"
1313
+ />
1314
+ </button>
1315
+ <span className="min-w-[20px] px-1 text-center text-sm font-medium">
1316
+ {item.quantity}
1317
+ </span>
1318
+ <button
1319
+ type="button"
1320
+ className="flex h-6 w-6 items-center justify-center rounded-full opacity-50 transition-colors hover:bg-slate-200 hover:opacity-100"
1321
+ aria-label={`Increase quantity of ${item.name}`}
1322
+ onClick={(event) => {
1323
+ event.stopPropagation();
1324
+ adjustQuantity(item.id, 1);
1325
+ }}
1326
+ >
1327
+ <Plus
1328
+ strokeWidth={2.5}
1329
+ className="h-3 w-3"
1330
+ aria-hidden="true"
1331
+ />
1332
+ </button>
1333
+ </div>
1334
+ </div>
1335
+ </div>
1336
+ </motion.article>
1337
+ );
1338
+ })}
1339
+ </AnimatePresence>
1340
+ </div>
1341
+ </LayoutGroup>
1342
+ </section>
1343
+ );
1344
+
1345
+ if (isCartModalView && !isCheckoutRoute) {
1346
+ return (
1347
+ <div className="flex w-full flex-col gap-6 px-4">
1348
+ <div className="divide-y divide-black/5">
1349
+ {cartSummaryItems.length ? (
1350
+ cartSummaryItems.map((item) => (
1351
+ <div
1352
+ key={`modal-${item.id}`}
1353
+ className="flex items-center gap-3 py-2"
1354
+ >
1355
+ <div className="relative h-10 w-10 overflow-hidden rounded-xl bg-white">
1356
+ {item.image ? (
1357
+ <Image
1358
+ src={item.image}
1359
+ alt={item.name}
1360
+ className="h-full w-full object-cover"
1361
+ />
1362
+ ) : null}
1363
+ <div className="absolute inset-0 bg-black/[0.05]" />
1364
+ </div>
1365
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-3">
1366
+ <div className="min-w-0">
1367
+ <p className="truncate font-medium text-slate-900">
1368
+ {item.name}
1369
+ </p>
1370
+ <p className="text-xs text-black/50">
1371
+ ${item.price.toFixed(2)} • Qty{" "}
1372
+ {Math.max(0, item.quantity)}
1373
+ </p>
1374
+ </div>
1375
+ <span className="text-sm font-medium text-black">
1376
+ ${(item.price * Math.max(0, item.quantity)).toFixed(2)}
1377
+ </span>
1378
+ </div>
1379
+ </div>
1380
+ ))
1381
+ ) : (
1382
+ <p className="rounded-2xl border border-dashed border-black/20 bg-white/90 p-6 text-center text-sm text-black/50">
1383
+ Your cart is empty.
1384
+ </p>
1385
+ )}
1386
+ </div>
1387
+
1388
+ <div className="space-y-0.5">
1389
+ <div className="flex items-center justify-between text-sm font-medium text-black">
1390
+ <span>Subtotal</span>
1391
+ <span>${cartSummarySubtotal.toFixed(2)}</span>
1392
+ </div>
1393
+ <div className="flex items-center justify-between text-sm text-black/60">
1394
+ <span>Total</span>
1395
+ <span>${cartSummaryTotal.toFixed(2)}</span>
1396
+ </div>
1397
+ </div>
1398
+ <button
1399
+ type="button"
1400
+ onClick={handleContinueToPayment}
1401
+ className="mx-auto mb-4 w-full rounded-full bg-[#FF5100] px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#ff6a26] disabled:cursor-not-allowed disabled:bg-black/20"
1402
+ disabled={cartSummaryTotalItems === 0}
1403
+ >
1404
+ Continue to checkout
1405
+ </button>
1406
+ </div>
1407
+ );
1408
+ }
1409
+
1410
+ const checkoutPanel = (
1411
+ <div
1412
+ className={
1413
+ shouldShowCheckoutOnly
1414
+ ? "space-y-4"
1415
+ : "space-y-4 overflow-hidden border-black/[0.075] pt-4 md:rounded-3xl md:border md:px-5 md:pb-5 md:shadow-[0px_6px_14px_rgba(0,0,0,0.06)]"
1416
+ }
1417
+ >
1418
+ {shouldShowSelectedCartItemPanel ? (
1419
+ <SelectedCartItemPanel
1420
+ item={selectedCartItem}
1421
+ onAdjustQuantity={adjustQuantity}
1422
+ />
1423
+ ) : (
1424
+ <CheckoutDetailsPanel
1425
+ shouldShowCheckoutOnly={shouldShowCheckoutOnly}
1426
+ subtotal={subtotal}
1427
+ total={total}
1428
+ onContinueToPayment={handleContinueToPayment}
1429
+ />
1430
+ )}
1431
+ </div>
1432
+ );
1433
+
1434
+ return (
1435
+ <div
1436
+ className={clsx(
1437
+ `flex items-center justify-center overflow-hidden`,
1438
+ isModalView ? "px-0 pb-4" : ""
1439
+ )}
1440
+ style={{
1441
+ maxHeight,
1442
+ height: displayMode === "fullscreen" ? maxHeight : undefined,
1443
+ overflow: "hidden",
1444
+ scrollbarGutter: "0px",
1445
+ scrollbarWidth: "none",
1446
+ msOverflowStyle: "none",
1447
+ }}
1448
+ >
1449
+ <main
1450
+ className={`w-full overflow-hidden ${isFullscreen ? "max-w-7xl" : ""}`}
1451
+ >
1452
+ {shouldShowCheckoutOnly ? (
1453
+ checkoutPanel
1454
+ ) : isFullscreen ? (
1455
+ <div className="mt-8 grid gap-0 overflow-hidden md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_360px] md:gap-8">
1456
+ <div className="md:col-span-2">{cartPanel}</div>
1457
+ <div>{checkoutPanel}</div>
1458
+ </div>
1459
+ ) : (
1460
+ cartPanel
1461
+ )}
1462
+ {!isFullscreen && !shouldShowCheckoutOnly && (
1463
+ <div className="flex justify-center">
1464
+ <button
1465
+ type="button"
1466
+ onClick={handleSeeAll}
1467
+ className="rounded-full border border-black/10 px-4 py-2 text-sm font-medium text-black/70 transition-colors hover:border-black/40 hover:text-black"
1468
+ >
1469
+ See all items
1470
+ </button>
1471
+ </div>
1472
+ )}
1473
+ </main>
1474
+ </div>
1475
+ );
1476
+ }
1477
+
1478
+ createRoot(document.getElementById("pizzaz-shop-root")!).render(
1479
+ <BrowserRouter>
1480
+ <App />
1481
+ </BrowserRouter>
1482
+ );