From 8634c3a4d6b43910f680db714b6f4f33e96c7a68 Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Mon, 8 Jun 2026 09:32:02 -0400 Subject: [PATCH 1/7] feat(apollo-vertex): add Guided Buying catalog prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Experiment → Guided Buying docs page links to a full-screen /guided-buying prototype, rendered in a separate React root to escape the Nextra docs layout. Catalog "Selection" workspace: - scan-row and card layouts (toggle) with a recommendation lead row - product detail overlay, cart peek drawer, full-canvas compare table - filters (brand/category/price/stock) with agent chips and price basis - routable, deep-linkable state (?item / ?compare) and Review & submit stub Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/apollo-vertex/app/_meta.ts | 3 + apps/apollo-vertex/app/experiment/_meta.ts | 4 + .../app/experiment/guided-buying/page.mdx | 18 + apps/apollo-vertex/app/guided-buying/page.tsx | 37 + apps/apollo-vertex/locales/en.json | 3 + .../guided-buying/GuidedBuyingShell.tsx | 149 ++++ .../guided-buying/catalog/Catalog.tsx | 14 + .../guided-buying/catalog/CatalogV1.tsx | 9 + .../guided-buying/catalog/CatalogV2.tsx | 9 + .../guided-buying/catalog/v1/CartDrawer.tsx | 114 +++ .../guided-buying/catalog/v1/CartLine.tsx | 67 ++ .../guided-buying/catalog/v1/CartProvider.tsx | 31 + .../guided-buying/catalog/v1/CartSummary.tsx | 42 ++ .../guided-buying/catalog/v1/ChatRail.tsx | 87 +++ .../guided-buying/catalog/v1/CompareView.tsx | 331 +++++++++ .../guided-buying/catalog/v1/FilterChips.tsx | 51 ++ .../catalog/v1/FiltersControl.tsx | 200 +++++ .../guided-buying/catalog/v1/IntentHeader.tsx | 25 + .../catalog/v1/NotInCatalogBanner.tsx | 37 + .../guided-buying/catalog/v1/ProductCard.tsx | 116 +++ .../catalog/v1/ProductDetail.tsx | 251 +++++++ .../catalog/v1/ProductDetailOverlay.tsx | 64 ++ .../guided-buying/catalog/v1/ProductImage.tsx | 89 +++ .../catalog/v1/QuantityStepper.tsx | 40 + .../guided-buying/catalog/v1/RailDock.tsx | 65 ++ .../catalog/v1/RecommendationCard.tsx | 68 ++ .../guided-buying/catalog/v1/Review.tsx | 151 ++++ .../guided-buying/catalog/v1/ScanRow.tsx | 162 +++++ .../guided-buying/catalog/v1/Selection.tsx | 687 ++++++++++++++++++ .../catalog/v1/StepIndicator.tsx | 67 ++ .../guided-buying/catalog/v1/Toolbar.tsx | 138 ++++ .../guided-buying/catalog/v1/cart-context.ts | 30 + .../guided-buying/catalog/v1/data.ts | 506 +++++++++++++ .../catalog/v1/price-basis-context.ts | 17 + .../guided-buying/catalog/v1/types.ts | 79 ++ .../guided-buying/catalog/v1/useRail.ts | 35 + .../guided-buying/catalog/variants.ts | 22 + 37 files changed, 3818 insertions(+) create mode 100644 apps/apollo-vertex/app/experiment/_meta.ts create mode 100644 apps/apollo-vertex/app/experiment/guided-buying/page.mdx create mode 100644 apps/apollo-vertex/app/guided-buying/page.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/GuidedBuyingShell.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/CatalogV1.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/CatalogV2.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/CartDrawer.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/CartProvider.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/CompareView.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/FilterChips.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/IntentHeader.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/NotInCatalogBanner.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetailOverlay.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductImage.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/QuantityStepper.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/RailDock.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/RecommendationCard.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/StepIndicator.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/Toolbar.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/cart-context.ts create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/data.ts create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/price-basis-context.ts create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/types.ts create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/useRail.ts create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/variants.ts diff --git a/apps/apollo-vertex/app/_meta.ts b/apps/apollo-vertex/app/_meta.ts index 152665709..66f65eda7 100644 --- a/apps/apollo-vertex/app/_meta.ts +++ b/apps/apollo-vertex/app/_meta.ts @@ -9,6 +9,9 @@ export default { "data-querying": "Data Querying", localization: "Localization", mcp: "MCP Server", + "guided-buying": { + display: "hidden", + }, auth_callback: { display: "hidden", }, diff --git a/apps/apollo-vertex/app/experiment/_meta.ts b/apps/apollo-vertex/app/experiment/_meta.ts new file mode 100644 index 000000000..13149c808 --- /dev/null +++ b/apps/apollo-vertex/app/experiment/_meta.ts @@ -0,0 +1,4 @@ +export default { + index: { display: "hidden" }, + "guided-buying": "Guided Buying", +}; diff --git a/apps/apollo-vertex/app/experiment/guided-buying/page.mdx b/apps/apollo-vertex/app/experiment/guided-buying/page.mdx new file mode 100644 index 000000000..27a1d3e2b --- /dev/null +++ b/apps/apollo-vertex/app/experiment/guided-buying/page.mdx @@ -0,0 +1,18 @@ +# Guided Buying + +An experimental prototype exploring a guided buying experience built on the +Apollo Vertex shell. The prototype opens in its own full-screen window with a +minimal shell and the following sections: **Dashboard**, **Buy**, **Catalog**, +and **Workbench**. + + + Open the prototype ↗ + + +The prototype launches in a new tab so it can take over the full viewport +without the documentation chrome. diff --git a/apps/apollo-vertex/app/guided-buying/page.tsx b/apps/apollo-vertex/app/guided-buying/page.tsx new file mode 100644 index 000000000..57f5ff2ba --- /dev/null +++ b/apps/apollo-vertex/app/guided-buying/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { ThemeProvider } from "@/registry/shell/shell-theme-provider"; +import { GuidedBuyingShell } from "@/templates/guided-buying/GuidedBuyingShell"; + +export default function GuidedBuyingPage() { + // The shell is a full-screen client app. Rendering it through Next's tree + // puts it inside Nextra's docs layout, whose interrupted hydration leaves the + // subtree stuck `display:none`. So we mount it in a *separate* React root + // attached to — built by a fresh client render (no hydration), fully + // outside Nextra's tree and Suspense, so nothing can hide it. It carries its + // own ThemeProvider (normally supplied by the app's root ThemeWrapper). + useEffect(() => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + root.render( + +
+ +
+
, + ); + return () => { + root.unmount(); + container.remove(); + }; + }, []); + + return ( +
+ Loading prototype… +
+ ); +} diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index fc8a0541a..bc77d4264 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -16,8 +16,10 @@ "autopilot_empty_description": "Ask me anything about your automation data.", "bad_response": "Bad response", "business_user": "Business user", + "buy": "Buy", "cancel": "Cancel", "card_component": "Card Component", + "catalog": "Catalog", "changes_apply_instantly": "Changes apply instantly", "chart_render_failed_title": "Couldn't render this chart", "chat_messages": "Chat messages", @@ -166,6 +168,7 @@ "view_customer": "View customer", "view_payment_details": "View payment details", "warning": "Warning", + "workbench": "Workbench", "wrong_dimension_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; this chart needs a {{expected}} field.", "wrong_dimension_type_in_joined": "Field \"{{field}}\" is {{actual}}; this chart needs a {{expected}} field.", "wrong_metric_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; {{aggregation}} requires a numeric field.", diff --git a/apps/apollo-vertex/templates/guided-buying/GuidedBuyingShell.tsx b/apps/apollo-vertex/templates/guided-buying/GuidedBuyingShell.tsx new file mode 100644 index 000000000..3b3af2b50 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/GuidedBuyingShell.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { + CheckCircle2, + LayoutDashboard, + Library, + ShoppingCart, + Wrench, +} from "lucide-react"; +import { useState } from "react"; +import { ApolloShell, type ShellNavItem } from "@/registry/shell/shell"; +import { Catalog } from "./catalog/Catalog"; +import { CartProvider } from "./catalog/v1/CartProvider"; +import { Review } from "./catalog/v1/Review"; + +const navItems: ShellNavItem[] = [ + { path: "/dashboard", label: "dashboard", icon: LayoutDashboard }, + { path: "/buy", label: "buy", icon: ShoppingCart }, + { path: "/catalog", label: "catalog", icon: Library }, + { path: "/workbench", label: "workbench", icon: Wrench }, +]; + +function EmptyPage({ title }: { title: string }) { + return ( +
+

{title}

+
+ ); +} + +// Stub destination for Submit — the real Track page is a separate task. +function TrackStub() { + return ( +
+
+ +

+ Request submitted +

+

+ Tracking is coming soon. +

+
+
+ ); +} + +function GuidedBuyingLayout() { + return ( + + + + ); +} + +const rootRoute = createRootRoute({ component: GuidedBuyingLayout }); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , +}); + +const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/dashboard", + component: () => , +}); + +const buyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/buy", + component: () => , +}); + +const catalogRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/catalog", + component: Catalog, +}); + +const workbenchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/workbench", + component: () => , +}); + +// Review & submit (reached from the cart's "Review & submit"). +const reviewRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/review", + component: Review, +}); + +// Track stub (Submit destination; the real Track page is a separate task). +const trackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/track", + component: TrackStub, +}); + +const routeTree = rootRoute.addChildren([ + indexRoute, + dashboardRoute, + buyRoute, + catalogRoute, + workbenchRoute, + reviewRoute, + trackRoute, +]); + +const queryClient = new QueryClient(); + +export function GuidedBuyingShell() { + const [router] = useState(() => + createRouter({ + routeTree, + // TODO: default to /dashboard once more sections are built; for now the + // prototype lands on /catalog (the Selection screen) — the active work. + history: createMemoryHistory({ initialEntries: ["/catalog"] }), + }), + ); + + return ( + + + + + + ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx new file mode 100644 index 000000000..c203ae212 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx @@ -0,0 +1,14 @@ +import { CATALOG_VARIANTS } from "./variants"; + +/** + * Catalog page host. Renders the default variant. The variant registry (and the + * in-app switcher) is kept for future use — the switcher is just not shown now. + */ +export function Catalog() { + const ActiveVariant = CATALOG_VARIANTS[0].Component; + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/CatalogV1.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/CatalogV1.tsx new file mode 100644 index 000000000..ab8931f79 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/CatalogV1.tsx @@ -0,0 +1,9 @@ +import { Selection } from "./v1/Selection"; + +/** + * Catalog — Variant 1: the Guided Buying "Selection" screen (catalog goods). + * Renders with a stubbed Intake request; see Selection's `request` prop seam. + */ +export function CatalogV1() { + return ; +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/CatalogV2.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/CatalogV2.tsx new file mode 100644 index 000000000..cf6705b21 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/CatalogV2.tsx @@ -0,0 +1,9 @@ +import { Selection } from "./v1/Selection"; + +/** + * Catalog — Variant 2: same Selection screen as V1, but tiles show the vendor's + * brand logo instead of a product photo. + */ +export function CatalogV2() { + return ; +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartDrawer.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartDrawer.tsx new file mode 100644 index 000000000..c4cb849af --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartDrawer.tsx @@ -0,0 +1,114 @@ +import { ShieldCheck, ShoppingCart, TriangleAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { useCart } from "./cart-context"; +import { CartLine } from "./CartLine"; +import { CartSummary } from "./CartSummary"; +import { activePrice, APPROVAL_LIMIT } from "./data"; +import { usePriceBasis } from "./price-basis-context"; + +interface CartDrawerProps { + /** Advances to the Review step (Selection navigates the router). */ + onReviewSubmit: () => void; +} + +/** Standard right-edge cart peek drawer (built on Sheet, fed by cart context). */ +export function CartDrawer({ onReviewSubmit }: CartDrawerProps) { + const { open, setOpen, items, quantities, count, setQuantity, remove } = + useCart(); + const basis = usePriceBasis(); + const subtotal = items.reduce( + (sum, i) => sum + activePrice(i, basis) * (quantities[i.id] ?? 0), + 0, + ); + const needsApproval = subtotal > APPROVAL_LIMIT; + + return ( + + + + Cart{count > 0 ? ` · ${count}` : ""} + + Items selected for purchase + + + + {items.length === 0 ? ( +
+ +
+

Your cart is empty

+

+ Browse the catalog to add items. +

