From dcc64e40ffaa5041565098439b547048c731ee49 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Wed, 5 Nov 2025 12:42:53 -0500 Subject: [PATCH 1/2] Add ecommerce modal example --- build-all.mts | 2 + pizzaz_server_node/src/server.ts | 9 + pizzaz_server_python/main.py | 9 + src/pizzaz-shop/index.tsx | 1458 ++++++++++++++++++++++++++++++ 4 files changed, 1478 insertions(+) create mode 100644 src/pizzaz-shop/index.tsx diff --git a/build-all.mts b/build-all.mts index 7249e71..c26db74 100644 --- a/build-all.mts +++ b/build-all.mts @@ -21,6 +21,8 @@ const targets: string[] = [ "pizzaz-carousel", "pizzaz-list", "pizzaz-albums", + "pizzaz-shop", + "ecommerce", ]; const builtNames: string[] = []; diff --git a/pizzaz_server_node/src/server.ts b/pizzaz_server_node/src/server.ts index 3053026..de2961a 100644 --- a/pizzaz_server_node/src/server.ts +++ b/pizzaz_server_node/src/server.ts @@ -128,6 +128,15 @@ const widgets: PizzazWidget[] = [ html: readWidgetHtml("pizzaz-list"), responseText: "Rendered a pizza list!", }, + { + id: "pizza-shop", + title: "Open Pizzaz Shop", + templateUri: "ui://widget/pizza-shop.html", + invoking: "Opening the shop", + invoked: "Shop opened", + html: readWidgetHtml("pizzaz-shop"), + responseText: "Rendered the Pizzaz shop!", + }, ]; const widgetsById = new Map(); diff --git a/pizzaz_server_python/main.py b/pizzaz_server_python/main.py index 2eb58e5..74177a6 100644 --- a/pizzaz_server_python/main.py +++ b/pizzaz_server_python/main.py @@ -87,6 +87,15 @@ def _load_widget_html(component_name: str) -> str: html=_load_widget_html("pizzaz-list"), response_text="Rendered a pizza list!", ), + PizzazWidget( + identifier="pizza-shop", + title="Open Pizzaz Shop", + template_uri="ui://widget/pizza-shop.html", + invoking="Opening the shop", + invoked="Shop opened", + html=_load_widget_html("pizzaz-shop"), + response_text="Rendered the Pizzaz shop!", + ), ] diff --git a/src/pizzaz-shop/index.tsx b/src/pizzaz-shop/index.tsx new file mode 100644 index 0000000..3fcc4cc --- /dev/null +++ b/src/pizzaz-shop/index.tsx @@ -0,0 +1,1458 @@ +import clsx from "clsx"; +import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; +import { Minus, Plus, ShoppingCart } from "lucide-react"; +import { + type MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter, useLocation, useNavigate } from "react-router-dom"; +import { useDisplayMode } from "../use-display-mode"; +import { useMaxHeight } from "../use-max-height"; +import { useOpenAiGlobal } from "../use-openai-global"; +import { useWidgetProps } from "../use-widget-props"; +import { useWidgetState } from "../use-widget-state"; + +type NutritionFact = { + label: string; + value: string; +}; + +type CartItem = { + id: string; + name: string; + price: number; + description: string; + shortDescription?: string; + detailSummary?: string; + nutritionFacts?: NutritionFact[]; + highlights?: string[]; + tags?: string[]; + quantity: number; + image: string; +}; + +type PizzazCartWidgetState = { + state?: "checkout" | null; + cartItems?: CartItem[]; + selectedCartItemId?: string | null; +}; + +type PizzazCartWidgetProps = { + cartItems?: CartItem[]; + widgetState?: Partial | null; +}; + +const SERVICE_FEE = 3; +const DELIVERY_FEE = 2.99; +const TAX_FEE = 3.4; +const CONTINUE_TO_PAYMENT_EVENT = "pizzaz-shop:continue-to-payment"; + +const FILTERS: Array<{ + id: "all" | "vegetarian" | "vegan" | "size" | "spicy"; + label: string; + tag?: string; +}> = [ + { id: "all", label: "All" }, + { id: "vegetarian", label: "Vegetarian", tag: "vegetarian" }, + { id: "vegan", label: "Vegan", tag: "vegan" }, + { id: "size", label: "Size", tag: "size" }, + { id: "spicy", label: "Spicy", tag: "spicy" }, +]; + +const INITIAL_CART_ITEMS: CartItem[] = [ + { + id: "marys-chicken", + name: "Mary's Chicken", + price: 19.48, + description: + "Tender organic chicken breasts trimmed for easy cooking. Raised without antibiotics and air chilled for exceptional flavor.", + shortDescription: "Organic chicken breasts", + detailSummary: "4 lbs • $3.99/lb", + nutritionFacts: [ + { label: "Protein", value: "8g" }, + { label: "Fat", value: "9g" }, + { label: "Sugar", value: "12g" }, + { label: "Calories", value: "160" }, + ], + highlights: [ + "No antibiotics or added hormones.", + "Air chilled and never frozen for peak flavor.", + "Raised in the USA on a vegetarian diet.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken.png", + tags: ["size"], + }, + { + id: "avocados", + name: "Avocados", + price: 1, + description: + "Creamy Hass avocados picked at peak ripeness. Ideal for smashing into guacamole or topping tacos.", + shortDescription: "Creamy Hass avocados", + detailSummary: "3 ct • $1.00/ea", + nutritionFacts: [ + { label: "Fiber", value: "7g" }, + { label: "Fat", value: "15g" }, + { label: "Potassium", value: "485mg" }, + { label: "Calories", value: "160" }, + ], + highlights: [ + "Perfectly ripe and ready for slicing.", + "Rich in healthy fats and naturally creamy.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/avocado.png", + tags: ["vegan"], + }, + { + id: "hojicha-pizza", + name: "Hojicha Pizza", + price: 15.5, + description: + "Wood-fired crust layered with smoky hojicha tea sauce and melted mozzarella with a drizzle of honey for an adventurous slice.", + shortDescription: "Smoky hojicha sauce & honey", + detailSummary: '12" pie • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "14g" }, + { label: "Fat", value: "18g" }, + { label: "Sugar", value: "9g" }, + { label: "Calories", value: "320" }, + ], + highlights: [ + "Smoky roasted hojicha glaze with honey drizzle.", + "Stone-fired crust with a delicate char.", + ], + quantity: 2, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/hojicha-pizza.png", + tags: ["vegetarian", "size", "spicy"], + }, + { + id: "chicken-pizza", + name: "Chicken Pizza", + price: 7, + description: + "Classic thin-crust pizza topped with roasted chicken, caramelized onions, and herb pesto.", + shortDescription: "Roasted chicken & pesto", + detailSummary: '10" personal • Serves 1', + nutritionFacts: [ + { label: "Protein", value: "20g" }, + { label: "Fat", value: "11g" }, + { label: "Carbs", value: "36g" }, + { label: "Calories", value: "290" }, + ], + highlights: [ + "Roasted chicken with caramelized onions.", + "Fresh basil pesto and mozzarella.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/chicken-pizza.png", + tags: ["size"], + }, + { + id: "matcha-pizza", + name: "Matcha Pizza", + price: 5, + description: + "Crisp dough spread with velvety matcha cream and mascarpone. Earthy green tea notes balance gentle sweetness.", + shortDescription: "Velvety matcha cream", + detailSummary: '8" dessert • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "6g" }, + { label: "Fat", value: "10g" }, + { label: "Sugar", value: "14g" }, + { label: "Calories", value: "240" }, + ], + highlights: [ + "Stone-baked crust with delicate crunch.", + "Matcha mascarpone with white chocolate drizzle.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + tags: ["vegetarian"], + }, + { + id: "pesto-pizza", + name: "Pesto Pizza", + price: 12.5, + description: + "Hand-tossed crust brushed with bright basil pesto, layered with fresh mozzarella, and finished with roasted cherry tomatoes.", + shortDescription: "Basil pesto & tomatoes", + detailSummary: '12" pie • Serves 2', + nutritionFacts: [ + { label: "Protein", value: "16g" }, + { label: "Fat", value: "14g" }, + { label: "Carbs", value: "28g" }, + { label: "Calories", value: "310" }, + ], + highlights: [ + "House-made pesto with sweet basil and pine nuts.", + "Roasted cherry tomatoes for a pop of acidity.", + ], + quantity: 1, + image: "https://persistent.oaistatic.com/pizzaz-cart-xl/matcha-pizza.png", + tags: ["vegetarian", "size"], + }, +]; + +const cloneCartItem = (item: CartItem): CartItem => ({ + ...item, + nutritionFacts: item.nutritionFacts?.map((fact) => ({ ...fact })), + highlights: item.highlights ? [...item.highlights] : undefined, + tags: item.tags ? [...item.tags] : undefined, +}); + +const createDefaultCartItems = (): CartItem[] => + INITIAL_CART_ITEMS.map((item) => cloneCartItem(item)); + +const createDefaultWidgetState = (): PizzazCartWidgetState => ({ + state: null, + cartItems: createDefaultCartItems(), + selectedCartItemId: null, +}); + +const nutritionFactsEqual = ( + a?: NutritionFact[], + b?: NutritionFact[] +): boolean => { + if (!a?.length && !b?.length) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + return a.every((fact, index) => { + const other = b[index]; + if (!other) { + return false; + } + return fact.label === other.label && fact.value === other.value; + }); +}; + +const highlightsEqual = (a?: string[], b?: string[]): boolean => { + if (!a?.length && !b?.length) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + return a.every((highlight, index) => highlight === b[index]); +}; + +const cartItemsEqual = (a: CartItem[], b: CartItem[]): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if (!right) { + return false; + } + if ( + left.id !== right.id || + left.quantity !== right.quantity || + left.name !== right.name || + left.price !== right.price || + left.description !== right.description || + left.shortDescription !== right.shortDescription || + left.detailSummary !== right.detailSummary || + !nutritionFactsEqual(left.nutritionFacts, right.nutritionFacts) || + !highlightsEqual(left.highlights, right.highlights) || + !highlightsEqual(left.tags, right.tags) || + left.image !== right.image + ) { + return false; + } + } + return true; +}; + +type SelectedCartItemPanelProps = { + item: CartItem; + onAdjustQuantity: (id: string, delta: number) => void; +}; + +function SelectedCartItemPanel({ + item, + onAdjustQuantity, +}: SelectedCartItemPanelProps) { + const nutritionFacts = Array.isArray(item.nutritionFacts) + ? item.nutritionFacts + : []; + const highlights = Array.isArray(item.highlights) ? item.highlights : []; + + const hasNutritionFacts = nutritionFacts.length > 0; + const hasHighlights = highlights.length > 0; + + return ( +
+
+
+ {item.name} +
+
+
+ +
+
+
+

+ ${item.price.toFixed(2)} +

+

{item.name}

+
+
+ + + {item.quantity} + + +
+
+ +

{item.description}

+ + {item.detailSummary ? ( +

{item.detailSummary}

+ ) : null} + + {hasNutritionFacts ? ( +
+ {nutritionFacts.map((fact) => ( +
+

{fact.value}

+

{fact.label}

+
+ ))} +
+ ) : null} + + {hasHighlights ? ( +
+ {highlights.map((highlight, index) => ( +

{highlight}

+ ))} +
+ ) : null} +
+
+ ); +} + +type CheckoutDetailsPanelProps = { + shouldShowCheckoutOnly: boolean; + subtotal: number; + total: number; + onContinueToPayment?: () => void; +}; + +function CheckoutDetailsPanel({ + shouldShowCheckoutOnly, + subtotal, + total, + onContinueToPayment, +}: CheckoutDetailsPanelProps) { + return ( + <> + {!shouldShowCheckoutOnly && ( +
+

Checkout details

+
+ )} + +
+
+

Delivery address

+
+
+

+ 1234 Main St, San Francisco, CA +

+

+ Leave at door - Delivery instructions +

+
+ +
+
+
+

Fast

+

+ 50 min - 2 hr 10 min +

+
+ Free +
+
+
+

Priority

+

35 min

+
+ Free +
+
+
+ +
+
+

Delivery tip

+

100% goes to the shopper

+
+
+ + + + +
+
+ +
+
+ Subtotal + ${subtotal.toFixed(2)} +
+
+ Total + + ${total.toFixed(2)} + +
+

+ +
+ + ); +} + +function App() { + const maxHeight = useMaxHeight() ?? undefined; + const displayMode = useDisplayMode(); + const isFullscreen = displayMode === "fullscreen"; + const widgetProps = useWidgetProps(() => ({})); + const [widgetState, setWidgetState] = useWidgetState( + createDefaultWidgetState + ); + const navigate = useNavigate(); + const location = useLocation(); + const isCheckoutRoute = useMemo(() => { + const pathname = location?.pathname ?? ""; + if (!pathname) { + return false; + } + + return pathname === "/checkout" || pathname.endsWith("/checkout"); + }, [location?.pathname]); + + const defaultCartItems = useMemo(() => createDefaultCartItems(), []); + const cartGridRef = useRef(null); + const [gridColumnCount, setGridColumnCount] = useState(1); + + const mergeWithDefaultItems = useCallback( + (items: CartItem[]): CartItem[] => { + const existingIds = new Set(items.map((item) => item.id)); + const merged = items.map((item) => { + const defaultItem = defaultCartItems.find( + (candidate) => candidate.id === item.id + ); + + if (!defaultItem) { + return cloneCartItem(item); + } + + const enriched: CartItem = { + ...cloneCartItem(defaultItem), + ...item, + tags: item.tags ? [...item.tags] : defaultItem.tags, + nutritionFacts: + item.nutritionFacts ?? + defaultItem.nutritionFacts?.map((fact) => ({ ...fact })), + highlights: + item.highlights != null + ? [...item.highlights] + : defaultItem.highlights + ? [...defaultItem.highlights] + : undefined, + }; + + return cloneCartItem(enriched); + }); + + defaultCartItems.forEach((defaultItem) => { + if (!existingIds.has(defaultItem.id)) { + merged.push(cloneCartItem(defaultItem)); + } + }); + + return merged; + }, + [defaultCartItems] + ); + + const resolvedCartItems = useMemo(() => { + if (Array.isArray(widgetState?.cartItems) && widgetState.cartItems.length) { + return mergeWithDefaultItems(widgetState.cartItems); + } + + if ( + Array.isArray(widgetProps?.widgetState?.cartItems) && + widgetProps.widgetState.cartItems.length + ) { + return mergeWithDefaultItems(widgetProps.widgetState.cartItems); + } + + if (Array.isArray(widgetProps?.cartItems) && widgetProps.cartItems.length) { + return mergeWithDefaultItems(widgetProps.cartItems); + } + + return mergeWithDefaultItems(defaultCartItems); + }, [ + defaultCartItems, + mergeWithDefaultItems, + widgetProps?.cartItems, + widgetProps?.widgetState?.cartItems, + widgetState, + ]); + + const [cartItems, setCartItems] = useState(resolvedCartItems); + + useEffect(() => { + setCartItems((previous) => + cartItemsEqual(previous, resolvedCartItems) ? previous : resolvedCartItems + ); + }, [resolvedCartItems]); + + const resolvedSelectedCartItemId = + widgetState?.selectedCartItemId ?? + widgetProps?.widgetState?.selectedCartItemId ?? + null; + + const [selectedCartItemId, setSelectedCartItemId] = useState( + resolvedSelectedCartItemId + ); + + useEffect(() => { + setSelectedCartItemId((prev) => + prev === resolvedSelectedCartItemId ? prev : resolvedSelectedCartItemId + ); + }, [resolvedSelectedCartItemId]); + + const view = useOpenAiGlobal("view"); + const viewParams = view?.params; + const isModalView = view?.mode === "modal"; + const checkoutFromState = + (widgetState?.state ?? widgetProps?.widgetState?.state) === "checkout"; + const modalParams = + viewParams && typeof viewParams === "object" + ? (viewParams as { + state?: unknown; + cartItems?: unknown; + subtotal?: unknown; + total?: unknown; + totalItems?: unknown; + }) + : null; + + const modalState = + modalParams && typeof modalParams.state === "string" + ? (modalParams.state as string) + : null; + + const isCartModalView = isModalView && modalState === "cart"; + const shouldShowCheckoutOnly = + isCheckoutRoute || (isModalView && !isCartModalView); + const wasModalViewRef = useRef(isModalView); + + useEffect(() => { + if (!viewParams || typeof viewParams !== "object") { + return; + } + + const paramsWithSelection = viewParams as { + selectedCartItemId?: unknown; + }; + + const selectedIdFromParams = paramsWithSelection.selectedCartItemId; + + if ( + typeof selectedIdFromParams === "string" && + selectedIdFromParams !== selectedCartItemId + ) { + setSelectedCartItemId(selectedIdFromParams); + return; + } + + if (selectedIdFromParams === null && selectedCartItemId !== null) { + setSelectedCartItemId(null); + } + }, [selectedCartItemId, viewParams]); + + const [hoveredCartItemId, setHoveredCartItemId] = useState( + null + ); + const [activeFilters, setActiveFilters] = useState([]); + + const updateWidgetState = useCallback( + (partial: Partial) => { + setWidgetState((previous) => ({ + ...createDefaultWidgetState(), + ...(previous ?? {}), + ...partial, + })); + }, + [setWidgetState] + ); + + useEffect(() => { + if (!Array.isArray(widgetState?.cartItems)) { + return; + } + + const merged = mergeWithDefaultItems(widgetState.cartItems); + + if (!cartItemsEqual(widgetState.cartItems, merged)) { + updateWidgetState({ cartItems: merged }); + } + }, [mergeWithDefaultItems, updateWidgetState, widgetState?.cartItems]); + + useEffect(() => { + if (wasModalViewRef.current && !isModalView && checkoutFromState) { + updateWidgetState({ state: null }); + } + + wasModalViewRef.current = isModalView; + }, [checkoutFromState, isModalView, updateWidgetState]); + + const adjustQuantity = useCallback( + (id: string, delta: number) => { + setCartItems((previousItems) => { + const updatedItems = previousItems.map((item) => + item.id === id + ? { ...item, quantity: Math.max(0, item.quantity + delta) } + : item + ); + + if (!cartItemsEqual(previousItems, updatedItems)) { + updateWidgetState({ cartItems: updatedItems }); + } + + return updatedItems; + }); + }, + [updateWidgetState] + ); + + useEffect(() => { + if (!shouldShowCheckoutOnly) { + return; + } + + setHoveredCartItemId(null); + }, [shouldShowCheckoutOnly]); + + const manualCheckoutTriggerRef = useRef(false); + + const requestModalWithAnchor = useCallback( + ({ + title, + params, + anchorElement, + }: { + title: string; + params: Record; + anchorElement?: HTMLElement | null; + }) => { + if (isModalView) { + return; + } + + const anchorRect = anchorElement?.getBoundingClientRect(); + const anchor = + anchorRect == null + ? undefined + : { + top: anchorRect.top, + left: anchorRect.left, + width: anchorRect.width, + height: anchorRect.height, + }; + + void (async () => { + try { + await window?.openai?.requestModal?.({ + title, + params, + ...(anchor ? { anchor } : {}), + }); + } catch (error) { + console.error("Failed to open checkout modal", error); + } + })(); + }, + [isModalView] + ); + + const openCheckoutModal = useCallback( + (anchorElement?: HTMLElement | null) => { + requestModalWithAnchor({ + title: "Checkout", + params: { state: "checkout" }, + anchorElement, + }); + }, + [requestModalWithAnchor] + ); + + const openCartItemModal = useCallback( + ({ + selectedId, + selectedName, + anchorElement, + }: { + selectedId: string; + selectedName: string | null; + anchorElement?: HTMLElement | null; + }) => { + requestModalWithAnchor({ + title: selectedName ?? selectedId, + params: { state: "checkout", selectedCartItemId: selectedId }, + anchorElement, + }); + }, + [requestModalWithAnchor] + ); + + const handleCartItemSelect = useCallback( + (id: string, anchorElement?: HTMLElement | null) => { + const itemName = cartItems.find((item) => item.id === id)?.name ?? null; + manualCheckoutTriggerRef.current = true; + setSelectedCartItemId(id); + updateWidgetState({ selectedCartItemId: id, state: "checkout" }); + openCartItemModal({ + selectedId: id, + selectedName: itemName, + anchorElement, + }); + }, + [cartItems, openCartItemModal, updateWidgetState] + ); + + const subtotal = useMemo( + () => + cartItems.reduce( + (total, item) => total + item.price * Math.max(0, item.quantity), + 0 + ), + [cartItems] + ); + + const total = subtotal + SERVICE_FEE + DELIVERY_FEE + TAX_FEE; + + const totalItems = useMemo( + () => + cartItems.reduce((total, item) => total + Math.max(0, item.quantity), 0), + [cartItems] + ); + + const visibleCartItems = useMemo(() => { + if (!activeFilters.length) { + return cartItems; + } + + return cartItems.filter((item) => { + const tags = item.tags ?? []; + + return activeFilters.every((filterId) => { + const filterMeta = FILTERS.find((filter) => filter.id === filterId); + if (!filterMeta?.tag) { + return true; + } + return tags.includes(filterMeta.tag); + }); + }); + }, [activeFilters, cartItems]); + + const updateItemColumnPlacement = useCallback(() => { + const gridNode = cartGridRef.current; + + const width = gridNode?.offsetWidth ?? 0; + + let baseColumnCount = 1; + if (width >= 768) { + baseColumnCount = 3; + } else if (width >= 640) { + baseColumnCount = 2; + } + + const columnCount = isFullscreen + ? Math.max(baseColumnCount, 3) + : baseColumnCount; + + if (gridNode) { + gridNode.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; + } + + setGridColumnCount(columnCount); + }, [isFullscreen]); + + const handleFilterToggle = useCallback( + (id: string) => { + setActiveFilters((previous) => { + if (id === "all") { + return []; + } + + const isActive = previous.includes(id); + if (isActive) { + return []; + } + + return [id]; + }); + + requestAnimationFrame(() => { + updateItemColumnPlacement(); + }); + }, + [updateItemColumnPlacement] + ); + + useEffect(() => { + const node = cartGridRef.current; + + if (!node) { + return; + } + + const observer = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => { + requestAnimationFrame(updateItemColumnPlacement); + }) + : null; + + observer?.observe(node); + window.addEventListener("resize", updateItemColumnPlacement); + + return () => { + observer?.disconnect(); + window.removeEventListener("resize", updateItemColumnPlacement); + }; + }, [updateItemColumnPlacement]); + + const openCartModal = useCallback( + (anchorElement?: HTMLElement | null) => { + if (isModalView || shouldShowCheckoutOnly) { + return; + } + + requestModalWithAnchor({ + title: "Cart", + params: { + state: "cart", + cartItems, + subtotal, + total, + totalItems, + }, + anchorElement, + }); + }, + [ + cartItems, + isModalView, + requestModalWithAnchor, + shouldShowCheckoutOnly, + subtotal, + total, + totalItems, + ] + ); + + type CartSummaryItem = { + id: string; + name: string; + price: number; + quantity: number; + image?: string; + }; + + const cartSummaryItems: CartSummaryItem[] = useMemo(() => { + if (!isCartModalView) { + return []; + } + + const items = Array.isArray(modalParams?.cartItems) + ? modalParams?.cartItems + : null; + + if (!items) { + return cartItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + quantity: Math.max(0, item.quantity), + image: item.image, + })); + } + + const sanitized = items + .map((raw, index) => { + if (!raw || typeof raw !== "object") { + return null; + } + const candidate = raw as Record; + const id = + typeof candidate.id === "string" ? candidate.id : `cart-${index}`; + const name = + typeof candidate.name === "string" ? candidate.name : "Item"; + const priceValue = Number(candidate.price); + const quantityValue = Number(candidate.quantity); + const price = Number.isFinite(priceValue) ? priceValue : 0; + const quantity = Number.isFinite(quantityValue) + ? Math.max(0, quantityValue) + : 0; + const image = + typeof candidate.image === "string" ? candidate.image : undefined; + + return { + id, + name, + price, + quantity, + image, + } as CartSummaryItem; + }) + .filter(Boolean) as CartSummaryItem[]; + + if (sanitized.length === 0) { + return cartItems.map((item) => ({ + id: item.id, + name: item.name, + price: item.price, + quantity: Math.max(0, item.quantity), + image: item.image, + })); + } + + return sanitized; + }, [cartItems, isCartModalView, modalParams?.cartItems]); + + const cartSummarySubtotal = useMemo(() => { + if (!isCartModalView) { + return subtotal; + } + + const candidate = Number(modalParams?.subtotal); + return Number.isFinite(candidate) ? candidate : subtotal; + }, [isCartModalView, modalParams?.subtotal, subtotal]); + + const cartSummaryTotal = useMemo(() => { + if (!isCartModalView) { + return total; + } + + const candidate = Number(modalParams?.total); + return Number.isFinite(candidate) ? candidate : total; + }, [isCartModalView, modalParams?.total, total]); + + const cartSummaryTotalItems = useMemo(() => { + if (!isCartModalView) { + return totalItems; + } + + const candidate = Number(modalParams?.totalItems); + return Number.isFinite(candidate) ? candidate : totalItems; + }, [isCartModalView, modalParams?.totalItems, totalItems]); + + const handleContinueToPayment = useCallback( + (event?: ReactMouseEvent) => { + const anchorElement = event?.currentTarget ?? null; + + if (typeof window !== "undefined") { + const detail = { + subtotal: isCartModalView ? cartSummarySubtotal : subtotal, + total: isCartModalView ? cartSummaryTotal : total, + totalItems: isCartModalView ? cartSummaryTotalItems : totalItems, + }; + + try { + window.dispatchEvent( + new CustomEvent(CONTINUE_TO_PAYMENT_EVENT, { detail }) + ); + } catch (error) { + console.error("Failed to dispatch checkout navigation event", error); + } + } + + if (isCartModalView) { + return; + } + + manualCheckoutTriggerRef.current = true; + updateWidgetState({ state: "checkout" }); + const shouldNavigateToCheckout = isCartModalView || !isCheckoutRoute; + + if (shouldNavigateToCheckout) { + navigate("/checkout"); + return; + } + + openCheckoutModal(anchorElement); + }, + [ + cartSummarySubtotal, + cartSummaryTotal, + cartSummaryTotalItems, + isCartModalView, + isCheckoutRoute, + navigate, + openCheckoutModal, + subtotal, + total, + totalItems, + updateWidgetState, + ] + ); + + const handleSeeAll = useCallback(async () => { + if (typeof window === "undefined") { + return; + } + + try { + await window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); + } catch (error) { + console.error("Failed to request fullscreen display mode", error); + } + }, []); + + useLayoutEffect(() => { + const raf = requestAnimationFrame(updateItemColumnPlacement); + + return () => { + cancelAnimationFrame(raf); + }; + }, [updateItemColumnPlacement, visibleCartItems]); + + const selectedCartItem = useMemo(() => { + if (selectedCartItemId == null) { + return null; + } + return cartItems.find((item) => item.id === selectedCartItemId) ?? null; + }, [cartItems, selectedCartItemId]); + + const selectedCartItemName = selectedCartItem?.name ?? null; + const shouldShowSelectedCartItemPanel = + selectedCartItem != null && !isFullscreen; + + useEffect(() => { + if (isCheckoutRoute) { + return; + } + + if (!checkoutFromState) { + return; + } + + if (manualCheckoutTriggerRef.current) { + manualCheckoutTriggerRef.current = false; + return; + } + + if (selectedCartItemId) { + openCartItemModal({ + selectedId: selectedCartItemId, + selectedName: selectedCartItemName, + }); + return; + } + + openCheckoutModal(); + }, [ + isCheckoutRoute, + checkoutFromState, + openCartItemModal, + openCheckoutModal, + selectedCartItemId, + selectedCartItemName, + ]); + + const cartPanel = ( +
+ {!shouldShowCheckoutOnly && ( +
+ {!isFullscreen ? ( +
+ +
+ ) : ( +
Results
+ )} + +
+ )} + + +
+ + {visibleCartItems.map((item, index) => { + const isHovered = hoveredCartItemId === item.id; + const shortDescription = + item.shortDescription ?? item.description.split(".")[0]; + const columnCount = Math.max(gridColumnCount, 1); + const rowStartIndex = + Math.floor(index / columnCount) * columnCount; + const itemsRemaining = visibleCartItems.length - rowStartIndex; + const rowSize = Math.min(columnCount, itemsRemaining); + const positionInRow = index - rowStartIndex; + + const isSingle = rowSize === 1; + const isLeft = positionInRow === 0; + const isRight = positionInRow === rowSize - 1; + + return ( + + handleCartItemSelect( + item.id, + event.currentTarget as HTMLElement + ) + } + onMouseEnter={() => setHoveredCartItemId(item.id)} + onMouseLeave={() => setHoveredCartItemId(null)} + className={clsx( + "group mb-4 flex cursor-pointer flex-col overflow-hidden border border-transparent bg-white transition-colors", + isHovered && "border-[#0f766e]" + )} + > +
+ {item.name} + +
+
+
+
+

+ {item.name} +

+

+ ${item.price.toFixed(2)} +

+
+ {shortDescription ? ( +

+ {shortDescription} +

+ ) : null} +
+
+ + + {item.quantity} + + +
+
+
+ + ); + })} + +
+ +
+ ); + + if (isCartModalView && !isCheckoutRoute) { + return ( +
+
+ {cartSummaryItems.length ? ( + cartSummaryItems.map((item) => ( +
+
+ {item.image ? ( + {item.name} + ) : null} +
+
+
+
+

+ {item.name} +

+

+ ${item.price.toFixed(2)} • Qty{" "} + {Math.max(0, item.quantity)} +

+
+ + ${(item.price * Math.max(0, item.quantity)).toFixed(2)} + +
+
+ )) + ) : ( +

+ Your cart is empty. +

+ )} +
+ +
+
+ Subtotal + ${cartSummarySubtotal.toFixed(2)} +
+
+ Total + ${cartSummaryTotal.toFixed(2)} +
+
+ +
+ ); + } + + const checkoutPanel = ( +
+ {shouldShowSelectedCartItemPanel ? ( + + ) : ( + + )} +
+ ); + + return ( +
+
+ {shouldShowCheckoutOnly ? ( + checkoutPanel + ) : isFullscreen ? ( +
+
{cartPanel}
+
{checkoutPanel}
+
+ ) : ( + cartPanel + )} + {!isFullscreen && !shouldShowCheckoutOnly && ( +
+ +
+ )} +
+
+ ); +} + +createRoot(document.getElementById("pizzaz-shop-root")!).render( + + + +); From 7e6a08706a394d86932703bccc60f520614f2f16 Mon Sep 17 00:00:00 2001 From: Yiren Lu Date: Wed, 5 Nov 2025 15:34:38 -0500 Subject: [PATCH 2/2] Add pizza shop --- build-all.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/build-all.mts b/build-all.mts index c26db74..045b4af 100644 --- a/build-all.mts +++ b/build-all.mts @@ -22,7 +22,6 @@ const targets: string[] = [ "pizzaz-list", "pizzaz-albums", "pizzaz-shop", - "ecommerce", ]; const builtNames: string[] = [];