+
+ +
+ ) : ( + <> +
+ {items.map((item) => ( + setQuantity(item, quantity)} + onRemove={() => remove(item)} + /> + ))} +
+ + + + +
+ {needsApproval ? ( + + ) : ( + + )} + {needsApproval + ? "Needs manager approval" + : "Within your approval limit"} +
+ + + +
+ + )} +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx new file mode 100644 index 000000000..7d598ddc9 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx @@ -0,0 +1,67 @@ +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { activePrice, formatPrice } from "./data"; +import { QuantityStepper } from "./QuantityStepper"; +import { BrandMark } from "./ScanRow"; +import type { CatalogItem, PriceBasis } from "./types"; + +interface CartLineProps { + item: CatalogItem; + quantity: number; + basis: PriceBasis; + /** Read-only mode (Review): shows "Qty N", no stepper/remove. */ + readOnly?: boolean; + onQtyChange?: (quantity: number) => void; + onRemove?: () => void; +} + +/** A single cart line — editable in the drawer, read-only on Review. */ +export function CartLine({ + item, + quantity, + basis, + readOnly = false, + onQtyChange, + onRemove, +}: CartLineProps) { + return ( +
+ +
+

+ {item.name} +

+

+ {item.specs.join(" · ")} +

+
+ {readOnly ? ( + + Qty {quantity} + + ) : ( + onQtyChange?.(value)} + /> + )} +
+ + {formatPrice(activePrice(item, basis) * quantity, item.currency)} + + {!readOnly && onRemove && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartProvider.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartProvider.tsx new file mode 100644 index 000000000..abf3e7f85 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartProvider.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { type ReactNode, useState } from "react"; +import { CartContext, type CartContextValue } from "./cart-context"; +import { CATALOG_ITEMS } from "./data"; + +/** + * Cart state, lifted above the in-memory router so both the catalog and the + * Review page (separate routes) share one cart. + */ +export function CartProvider({ children }: { children: ReactNode }) { + const [quantities, setQuantities] = useState>({}); + const [open, setOpen] = useState(false); + + const value: CartContextValue = { + quantities, + items: CATALOG_ITEMS.filter((item) => (quantities[item.id] ?? 0) > 0), + count: Object.values(quantities).reduce((sum, qty) => sum + qty, 0), + open, + setOpen, + inCart: (id) => Boolean(quantities[id]), + // 0 = not in cart (avoids dynamic delete). + toggle: (item) => + setQuantities((prev) => ({ ...prev, [item.id]: prev[item.id] ? 0 : 1 })), + setQuantity: (item, quantity) => + setQuantities((prev) => ({ ...prev, [item.id]: quantity })), + remove: (item) => setQuantities((prev) => ({ ...prev, [item.id]: 0 })), + }; + + return {children}; +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx new file mode 100644 index 000000000..ecfde258b --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx @@ -0,0 +1,42 @@ +import { activePrice, activeSavings, formatPrice } from "./data"; +import type { CatalogItem, PriceBasis } from "./types"; + +interface CartSummaryProps { + items: CatalogItem[]; + quantities: Record; + basis: PriceBasis; +} + +/** Shared cart totals — subtotal, EPP savings, item count. */ +export function CartSummary({ items, quantities, basis }: CartSummaryProps) { + const count = items.reduce((sum, i) => sum + (quantities[i.id] ?? 0), 0); + const subtotal = items.reduce( + (sum, i) => sum + activePrice(i, basis) * (quantities[i.id] ?? 0), + 0, + ); + const savings = items.reduce( + (sum, i) => sum + activeSavings(i, basis) * (quantities[i.id] ?? 0), + 0, + ); + + return ( +
+
+ Items + {count} +
+ {savings > 0 && ( +
+ EPP savings + + −{formatPrice(savings, "USD")} + +
+ )} +
+ Subtotal + {formatPrice(subtotal, "USD")} +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx new file mode 100644 index 000000000..0d887d1e2 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx @@ -0,0 +1,87 @@ +import { PanelRightClose, Send } from "lucide-react"; +import type { Ref } from "react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { BuyRequest, RailNote } from "./types"; + +interface ChatRailProps { + request: BuyRequest; + /** Agent notes appended as filters change. */ + notes: RailNote[]; + /** Lets the workspace focus the input (the "ask the agent" hook). */ + inputRef?: Ref; + onCollapse: () => void; +} + +/** + * Docked assistant rail. Visual placeholder for the prototype — real chat + * behavior is out of scope; the input is exposed so "Ask the agent" can focus it. + */ +export function ChatRail({ + request, + notes, + inputRef, + onCollapse, +}: ChatRailProps) { + return ( +
+
+
+ + + + Autopilot +
+ +
+ +
+

Here’s what I’m on:

+
+ {request.summary} +
+

{request.agentNote}

+ {notes.map((note) => ( +

+ + {note.text} +

+ ))} +
+ +
+ {/* TODO(agent): wire real chat. Stubbed input for now. */} +
+ + +
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CompareView.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CompareView.tsx new file mode 100644 index 000000000..5ccb864cc --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CompareView.tsx @@ -0,0 +1,331 @@ +import { useFocusTrap, useHotkeys } from "@mantine/hooks"; +import { ArrowLeft, Check, Plus, X } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { activePrice, formatPrice, leadTime, showsListStrike } from "./data"; +import { usePriceBasis } from "./price-basis-context"; +import { BrandMark } from "./ScanRow"; +import type { CatalogItem, PriceBasis } from "./types"; + +const ACCENT = "bg-[#0f7b8a] text-white hover:bg-[#0c6976]"; + +interface CompareRow { + label: string; + values: (string | null)[]; + /** "price" rows get a "best" (lowest) indicator. */ + kind?: "price"; +} +interface CompareGroup { + label: string; + rows: CompareRow[]; +} + +function buildGroups( + products: CatalogItem[], + basis: PriceBasis, +): CompareGroup[] { + const groups: CompareGroup[] = [ + { + label: "Price & source", + rows: [ + { + label: "Price", + kind: "price", + values: products.map((p) => + formatPrice(activePrice(p, basis), p.currency), + ), + }, + { + label: "List price", + values: products.map((p) => + p.eppPrice ? formatPrice(p.listPrice, p.currency) : "—", + ), + }, + { label: "Source", values: products.map((p) => p.source) }, + { + label: "Availability", + values: products.map((p) => + p.inStock ? "In stock" : "Out of stock", + ), + }, + { label: "Lead time", values: products.map((p) => leadTime(p)) }, + ], + }, + ]; + + // Spec groups: union of group/row labels in first-seen order. + const groupOrder: string[] = []; + for (const p of products) { + for (const g of p.specGroups ?? []) { + if (!groupOrder.includes(g.label)) groupOrder.push(g.label); + } + } + for (const groupLabel of groupOrder) { + const rowOrder: string[] = []; + for (const p of products) { + const group = p.specGroups?.find((g) => g.label === groupLabel); + for (const row of group?.rows ?? []) { + if (!rowOrder.includes(row.label)) rowOrder.push(row.label); + } + } + groups.push({ + label: groupLabel, + rows: rowOrder.map((rowLabel) => ({ + label: rowLabel, + values: products.map( + (p) => + p.specGroups + ?.find((g) => g.label === groupLabel) + ?.rows.find((r) => r.label === rowLabel)?.value ?? null, + ), + })), + }); + } + return groups; +} + +interface CompareViewProps { + products: CatalogItem[]; + /** Items available to add to the comparison. */ + addable: CatalogItem[]; + recommendation: string; + onClose: () => void; + onRemove: (item: CatalogItem) => void; + onAdd: (item: CatalogItem) => void; + onAddToCart: (item: CatalogItem) => void; +} + +/** Full-canvas comparison table (conventional, sticky headers). */ +export function CompareView({ + products, + addable, + recommendation, + onClose, + onRemove, + onAdd, + onAddToCart, +}: CompareViewProps) { + const focusTrapRef = useFocusTrap(true); + useHotkeys([["Escape", onClose]]); + const [highlightDiff, setHighlightDiff] = useState(false); + const [recoOpen, setRecoOpen] = useState(true); + + const restoreRef = useRef(null); + useEffect(() => { + restoreRef.current = document.activeElement as HTMLElement | null; + return () => restoreRef.current?.focus?.(); + }, []); + + const basis = usePriceBasis(); + const groups = buildGroups(products, basis); + // Lowest price column (best indicator). + const prices = products.map((p) => activePrice(p, basis)); + const bestPrice = Math.min(...prices); + + return ( +
+
+
+ +

Compare

+
+
+ + +
+
+ + {recommendation && recoOpen && ( +
+ +

{recommendation}

+ +
+ )} + +
+ + + + + ))} + {addable.length > 0 && products.length < 4 && ( + + )} + + + + {groups.map((group) => ( + + {group.rows.map((row) => { + const allSame = row.values.every((v) => v === row.values[0]); + const dim = highlightDiff && allSame; + return ( + + + {products.map((product, index) => { + const value = row.values[index]; + const isBest = + row.kind === "price" && prices[index] === bestPrice; + return ( + + ); + })} + + ); + })} + + ))} + +
+ {products.map((product) => ( + +
+ + +
+
+ {product.name} +
+
+ + {formatPrice( + activePrice(product, basis), + product.currency, + )} + + {showsListStrike(product, basis) && ( + + {formatPrice(product.listPrice, product.currency)} + + )} +
+ +
+ +
+ {row.label} + + + {value ?? "—"} + {isBest && ( + + + Best + + )} + +
+
+
+ ); +} + +function Group({ + label, + span, + children, +}: { + label: string; + span: number; + children: ReactNode; +}) { + return ( + <> + + + {label} + + + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/FilterChips.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/FilterChips.tsx new file mode 100644 index 000000000..081cc7d20 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/FilterChips.tsx @@ -0,0 +1,51 @@ +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export interface FilterChip { + key: string; + label: string; + /** Agent-applied chips carry the Autopilot sparkle (same chrome otherwise). */ + isAgent: boolean; + onRemove: () => void; +} + +interface FilterChipsProps { + chips: FilterChip[]; + onClearAll: () => void; +} + +/** Active-filter chip row. Collapses to nothing when no constraints are active. */ +export function FilterChips({ chips, onClearAll }: FilterChipsProps) { + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} + {chips.length > 1 && ( + + )} +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx new file mode 100644 index 000000000..c38b5693e --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx @@ -0,0 +1,200 @@ +import { SlidersHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { + CATALOG_BRANDS, + CATALOG_CATEGORIES, + CATALOG_PRICE_MAX, + CATALOG_PRICE_MIN, +} from "./data"; +import type { CatalogCategory, Filters } from "./types"; + +interface FiltersControlProps { + filters: Filters; + activeCount: number; + onChange: (filters: Filters) => void; + onClearAll: () => void; +} + +/** Toolbar Filters button + anchored overlay panel (live apply). */ +export function FiltersControl({ + filters, + activeCount, + onChange, + onClearAll, +}: FiltersControlProps) { + const toggleBrand = (brand: string, checked: boolean) => { + onChange({ + ...filters, + brands: checked + ? [...filters.brands, brand] + : filters.brands.filter((b) => b !== brand), + }); + }; + + const toggleCategory = (category: CatalogCategory, checked: boolean) => { + onChange({ + ...filters, + categories: checked + ? [...filters.categories, category] + : filters.categories.filter((c) => c !== category), + }); + }; + + return ( + + + + + +
+ + {CATALOG_BRANDS.map((brand) => ( + toggleBrand(brand, checked)} + /> + ))} + + + + + + {CATALOG_CATEGORIES.map((category) => ( + toggleCategory(category, checked)} + /> + ))} + + + + + +
+ + onChange({ + ...filters, + priceMin: Number(event.target.value) || CATALOG_PRICE_MIN, + }) + } + aria-label="Minimum price" + className="h-9" + /> + + + onChange({ + ...filters, + priceMax: Number(event.target.value) || CATALOG_PRICE_MAX, + }) + } + aria-label="Maximum price" + className="h-9" + /> +
+
+ + + +
+ + + onChange({ ...filters, inStockOnly: checked }) + } + /> +
+
+ + +
+ +
+
+
+ ); +} + +function FilterGroup({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} + +function CheckRow({ + id, + label, + checked, + onCheckedChange, +}: { + id: string; + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
+ onCheckedChange(value === true)} + /> + +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/IntentHeader.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/IntentHeader.tsx new file mode 100644 index 000000000..0f861d4b4 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/IntentHeader.tsx @@ -0,0 +1,25 @@ +import { Sparkles } from "lucide-react"; +import type { BuyRequest } from "./types"; + +interface IntentHeaderProps { + request: BuyRequest; +} + +/** + * The hero for the Selection screen: the requester's restated request as the + * visual focus, with one quiet, auditable agent line beneath it. + */ +export function IntentHeader({ request }: IntentHeaderProps) { + return ( +
+

For

+

+ “{request.summary}” +

+

+ + {request.agentNote} +

+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/NotInCatalogBanner.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/NotInCatalogBanner.tsx new file mode 100644 index 000000000..eb8c0c068 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/NotInCatalogBanner.tsx @@ -0,0 +1,37 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +/** + * Agent-surfaced off-catalog request: something the requester asked for that + * isn't a catalog good, with a path to a custom quote or agent configuration. + * Static for the prototype. TODO(intake): drive from real off-catalog matches. + */ +export function NotInCatalogBanner() { + return ( +
+
+
+

+ Mobile service 12 lines for Denver +

+ + Not in catalog + +
+

+ Your T-Mobile MSA can absorb new lines. Want me to walk through plan + tier, devices, and activation? +

+
+ +
+ {/* TODO(quote): kick off the custom-quote (Workbench) path. */} + + {/* TODO(agent): launch the agent configuration flow. */} + +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx new file mode 100644 index 000000000..6fb06d92e --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx @@ -0,0 +1,116 @@ +import { Check, Plus } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { activePrice, formatPrice, showsListStrike } from "./data"; +import { usePriceBasis } from "./price-basis-context"; +import { ProductImage } from "./ProductImage"; +import type { CatalogItem } from "./types"; + +interface ProductCardProps { + item: CatalogItem; + inCart: boolean; + /** Featured (recommended) styling: teal Add-to-cart accent. */ + featured?: boolean; + /** "photo" shows the product image; "logo" shows the brand logo. */ + imageMode?: "photo" | "logo"; + onToggleCart: (item: CatalogItem) => void; + onOpenDetail: (item: CatalogItem) => void; + /** Optional override for the container (used when nested in the rec card). */ + className?: string; +} + +export function ProductCard({ + item, + inCart, + featured = false, + imageMode = "photo", + onToggleCart, + onOpenDetail, + className, +}: ProductCardProps) { + const basis = usePriceBasis(); + const showStrike = showsListStrike(item, basis); + + return ( + + {/* Tier + source chips */} +
+ + {item.tier} + + + + {item.source} + +
+ + + +
+

+ {item.name} +

+

+ {item.specs.join(" | ")} +

+
+ +
+
+ + {formatPrice(activePrice(item, basis), item.currency)} + + {showStrike && ( + + {formatPrice(item.listPrice, item.currency)} + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx new file mode 100644 index 000000000..705a6c03d --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx @@ -0,0 +1,251 @@ +import { ArrowLeft, Check, Columns3, Plus } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + activePrice, + activeSavings, + formatPrice, + leadTime, + priceBasis, + showsListStrike, +} from "./data"; +import { usePriceBasis } from "./price-basis-context"; +import { ProductImage } from "./ProductImage"; +import { QuantityStepper } from "./QuantityStepper"; +import { BrandMark } from "./ScanRow"; +import type { CatalogItem } from "./types"; + +const ACCENT = "bg-[#0f7b8a] text-white hover:bg-[#0c6976]"; + +interface ProductDetailProps { + item: CatalogItem; + defaultQuantity: number; + inCart: boolean; + comparing: boolean; + isPicked: boolean; + recommendationNote: string; + imageMode?: "photo" | "logo"; + onAddToCart: (quantity: number) => void; + onToggleCompare: () => void; + onAskAgent: () => void; + onClose: () => void; +} + +export function ProductDetail({ + item, + defaultQuantity, + inCart, + comparing, + isPicked, + recommendationNote, + imageMode = "photo", + onAddToCart, + onToggleCompare, + onAskAgent, + onClose, +}: ProductDetailProps) { + const [quantity, setQuantity] = useState(defaultQuantity); + const basis = usePriceBasis(); + const showStrike = showsListStrike(item, basis); + const savings = activeSavings(item, basis); + + return ( + <> +
+ + +
+ +
+ {/* Top: image + identity/price */} +
+ + +
+
+ + + {item.vendor} + +
+
+

+ {item.name} +

+

+ {item.specs.join(" · ")} +

+
+ +
+
+ + {formatPrice(activePrice(item, basis), item.currency)} + + {showStrike && ( + + {formatPrice(item.listPrice, item.currency)} + + )} +
+ {showStrike && ( +

+ EPP pricing · save {formatPrice(savings, item.currency)}/unit +

+ )} +
+ + {isPicked && ( +
+ +

{recommendationNote}

+
+ )} + +
+ + +
+ +
+ + +
+
+
+ + {/* Source-of-truth panel */} +
+

+ Source & availability +

+
+ + + + +
+
+ + {/* Full spec breakdown */} +
+

+ Specifications +

+ {item.specGroups ? ( +
+ {item.specGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.rows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+
+ ))} +
+ ) : ( +
    + {item.specs.map((spec) => ( +
  • + {spec} +
  • + ))} +
+ )} +
+
+ + ); +} + +function SourceRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetailOverlay.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetailOverlay.tsx new file mode 100644 index 000000000..d1356cfec --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetailOverlay.tsx @@ -0,0 +1,64 @@ +import { useFocusTrap, useHotkeys } from "@mantine/hooks"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ProductDetailOverlayProps { + onClose: () => void; + children: ReactNode; +} + +/** + * Scoped overlay layered over the catalog main column (never the rail). Handles + * the overlay hygiene: focus trap, Esc to close, dim-to-close, restored focus, + * and a slide-in from the right edge. + */ +export function ProductDetailOverlay({ + onClose, + children, +}: ProductDetailOverlayProps) { + const focusTrapRef = useFocusTrap(true); + useHotkeys([["Escape", onClose]]); + + // Return focus to the triggering element when the overlay unmounts. + const restoreRef = useRef(null); + useEffect(() => { + restoreRef.current = document.activeElement as HTMLElement | null; + return () => restoreRef.current?.focus?.(); + }, []); + + // Slide-in on mount. + const [entered, setEntered] = useState(false); + useEffect(() => { + const id = requestAnimationFrame(() => setEntered(true)); + return () => cancelAnimationFrame(id); + }, []); + + return ( +
+ {/* Dimmed grid behind — click to close. */} +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductImage.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductImage.tsx new file mode 100644 index 000000000..8463d0e7f --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductImage.tsx @@ -0,0 +1,89 @@ +import { Cable, Laptop, type LucideIcon, Monitor, Package } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { vendorLogoUrl } from "./data"; +import type { CatalogCategory } from "./types"; + +const CATEGORY_ICON: Record = { + Laptops: Laptop, + Monitors: Monitor, + Docking: Cable, + Accessories: Package, +}; + +interface ProductImageProps { + src?: string; + alt: string; + category: CatalogCategory; + vendor: string; + /** "photo" shows the product image; "logo" shows the brand logo. */ + mode?: "photo" | "logo"; + className?: string; +} + +/** + * Product visual with graceful degradation. In "photo" mode it shows the remote + * product image (icon fallback on error); in "logo" mode it shows the vendor's + * brand logo (brand-wordmark fallback when no logo is available). + */ +export function ProductImage({ + src, + alt, + category, + vendor, + mode = "photo", + className, +}: ProductImageProps) { + const [failed, setFailed] = useState(false); + const Icon = CATEGORY_ICON[category]; + + if (mode === "logo") { + const logo = vendorLogoUrl(vendor); + return ( +
+ {logo && !failed ? ( + // oxlint-disable-next-line next/no-img-element + {`${vendor} setFailed(true)} + className="max-h-12 w-auto object-contain" + /> + ) : ( + + {vendor} + + )} +
+ ); + } + + const showImage = src && !failed; + return ( +
+ {showImage ? ( + // oxlint-disable-next-line next/no-img-element + {alt} setFailed(true)} + className="size-full object-cover" + /> + ) : ( + + )} +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/QuantityStepper.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/QuantityStepper.tsx new file mode 100644 index 000000000..8cb2e9262 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/QuantityStepper.tsx @@ -0,0 +1,40 @@ +import { Minus, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface QuantityStepperProps { + value: number; + onChange: (value: number) => void; + min?: number; +} + +/** Controlled −/+ quantity stepper, shared by the detail overlay and cart. */ +export function QuantityStepper({ + value, + onChange, + min = 1, +}: QuantityStepperProps) { + return ( +
+ + + {value} + + +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/RailDock.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/RailDock.tsx new file mode 100644 index 000000000..67d09c0aa --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/RailDock.tsx @@ -0,0 +1,65 @@ +import type { Ref } from "react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { cn } from "@/lib/utils"; +import { ChatRail } from "./ChatRail"; +import type { BuyRequest, RailNote } from "./types"; + +interface RailDockProps { + open: boolean; + hasUpdates: boolean; + request: BuyRequest; + notes: RailNote[]; + inputRef?: Ref; + onCollapse: () => void; + onExpand: () => void; +} + +/** + * The docked rail container. Animates between the full assistant panel and a + * slim launcher; the main column reflows into the reclaimed width. + */ +export function RailDock({ + open, + hasUpdates, + request, + notes, + inputRef, + onCollapse, + onExpand, +}: RailDockProps) { + return ( + + ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/RecommendationCard.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/RecommendationCard.tsx new file mode 100644 index 000000000..7edbcf445 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/RecommendationCard.tsx @@ -0,0 +1,68 @@ +import { Sparkles } from "lucide-react"; +import { eppSavings, formatPrice } from "./data"; +import { ProductCard } from "./ProductCard"; +import type { CatalogItem } from "./types"; + +interface RecommendationCardProps { + item: CatalogItem; + /** Number of alternative picks the agent is holding back. */ + alternatives: number; + /** Total catalog size, for the "browse all N" line. */ + totalCount: number; + inCart: boolean; + /** "photo" shows the product image; "logo" shows the brand logo. */ + imageMode?: "photo" | "logo"; + onToggleCart: (item: CatalogItem) => void; + onOpenDetail: (item: CatalogItem) => void; +} + +/** + * The agent's top pick: a gradient promo panel fused with the featured product + * tile, framed as one highlighted unit. Spans two grid columns on wider screens. + */ +export function RecommendationCard({ + item, + alternatives, + totalCount, + inCart, + imageMode = "photo", + onToggleCart, + onOpenDetail, +}: RecommendationCardProps) { + const savings = eppSavings(item); + const headline = + savings > 0 + ? `${item.name} matches your request — ${formatPrice(savings, item.currency)} cheaper per unit with EPP applied` + : `${item.name} is the best match for your request`; + + return ( +
+
+ {/* Promo panel */} +
+
+ + Picked for you +
+

{headline}

+

+ {alternatives > 0 && + `There ${alternatives === 1 ? "is 1 alternative" : `are ${alternatives} alternatives`} in case the team has preferences. `} + Or browse all {totalCount} catalog options. +

+
+ + {/* Featured product — borderless so the outer frame reads as one unit */} + +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx new file mode 100644 index 000000000..7b85b271e --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useNavigate } from "@tanstack/react-router"; +import { ChevronDown, Pencil, ShieldCheck } from "lucide-react"; +import { useState } from "react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { useCart } from "./cart-context"; +import { CartLine } from "./CartLine"; +import { CartSummary } from "./CartSummary"; +import { APPROVAL_LIMIT, formatPrice, leadTime, SAMPLE_REQUEST } from "./data"; + +// Review commits the EPP-priced catalog scenario. +const BASIS = "epp" as const; + +/** Review & submit — the commit surface for the catalog path. */ +export function Review() { + const navigate = useNavigate(); + const { items, quantities, setOpen } = useCart(); + const [agentOpen, setAgentOpen] = useState(false); + + const editCart = () => { + setOpen(true); + void navigate({ to: "/catalog" }); + }; + + if (items.length === 0) { + return ( +
+

Your cart is empty.

+ +
+ ); + } + + return ( +
+
+ {/* Intent hero */} +
+

Review & submit

+

+ “{SAMPLE_REQUEST.summary}” +

+
+ + {/* Order summary (read-only) */} +
+
+

+ Order summary +

+ +
+
+ {items.map((item) => ( + + ))} +
+
+ +
+
+ + {/* Source & validation */} +
+

+ Source & validation +

+

+ {items[0].source} · {leadTime(items[0])} · EPP · validated today +

+
+ + {/* Approval routing (in-limit happy path) */} +
+ + + Within your {formatPrice(APPROVAL_LIMIT, "USD")} limit · no approval + needed + +
+ + {/* Agent summary — one collapsed line, expandable */} + + + + + +
    +
  • · Sourced 12 catalog options matching your request
  • +
  • · Applied EPP pricing across eligible items
  • +
  • · Validated stock and price today
  • +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx new file mode 100644 index 000000000..b0f6cff2e --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx @@ -0,0 +1,162 @@ +import { Check, Plus } from "lucide-react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { + activePrice, + formatPrice, + showsListStrike, + vendorLogoUrl, +} from "./data"; +import { usePriceBasis } from "./price-basis-context"; +import type { CatalogItem } from "./types"; + +// Shared teal accent (already used by the featured card + escalation banner). +const ACCENT = "bg-[#0f7b8a] text-white hover:bg-[#0c6976]"; +// Vibrant AI gradient (readable with white text), used for the picked-for-you +// row's border and badge. +const AI_GRADIENT = { background: "var(--ai-gradient-strong)" }; + +interface ScanRowProps { + item: CatalogItem; + inCart: boolean; + comparing: boolean; + /** Elevated "Picked for you" lead row. */ + recommended?: boolean; + /** Agent rationale shown under the name on the recommended row. */ + note?: string; + onToggleCart: (item: CatalogItem) => void; + onToggleCompare: (item: CatalogItem) => void; + onOpenDetail: (item: CatalogItem) => void; +} + +/** Small brand mark — logo when available, vendor initials otherwise. */ +export function BrandMark({ item }: { item: CatalogItem }) { + const logo = vendorLogoUrl(item.vendor); + return ( +
+ {logo ? ( + // oxlint-disable-next-line next/no-img-element + {`${item.vendor} + ) : ( + + {item.vendor.slice(0, 3).toUpperCase()} + + )} +
+ ); +} + +/** A single catalog item rendered as a scannable horizontal row. */ +export function ScanRow({ + item, + inCart, + comparing, + recommended = false, + note, + onToggleCart, + onToggleCompare, + onOpenDetail, +}: ScanRowProps) { + const basis = usePriceBasis(); + const showStrike = showsListStrike(item, basis); + const stockLine = `${item.inStock ? "In stock" : "Out of stock"} · ${item.source}`; + + return ( + // Recommended row gets a 2px AI-gradient border via the padding-box trick. +
+
+ onToggleCompare(item)} + aria-label={`Compare ${item.name}`} + className="shrink-0" + /> + + + +
+
+

{item.name}

+ {recommended && ( + + + Picked for you + + )} +
+

+ {item.specs.join(" · ")} +

+ {recommended && note && ( +

{note}

+ )} +
+ +
+
+
+ + {formatPrice(activePrice(item, basis), item.currency)} + + {showStrike && ( + + {formatPrice(item.listPrice, item.currency)} + + )} +
+

{stockLine}

+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx new file mode 100644 index 000000000..e2fc7afd9 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx @@ -0,0 +1,687 @@ +"use client"; + +// oxlint-disable max-lines -- /buy workspace orchestrator; holds the wired state +import { useNavigate } from "@tanstack/react-router"; +import { Columns3, ShoppingCart, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderNav, + PageHeaderTitle, +} from "@/components/ui/page-header"; +import { cn } from "@/lib/utils"; +import { + activePrice, + CATALOG_ITEMS, + CATALOG_PRICE_MAX, + CATALOG_PRICE_MIN, + eppSavings, + formatPrice, + INFERRED_REQUEST_QUANTITY, + RECOMMENDATION, + SAMPLE_REQUEST, +} from "./data"; +import { useCart } from "./cart-context"; +import { CartDrawer } from "./CartDrawer"; +import { CompareView } from "./CompareView"; +import { type FilterChip, FilterChips } from "./FilterChips"; +import { NotInCatalogBanner } from "./NotInCatalogBanner"; +import { ProductCard } from "./ProductCard"; +import { ProductDetail } from "./ProductDetail"; +import { ProductDetailOverlay } from "./ProductDetailOverlay"; +import { RecommendationCard } from "./RecommendationCard"; +import { RailDock } from "./RailDock"; +import { PriceBasisProvider } from "./price-basis-context"; +import { ScanRow } from "./ScanRow"; +import { Toolbar } from "./Toolbar"; +import { useRail } from "./useRail"; +import type { + CatalogCategory, + CatalogItem, + Filters, + LayoutMode, + PriceBasis, + RailNote, + SortKey, +} from "./types"; + +const MAX_COMPARE = 4; + +const DEFAULT_FILTERS: Filters = { + brands: [], + categories: [], + priceMin: CATALOG_PRICE_MIN, + priceMax: CATALOG_PRICE_MAX, + // The agent's inferences, promoted to real state. + inStockOnly: true, + priceBasis: "epp", +}; + +// "Clear all" strips every constraint, including the agent's inferences. +const CLEARED_FILTERS: Filters = { + brands: [], + categories: [], + priceMin: CATALOG_PRICE_MIN, + priceMax: CATALOG_PRICE_MAX, + inStockOnly: false, + priceBasis: "list", +}; + +function sortItems( + items: CatalogItem[], + sort: SortKey, + basis: PriceBasis, +): CatalogItem[] { + switch (sort) { + case "price-asc": + return items.toSorted( + (a, b) => activePrice(a, basis) - activePrice(b, basis), + ); + case "price-desc": + return items.toSorted( + (a, b) => activePrice(b, basis) - activePrice(a, basis), + ); + case "name": + return items.toSorted((a, b) => a.name.localeCompare(b.name)); + // "recommended" — keep curated order + default: + return items; + } +} + +/** Whether an item satisfies the active facet filters + search query. */ +function matches(item: CatalogItem, filters: Filters, query: string): boolean { + if (filters.inStockOnly && !item.inStock) return false; + if (filters.brands.length > 0 && !filters.brands.includes(item.vendor)) { + return false; + } + if ( + filters.categories.length > 0 && + !filters.categories.includes(item.category) + ) { + return false; + } + const price = activePrice(item, filters.priceBasis); + if (price < filters.priceMin || price > filters.priceMax) return false; + if (query) { + const haystack = + `${item.name} ${item.vendor} ${item.category} ${item.specs.join(" ")}`.toLowerCase(); + if (!haystack.includes(query)) return false; + } + return true; +} + +interface SelectionProps { + /** "photo" shows product images; "logo" shows brand logos. */ + imageMode?: "photo" | "logo"; +} + +/** Selection step of Guided Buying — catalog browse-and-pick (catalog goods only). */ +export function Selection({ imageMode = "photo" }: SelectionProps) { + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("recommended"); + const [layout, setLayout] = useState("rows"); + const [compareIds, setCompareIds] = useState([]); + const [compareOpen, setCompareOpen] = useState(false); + const [detailSlug, setDetailSlug] = useState(null); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [railNotes, setRailNotes] = useState([]); + const [railUnread, setRailUnread] = useState(false); + + // Cart state lives above the router so Review (a separate route) shares it. + const { + quantities, + count: cartCount, + open: cartOpen, + setOpen: setCartOpen, + inCart, + toggle: toggleCart, + setQuantity: addToCart, + } = useCart(); + const rail = useRail(); + const navigate = useNavigate(); + const railInputRef = useRef(null); + const pushedRef = useRef(false); + const noteIdRef = useRef(0); + + // Reflect detail/compare state in the real browser URL (?item / ?compare) so + // it's shareable and deep-linkable. The shell runs in an in-memory router, so + // we drive this directly off window.history / popstate. + useEffect(() => { + const syncFromUrl = () => { + const params = new URLSearchParams(window.location.search); + setDetailSlug(params.get("item")); + const compareParam = params.get("compare"); + if (compareParam) { + setCompareIds( + compareParam.split(",").filter(Boolean).slice(0, MAX_COMPARE), + ); + setCompareOpen(true); + } else { + setCompareOpen(false); + } + pushedRef.current = false; + }; + syncFromUrl(); + window.addEventListener("popstate", syncFromUrl); + return () => window.removeEventListener("popstate", syncFromUrl); + }, []); + + // Keep the URL in sync as products are added/removed within the compare view. + useEffect(() => { + if (compareOpen && compareIds.length > 0) { + window.history.replaceState( + {}, + "", + `${window.location.pathname}?compare=${compareIds.join(",")}`, + ); + } + }, [compareOpen, compareIds]); + + const recommendedItem = CATALOG_ITEMS.find( + (item) => item.id === RECOMMENDATION.itemId, + ); + const query = search.trim().toLowerCase(); + // Feature the recommendation only on the default landing, under EPP, and only + // when it satisfies the active filters — never contradict the filter state. + const showRecommendation = Boolean( + sort === "recommended" && + query === "" && + filters.priceBasis === "epp" && + recommendedItem && + matches(recommendedItem, filters, query), + ); + + const baseItems = showRecommendation + ? CATALOG_ITEMS.filter((item) => item.id !== RECOMMENDATION.itemId) + : CATALOG_ITEMS; + const gridItems = sortItems( + baseItems.filter((item) => matches(item, filters, query)), + sort, + filters.priceBasis, + ); + + const subtotal = CATALOG_ITEMS.reduce( + (sum, item) => + sum + activePrice(item, filters.priceBasis) * (quantities[item.id] ?? 0), + 0, + ); + const compareItems = CATALOG_ITEMS.filter((item) => + compareIds.includes(item.id), + ); + const detailItem = detailSlug + ? (CATALOG_ITEMS.find((item) => item.id === detailSlug) ?? null) + : null; + // The rail yields the right edge to the cart/compare surfaces, then restores. + const railVisible = rail.open && !cartOpen && !compareOpen; + + // Clear the launcher's unread dot once the rail is visible again. + useEffect(() => { + if (railVisible) setRailUnread(false); + }, [railVisible]); + + const addRailNote = (text: string) => { + noteIdRef.current += 1; + setRailNotes((prev) => [...prev, { id: noteIdRef.current, text }]); + if (!railVisible) setRailUnread(true); + }; + + const reviewSubmit = () => { + setCartOpen(false); + void navigate({ to: "/review" }); + }; + + const toggleCompare = (item: CatalogItem) => { + setCompareIds((prev) => + prev.includes(item.id) + ? prev.filter((id) => id !== item.id) + : prev.length >= MAX_COMPARE + ? prev + : [...prev, item.id], + ); + }; + + const addToCompare = (item: CatalogItem) => { + setCompareIds((prev) => + prev.includes(item.id) || prev.length >= MAX_COMPARE + ? prev + : [...prev, item.id], + ); + }; + + const openCompare = () => { + if (compareIds.length < 2) return; + setCompareOpen(true); + window.history.pushState( + {}, + "", + `${window.location.pathname}?compare=${compareIds.join(",")}`, + ); + pushedRef.current = true; + }; + + const closeCompare = () => { + setCompareOpen(false); + if (pushedRef.current) { + pushedRef.current = false; + window.history.back(); + } else { + window.history.pushState({}, "", window.location.pathname); + } + }; + + const removeFromCompare = (item: CatalogItem) => { + const next = compareIds.filter((id) => id !== item.id); + setCompareIds(next); + if (next.length === 0) closeCompare(); + }; + + const openDetail = (item: CatalogItem) => { + setDetailSlug(item.id); + window.history.pushState( + {}, + "", + `${window.location.pathname}?item=${item.id}`, + ); + pushedRef.current = true; + }; + + const closeDetail = () => { + if (pushedRef.current) { + pushedRef.current = false; + // popstate clears detailSlug from the URL + window.history.back(); + } else { + window.history.pushState({}, "", window.location.pathname); + setDetailSlug(null); + } + }; + + const askAgent = () => { + rail.expand(); + requestAnimationFrame(() => railInputRef.current?.focus()); + }; + + const visibleCount = (f: Filters) => + CATALOG_ITEMS.filter((item) => matches(item, f, query)).length; + + const applyFilters = (next: Filters, label: string) => { + const delta = visibleCount(next) - visibleCount(filters); + addRailNote(delta > 0 ? `${label} — showing ${delta} more results` : label); + setFilters(next); + }; + + const removeInStock = () => + applyFilters({ ...filters, inStockOnly: false }, "Removed In stock"); + const removeEpp = () => { + setFilters((f) => ({ ...f, priceBasis: "list" })); + addRailNote("Showing list pricing"); + }; + const removeBrand = (brand: string) => + applyFilters( + { ...filters, brands: filters.brands.filter((b) => b !== brand) }, + `Removed ${brand}`, + ); + const removeCategory = (category: CatalogCategory) => + applyFilters( + { + ...filters, + categories: filters.categories.filter((c) => c !== category), + }, + `Removed ${category}`, + ); + const removePrice = () => + applyFilters( + { ...filters, priceMin: CATALOG_PRICE_MIN, priceMax: CATALOG_PRICE_MAX }, + "Removed price range", + ); + const clearAllFilters = () => { + setFilters(CLEARED_FILTERS); + addRailNote("Cleared all filters · showing list pricing"); + }; + + // Agent-applied chips (sparkle) first, then user-applied facet chips. + const chips: FilterChip[] = []; + if (filters.inStockOnly) { + chips.push({ + key: "stock", + label: "In stock", + isAgent: true, + onRemove: removeInStock, + }); + } + if (filters.priceBasis === "epp") { + chips.push({ + key: "epp", + label: "EPP pricing", + isAgent: true, + onRemove: removeEpp, + }); + } + for (const brand of filters.brands) { + chips.push({ + key: `brand-${brand}`, + label: brand, + isAgent: false, + onRemove: () => removeBrand(brand), + }); + } + for (const category of filters.categories) { + chips.push({ + key: `cat-${category}`, + label: category, + isAgent: false, + onRemove: () => removeCategory(category), + }); + } + if ( + filters.priceMin > CATALOG_PRICE_MIN || + filters.priceMax < CATALOG_PRICE_MAX + ) { + chips.push({ + key: "price", + label: `${formatPrice(filters.priceMin, "USD")}–${formatPrice(filters.priceMax, "USD")}`, + isAgent: false, + onRemove: removePrice, + }); + } + + // Over-filtered: suggest the single removal that surfaces the most results. + const overFiltered = gridItems.length === 0 && !showRecommendation; + const relaxCandidates: { + label: string; + count: number; + onRemove: () => void; + }[] = []; + if (overFiltered) { + if (query) { + relaxCandidates.push({ + label: `search “${search.trim()}”`, + count: CATALOG_ITEMS.filter((i) => matches(i, filters, "")).length, + onRemove: () => setSearch(""), + }); + } + if (filters.inStockOnly) { + relaxCandidates.push({ + label: "In stock", + count: visibleCount({ ...filters, inStockOnly: false }), + onRemove: removeInStock, + }); + } + for (const brand of filters.brands) { + relaxCandidates.push({ + label: brand, + count: visibleCount({ + ...filters, + brands: filters.brands.filter((b) => b !== brand), + }), + onRemove: () => removeBrand(brand), + }); + } + for (const category of filters.categories) { + relaxCandidates.push({ + label: category, + count: visibleCount({ + ...filters, + categories: filters.categories.filter((c) => c !== category), + }), + onRemove: () => removeCategory(category), + }); + } + if ( + filters.priceMin > CATALOG_PRICE_MIN || + filters.priceMax < CATALOG_PRICE_MAX + ) { + relaxCandidates.push({ + label: "price range", + count: visibleCount({ + ...filters, + priceMin: CATALOG_PRICE_MIN, + priceMax: CATALOG_PRICE_MAX, + }), + onRemove: removePrice, + }); + } + } + const relaxSuggestion = + relaxCandidates.toSorted((a, b) => b.count - a.count)[0] ?? null; + + const recommendationNote = recommendedItem + ? `Matches your request · ${formatPrice(eppSavings(recommendedItem), recommendedItem.currency)}/unit cheaper with EPP applied` + : ""; + + return ( + +
+ {/* Main column — catalog grid + detail overlay */} +
+
+ + + Guided buying + + + + + + +
+ = 2} + onCompare={openCompare} + /> + + + + + + {gridItems.length > 0 || showRecommendation ? ( + layout === "rows" ? ( +
+ {showRecommendation && recommendedItem && ( + + )} + {gridItems.map((item) => ( + + ))} +
+ ) : ( +
+ {showRecommendation && recommendedItem && ( + + )} + {gridItems.map((item) => ( + + ))} +
+ ) + ) : ( +
+

+ + {relaxSuggestion && relaxSuggestion.count > 0 + ? `Nothing matches — drop ${relaxSuggestion.label} to show ${relaxSuggestion.count}?` + : "Nothing matches the current filters."} +

+ {relaxSuggestion && relaxSuggestion.count > 0 ? ( + + ) : ( + + )} +
+ )} +
+
+ + {detailItem && ( + + addToCart(detailItem, quantity)} + onToggleCompare={() => toggleCompare(detailItem)} + onAskAgent={askAgent} + onClose={closeDetail} + /> + + )} + + {/* Sticky compare bar (selection from the row checkboxes) */} + {compareIds.length > 0 && !compareOpen && !detailItem && ( +
+ + {compareIds.length} selected + + + +
+ )} + + {compareOpen && compareItems.length > 0 && ( + !compareIds.includes(item.id), + )} + recommendation={ + recommendedItem + ? `For design work, the ${recommendedItem.name}'s OLED and 32GB make it the strongest fit.` + : "" + } + onClose={closeCompare} + onRemove={removeFromCompare} + onAdd={addToCompare} + onAddToCart={(item) => + addToCart( + item, + item.category === "Laptops" ? INFERRED_REQUEST_QUANTITY : 1, + ) + } + /> + )} +
+ + {/* Docked assistant rail (collapsible) */} + + + +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/StepIndicator.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/StepIndicator.tsx new file mode 100644 index 000000000..b2a5694f5 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/StepIndicator.tsx @@ -0,0 +1,67 @@ +import { Check } from "lucide-react"; +import { Fragment } from "react"; +import { cn } from "@/lib/utils"; + +// Exact vocabulary — must match the Guided Buying documentation. +const STEPS = ["Intake", "Selection", "Review", "Track"] as const; + +type Step = (typeof STEPS)[number]; + +interface StepIndicatorProps { + /** The currently active step. */ + current?: Step; +} + +/** Slim progress nav for the catalog path: Intake → Selection → Review → Track. */ +export function StepIndicator({ current = "Selection" }: StepIndicatorProps) { + const currentIndex = STEPS.indexOf(current); + + return ( + + ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Toolbar.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Toolbar.tsx new file mode 100644 index 000000000..707cffb9a --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Toolbar.tsx @@ -0,0 +1,138 @@ +import { Columns3, LayoutGrid, List, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { SORT_OPTIONS } from "./data"; +import { FiltersControl } from "./FiltersControl"; +import type { Filters, LayoutMode, SortKey } from "./types"; + +interface ToolbarProps { + search: string; + onSearchChange: (value: string) => void; + sort: SortKey; + onSortChange: (value: SortKey) => void; + layout: LayoutMode; + onLayoutChange: (value: LayoutMode) => void; + filters: Filters; + activeFilterCount: number; + onFiltersChange: (filters: Filters) => void; + onClearAllFilters: () => void; + /** Whether enough products are selected (≥2) to compare. */ + canCompare: boolean; + onCompare: () => void; +} + +/** Catalog toolbar: label, AI-assisted search, layout toggle, sort, and compare. */ +export function Toolbar({ + search, + onSearchChange, + sort, + onSortChange, + layout, + onLayoutChange, + filters, + activeFilterCount, + onFiltersChange, + onClearAllFilters, + canCompare, + onCompare, +}: ToolbarProps) { + return ( +
+ + Catalog + + +
+ + onSearchChange(event.target.value)} + placeholder="Search by model, brand, or spec…" + className="h-11 rounded-xl border-transparent bg-muted/60 pl-9" + aria-label="Search catalog" + /> +
+ +
+ + {/* TEMP layout toggle for comparing variants — removable before ship. */} + { + if (value) onLayoutChange(value as LayoutMode); + }} + > + + + + + + + + + + + + + + {!canCompare && ( + + Select 2 or more products to compare + + )} + + +
+
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/cart-context.ts b/apps/apollo-vertex/templates/guided-buying/catalog/v1/cart-context.ts new file mode 100644 index 000000000..4a8c65bb2 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/cart-context.ts @@ -0,0 +1,30 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { CatalogItem } from "./types"; + +export interface CartContextValue { + /** item id → quantity (0 = not in cart). */ + quantities: Record; + /** Items currently in the cart, in catalog order. */ + items: CatalogItem[]; + /** Total units across the cart. */ + count: number; + /** Whether the cart peek drawer is open. */ + open: boolean; + setOpen: (open: boolean) => void; + inCart: (id: string) => boolean; + toggle: (item: CatalogItem) => void; + setQuantity: (item: CatalogItem, quantity: number) => void; + remove: (item: CatalogItem) => void; +} + +export const CartContext = createContext(null); + +export function useCart(): CartContextValue { + const context = useContext(CartContext); + if (context == null) { + throw new Error("useCart must be used within a CartProvider"); + } + return context; +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/data.ts b/apps/apollo-vertex/templates/guided-buying/catalog/v1/data.ts new file mode 100644 index 000000000..13bf8964c --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/data.ts @@ -0,0 +1,506 @@ +// oxlint-disable max-lines -- mock catalog dataset (fixtures); grows with items +import type { + BuyRequest, + CatalogCategory, + CatalogItem, + PriceBasis, + SortKey, +} from "./types"; + +/** + * Stubbed Intake output. Replacing this (or passing a different `request` prop + * to ) is the seam where real intake wires in later. + */ +export const SAMPLE_REQUEST: BuyRequest = { + summary: "2 ThinkPad X1 laptops for our new designers.", + agentNote: "Found 12 catalog matches · applied EPP pricing · in-stock only.", +}; + +/** Quantity inferred from the request ("2 ThinkPad X1 laptops…"). */ +export const INFERRED_REQUEST_QUANTITY = 2; + +/** Mock self-service approval limit; orders under this need no approver. */ +export const APPROVAL_LIMIT = 10000; + +// Every mock item ships from the same tier/source as in the reference design. +const TIER = "Standard"; +const SOURCE = "CDW Netherlands"; + +/** + * Curated, verified product imagery (Unsplash CDN, category-appropriate). + * falls back to a category icon if a URL ever fails. Swap to + * local /public assets here for exact-SKU shots. + */ +function unsplashImage(id: string): string { + return `https://images.unsplash.com/photo-${id}?w=600&h=450&fit=crop&q=80`; +} + +/** Mock catalog — laptops plus the accessories a new-hire setup needs. */ +export const CATALOG_ITEMS: CatalogItem[] = [ + { + id: "lnv-x1c-g12", + name: "Lenovo ThinkPad X1 Carbon", + vendor: "Lenovo", + category: "Laptops", + tier: TIER, + source: SOURCE, + listPrice: 2199, + eppPrice: 1849, + currency: "USD", + specs: ["Carbon Gen 12", '14" 2.8K OLED', "32GB · 1TB"], + inStock: true, + image: unsplashImage("1763162410742-1d0097cea556"), + specGroups: [ + { + label: "Display", + rows: [ + { label: "Size", value: '14" 2.8K (2880×1800)' }, + { label: "Panel", value: "OLED · 400 nits · anti-glare" }, + ], + }, + { + label: "Performance", + rows: [ + { label: "Processor", value: "Intel Core Ultra 7 165U" }, + { label: "Graphics", value: "Intel Arc integrated" }, + ], + }, + { + label: "Memory & storage", + rows: [ + { label: "Memory", value: "32GB LPDDR5x" }, + { label: "Storage", value: "1TB PCIe Gen4 SSD" }, + ], + }, + { + label: "Connectivity", + rows: [ + { label: "Ports", value: "2× Thunderbolt 4 · 2× USB-A · HDMI 2.1" }, + { label: "Wireless", value: "Wi-Fi 6E · Bluetooth 5.3" }, + ], + }, + { + label: "Physical", + rows: [ + { label: "Weight", value: "1.12 kg" }, + { label: "Battery", value: "57Wh · up to 15h" }, + ], + }, + { + label: "Warranty/support", + rows: [{ label: "Coverage", value: "3-year onsite · Premier Support" }], + }, + ], + }, + { + id: "lnv-x1-yoga-g9", + name: "Lenovo ThinkPad X1 Yoga", + vendor: "Lenovo", + category: "Laptops", + tier: TIER, + source: SOURCE, + listPrice: 2399, + eppPrice: 1999, + currency: "USD", + specs: ["Gen 9", '14" 2.8K touch', "16GB · 1TB"], + inStock: true, + image: unsplashImage("1673431738089-c4fc9c2e96a7"), + specGroups: [ + { + label: "Display", + rows: [ + { label: "Size", value: '14" 2.8K (2880×1800)' }, + { label: "Panel", value: "OLED touch · pen support" }, + ], + }, + { + label: "Performance", + rows: [{ label: "Processor", value: "Intel Core Ultra 7 165U" }], + }, + { + label: "Memory & storage", + rows: [ + { label: "Memory", value: "16GB LPDDR5x" }, + { label: "Storage", value: "1TB PCIe Gen4 SSD" }, + ], + }, + { + label: "Connectivity", + rows: [ + { label: "Ports", value: "2× Thunderbolt 4 · 2× USB-A · HDMI" }, + { label: "Wireless", value: "Wi-Fi 6E · Bluetooth 5.3" }, + ], + }, + { + label: "Physical", + rows: [ + { label: "Weight", value: "1.38 kg" }, + { label: "Form factor", value: "360° convertible" }, + ], + }, + { + label: "Warranty/support", + rows: [{ label: "Coverage", value: "3-year onsite · Premier Support" }], + }, + ], + }, + { + id: "dell-xps-14", + name: "Dell XPS 14", + vendor: "Dell", + category: "Laptops", + tier: TIER, + source: SOURCE, + listPrice: 2099, + eppPrice: 1799, + currency: "USD", + specs: ["9440", '14.5" 3.2K', "32GB · 1TB"], + inStock: true, + image: unsplashImage("1611186871348-b1ce696e52c9"), + specGroups: [ + { + label: "Display", + rows: [ + { label: "Size", value: '14.5" 3.2K (3200×2000)' }, + { label: "Panel", value: "OLED touch · 120Hz" }, + ], + }, + { + label: "Performance", + rows: [ + { label: "Processor", value: "Intel Core Ultra 7 155H" }, + { label: "Graphics", value: "Intel Arc integrated" }, + ], + }, + { + label: "Memory & storage", + rows: [ + { label: "Memory", value: "32GB LPDDR5x" }, + { label: "Storage", value: "1TB PCIe Gen4 SSD" }, + ], + }, + { + label: "Connectivity", + rows: [ + { label: "Ports", value: "3× Thunderbolt 4" }, + { label: "Wireless", value: "Wi-Fi 6E · Bluetooth 5.4" }, + ], + }, + { + label: "Physical", + rows: [ + { label: "Weight", value: "1.69 kg" }, + { label: "Battery", value: "70Wh · up to 13h" }, + ], + }, + { + label: "Warranty/support", + rows: [{ label: "Coverage", value: "3-year ProSupport" }], + }, + ], + }, + { + id: "apple-mbp-14", + name: "Apple MacBook Pro", + vendor: "Apple", + category: "Laptops", + tier: TIER, + source: SOURCE, + listPrice: 2199, + eppPrice: 2035, + currency: "USD", + specs: ['14" M4 Pro', "24GB", "1TB SSD"], + inStock: true, + image: unsplashImage("1517336714731-489689fd1ca8"), + specGroups: [ + { + label: "Display", + rows: [ + { label: "Size", value: '14.2" Liquid Retina XDR' }, + { label: "Panel", value: "3024×1964 · 120Hz ProMotion" }, + ], + }, + { + label: "Performance", + rows: [ + { label: "Chip", value: "Apple M4 Pro (12-core CPU)" }, + { label: "GPU", value: "16-core" }, + ], + }, + { + label: "Memory & storage", + rows: [ + { label: "Memory", value: "24GB unified" }, + { label: "Storage", value: "1TB SSD" }, + ], + }, + { + label: "Connectivity", + rows: [ + { label: "Ports", value: "3× Thunderbolt 5 · HDMI · SDXC · MagSafe" }, + { label: "Wireless", value: "Wi-Fi 6E · Bluetooth 5.3" }, + ], + }, + { + label: "Physical", + rows: [ + { label: "Weight", value: "1.55 kg" }, + { label: "Battery", value: "Up to 22h video" }, + ], + }, + { + label: "Warranty/support", + rows: [ + { label: "Coverage", value: "AppleCare+ for Business · 3-year" }, + ], + }, + ], + }, + { + id: "apple-mbp-16", + name: "Apple MacBook Pro", + vendor: "Apple", + category: "Laptops", + tier: TIER, + source: SOURCE, + listPrice: 2835, + currency: "USD", + specs: ['16" M4 Pro', "48GB", "1TB SSD"], + inStock: true, + image: unsplashImage("1541807084-5c52b6b3adef"), + specGroups: [ + { + label: "Display", + rows: [ + { label: "Size", value: '16.2" Liquid Retina XDR' }, + { label: "Panel", value: "3456×2234 · 120Hz ProMotion" }, + ], + }, + { + label: "Performance", + rows: [ + { label: "Chip", value: "Apple M4 Pro (14-core CPU)" }, + { label: "GPU", value: "20-core" }, + ], + }, + { + label: "Memory & storage", + rows: [ + { label: "Memory", value: "48GB unified" }, + { label: "Storage", value: "1TB SSD" }, + ], + }, + { + label: "Connectivity", + rows: [ + { label: "Ports", value: "3× Thunderbolt 5 · HDMI · SDXC · MagSafe" }, + { label: "Wireless", value: "Wi-Fi 6E · Bluetooth 5.3" }, + ], + }, + { + label: "Physical", + rows: [ + { label: "Weight", value: "2.14 kg" }, + { label: "Battery", value: "Up to 24h video" }, + ], + }, + { + label: "Warranty/support", + rows: [ + { label: "Coverage", value: "AppleCare+ for Business · 3-year" }, + ], + }, + ], + }, + { + id: "dell-u2724de", + name: "Dell UltraSharp 27 4K", + vendor: "Dell", + category: "Monitors", + tier: TIER, + source: SOURCE, + listPrice: 689, + eppPrice: 579, + currency: "USD", + specs: ['27" 4K IPS', "120Hz", "USB-C hub"], + inStock: true, + image: unsplashImage("1484788984921-03950022c9ef"), + }, + { + id: "lg-27uq850", + name: "LG 27UQ850 4K UHD", + vendor: "LG", + category: "Monitors", + tier: TIER, + source: SOURCE, + listPrice: 649, + currency: "USD", + specs: ['27" Nano IPS', "USB-C 90W", "Height adj."], + inStock: true, + image: unsplashImage("1527443224154-c4a3942d3acf"), + }, + { + id: "lnv-tb4-dock", + name: "ThinkPad Thunderbolt 4 Dock", + vendor: "Lenovo", + category: "Docking", + tier: TIER, + source: SOURCE, + listPrice: 299, + eppPrice: 249, + currency: "USD", + specs: ["Dual 4K", "100W", "Wired"], + inStock: true, + image: unsplashImage("1527800792452-506aacb2101f"), + }, + { + id: "caldigit-ts4", + name: "CalDigit TS4 Dock", + vendor: "CalDigit", + category: "Docking", + tier: TIER, + source: SOURCE, + listPrice: 399, + currency: "USD", + specs: ["18 ports", "98W", "2.5GbE"], + inStock: true, + image: unsplashImage("1567521463850-4939134bcd4a"), + }, + { + id: "logi-mx-master-3s", + name: "Logitech MX Master 3S", + vendor: "Logitech", + category: "Accessories", + tier: TIER, + source: SOURCE, + listPrice: 99, + eppPrice: 79, + currency: "USD", + specs: ["8K DPI", "Quiet", "Multi-device"], + inStock: true, + image: unsplashImage("1527864550417-7fd91fc51a46"), + }, + { + id: "logi-mx-keys-s", + name: "Logitech MX Keys S", + vendor: "Logitech", + category: "Accessories", + tier: TIER, + source: SOURCE, + listPrice: 109, + eppPrice: 89, + currency: "USD", + specs: ["Backlit", "Multi-device", "USB-C"], + inStock: true, + image: unsplashImage("1618384887929-16ec33fab9ef"), + }, + { + id: "jabra-evolve2-65", + name: "Jabra Evolve2 65", + vendor: "Jabra", + category: "Accessories", + tier: TIER, + source: SOURCE, + listPrice: 269, + eppPrice: 219, + currency: "USD", + specs: ["ANC", "Wireless", "37h"], + inStock: true, + image: unsplashImage("1505740420928-5e560c06d30e"), + }, +]; + +/** The agent's top recommendation — surfaced in the gradient promo card. */ +export const RECOMMENDATION = { + itemId: "lnv-x1c-g12", + alternatives: 2, +} as const; + +/** Distinct brands (vendors), for the Brand filter facet. */ +export const CATALOG_BRANDS: string[] = Array.from( + new Set(CATALOG_ITEMS.map((item) => item.vendor)), +).toSorted(); + +/** Distinct categories present in the catalog, for the Category facet. */ +export const CATALOG_CATEGORIES: CatalogCategory[] = Array.from( + new Set(CATALOG_ITEMS.map((item) => item.category)), +); + +/** Lowest / highest list price, spanning the price-range facet. */ +export const CATALOG_PRICE_MIN: number = Math.min( + ...CATALOG_ITEMS.map((item) => item.listPrice), +); +export const CATALOG_PRICE_MAX: number = Math.max( + ...CATALOG_ITEMS.map((item) => item.listPrice), +); + +/** Sort options for the toolbar dropdown. */ +export const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: "recommended", label: "Recommended" }, + { value: "price-asc", label: "Price: Low to High" }, + { value: "price-desc", label: "Price: High to Low" }, + { value: "name", label: "Name" }, +]; + +// Vendors with a Simple Icons brand logo (verified). Others fall back to a +// brand wordmark in the logo variant. +const VENDOR_LOGO_SLUG: Record = { + Apple: "apple", + Lenovo: "lenovo", + Dell: "dell", + LG: "lg", +}; + +/** Brand logo URL for a vendor, or "" if none is available. */ +export function vendorLogoUrl(vendor: string): string { + const slug = VENDOR_LOGO_SLUG[vendor]; + return slug ? `https://cdn.simpleicons.org/${slug}` : ""; +} + +/** The price a buyer actually pays — EPP when present, otherwise list. */ +export function effectivePrice(item: CatalogItem): number { + return item.eppPrice ?? item.listPrice; +} + +/** EPP savings per unit, or 0 when there's no special pricing. */ +export function eppSavings(item: CatalogItem): number { + return item.eppPrice ? item.listPrice - item.eppPrice : 0; +} + +/** The active price given the price basis (EPP when applied, else list). */ +export function activePrice(item: CatalogItem, basis: PriceBasis): number { + return basis === "epp" ? effectivePrice(item) : item.listPrice; +} + +/** Active per-unit savings — only meaningful under EPP pricing. */ +export function activeSavings(item: CatalogItem, basis: PriceBasis): number { + return basis === "epp" ? eppSavings(item) : 0; +} + +/** Whether to show the struck-through list price (only under EPP). */ +export function showsListStrike(item: CatalogItem, basis: PriceBasis): boolean { + return basis === "epp" && Boolean(item.eppPrice); +} + +/** The agreement / price basis with a recency note, for the source panel. */ +export function priceBasis(item: CatalogItem): string { + return item.eppPrice + ? "EPP pricing · validated today" + : "List pricing · standard catalog rate"; +} + +/** Stocking / lead-time line for the source panel. */ +export function leadTime(item: CatalogItem): string { + return item.inStock + ? "Ships in 2–3 business days" + : "Backordered · 3–4 weeks"; +} + +/** Format a price for display, e.g. $1,849. */ +export function formatPrice(amount: number, currency: string): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/price-basis-context.ts b/apps/apollo-vertex/templates/guided-buying/catalog/v1/price-basis-context.ts new file mode 100644 index 000000000..15cf5d8bc --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/price-basis-context.ts @@ -0,0 +1,17 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { PriceBasis } from "./types"; + +/** + * The active price basis for the whole catalog surface. Components read this to + * decide which price to show, so removing the EPP filter reverts prices + * everywhere (grid, detail, cart, compare) without prop-drilling. + */ +const PriceBasisContext = createContext("epp"); + +export const PriceBasisProvider = PriceBasisContext.Provider; + +export function usePriceBasis(): PriceBasis { + return useContext(PriceBasisContext); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/types.ts b/apps/apollo-vertex/templates/guided-buying/catalog/v1/types.ts new file mode 100644 index 000000000..5baba0634 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/types.ts @@ -0,0 +1,79 @@ +/** Product category — drives the placeholder icon and keyword imagery. */ +export type CatalogCategory = + | "Laptops" + | "Monitors" + | "Docking" + | "Accessories"; + +/** A single catalog good available for selection. */ +export interface CatalogItem { + id: string; + name: string; + vendor: string; + category: CatalogCategory; + /** Procurement tier shown as a chip, e.g. "Standard". */ + tier: string; + /** Fulfillment source / reseller shown as a chip, e.g. "CDW Netherlands". */ + source: string; + /** Standard list price. */ + listPrice: number; + /** Negotiated / EPP price when applicable. Undefined = no special pricing. */ + eppPrice?: number; + currency: string; + /** 1–3 key specs surfaced on the tile. */ + specs: string[]; + inStock: boolean; + /** Remote product image URL; falls back to a category icon on error. */ + image?: string; + /** Full, grouped spec breakdown for the detail view (flat specs used if absent). */ + specGroups?: SpecGroup[]; +} + +/** One labelled value within a spec group. */ +export interface SpecRow { + label: string; + value: string; +} + +/** A named group of spec rows (e.g. Display, Performance). */ +export interface SpecGroup { + label: string; + rows: SpecRow[]; +} + +/** + * The restated request that seeds the Selection screen. In production this is + * produced by the Intake step; here it is stubbed (see SAMPLE_REQUEST) and + * passed into via the `request` prop — the seam for real intake. + */ +export interface BuyRequest { + /** Restated request. */ + summary: string; + /** One quiet, auditable agent line. */ + agentNote: string; +} + +/** How the catalog grid is ordered. */ +export type SortKey = "recommended" | "price-asc" | "price-desc" | "name"; + +/** How results are laid out: scannable rows or product cards. */ +export type LayoutMode = "rows" | "cards"; + +/** Which price is "active": negotiated EPP, or standard list. */ +export type PriceBasis = "epp" | "list"; + +/** A one-line note appended to the Autopilot rail thread. */ +export interface RailNote { + id: number; + text: string; +} + +/** Active catalog constraints. Stock + priceBasis are the agent's inferences. */ +export interface Filters { + brands: string[]; + categories: CatalogCategory[]; + priceMin: number; + priceMax: number; + inStockOnly: boolean; + priceBasis: PriceBasis; +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/useRail.ts b/apps/apollo-vertex/templates/guided-buying/catalog/v1/useRail.ts new file mode 100644 index 000000000..112a866e7 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/useRail.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "gb-rail-open"; + +export interface RailController { + /** The user's manual open preference (persisted for the session). */ + open: boolean; + /** Manual collapse (chevron). */ + collapse: () => void; + /** Manual expand (launcher). */ + expand: () => void; +} + +/** + * Tracks the user's manual open/collapse preference for the docked assistant + * rail, persisted for the session. Surfaces that need the right edge (cart, + * compare) collapse the rail by deriving visibility from their own open state — + * see `railVisible` in Selection — which restores the user's choice on close. + */ +export function useRail(): RailController { + const [pref, setPref] = useState(() => { + if (typeof window === "undefined") return true; + return sessionStorage.getItem(STORAGE_KEY) !== "0"; + }); + + useEffect(() => { + sessionStorage.setItem(STORAGE_KEY, pref ? "1" : "0"); + }, [pref]); + + return { + open: pref, + collapse: () => setPref(false), + expand: () => setPref(true), + }; +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/variants.ts b/apps/apollo-vertex/templates/guided-buying/catalog/variants.ts new file mode 100644 index 000000000..5aff94d92 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/variants.ts @@ -0,0 +1,22 @@ +import type { ComponentType } from "react"; +import { CatalogV1 } from "./CatalogV1"; +import { CatalogV2 } from "./CatalogV2"; + +export interface CatalogVariant { + /** Stable id used by the switcher and as the React key. */ + id: string; + /** Human-readable label shown in the variant switcher. */ + label: string; + /** The variant's root component. Rendered full-bleed inside the Catalog page. */ + Component: ComponentType; +} + +/** + * Registry of Catalog variants. Add a new variant by creating a component file + * (e.g. CatalogV2.tsx) and appending one entry here — the in-app switcher picks + * it up automatically. + */ +export const CATALOG_VARIANTS: CatalogVariant[] = [ + { id: "v1", label: "Product photos", Component: CatalogV1 }, + { id: "v2", label: "Brand logos", Component: CatalogV2 }, +]; From 50539dabe548d1096e00b389da45b13e884d52c0 Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Mon, 8 Jun 2026 16:28:06 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat(apollo-vertex):=20Guided=20Buying=20In?= =?UTF-8?q?take=E2=86=92Bridge=E2=86=92Selection=20+=20Workbench?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build out the Guided Buying prototype's front-door flow and the buyer's escalation queue, all scripted/mocked atop the Apollo AI Chat pattern. Buy conversation (catalog/v1): - Intake hero → in-chat Bridge: streamed restatement, then an inferred request envelope (cost center / ship-to / need-by / approver with provenance + edit affordances) and the routing consequence, revealed with a staggered field animation. - Continue to selection → sourcing summary + a results carousel rendered inline in the thread (pick + 2 alternatives, skeleton→reveal), with the pick mirroring the catalog's Picked-for-you treatment. - Add to cart confirms in-chat with amount + approval-limit status and a Review affordance; cart pill pulses on increment. - ConversationProvider drives the shared thread across the Buy hero and the docked rail; catalog cards are fully clickable for details. Workbench (workbench/): the off-catalog fork's escalation queue, adapted from the Invoice Processing layout — list with stat cards + Quote (amber) / Contract (red) chips, and a three-region detail (queue · finding + Approve/Counter/Reject + Autopilot composer · Activity/Details/Line items/Source). Seeded with the standing-desks quote and mobile-lines contract. Actions stubbed. Shared Ai Chat tweaks: hover-only message actions, tighter empty-state composer, stable scrollbar gutter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/ai-chat-input.tsx | 2 +- .../components/ai-chat-message-actions.tsx | 6 +- .../registry/ai-chat/components/ai-chat.tsx | 4 +- .../guided-buying/GuidedBuyingShell.tsx | 44 +- .../guided-buying/catalog/Catalog.tsx | 23 +- .../guided-buying/catalog/v1/BuyFlow.tsx | 98 +++ .../guided-buying/catalog/v1/CartLine.tsx | 47 +- .../guided-buying/catalog/v1/CartSummary.tsx | 13 +- .../guided-buying/catalog/v1/ChatRail.tsx | 122 ++- .../catalog/v1/ConversationProvider.tsx | 268 ++++++ .../catalog/v1/FiltersControl.tsx | 40 +- .../catalog/v1/MatchCarousel.tsx | 261 ++++++ .../guided-buying/catalog/v1/ProductCard.tsx | 28 +- .../catalog/v1/ProductDetail.tsx | 13 +- .../guided-buying/catalog/v1/RailDock.tsx | 15 +- .../catalog/v1/RecommendationCard.tsx | 3 + .../catalog/v1/RequestEnvelope.tsx | 176 ++++ .../guided-buying/catalog/v1/Review.tsx | 28 +- .../guided-buying/catalog/v1/ScanRow.tsx | 33 +- .../guided-buying/catalog/v1/Selection.tsx | 79 +- .../guided-buying/catalog/v1/Toolbar.tsx | 4 - .../catalog/v1/conversation-context.ts | 41 + .../guided-buying/catalog/v1/data.ts | 8 + .../guided-buying/workbench/Workbench.tsx | 26 + .../workbench/WorkbenchDetail.tsx | 795 ++++++++++++++++++ .../guided-buying/workbench/WorkbenchList.tsx | 222 +++++ .../templates/guided-buying/workbench/data.ts | 368 ++++++++ 27 files changed, 2565 insertions(+), 202 deletions(-) create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/BuyFlow.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/ConversationProvider.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/MatchCarousel.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/RequestEnvelope.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/catalog/v1/conversation-context.ts create mode 100644 apps/apollo-vertex/templates/guided-buying/workbench/Workbench.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/workbench/WorkbenchDetail.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/workbench/WorkbenchList.tsx create mode 100644 apps/apollo-vertex/templates/guided-buying/workbench/data.ts diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx index e35fd1bbd..ce66218d5 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx @@ -309,7 +309,7 @@ export function AiChatInput({ onPaste={handlePaste} placeholder={displayPlaceholder} aria-label={displayPlaceholder} - className="w-full resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none h-[80px] px-[22px] pt-5 pb-2 leading-relaxed" + className="w-full resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none h-[52px] px-[22px] pt-4 pb-2 leading-relaxed" rows={1} disabled={disabled} /> diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx index b5b1ccf5a..f74e48ac9 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx @@ -115,9 +115,9 @@ export function AiChatMessageActions({
{content.trim() && } diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx index 412f8812e..dc8c527e4 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx @@ -248,7 +248,7 @@ export function AiChat< {messages.length === 0 ? (
-
+
{emptyState ?? defaultEmptyState}
@@ -277,7 +277,7 @@ export function AiChat< aria-live="polite" aria-atomic="false" aria-busy={isInFlight} - className="relative h-full overflow-y-auto py-4 pl-10 pr-10" + className="relative h-full overflow-y-auto py-4 pl-10 pr-10 [scrollbar-gutter:stable]" > {enableTextSelection && ( -
- + +
+

Request submitted

-

- Tracking is coming soon. -

+ {/* Slim Autopilot presence — the agent stays through confirmation. */} +
+
-
+ ); } @@ -88,7 +104,7 @@ const dashboardRoute = createRoute({ const buyRoute = createRoute({ getParentRoute: () => rootRoute, path: "/buy", - component: () => , + component: BuyFlow, }); const catalogRoute = createRoute({ @@ -100,7 +116,7 @@ const catalogRoute = createRoute({ const workbenchRoute = createRoute({ getParentRoute: () => rootRoute, path: "/workbench", - component: () => , + component: Workbench, }); // Review & submit (reached from the cart's "Review & submit"). @@ -133,16 +149,18 @@ export function GuidedBuyingShell() { const [router] = useState(() => createRouter({ routeTree, - // TODO: default to /dashboard once more sections are built; for now the - // prototype lands on /catalog (the Selection screen) — the active work. - history: createMemoryHistory({ initialEntries: ["/catalog"] }), + // The prototype lands on /buy — the Intake front door. The /catalog link + // jumps straight to the resolved workspace (Catalog seeds the thread). + history: createMemoryHistory({ initialEntries: ["/buy"] }), }), ); return ( - + + + ); diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx index c203ae212..4322ffe4a 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/Catalog.tsx @@ -1,11 +1,32 @@ +"use client"; + +import { useEffect } from "react"; +import { useConversation } from "./v1/conversation-context"; import { CATALOG_VARIANTS } from "./variants"; +interface CatalogProps { + /** + * Seed the resolved thread on mount. True for direct /catalog entry (the rail + * should already have context); false when hosted behind the Buy Intake hero, + * which drives the resolve itself. + */ + seedOnMount?: boolean; +} + /** * Catalog page host. Renders the default variant. The variant registry (and the * in-app switcher) is kept for future use — the switcher is just not shown now. */ -export function Catalog() { +export function Catalog({ seedOnMount = true }: CatalogProps) { + const { hasResolved, resolveDefault } = useConversation(); const ActiveVariant = CATALOG_VARIANTS[0].Component; + + // Seed the resolved thread for direct entry; the guard makes it run once + // (resolveDefault flips hasResolved, so re-runs are no-ops). + useEffect(() => { + if (seedOnMount && !hasResolved) resolveDefault(); + }, [seedOnMount, hasResolved, resolveDefault]); + return (
diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/BuyFlow.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/BuyFlow.tsx new file mode 100644 index 000000000..6e9d4ee0d --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/BuyFlow.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { AiChat } from "@/registry/ai-chat/components/ai-chat"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { AutopilotGradientIcon } from "@/registry/ai-chat/components/icons/autopilot-gradient"; +import { useConversation } from "./conversation-context"; +import { MatchCarousel, ReviewCta } from "./MatchCarousel"; +import { RequestEnvelope } from "./RequestEnvelope"; + +// The catalog (demo) path; the other two preview the quote/contract forks. +const CATALOG_STARTER = "2 ThinkPad X1 laptops for our new designers."; +const STARTERS = [ + CATALOG_STARTER, + "5 standing desks for the Berlin office.", + "Add 12 mobile lines for the Denver team.", +]; + +/** + * The `/buy` front door. A centered chat column within the Buy section (nav + * stays visible — no full-screen takeover). Autopilot delivers the answer in + * the thread: a streamed restatement, then a carousel of matches. The chat + * starts fresh each time the user lands here. + */ +export function BuyFlow() { + const { + messages, + status, + sendCatalogRequest, + sendOffCatalog, + stop, + startFresh, + } = useConversation(); + + // Reset to the Intake empty state whenever the user (re)enters Buy. + const didReset = useRef(false); + useEffect(() => { + if (didReset.current) return; + didReset.current = true; + startFresh(); + }, [startFresh]); + + const handleSuggestion = (suggestion: string) => { + if (suggestion === CATALOG_STARTER) sendCatalogRequest(suggestion); + else sendOffCatalog(suggestion); + }; + + return ( +
+ + part.name === "presentEnvelope" ? ( + + ) : part.name === "presentMatches" ? ( + + ) : part.name === "reviewCta" ? ( + + ) : null + } + suggestions={STARTERS} + onSuggestionClick={handleSuggestion} + placeholder="Describe what you need…" + // Header is absent on the empty hero; it appears once it's a conversation. + header={ + messages.length > 0 ? ( +
+
+ ) : null + } + emptyState={ +
+ + + +

+ What do you need? +

+

+ Tell Autopilot what to buy — it sources, prices, and routes the + request. +

+
+ } + /> +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx index 7d598ddc9..7e29d5c00 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartLine.tsx @@ -24,6 +24,8 @@ export function CartLine({ onQtyChange, onRemove, }: CartLineProps) { + const unit = activePrice(item, basis); + const lineTotal = unit * quantity; return (
@@ -37,29 +39,34 @@ export function CartLine({
{readOnly ? ( - Qty {quantity} + {formatPrice(unit, item.currency)} × {quantity} ={" "} + + {formatPrice(lineTotal, item.currency)} + ) : ( - onQtyChange?.(value)} - /> + <> + onQtyChange?.(value)} + /> +
+ + {formatPrice(lineTotal, item.currency)} + + {onRemove && ( + + )} +
+ )} -
- - {formatPrice(activePrice(item, basis) * quantity, item.currency)} - - {!readOnly && onRemove && ( - - )} -
diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx index ecfde258b..766370a29 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/CartSummary.tsx @@ -5,10 +5,17 @@ interface CartSummaryProps { items: CatalogItem[]; quantities: Record; basis: PriceBasis; + /** Bottom-line label — "Subtotal" in the cart, "Total" on Review. */ + totalLabel?: string; } -/** Shared cart totals — subtotal, EPP savings, item count. */ -export function CartSummary({ items, quantities, basis }: CartSummaryProps) { +/** Shared cart totals — subtotal/total, EPP savings, item count. */ +export function CartSummary({ + items, + quantities, + basis, + totalLabel = "Subtotal", +}: CartSummaryProps) { const count = items.reduce((sum, i) => sum + (quantities[i.id] ?? 0), 0); const subtotal = items.reduce( (sum, i) => sum + activePrice(i, basis) * (quantities[i.id] ?? 0), @@ -34,7 +41,7 @@ export function CartSummary({ items, quantities, basis }: CartSummaryProps) {
)}
- Subtotal + {totalLabel} {formatPrice(subtotal, "USD")}
diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx index 0d887d1e2..c9e11eeae 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ChatRail.tsx @@ -1,87 +1,61 @@ -import { PanelRightClose, Send } from "lucide-react"; -import type { Ref } from "react"; +import { PanelRightClose } from "lucide-react"; +import { AiChat } from "@/registry/ai-chat/components/ai-chat"; import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import type { BuyRequest, RailNote } from "./types"; +import { useConversation } from "./conversation-context"; +import { MatchCarousel, ReviewCta } from "./MatchCarousel"; +import { RequestEnvelope } from "./RequestEnvelope"; interface ChatRailProps { - request: BuyRequest; - /** Agent notes appended as filters change. */ - notes: RailNote[]; - /** Lets the workspace focus the input (the "ask the agent" hook). */ - inputRef?: Ref; onCollapse: () => void; } /** - * Docked assistant rail. Visual placeholder for the prototype — real chat - * behavior is out of scope; the input is exposed so "Ask the agent" can focus it. + * Docked assistant rail — the same and the same conversation as + * Intake/Bridge, so typing a fresh request here just continues the thread. */ -export function ChatRail({ - request, - notes, - inputRef, - onCollapse, -}: ChatRailProps) { - return ( -
-
-
- - - - Autopilot -
- -
- -
-

Here’s what I’m on:

-
- {request.summary} -
-

{request.agentNote}

- {notes.map((note) => ( -

- - {note.text} -

- ))} -
+export function ChatRail({ onCollapse }: ChatRailProps) { + const { messages, status, sendCatalogRequest, stop } = useConversation(); -
- {/* TODO(agent): wire real chat. Stubbed input for now. */} -
- - -
-
+ return ( +
+ sendCatalogRequest(content)} + onStop={stop} + renderToolPart={(part) => + part.name === "presentEnvelope" ? ( + + ) : part.name === "presentMatches" ? ( + + ) : part.name === "reviewCta" ? ( + + ) : null + } + placeholder="Ask about catalog items…" + header={ +
+
+ + + + Autopilot +
+ +
+ } + />
); } diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ConversationProvider.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ConversationProvider.tsx new file mode 100644 index 000000000..90c47e72b --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ConversationProvider.tsx @@ -0,0 +1,268 @@ +"use client"; + +import type { + ChatClientState, + MessagePart, + UIMessage, +} from "@tanstack/ai-client"; +import { type ReactNode, useRef, useState } from "react"; +import { + ConversationContext, + type ConversationContextValue, +} from "./conversation-context"; +import { + APPROVAL_LIMIT, + CATALOG_ITEMS, + effectivePrice, + formatPrice, + RECOMMENDATION, + SAMPLE_REQUEST, +} from "./data"; +import type { CatalogItem } from "./types"; + +// The Bridge intro — streams in word-by-word above the inferred envelope. +const INTRO = + "I'll take it from here. Here's what I inferred from your team's past requests and your profile — edit anything that's off."; + +// Sourcing summary — the products layer, shown when the user continues to +// selection (kept separate from the request envelope). +const SOURCING_SUMMARY = + "Found 12 catalog matches · EPP pricing · in-stock only"; + +// Direct /catalog entry seeds this resting summary in the rail (no Bridge). +const RESOLVED_SUMMARY = `Here's what I'm on: ${SAMPLE_REQUEST.summary}\n\n${SAMPLE_REQUEST.agentNote}`; + +// Tool-call part names rendered inline in the thread. +const ENVELOPE_TOOL = "presentEnvelope"; +const MATCHES_TOOL = "presentMatches"; +const REVIEW_TOOL = "reviewCta"; + +// Two laptop alternatives behind the pick (the "2 alternatives" move). +const ALT_IDS = CATALOG_ITEMS.filter( + (item) => item.category === "Laptops" && item.id !== RECOMMENDATION.itemId, +) + .slice(0, RECOMMENDATION.alternatives) + .map((item) => item.id); + +const START_MS = 350; +const WORD_MS = 42; +const SKELETON_MS = 900; + +function textMessage( + id: string, + role: "user" | "assistant", + content: string, +): UIMessage { + return { id, role, parts: [{ type: "text", content }] }; +} + +// A mocked tool-call part — the gate is `output != null`, so renderToolPart +// runs and renders our rich content inline in the message bubble. +function toolPart(id: string, name: string, output: unknown): MessagePart { + return { + type: "tool-call", + id, + name, + arguments: "{}", + state: "input-complete", + output, + }; +} + +/** + * Scripted conversation shared across Intake (Buy hero), the in-chat Bridge, and + * the docked rail — one thread in three framings. Mocked: no live LLM/AgentHub. + */ +export function ConversationProvider({ children }: { children: ReactNode }) { + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState("ready"); + const [hasResolved, setHasResolved] = useState(false); + + const idRef = useRef(0); + const timers = useRef[]>([]); + const cartConfirmedRef = useRef(false); + const selectionStartedRef = useRef(false); + + const nextId = () => { + idRef.current += 1; + return `m${idRef.current}`; + }; + const clearTimers = () => { + for (const t of timers.current) clearTimeout(t); + timers.current = []; + }; + + const setAssistantParts = (id: string, parts: MessagePart[]) => { + setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, parts } : m))); + }; + + const matchesPart = (assistantId: string, loading: boolean): MessagePart => + toolPart(`${assistantId}-matches`, MATCHES_TOOL, { + leadId: RECOMMENDATION.itemId, + altIds: ALT_IDS, + totalCount: CATALOG_ITEMS.length, + loading, + }); + + const sendCatalogRequest = (text: string) => { + clearTimers(); + selectionStartedRef.current = false; + const userMsg = textMessage(nextId(), "user", text); + const assistantId = nextId(); + setMessages((prev) => [ + ...prev, + userMsg, + textMessage(assistantId, "assistant", ""), + ]); + setStatus("submitted"); + + // Stream the intro line word-by-word for a seamless typing feel. + const words = INTRO.split(" "); + words.forEach((_, i) => { + timers.current.push( + setTimeout( + () => { + setStatus("streaming"); + setAssistantParts(assistantId, [ + { type: "text", content: words.slice(0, i + 1).join(" ") }, + ]); + }, + START_MS + i * WORD_MS, + ), + ); + }); + + // Intro settles, then the inferred request envelope lands in the same turn + // (the envelope self-animates its own staggered field reveal). + const streamEnd = START_MS + words.length * WORD_MS; + timers.current.push( + setTimeout(() => { + setAssistantParts(assistantId, [ + { type: "text", content: INTRO }, + toolPart(`${assistantId}-envelope`, ENVELOPE_TOOL, { + kind: "envelope", + }), + ]); + setStatus("ready"); + setHasResolved(true); + }, streamEnd + 150), + ); + }; + + const continueToSelection = () => { + if (selectionStartedRef.current) return; + selectionStartedRef.current = true; + clearTimers(); + const assistantId = nextId(); + setMessages((prev) => [ + ...prev, + { + id: assistantId, + role: "assistant", + parts: [ + { type: "text", content: SOURCING_SUMMARY }, + matchesPart(assistantId, true), + ], + }, + ]); + // Brief skeleton, then the real cards reveal. + timers.current.push( + setTimeout(() => { + setAssistantParts(assistantId, [ + { type: "text", content: SOURCING_SUMMARY }, + matchesPart(assistantId, false), + ]); + }, SKELETON_MS), + ); + }; + + const resolveDefault = () => { + clearTimers(); + setMessages([ + textMessage(nextId(), "user", SAMPLE_REQUEST.summary), + textMessage(nextId(), "assistant", RESOLVED_SUMMARY), + ]); + setStatus("ready"); + setHasResolved(true); + }; + + const sendOffCatalog = (text: string) => { + clearTimers(); + setMessages((prev) => [ + ...prev, + textMessage(nextId(), "user", text), + textMessage( + nextId(), + "assistant", + "That's an off-catalog request — I'll hand it to the Workbench once it's available.", + ), + ]); + setStatus("ready"); + // Stays in Intake: off-catalog doesn't resolve into the catalog workspace. + }; + + const addNote = (text: string) => { + setMessages((prev) => [...prev, textMessage(nextId(), "assistant", text)]); + }; + + const confirmAddToCart = (item: CatalogItem, quantity: number) => { + if (cartConfirmedRef.current) return; + cartConfirmedRef.current = true; + // Short product name (drop the vendor prefix) + amount + limit status — the + // confirmation pre-answers the approval question before Review. + const shortName = item.name.replace(/^Lenovo\s+/, ""); + const amount = quantity * effectivePrice(item); + const id = nextId(); + setMessages((prev) => [ + ...prev, + { + id, + role: "assistant", + parts: [ + { + type: "text", + content: `${quantity} ${shortName}${quantity > 1 ? "s" : ""} added — ${formatPrice( + amount, + item.currency, + )}, within your ${formatPrice(APPROVAL_LIMIT, item.currency)} limit. Ready to review?`, + }, + toolPart(`${id}-review`, REVIEW_TOOL, { ready: true }), + ], + }, + ]); + }; + + const startFresh = () => { + clearTimers(); + cartConfirmedRef.current = false; + selectionStartedRef.current = false; + setMessages([]); + setStatus("ready"); + setHasResolved(false); + }; + + const stop = () => { + clearTimers(); + setStatus("ready"); + }; + + const value: ConversationContextValue = { + messages, + status, + hasResolved, + sendCatalogRequest, + continueToSelection, + resolveDefault, + sendOffCatalog, + addNote, + confirmAddToCart, + startFresh, + stop, + }; + + return ( + + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx index c38b5693e..acaf5cd25 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/FiltersControl.tsx @@ -127,17 +127,31 @@ export function FiltersControl({ -
- - - onChange({ ...filters, inStockOnly: checked }) - } - /> +
+
+ + + onChange({ ...filters, inStockOnly: checked }) + } + /> +
+
+ + + onChange({ ...filters, priceBasis: checked ? "epp" : "list" }) + } + /> +
@@ -166,9 +180,7 @@ function FilterGroup({ }) { return (
-

- {label} -

+

{label}

{children}
); diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/MatchCarousel.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/MatchCarousel.tsx new file mode 100644 index 000000000..592911e16 --- /dev/null +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/MatchCarousel.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useNavigate } from "@tanstack/react-router"; +import { motion, useReducedMotion } from "framer-motion"; +import { Check, Plus } from "lucide-react"; +import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { Button } from "@/components/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/registry/carousel/carousel"; +import { cn } from "@/lib/utils"; +import { useCart } from "./cart-context"; +import { + activePrice, + CATALOG_ITEMS, + defaultQuantityFor, + eppSavings, + formatPrice, + showsListStrike, +} from "./data"; +import { ProductImage } from "./ProductImage"; +import type { CatalogItem } from "./types"; +import { useConversation } from "./conversation-context"; + +// The agent's signature gradient — reused (not new) so the in-chat pick reads as +// the same recommendation as the catalog's Picked-for-you lead row. +const AI_GRADIENT = { background: "var(--ai-gradient-strong)" }; +const ACCENT = "bg-[#0f7b8a] text-white hover:bg-[#0c6976]"; +// Request applied EPP, so cards price per unit under EPP. +const BASIS = "epp" as const; +// Soft ease-out for the reveal/morph beats. +const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; + +interface MatchesOutput { + leadId: string; + altIds: string[]; + totalCount: number; + /** While true, the carousel shows skeleton placeholders. */ + loading?: boolean; +} + +interface MatchCardProps { + item: CatalogItem; + lead?: boolean; + /** Stagger index for the reveal (pick = 0, first). */ + index: number; +} + +/** + * One result card. Every card shares the template — image → title → spec → + * rationale slot → price → CTA — so prices and buttons align across the row. + * The pick fills the rationale and carries the badge + elevation; alternatives + * reserve the rationale slot empty and stay quiet. + */ +function MatchCard({ item, lead = false, index }: MatchCardProps) { + const reduceMotion = useReducedMotion(); + const { inCart, setQuantity, quantities } = useCart(); + const { confirmAddToCart } = useConversation(); + + const added = inCart(item.id); + const requestQty = defaultQuantityFor(item); + const qty = added ? (quantities[item.id] ?? requestQty) : requestQty; + const showStrike = showsListStrike(item, BASIS); + const savings = eppSavings(item); + + const onAdd = () => { + if (added) { + setQuantity(item, 0); + return; + } + setQuantity(item, requestQty); + confirmAddToCart(item, requestQty); + }; + + const card = ( +
+ {/* Image — the badge overlays here so the text rows below stay aligned. */} +
+ + {lead && ( + + + Picked for you + + )} +
+ +
+

+ {item.name} +

+

+ {item.specs.join(" · ")} +

+
+ + {/* Rationale slot — filled on the pick, reserved (empty) on alternatives. */} +

+ {lead && savings > 0 + ? `Matches your request · ${formatPrice(savings, item.currency)}/unit cheaper with EPP applied` + : ""} +

+ +
+
+ + {formatPrice(activePrice(item, BASIS), item.currency)} + + {showStrike && ( + + {formatPrice(item.listPrice, item.currency)} + + )} +
+ +
+
+ ); + + // Reveal: staggered fade-up, pick first. The gradient border wraps the pick. + return ( + + {lead ? ( +
+ {card} +
+ ) : ( + card + )} +
+ ); +} + +/** Pulsing placeholder shown while the matches "load". */ +function MatchCardSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +const SKELETON_KEYS = ["s1", "s2", "s3"]; + +/** The matches carousel Autopilot presents inline in the chat after the confirm. */ +export function MatchCarousel({ output }: { output: MatchesOutput }) { + const navigate = useNavigate(); + const lead = CATALOG_ITEMS.find((item) => item.id === output.leadId); + const alts = output.altIds + .map((id) => CATALOG_ITEMS.find((item) => item.id === id)) + .filter((item): item is CatalogItem => item != null); + + if (!lead) return null; + + return ( +
+ + {/* basis < container so ~2.5 cards peek and it reads as swipeable. */} + + {output.loading ? ( + SKELETON_KEYS.map((key) => ( + + + + )) + ) : ( + <> + + + + {alts.map((item, i) => ( + + + + ))} + + )} + + + {/* Controls live below the row — never overlapping card content. */} + {!output.loading && ( +
+ + + +
+ )} +
+
+ ); +} + +/** "Review & submit" affordance shown after an in-chat add-to-cart. */ +export function ReviewCta() { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx index 6fb06d92e..c6c502f2c 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductCard.tsx @@ -11,6 +11,8 @@ import type { CatalogItem } from "./types"; interface ProductCardProps { item: CatalogItem; inCart: boolean; + /** Quantity shown on the Add button (request qty, or cart qty once added). */ + quantity: number; /** Featured (recommended) styling: teal Add-to-cart accent. */ featured?: boolean; /** "photo" shows the product image; "logo" shows the brand logo. */ @@ -24,6 +26,7 @@ interface ProductCardProps { export function ProductCard({ item, inCart, + quantity, featured = false, imageMode = "photo", onToggleCart, @@ -36,8 +39,19 @@ export function ProductCard({ return ( onOpenDetail(item)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpenDetail(item); + } + }} className={cn( - "h-full gap-3 rounded-2xl border p-4 shadow-sm transition-shadow hover:shadow-md", + "h-full cursor-pointer gap-3 rounded-2xl border p-4 shadow-sm transition-shadow hover:shadow-md", className, )} > @@ -83,13 +97,13 @@ export function ProductCard({
- diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx index 705a6c03d..96e4d6020 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ProductDetail.tsx @@ -21,7 +21,10 @@ const ACCENT = "bg-[#0f7b8a] text-white hover:bg-[#0c6976]"; interface ProductDetailProps { item: CatalogItem; + /** Pending quantity to add when the item isn't in the cart yet. */ defaultQuantity: number; + /** Live cart quantity (0 if not in cart) — the source of truth once added. */ + cartQuantity: number; inCart: boolean; comparing: boolean; isPicked: boolean; @@ -36,6 +39,7 @@ interface ProductDetailProps { export function ProductDetail({ item, defaultQuantity, + cartQuantity, inCart, comparing, isPicked, @@ -46,7 +50,12 @@ export function ProductDetail({ onAskAgent, onClose, }: ProductDetailProps) { - const [quantity, setQuantity] = useState(defaultQuantity); + // Pending qty applies before the item is in the cart; once in the cart, the + // stepper reads and edits the cart quantity directly (single source of truth). + const [pendingQty, setPendingQty] = useState(defaultQuantity); + const quantity = inCart ? cartQuantity : pendingQty; + const onQtyChange = (next: number) => + inCart ? onAddToCart(next) : setPendingQty(next); const basis = usePriceBasis(); const showStrike = showsListStrike(item, basis); const savings = activeSavings(item, basis); @@ -133,7 +142,7 @@ export function ProductDetail({ )}
- + + + ); + })} +
+ + {/* Routing consequence — where the catalog/standard fork becomes visible. */} + + + + Catalog standard config means this routes directly to{" "} + {APPROVER} for + approval — no procurement review needed. + + + + + + + +
+ ); +} diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx index 7b85b271e..c402092a1 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Review.tsx @@ -1,9 +1,11 @@ "use client"; import { useNavigate } from "@tanstack/react-router"; +import { motion, useReducedMotion } from "framer-motion"; import { ChevronDown, Pencil, ShieldCheck } from "lucide-react"; import { useState } from "react"; import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; +import { AutopilotGradientIcon } from "@/registry/ai-chat/components/icons/autopilot-gradient"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -22,6 +24,7 @@ const BASIS = "epp" as const; /** Review & submit — the commit surface for the catalog path. */ export function Review() { const navigate = useNavigate(); + const reduceMotion = useReducedMotion(); const { items, quantities, setOpen } = useCart(); const [agentOpen, setAgentOpen] = useState(false); @@ -45,8 +48,22 @@ export function Review() { } return ( -
+
+ {/* Slim Autopilot presence — the agent stays with you through commit. */} +
+
+ {/* Intent hero */}

Review & submit

@@ -78,7 +95,12 @@ export function Review() { ))}
- +
@@ -146,6 +168,6 @@ export function Review() {
-
+ ); } diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx index b0f6cff2e..12d935185 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/ScanRow.tsx @@ -21,6 +21,8 @@ const AI_GRADIENT = { background: "var(--ai-gradient-strong)" }; interface ScanRowProps { item: CatalogItem; inCart: boolean; + /** Quantity shown on the Add button (request qty, or cart qty once added). */ + quantity: number; comparing: boolean; /** Elevated "Picked for you" lead row. */ recommended?: boolean; @@ -57,6 +59,7 @@ export function BrandMark({ item }: { item: CatalogItem }) { export function ScanRow({ item, inCart, + quantity, comparing, recommended = false, note, @@ -75,14 +78,26 @@ export function ScanRow({ style={recommended ? AI_GRADIENT : {}} >
onOpenDetail(item)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpenDetail(item); + } + }} className={cn( - "flex flex-wrap items-center gap-x-4 gap-y-3 bg-card p-4 transition-shadow hover:shadow-sm", + "flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-3 bg-card p-4 transition-shadow hover:shadow-sm", recommended ? "rounded-[7px]" : "rounded-lg border", )} > onToggleCompare(item)} + onClick={(e) => e.stopPropagation()} aria-label={`Compare ${item.name}`} className="shrink-0" /> @@ -126,31 +141,27 @@ export function ScanRow({
- diff --git a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx index e2fc7afd9..cce73e923 100644 --- a/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx +++ b/apps/apollo-vertex/templates/guided-buying/catalog/v1/Selection.tsx @@ -2,6 +2,7 @@ // oxlint-disable max-lines -- /buy workspace orchestrator; holds the wired state import { useNavigate } from "@tanstack/react-router"; +import { motion, useReducedMotion } from "framer-motion"; import { Columns3, ShoppingCart, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { AutopilotIcon } from "@/registry/ai-chat/components/icons/autopilot"; @@ -10,8 +11,10 @@ import { Button } from "@/components/ui/button"; import { PageHeader, PageHeaderActions, + PageHeaderDescription, PageHeaderNav, PageHeaderTitle, + PageHeaderTitleGroup, } from "@/components/ui/page-header"; import { cn } from "@/lib/utils"; import { @@ -19,9 +22,9 @@ import { CATALOG_ITEMS, CATALOG_PRICE_MAX, CATALOG_PRICE_MIN, + defaultQuantityFor, eppSavings, formatPrice, - INFERRED_REQUEST_QUANTITY, RECOMMENDATION, SAMPLE_REQUEST, } from "./data"; @@ -39,13 +42,13 @@ import { PriceBasisProvider } from "./price-basis-context"; import { ScanRow } from "./ScanRow"; import { Toolbar } from "./Toolbar"; import { useRail } from "./useRail"; +import { useConversation } from "./conversation-context"; import type { CatalogCategory, CatalogItem, Filters, LayoutMode, PriceBasis, - RailNote, SortKey, } from "./types"; @@ -129,7 +132,6 @@ export function Selection({ imageMode = "photo" }: SelectionProps) { const [compareOpen, setCompareOpen] = useState(false); const [detailSlug, setDetailSlug] = useState(null); const [filters, setFilters] = useState(DEFAULT_FILTERS); - const [railNotes, setRailNotes] = useState([]); const [railUnread, setRailUnread] = useState(false); // Cart state lives above the router so Review (a separate route) shares it. @@ -139,14 +141,23 @@ export function Selection({ imageMode = "photo" }: SelectionProps) { open: cartOpen, setOpen: setCartOpen, inCart, - toggle: toggleCart, setQuantity: addToCart, } = useCart(); + + // Adding lands the item's default quantity (2 for the recommended item); + // toggling again removes it. Quantity then lives in cart state everywhere. + const toggleCart = (item: CatalogItem) => + addToCart(item, inCart(item.id) ? 0 : defaultQuantityFor(item)); + + // Quantity surfaced on the Add button: cart qty once added, else the qty that + // adding would land (the request quantity for laptops). + const addQuantityFor = (item: CatalogItem) => + inCart(item.id) ? (quantities[item.id] ?? 1) : defaultQuantityFor(item); const rail = useRail(); const navigate = useNavigate(); - const railInputRef = useRef(null); + const { addNote } = useConversation(); + const reduceMotion = useReducedMotion(); const pushedRef = useRef(false); - const noteIdRef = useRef(0); // Reflect detail/compare state in the real browser URL (?item / ?compare) so // it's shareable and deep-linkable. The shell runs in an in-memory router, so @@ -224,9 +235,10 @@ export function Selection({ imageMode = "photo" }: SelectionProps) { if (railVisible) setRailUnread(false); }, [railVisible]); + // Agent-authored notes (filter changes) append to the shared thread; flag the + // launcher when the rail isn't on screen to see them. const addRailNote = (text: string) => { - noteIdRef.current += 1; - setRailNotes((prev) => [...prev, { id: noteIdRef.current, text }]); + addNote(text); if (!railVisible) setRailUnread(true); }; @@ -301,10 +313,8 @@ export function Selection({ imageMode = "photo" }: SelectionProps) { } }; - const askAgent = () => { - rail.expand(); - requestAnimationFrame(() => railInputRef.current?.focus()); - }; + // AiChat owns its composer, so we just surface the rail; the user types there. + const askAgent = () => rail.expand(); const visibleCount = (f: Filters) => CATALOG_ITEMS.filter((item) => matches(item, f, query)).length; @@ -467,7 +477,13 @@ export function Selection({ imageMode = "photo" }: SelectionProps) { > - Guided buying + + Catalog + + Sourcing {SAMPLE_REQUEST.summary.replace(/\.$/, "")} · + prices shown per unit + + + ); +} + +function NavSectionLabel({ label, count }: { label: string; count: number }) { + return ( +
+ + {label}{" "} + + ({count}) + + +
+ ); +} + +function LeftQueue({ + activeId, + onSelect, + onBack, +}: { + activeId: string; + onSelect: (id: string) => void; + onBack: () => void; +}) { + const dueToday = QUEUE.filter((r) => r.dueGroup === "today"); + const dueTomorrow = QUEUE.filter((r) => r.dueGroup === "tomorrow"); + const index = QUEUE.findIndex((r) => r.id === activeId); + const hasPrev = index > 0; + const hasNext = index >= 0 && index < QUEUE.length - 1; + + return ( +
+
+ + + My queue{" "} + + ({QUEUE.length}) + + +
+ +
+ {dueToday.length > 0 && ( + <> + +
+ {dueToday.map((r) => ( + + ))} +
+ + )} + {dueTomorrow.length > 0 && ( + <> + +
+ {dueTomorrow.map((r) => ( + + ))} +
+ + )} +
+ +
+ + + {index + 1} of {QUEUE.length} + + +
+
+ ); +} + +// ── Center: the finding + the call ──────────────────────────────────────────── + +function ResolvedAlert({ + detail, + resolution, +}: { + detail: Detail; + resolution: Resolution; +}) { + if (resolution === "approved") { + return ( + + + {detail.type === "quote" ? "Quote approved" : "Terms accepted"} + + + {detail.type === "quote" + ? "Approved and routed for sign-off — I'll place the order once cleared." + : "Accepted at the MSA rate — I'll provision the lines."} + + + ); + } + if (resolution === "countered") { + return ( + + Counter sent + + { + "I've sent the counter and will let you know when they respond. (Prototype stub.)" + } + + + ); + } + return ( + + Request rejected + The requester has been notified. + + ); +} + +function Finding({ + detail, + resolution, + onResolve, +}: { + detail: Detail; + resolution: Resolution; + onResolve: (r: Resolution) => void; +}) { + const [query, setQuery] = useState(""); + const [focused, setFocused] = useState(false); + + return ( +
+ + {detail.finding.tag} + + +

+ {detail.finding.headline} +

+ +
+ {detail.finding.metrics.map((m) => ( +
+ + {m.label} + + + {m.value} + +
+ ))} +
+ +

+ {detail.finding.body} +

+ + {resolution ? ( + + ) : ( + <> +
+ + + +
+ + {/* Autopilot follow-up chips + ask composer */} +
+
+ {detail.suggestions.map((s) => ( + + ))} +
+
+ setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + placeholder={detail.composerPlaceholder} + className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/60" + /> + +
+
+ + )} +
+ ); +} + +// ── Right reference panel ───────────────────────────────────────────────────── + +function renderDot(indicator: TimelineEntry["indicator"]) { + if (indicator === "pending") + return ( +
+ ); + if (indicator === "user") + return ( + + + {REVIEWER_INITIALS} + + + ); + if (indicator === "ai-warn") + return ( +
+ +
+ ); + if (indicator === "ai-pass") + return ( +
+ +
+ ); + return ( +
+
+
+ ); +} + +function ActivityTab({ detail }: { detail: Detail }) { + const [noteState, setNoteState] = useState<"default" | "input">("default"); + const [noteText, setNoteText] = useState(""); + const [notes, setNotes] = useState([]); + + const items = [...notes, ...detail.activity]; + + const addNote = () => { + const text = noteText.trim(); + if (!text) return; + setNotes((prev) => [ + { + id: `note-${prev.length}`, + label: "You added a note", + desc: text, + indicator: "user", + }, + ...prev, + ]); + setNoteText(""); + setNoteState("default"); + }; + + return ( +
+
+ {items.map((item, i) => { + const isLast = i === items.length - 1; + return ( +
+
+ {renderDot(item.indicator)} + {!isLast && ( +
+ )} +
+
+
+

+ {item.label} +

+ {item.time && ( + + {item.time} + + )} +
+ {item.desc && ( +

+ {item.desc} +

+ )} +
+
+ ); + })} +
+
+ + {noteState === "input" ? ( +
+