diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 09aa6cee8..9f15b1df0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,6 +91,10 @@ jobs: working-directory: ./packages/create-skybridge/templates/blank run: pnpm pkg set 'dependencies.skybridge=^${{ steps.version.outputs.version }}' + - name: Pin ecom template skybridge dep for publish + working-directory: ./packages/create-skybridge/templates/ecom + run: pnpm pkg set 'dependencies.skybridge=^${{ steps.version.outputs.version }}' + - name: Publish create-skybridge to npm working-directory: ./packages/create-skybridge run: pnpm publish --tag ${{ steps.version.outputs.tag }} --access public --provenance --no-git-checks @@ -101,7 +105,7 @@ jobs: - name: Restore templates workspace deps if: github.event_name == 'release' - run: git checkout -- packages/create-skybridge/templates/demo/package.json packages/create-skybridge/templates/blank/package.json + run: git checkout -- packages/create-skybridge/templates/demo/package.json packages/create-skybridge/templates/blank/package.json packages/create-skybridge/templates/ecom/package.json - name: Deploy docs to Mintlify if: github.event_name == 'release' diff --git a/docs/docs.json b/docs/docs.json index 23c5f3a5b..692f538be 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,11 +72,12 @@ "root": "guides/index", "pages": [ "guides/csp", - "guides/ux", + "guides/ecommerce", "guides/files", - "guides/skills", "guides/auth-providers", - "guides/migrate" + "guides/migrate", + "guides/skills", + "guides/ux" ], "expanded": true }, diff --git a/docs/examples.mdx b/docs/examples.mdx index 486c89d46..f7ebdb7be 100644 --- a/docs/examples.mdx +++ b/docs/examples.mdx @@ -90,7 +90,7 @@ Open-source apps that show how Skybridge supports maps, commerce, travel, games, prompt="Show me some clothes I can buy." src="/images/showcase-ecommerce.png" alt="Ecommerce Carousel showcase" - description="Product carousel with persistent cart, localization support, theme switching, and modal dialogs." + description="Ecommerce with a search results carousel and a fullscreen product detail. The model curates from a viewless search tool, then renders its picks." demo="https://ecommerce.skybridge.tech/try" source="https://github.com/alpic-ai/skybridge/tree/main/examples/ecom-carousel" /> diff --git a/docs/examples/ecommerce-carousel.mdx b/docs/examples/ecommerce-carousel.mdx index 451c23ba3..53978f8e9 100644 --- a/docs/examples/ecommerce-carousel.mdx +++ b/docs/examples/ecommerce-carousel.mdx @@ -1,14 +1,14 @@ --- title: Ecommerce Carousel -description: Product carousel with persistent cart, localization, and modal checkout flow. +description: Ecommerce with search results carousel and product detail view. --- import { ChatExample } from "/components/chat-example.jsx"; -Ecommerce Carousel highlights localization, modal dialogs, persistent state, and external checkout flows. +Ask for something to buy and the model searches the catalog, curates the results, and renders its picks as a carousel. Tap a card to open a fullscreen detail with an image gallery, a variant picker, specs, and a link out to the store. Showcases a two-tool split: a viewless search tool the model curates from, then a render tool that carries the full product data to the view, where variants resolve in place and prices follow the user's locale. >Model: I need a warm jacket + Model->>Server: search-products("jacket") + Server-->>Model: 42 hits: ids + facts + Model->>Server: search-products("jacket", maxPrice: 300) + Server-->>Model: 12 hits: ids + facts + Model->>Server: render-carousel([a, c, f]) + Server-->>View: full products in _meta + Server-->>Model: trimmed grounding + View-->>User: carousel of 3 products +``` + +`render-carousel` answers on three channels: full products (every variant, all media) ride `_meta` for the view; a trimmed projection goes to `structuredContent` so the model can answer follow-ups; `content` is a one-line status. + +### Open the Product Detail + +Tapping a card switches to `fullscreen` and renders the product detail over the carousel: one view, two screens. The detail reads the same `_meta` products, so no extra fetch. The model gets the full product spec through [view state](/build/state). + +### Pick Variants + +Every variant is a complete, buyable product; a `Product` groups siblings and declares option axes. The variant list is sparse: a combination that does not exist is simply absent, and availability is derived from the list, never encoded as rules. Each option value resolves to in stock, sold out (selectable, only the buy CTA locks), or nonexistent (disabled). + +The model knows which variant the user is looking at. It can act as a salesperson: answer about any variant, compare, and advise on variations. + +### Restyle from Tokens + +A vanilla-extract design system under `src/design/` styles every component from one set of tokens: primitives, a semantic color contract, light and dark themes, sprinkles, and a typography recipe. A theme that leaves a contract slot unset fails the build. The template ships brand-neutral placeholders. + +Every component comes with [Ladle](https://ladle.dev) stories covering its edge cases (long titles, missing images, sold-out variants); `npm run ladle` previews them against the tokens, light and dark one click apart. + +## Fill It with Your Agent + +Every decision the skeleton defers is marked with a `@todo` comment in `src/`: filter facets, image aspect ratios, section order, brand tokens. The skill walks a coding agent through that worklist in six gated phases, recording each decision in `SPEC.md`. + +```mermaid +flowchart LR + G[1. Gather] --> E[2. Explore data] --> UX["3. Decide UX ✍️"] --> S[4. Server] --> C[6. Components] --> F["Final gate ✍️"] + G --> D["5. Design ✍️"] --> C +``` + + +Fill this ecom template following the chatgpt-app-builder skill's ecommerce reference. Start with phase 1 and ask me for everything you need. + + +The agent asks for your inputs up front: the data source (a Shopify or Medusa API, your own database, docs, credentials), brand assets (Figma file, live site, or screenshots, plus fonts), and the live site for layout inspiration. Then it explores, proposes, and builds. You are pulled in at three gates: + +- **Wireframes.** Before any UI code, the agent plays back the carousel card and the product detail as ASCII wireframes populated with real catalog values. +- **Retheme.** The extracted brand tokens, previewed on the Ladle stories. +- **Final gate.** The worklist is empty, the build passes, and both tools are verified against live data. + +## Verify the Result + +`npm run dev` serves DevTools on the root: call both tools with real arguments and drive the view through display modes, themes, mobile widths, and locales. + +Add the [tunnel](/test/tunnel) flag to also get a [Playground](/test/playground) on `/try` of the printed public URL, and run the app in a real host with an actual model. + +```bash +npm run dev -- --tunnel +``` + +For a finished build, the [ecommerce example](https://github.com/alpic-ai/skybridge/tree/main/examples/ecom-carousel) connects this template to a Medusa catalog. + + + + Define what humans and agents can do + + + Decide what the model sees + + + Call tools and render views locally, without a host + + diff --git a/docs/images/showcase-ecommerce.png b/docs/images/showcase-ecommerce.png index bd92ee54d..c51b0815e 100644 Binary files a/docs/images/showcase-ecommerce.png and b/docs/images/showcase-ecommerce.png differ diff --git a/examples/ecom-carousel/.env.example b/examples/ecom-carousel/.env.example deleted file mode 100644 index 0e824bfb2..000000000 --- a/examples/ecom-carousel/.env.example +++ /dev/null @@ -1 +0,0 @@ -STRIPE_SECRET_KEY=sk_test_... diff --git a/examples/ecom-carousel/.env.template b/examples/ecom-carousel/.env.template new file mode 100644 index 000000000..7285fba0d --- /dev/null +++ b/examples/ecom-carousel/.env.template @@ -0,0 +1,3 @@ +# Copy to .env and fill in. Sourced by the server at startup. +MEDUSA_BASE_URL= +MEDUSA_PUBLISHABLE_KEY= diff --git a/examples/ecom-carousel/.gitignore b/examples/ecom-carousel/.gitignore index 87d87350b..bf00f37ef 100644 --- a/examples/ecom-carousel/.gitignore +++ b/examples/ecom-carousel/.gitignore @@ -1,5 +1,9 @@ node_modules/ dist/ +build/ .env* -!.env.example +!.env.template .DS_store +*.tsbuildinfo +.skybridge/ +.vercel/ diff --git a/examples/ecom-carousel/.ladle/components.tsx b/examples/ecom-carousel/.ladle/components.tsx new file mode 100644 index 000000000..cb0e85db9 --- /dev/null +++ b/examples/ecom-carousel/.ladle/components.tsx @@ -0,0 +1,26 @@ +import type { GlobalProvider } from "@ladle/react"; +import "../src/index.css"; +import { viewFrame } from "../src/components/view-frame.css"; +import { darkTheme, lightTheme } from "../src/design/tokens"; +import { cx } from "../src/lib/cx"; + +/** + * Ladle's built-in theme toggle (sun/moon in the toolbar) drives + * globalState.theme. We reuse it to flip the widget's own palette, so the + * preview theme and the widget theme always match. The Provider applies the + * real ViewFrame surface (viewFrame + theme class), and index.css loads the + * brand @font-face rules, so stories preview against the actual frame. + */ +export const Provider: GlobalProvider = ({ children, globalState }) => { + const themeClass = globalState.theme === "dark" ? darkTheme : lightTheme; + + return ( +
+ {children} +
+ ); +}; diff --git a/examples/ecom-carousel/.ladle/config.mjs b/examples/ecom-carousel/.ladle/config.mjs new file mode 100644 index 000000000..fe832afbe --- /dev/null +++ b/examples/ecom-carousel/.ladle/config.mjs @@ -0,0 +1,11 @@ +/** @type {import('@ladle/react').UserConfig} */ +export default { + stories: "src/**/*.stories.{ts,tsx}", + viteConfig: ".ladle/vite.config.ts", + addons: { + theme: { + enabled: true, + defaultState: "light", + }, + }, +}; diff --git a/examples/ecom-carousel/.ladle/vite.config.ts b/examples/ecom-carousel/.ladle/vite.config.ts new file mode 100644 index 000000000..8496616bb --- /dev/null +++ b/examples/ecom-carousel/.ladle/vite.config.ts @@ -0,0 +1,11 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { defineConfig } from "vite"; + +/** + * Ladle-only vite config. Intentionally omits the Skybridge plugin (which + * transforms MCP view boilerplate and crashes Ladle's bundler) and the + * @vitejs/plugin-react call (Ladle provides its own). + */ +export default defineConfig({ + plugins: [vanillaExtractPlugin()], +}); diff --git a/examples/ecom-carousel/README.md b/examples/ecom-carousel/README.md index 06ab1292b..aa566ca80 100644 --- a/examples/ecom-carousel/README.md +++ b/examples/ecom-carousel/README.md @@ -1,16 +1,33 @@ -# Ecommerce Carousel Example +# Ecommerce Example -An example MCP app built with [Skybridge](https://docs.skybridge.tech/home): an interactive product carousel with cart, localization, and Stripe Checkout integration. +An example MCP app built with [Skybridge](https://docs.skybridge.tech/home): a winter-sports shop where the model searches a product catalog by keyword and filters, then renders a curated product carousel with a fullscreen product detail. + +This is the Skybridge **ecommerce template** — scaffold your own copy with: + +```bash +npx skybridge create my-shop --ecom +``` + +The catalog is served from a [Medusa](https://medusajs.com/) store, but the data source is swappable: the whole integration lives in `src/lib/medusa.ts`. ## What This Example Showcases -- **Stripe Checkout**: Real payment flow using Stripe-hosted Checkout (redirect mode) with session status polling -- **Interactive Widget Rendering**: A React-based widget that displays an interactive product carousel directly in AI conversations -- **Tool Calling from Widget**: Widget invokes `create-checkout` and `check-checkout-status` server tools via `useCallTool()` -- **Theme Support**: Adapts to light/dark mode using the `useLayout()` hook -- **Localization**: Translates UI based on user locale via `useUser()` hook (English, French, Spanish, German) -- **Persistent State**: Maintains cart state across re-renders using `useWidgetState()` hook -- **Hot Module Replacement**: Live reloading of widget components during development +- **Two-tool search + render pattern**: A view-less `search-products` tool returns data-only grounding for the model to curate; a separate `render-carousel` tool draws the chosen products as an inline carousel — the classic "reason, then present" split +- **Model context vs. view data**: `search-products` returns everything in `structuredContent` (never shown to the user); `render-carousel` puts full presentational data (images, variants, media) in `_meta` for the view, and only trimmed grounding in `structuredContent` +- **Tool descriptions as behavior**: The server `instructions` and tool descriptions drive a two-phase flow — search silently, then speak only once the carousel renders +- **Inline View Rendering**: A React carousel with a fullscreen product detail (image gallery, variant picker, specs, CTA) rendered directly in AI conversations via a tool `view` +- **Variant-as-product model**: Products expose variation axes (color, size, length) with a sparse variant matrix; the detail view narrows availability per axis +- **CSP Configuration**: Allows the product image host via `resourceDomains` and the storefront CTA via `redirectDomains` +- **Vanilla Extract Design System**: Themed design tokens, sprinkles, and light/dark themes under `src/design/` +- **Ladle Component Stories**: `*.stories.tsx` for every component, previewed with `pnpm ladle` +- **Swappable Data Source**: The catalog integration is isolated in `src/lib/medusa.ts` — point it at any store by editing that one file +- **Hot Module Replacement**: [Live reloading](https://docs.skybridge.tech/concepts/fast-iteration#hmr-with-vite-plugin) of view components during development + +## Example Prompts + +- Show me some skis +- I need goggles for a bright day +- What cold-weather apparel do you have? ## Live Demo @@ -21,7 +38,6 @@ An example MCP app built with [Skybridge](https://docs.skybridge.tech/home): an ### Prerequisites - Node.js 24+ -- A Stripe account (sandbox mode works) ### Local Development @@ -37,13 +53,9 @@ pnpm install bun install ``` -#### 2. Configure Stripe - -```bash -cp .env.example .env -``` +#### 2. Point at your own catalog (optional) -Open `.env` and paste your Stripe **test** secret key (starts with `sk_test_`). You can find it in the [Stripe Dashboard > Developers > API keys](https://dashboard.stripe.com/test/apikeys). +The example ships pointed at a demo store, so it runs as-is. To use your own catalog, copy `.env.template` to `.env` and fill in `MEDUSA_BASE_URL` and `MEDUSA_PUBLISHABLE_KEY`. Swapping to a different backend entirely is a matter of rewriting `src/lib/medusa.ts`. #### 3. Start your local server @@ -67,32 +79,45 @@ This command starts: #### 4. Project structure ``` -│ ├── server.ts # Server entry point -│ └── products.ts # Product data -│ ├── src/ -│ │ ├── views/ # React components (one per widget) -│ │ ├── helpers.ts # Shared utilities -│ │ └── index.css # Global styles -│ └── vite.config.ts -├── alpic.json # Deployment config -├── nodemon.json # Dev server config +│ ├── server.ts # Server entry point (registers both tools) +│ ├── config.ts # Search/carousel tuning constants +│ ├── tools/ +│ │ ├── search-products.ts # View-less search tool (data only) +│ │ └── render-carousel.ts # Carousel tool + product model +│ ├── lib/ +│ │ └── medusa.ts # Catalog data source (swap for your own backend) +│ ├── design/ # Vanilla Extract tokens, sprinkles, themes +│ ├── components/ # Carousel UI + Ladle stories +│ ├── views/ +│ │ └── carousel/ # Carousel view + fullscreen product detail +│ └── index.css # Global styles +├── alpic.json # Deployment config +├── .env.template # Medusa credentials template └── package.json ``` -### Create your first widget +### Component stories + +Preview and develop the UI components in isolation with [Ladle](https://ladle.dev/): + +```bash +pnpm ladle +``` + +### Create your first view -#### 1. Add a new widget +#### 1. Add a new view -- Register a widget in `src/server.ts` with a unique name (e.g., `my-widget`) using [`registerTool`](https://docs.skybridge.tech/api-reference/register-tool) -- Create a matching React component at `src/views/my-widget.tsx`. **The file name must match the widget name exactly**. +- Register a view in `src/server.ts` with a unique name (e.g., `my-view`) using [`registerTool`](https://docs.skybridge.tech/api-reference/register-tool) +- Create a matching React component at `src/views/my-view.tsx`. **The file name must match the view name exactly**. -#### 2. Edit widgets with Hot Module Replacement (HMR) +#### 2. Edit views with Hot Module Replacement (HMR) Edit and save components in `src/views/` — changes will appear instantly inside your App. #### 3. Edit server code -Modify files in `server/` and refresh the connection with your testing MCP Client to see the changes. +Modify files in `src/` and refresh the connection with your testing MCP Client to see the changes. ### Testing your App @@ -110,9 +135,13 @@ The simplest way to deploy your App in minutes is [Alpic](https://alpic.ai/). 2. Connect your GitHub repository to automatically deploy at each commit. 3. Use your remote App URL to connect it to MCP Clients, or use the Alpic Playground to easily test your App. +[![Deploy it on Alpic](https://assets.alpic.ai/button.svg)](https://app.alpic.ai/new/clone?repositoryUrl=https://github.com/alpic-ai/skybridge&rootDir=examples/ecom-carousel) + ## Resources - [Skybridge Documentation](https://docs.skybridge.tech/) +- [Medusa Documentation](https://docs.medusajs.com/) - [Apps SDK Documentation](https://developers.openai.com/apps-sdk) +- [MCP Apps Documentation](https://github.com/modelcontextprotocol/ext-apps/tree/main) - [Model Context Protocol Documentation](https://modelcontextprotocol.io/) - [Alpic Documentation](https://docs.alpic.ai/) diff --git a/examples/ecom-carousel/package.json b/examples/ecom-carousel/package.json index 90d631310..9b6f8b40a 100644 --- a/examples/ecom-carousel/package.json +++ b/examples/ecom-carousel/package.json @@ -1,36 +1,41 @@ { - "name": "ecom-carousel", + "name": "ecommerce", "version": "0.0.1", "private": true, - "description": "An e-commerce carousel example", + "description": "Skybridge MCP ecommerce template", "type": "module", "scripts": { "dev": "skybridge dev", "dev:tunnel": "skybridge dev --tunnel", "build": "skybridge build", "start": "skybridge start", - "deploy": "alpic deploy" + "deploy": "alpic deploy", + "ladle": "ladle serve", + "ladle:build": "ladle build" }, "dependencies": { - "@alpic-ai/insights": "^1.142.1", - "@modelcontextprotocol/sdk": "^1.29.0", - "@t3-oss/env-core": "^0.13.11", - "dotenv": "^17.4.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "skybridge": "^1.1.0", - "stripe": "^18.5.0", - "vite": "^8.1.5", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "skybridge": "^1.3.0", + "vite": "^8.1.3", "zod": "^4.4.3" }, "devDependencies": { - "@skybridge/devtools": "^1.2.3", - "@types/node": "^22.20.0", - "@types/react": "^19.2.17", + "@ladle/react": "^5.1.1", + "@skybridge/devtools": "^1.2.4", + "@types/node": "^24.13.2", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", - "alpic": "^1.142.1", - "tsx": "^4.22.4", - "typescript": "^5.9.3" + "@vanilla-extract/css": "^1.20.1", + "@vanilla-extract/recipes": "^0.5.7", + "@vanilla-extract/sprinkles": "^1.6.5", + "@vanilla-extract/vite-plugin": "^5.2.2", + "@vitejs/plugin-react": "^6.0.1", + "alpic": "^1.147.1", + "tsx": "^4.23.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=24.18.0" } } diff --git a/examples/ecom-carousel/src/components/checkout-summary.tsx b/examples/ecom-carousel/src/components/checkout-summary.tsx deleted file mode 100644 index 2b57af9e0..000000000 --- a/examples/ecom-carousel/src/components/checkout-summary.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { CheckoutPhase } from "../hooks/use-checkout-polling.js"; -import { useTranslate } from "../i18n.js"; -import type { Product } from "../types.js"; - -interface CheckoutSummaryProps { - items: Product[]; - phase: CheckoutPhase; - checkoutPending: boolean; - onPay: () => void; -} - -export function CheckoutSummary({ - items, - phase, - checkoutPending, - onPay, -}: CheckoutSummaryProps) { - const t = useTranslate(); - - let total = 0; - for (const p of items) { - total += p.price; - } - - const isPolling = phase === "polling"; - - return ( - <> -
{t("orderSummary")}
-
- {items.map((item) => ( -
- {item.title} - ${item.price.toFixed(2)} -
- ))} -
-
- {t("total")} - ${total.toFixed(2)} -
- {isPolling ? ( - - ) : ( - - )} - - ); -} diff --git a/examples/ecom-carousel/src/components/chip.css.ts b/examples/ecom-carousel/src/components/chip.css.ts new file mode 100644 index 000000000..282fa9345 --- /dev/null +++ b/examples/ecom-carousel/src/components/chip.css.ts @@ -0,0 +1,62 @@ +import { style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { colors, primitives } from "../design/tokens"; + +// A selectable pill used for option values (sizes, colors…). `selected`, +// `outOfStock` and `nonExistent` are driven by the variant picker from the +// sparse variant list. Selected = magenta accent border (Alpic). +export const chip = recipe({ + base: { + display: "inline-flex", + alignItems: "center", + gap: primitives.space["4xs"], + minHeight: "44px", // touch target + padding: `${primitives.space["4xs"]} ${primitives.space["3xs"]}`, + borderRadius: primitives.radius.m, + border: `${primitives.stroke.thin} solid ${colors.border.subtle}`, + backgroundColor: colors.surface.extraLight, + color: colors.content.intense, + fontFamily: primitives.font.family.primary, + fontSize: primitives.font.size.s, + cursor: "pointer", + transition: "border-color 150ms ease", + "@media": { + "(prefers-reduced-motion: reduce)": { transition: "none" }, + }, + }, + variants: { + selected: { + true: { + borderColor: colors.common.accent, + borderWidth: primitives.stroke.medium, + }, + false: {}, + }, + outOfStock: { + // Struck but still clickable; the buy CTA carries the state. + true: { + color: colors.content.subtle, + textDecoration: "line-through", + }, + false: {}, + }, + nonExistent: { + // Faded and non-interactive, distinct from sold out above. + true: { + opacity: 0.35, + cursor: "default", + }, + false: {}, + }, + }, + defaultVariants: { selected: false, outOfStock: false, nonExistent: false }, +}); + +// Small color/material swatch shown before the label on image chips. +export const swatch = style({ + width: "20px", + height: "20px", + borderRadius: primitives.radius.full, + objectFit: "cover", + display: "block", +}); diff --git a/examples/ecom-carousel/src/components/chip.stories.tsx b/examples/ecom-carousel/src/components/chip.stories.tsx new file mode 100644 index 000000000..f371c69e3 --- /dev/null +++ b/examples/ecom-carousel/src/components/chip.stories.tsx @@ -0,0 +1,27 @@ +import { Chip } from "./chip"; + +const SWATCH = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect width='20' height='20' fill='%233b6cf0'/%3E%3C/svg%3E"; + +const row = { + display: "flex", + gap: 8, + flexWrap: "wrap" as const, + maxWidth: 320, +}; + +export const States = () => ( +
+ + + {/* exists, sold out: clickable */} + {/* combination does not exist */} +
+); + +export const WithSwatch = () => ( +
+ + +
+); diff --git a/examples/ecom-carousel/src/components/chip.tsx b/examples/ecom-carousel/src/components/chip.tsx new file mode 100644 index 000000000..e1708f06d --- /dev/null +++ b/examples/ecom-carousel/src/components/chip.tsx @@ -0,0 +1,49 @@ +import * as styles from "./chip.css"; + +type ChipProps = { + label: string; + selected?: boolean; + // Exists but sold out: struck-through and greyed, still clickable + outOfStock?: boolean; + // The combination does not exist at all (hole in the variant matrix): + // faded and non-interactive + nonExistent?: boolean; + onSelect?: () => void; + // Optional swatch image (e.g. one per color); omit for a plain text chip. + media?: string; +}; + +/** + * One option value as a selectable pill. Rendered as a `radio` inside the + * variant picker's `radiogroup`; the picker owns selection state and passes + * `selected`/`outOfStock`/`nonExistent` computed from the sparse variant list. + * An out-of-stock chip stays operable: aria-disabled announces the state to + * assistive tech without blocking activation. A nonexistent chip is natively + * disabled: the combination cannot be composed. + */ +export function Chip({ + label, + selected, + outOfStock, + nonExistent, + onSelect, + media, +}: ChipProps) { + return ( + // biome-ignore lint/a11y/useSemanticElements: custom radio; a styled button carries the swatch and availability states a native radio cannot, inside the picker radiogroup. + + ); +} diff --git a/examples/ecom-carousel/src/components/empty-state.stories.tsx b/examples/ecom-carousel/src/components/empty-state.stories.tsx new file mode 100644 index 000000000..07275c3fc --- /dev/null +++ b/examples/ecom-carousel/src/components/empty-state.stories.tsx @@ -0,0 +1,3 @@ +import { EmptyState } from "./empty-state"; + +export const NoResults = () => ; diff --git a/examples/ecom-carousel/src/components/empty-state.tsx b/examples/ecom-carousel/src/components/empty-state.tsx new file mode 100644 index 000000000..ab8cc6023 --- /dev/null +++ b/examples/ecom-carousel/src/components/empty-state.tsx @@ -0,0 +1,12 @@ +import { sprinkles, text } from "../design/tokens"; + +/** Centered message shown when there is nothing to render (e.g. no results). */ +export function EmptyState({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} diff --git a/examples/ecom-carousel/src/components/expandable-text.css.ts b/examples/ecom-carousel/src/components/expandable-text.css.ts new file mode 100644 index 000000000..33f9eef71 --- /dev/null +++ b/examples/ecom-carousel/src/components/expandable-text.css.ts @@ -0,0 +1,49 @@ +import { style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { colors, primitives } from "../design/tokens"; + +// Collapsed height before "read more" appears (~4 lines at 1.4 line-height). +const COLLAPSED_MAX_HEIGHT = "6em"; + +export const container = style({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: primitives.space["4xs"], +}); + +// `clamp` caps the height (applied whenever collapsed, so overflow can be +// measured); `fade` masks the last line to a soft cutoff instead of an ellipsis +// and is applied only when the text actually overflows (never on short copy). +export const body = recipe({ + base: { + color: colors.content.intense, + whiteSpace: "pre-line", // preserve paragraph breaks from the source + }, + variants: { + clamp: { + true: { maxHeight: COLLAPSED_MAX_HEIGHT, overflow: "hidden" }, + false: {}, + }, + fade: { + true: { + maskImage: + "linear-gradient(to bottom, black 0%, black 55%, transparent 100%)", + WebkitMaskImage: + "linear-gradient(to bottom, black 0%, black 55%, transparent 100%)", + }, + false: {}, + }, + }, + defaultVariants: { clamp: false, fade: false }, +}); + +export const toggle = style({ + padding: 0, + border: "none", + background: "none", + color: colors.common.accent, + fontFamily: primitives.font.family.primary, + fontSize: primitives.font.size.s, + cursor: "pointer", +}); diff --git a/examples/ecom-carousel/src/components/expandable-text.stories.tsx b/examples/ecom-carousel/src/components/expandable-text.stories.tsx new file mode 100644 index 000000000..e6cbc26bd --- /dev/null +++ b/examples/ecom-carousel/src/components/expandable-text.stories.tsx @@ -0,0 +1,20 @@ +import { ExpandableText } from "./expandable-text"; + +const frame = { maxWidth: 360 }; + +const LONG = + "A relaxed-fit jacket in water-repellent cotton.\n\nDropped shoulders, a two-way zip, and ribbed cuffs. Fully lined, with two zip pockets at the front and one inside. Designed to layer over a knit through the cooler months, and cut long enough to sit past the hip."; + +export const Long = () => ( +
+ {LONG} +
+); + +export const Short = () => ( +
+ + A short description that never needs a toggle. + +
+); diff --git a/examples/ecom-carousel/src/components/expandable-text.tsx b/examples/ecom-carousel/src/components/expandable-text.tsx new file mode 100644 index 000000000..d0f2e79c8 --- /dev/null +++ b/examples/ecom-carousel/src/components/expandable-text.tsx @@ -0,0 +1,53 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { text } from "../design/tokens"; +import { useLabels } from "../i18n"; +import { cx } from "../lib/cx"; +import * as styles from "./expandable-text.css"; + +/** + * Clamps long copy to a few lines with a "read more" toggle. Truncation is + * measured, not assumed: the toggle only appears when the text actually + * overflows the collapsed height, so short copy shows no button. + */ +export function ExpandableText({ children }: { children: string }) { + const labels = useLabels(); + const bodyRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [overflows, setOverflows] = useState(false); + + const collapsed = !expanded; + + // Measure against the collapsed height (the clamp is applied whenever + // collapsed, so scrollHeight vs clientHeight is meaningful). Re-run when the + // text changes (e.g. the client switches variant) so the toggle tracks it. + // biome-ignore lint/correctness/useExhaustiveDependencies: children re-triggers the measure, it is not read in the body. + useLayoutEffect(() => { + const el = bodyRef.current; + if (el && collapsed) { + setOverflows(el.scrollHeight > el.clientHeight + 1); + } + }, [children, collapsed]); + + return ( +
+

+ {children} +

+ {overflows ? ( + + ) : null} +
+ ); +} diff --git a/examples/ecom-carousel/src/components/image-gallery.css.ts b/examples/ecom-carousel/src/components/image-gallery.css.ts new file mode 100644 index 000000000..6574f76f2 --- /dev/null +++ b/examples/ecom-carousel/src/components/image-gallery.css.ts @@ -0,0 +1,171 @@ +import { globalStyle, style } from "@vanilla-extract/css"; +import { colors, primitives } from "../design/tokens"; + +export const gallery = style({ + position: "relative", +}); + +// Rail mode only (THUMBNAIL_RAIL): lay the rail beside the image on desktop. The +// breakpoint matches the PDP's two-column grid and resolves against the same +// `.detail` container, so the rail appears exactly when the page goes two-column. +export const galleryRail = style({ + "@container": { + "(min-width: 560px)": { + display: "flex", + flexDirection: "row", + gap: primitives.space["2xs"], + // Top-align so the rail sits beside the image; the rail's own max-height + // (set from the measured image height) caps it and scrolls the excess. + alignItems: "flex-start", + }, + }, +}); + +// Image column: the positioning context for the overlaid chevrons, and the flex +// child that takes the width left of the rail on desktop. +export const mainCol = style({ + position: "relative", + minWidth: 0, + "@container": { + "(min-width: 560px)": { flex: 1 }, + }, +}); + +// Scroll-snap track: one image per view. Native swipe on touch; the chevrons +// are the pointer affordance. Scrollbar hidden. +export const track = style({ + display: "flex", + overflowX: "auto", + scrollSnapType: "x mandatory", + scrollBehavior: "smooth", + scrollbarWidth: "none", + borderRadius: primitives.radius.m, + "@media": { + "(prefers-reduced-motion: reduce)": { scrollBehavior: "auto" }, + }, +}); + +globalStyle(`${track}::-webkit-scrollbar`, { display: "none" }); + +export const slide = style({ + flex: "0 0 100%", + scrollSnapAlign: "start", + // Reserve the box so images load without shifting the page. Same 1:1 + cover + // as the card (phase 2: square full-bleed lifestyle photos). + aspectRatio: "1", + backgroundColor: colors.surface.subtle, +}); + +export const image = style({ + width: "100%", + height: "100%", + objectFit: "cover", + display: "block", + pointerEvents: "none", + userSelect: "none", +}); + +// Prev/next chevrons, vertically centered, disabled at the ends. +export const nav = style({ + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + display: "grid", + placeItems: "center", + width: "36px", + height: "36px", + padding: 0, + borderRadius: primitives.radius.full, + border: `${primitives.stroke.thin} solid ${colors.border.subtle}`, + backgroundColor: colors.surface.extraLight, + color: colors.content.intense, + cursor: "pointer", + transition: "opacity 150ms ease", + selectors: { + "&:disabled": { opacity: 0, pointerEvents: "none" }, + }, +}); + +export const navPrev = style({ left: primitives.space["3xs"] }); +export const navNext = style({ right: primitives.space["3xs"] }); + +// Position indicator: a thin track with a fill sized to (index + 1) / count. +export const progress = style({ + height: "3px", + marginTop: primitives.space["3xs"], + borderRadius: primitives.radius.full, + backgroundColor: colors.border.thin, + overflow: "hidden", +}); + +export const progressFill = style({ + height: "100%", + borderRadius: primitives.radius.full, + backgroundColor: colors.content.intense, + transition: "width 200ms ease", + "@media": { + "(prefers-reduced-motion: reduce)": { transition: "none" }, + }, +}); + +// In rail mode the rail conveys position on desktop, so the progress bar is +// mobile-only there. +export const progressMobileOnly = style({ + "@container": { + "(min-width: 560px)": { display: "none" }, + }, +}); + +// Desktop thumbnail rail (THUMBNAIL_RAIL). Hidden on mobile, where the swipe +// track + progress bar own navigation. Capped to the image height via an inline +// max-height and scrolls the excess. (Unused: D4 keeps the rail off.) +export const rail = style({ + display: "none", + "@container": { + "(min-width: 560px)": { + display: "flex", + flexDirection: "column", + flexShrink: 0, + gap: primitives.space["2xs"], + width: "64px", + minHeight: 0, + overflowY: "auto", + scrollbarWidth: "none", + }, + }, +}); + +globalStyle(`${rail}::-webkit-scrollbar`, { display: "none" }); + +export const thumb = style({ + boxSizing: "border-box", + flexShrink: 0, + width: "100%", + aspectRatio: "1", + minHeight: 0, // let aspect-ratio win over the flex-item default min-height + padding: primitives.space["4xs"], + borderRadius: primitives.radius.m, + border: `${primitives.stroke.thin} solid ${colors.border.subtle}`, + backgroundColor: colors.surface.extraLight, + cursor: "pointer", + transition: "border-color 150ms ease", + "@media": { + "(prefers-reduced-motion: reduce)": { transition: "none" }, + }, +}); + +// Selected thumb: thicker, accented border. border-box keeps the width swap from +// shifting the rail layout. +export const thumbActive = style({ + borderColor: colors.common.accent, + borderWidth: primitives.stroke.medium, +}); + +export const thumbImage = style({ + width: "100%", + height: "100%", + objectFit: "contain", + display: "block", + pointerEvents: "none", + userSelect: "none", +}); diff --git a/examples/ecom-carousel/src/components/image-gallery.stories.tsx b/examples/ecom-carousel/src/components/image-gallery.stories.tsx new file mode 100644 index 000000000..6a84fb2fd --- /dev/null +++ b/examples/ecom-carousel/src/components/image-gallery.stories.tsx @@ -0,0 +1,30 @@ +import { ImageGallery } from "./image-gallery"; + +function shot(fill: string) { + return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect width='400' height='400' fill='%23${fill}'/%3E%3Ccircle cx='200' cy='200' r='110' fill='%23ffffff' fill-opacity='0.5'/%3E%3C/svg%3E`; +} + +// The gallery's desktop layout (and the optional THUMBNAIL_RAIL) query a +// `container-type` host, which the PDP's `.detail` provides in the app. The +// story supplies its own so it previews at a realistic width and renders +// correctly whichever way THUMBNAIL_RAIL is set — swipe when off, rail when on. +const frame = { + containerType: "inline-size" as const, + width: 600, + maxWidth: "100%", +}; + +export const Multiple = () => ( +
+ +
+); + +export const Single = () => ( +
+ +
+); diff --git a/examples/ecom-carousel/src/components/image-gallery.tsx b/examples/ecom-carousel/src/components/image-gallery.tsx new file mode 100644 index 000000000..69d31d359 --- /dev/null +++ b/examples/ecom-carousel/src/components/image-gallery.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "react"; +import { useLabels } from "../i18n"; +import { cx } from "../lib/cx"; +import * as styles from "./image-gallery.css"; + +// D4: no thumbnail rail — swipe-only gallery. (Rail code kept below, unused.) +const THUMBNAIL_RAIL: boolean = false; + +function Chevron({ direction }: { direction: "left" | "right" }) { + const d = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; + return ( + + ); +} + +/** + * Product image gallery: a native scroll-snap track with prev/next chevrons and + * a progress bar; the current index is read from scroll position (no library). + * With the rail on, a desktop thumbnail rail controls the SAME track (shared + * index, click = scroll), and the progress bar is hidden where the rail shows + * (desktop) so the two never appear together. Mobile is always the swipe track. + */ +export function ImageGallery({ media, alt }: { media: string[]; alt: string }) { + const labels = useLabels(); + const trackRef = useRef(null); + const [index, setIndex] = useState(0); + const [imageHeight, setImageHeight] = useState(); + + function onScroll() { + const el = trackRef.current; + if (el) { + setIndex(Math.round(el.scrollLeft / el.clientWidth)); + } + } + + function scrollToIndex(i: number) { + const el = trackRef.current; + if (el) { + el.scrollTo({ left: i * el.clientWidth }); + } + } + + // Rail only: cap the rail to the main image's height so its extra thumbnails + // scroll instead of dangling past the image. A vertical scroll container needs + // a definite height; the square image provides it, measured here. + useEffect(() => { + if (!THUMBNAIL_RAIL) { + return; + } + const el = trackRef.current; + if (!el) { + return; + } + const measure = () => setImageHeight(el.clientHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const multiple = media.length > 1; + + return ( +
+ {THUMBNAIL_RAIL && multiple ? ( +
+ {media.map((src, i) => ( + + ))} +
+ ) : null} + +
+
+ {media.map((src, i) => ( +
+ {i +
+ ))} +
+ + {multiple ? ( + <> + + + +
+ ); +} diff --git a/examples/ecom-carousel/src/components/payment-success.tsx b/examples/ecom-carousel/src/components/payment-success.tsx deleted file mode 100644 index 54f4d0b3a..000000000 --- a/examples/ecom-carousel/src/components/payment-success.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useTranslate } from "../i18n.js"; -import type { Product } from "../types.js"; - -function SuccessIcon() { - return ( -
- - - - - - -
- ); -} - -export function PaymentSuccess({ items }: { items: Product[] }) { - const t = useTranslate(); - - let total = 0; - for (const p of items) { - total += p.price; - } - - return ( -
- -
-
{t("paymentSuccess")}
-
- {items.length === 1 - ? t("itemCount_one") - : t("itemCount_other").replace("{count}", String(items.length))} -
-
-
-
- {items.map((item) => ( -
- {item.title} - ${item.price.toFixed(2)} -
- ))} -
-
-
- {t("total")} - ${total.toFixed(2)} -
-
- ); -} diff --git a/examples/ecom-carousel/src/components/product-card.css.ts b/examples/ecom-carousel/src/components/product-card.css.ts new file mode 100644 index 000000000..7ae78b729 --- /dev/null +++ b/examples/ecom-carousel/src/components/product-card.css.ts @@ -0,0 +1,181 @@ +import { keyframes, style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { colors, primitives } from "../design/tokens"; + +// Title lines before truncation (D1: 2-line clamp). Exported so the skeleton +// reserves the same number of lines. +export const TITLE_LINES = 2; + +// `framed: true` gives each card its own container; the default is a plain +// layout shell (pair it with a framed carousel instead). Pick one, not both. +export const card = recipe({ + base: { + display: "flex", + flexDirection: "column", + gap: primitives.space["3xs"], + width: "100%", + textAlign: "left", + }, + variants: { + framed: { + // D2: each card boxed. Alpic card radius (20px), hairline border, surface. + true: { + padding: primitives.space["3xs"], + borderRadius: primitives.radius.l, + border: `${primitives.stroke.thin} solid ${colors.border.thin}`, + backgroundColor: colors.surface.extraLight, + }, + false: {}, + }, + }, + defaultVariants: { framed: false }, +}); + +// Turns a whole card into one tap target (opens the detail view) using the +// stretched-link pattern: the card renders normally, and a transparent -
- {products.map((product) => { - const inCart = cartIds.includes(product.id); - return ( -
- - -
- ); - })} -
-
-
{activeProduct.title}
-
- ⭐ {activeProduct.rating.rate} ({activeProduct.rating.count} reviews) -
-
{activeProduct.description}
-
- + {cells} + + {loading ? null : ( + <> + + + + )} +
); } diff --git a/examples/ecom-carousel/src/components/variant-picker.css.ts b/examples/ecom-carousel/src/components/variant-picker.css.ts new file mode 100644 index 000000000..15f4b83aa --- /dev/null +++ b/examples/ecom-carousel/src/components/variant-picker.css.ts @@ -0,0 +1,26 @@ +import { style } from "@vanilla-extract/css"; +import { colors, primitives } from "../design/tokens"; + +export const picker = style({ + display: "flex", + flexDirection: "column", + gap: primitives.space.xs, +}); + +export const section = style({ + display: "flex", + flexDirection: "column", + gap: primitives.space["3xs"], +}); + +export const label = style({ + color: colors.content.subtle, +}); + +// Chip row: wraps to the next line when it overflows. Our axes carry at most a +// handful of values (≤4), so wrapping is fine — no scroll row needed. +export const values = style({ + display: "flex", + flexWrap: "wrap", + gap: primitives.space["3xs"], +}); diff --git a/examples/ecom-carousel/src/components/variant-picker.stories.tsx b/examples/ecom-carousel/src/components/variant-picker.stories.tsx new file mode 100644 index 000000000..e78e32634 --- /dev/null +++ b/examples/ecom-carousel/src/components/variant-picker.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { initialSelection, type Selection } from "../lib/variants.js"; +import type { Product } from "../tools/render-carousel.js"; +import { VariantPicker } from "./variant-picker"; + +// A sparse catalog: {white,42} does not exist, so "42" is hard-disabled under +// "White"; {black,42} is sold out, so it renders struck (yet clickable) under +// "Black". +function variant( + id: string, + color: string, + size: string, + outOfStock = false, +): Product["variants"][number] { + return { + id, + selection: { color, size }, + title: `Sneaker ${color} ${size}`, + price: { amount: 120, currency: "EUR" }, + media: [], + specs: [], + outOfStock, + }; +} + +const PRODUCT: Product = { + id: "sneaker", + options: [ + { + id: "color", + label: "Color", + values: [ + { id: "black", label: "Black" }, + { id: "white", label: "White" }, + ], + }, + { + id: "size", + label: "Size", + values: [ + { id: "40", label: "40" }, + { id: "42", label: "42" }, + ], + }, + ], + variants: [ + variant("s-b-40", "black", "40"), + variant("s-b-42", "black", "42", true), + variant("s-w-40", "white", "40"), + ], + card: { title: "Sneaker", media: [], specs: [] }, +}; + +export const Contingency = () => { + const [selection, setSelection] = useState(() => + initialSelection(PRODUCT), + ); + return ( +
+ +
+ ); +}; diff --git a/examples/ecom-carousel/src/components/variant-picker.tsx b/examples/ecom-carousel/src/components/variant-picker.tsx new file mode 100644 index 000000000..d0cc8fe58 --- /dev/null +++ b/examples/ecom-carousel/src/components/variant-picker.tsx @@ -0,0 +1,70 @@ +import { text } from "../design/tokens"; +import { cx } from "../lib/cx"; +import { applyChoice, axisStates, type Selection } from "../lib/variants.js"; +import type { Product } from "../tools/render-carousel.js"; +import { Chip } from "./chip"; +import * as styles from "./variant-picker.css"; + +type VariantPickerProps = { + product: Product; + selection: Selection; + onChange: (selection: Selection) => void; +}; + +/** + * Renders one section per option axis. Chip states come from axisStates + * (lib/variants.ts, top-down): nonexistent values are hard-disabled, sold-out + * ones struck but clickable (the CTA names the cause), and an axis that does + * not apply hides its row. A pick goes through applyChoice, which keeps + * still-existing later choices and snaps the rest onto a real variant. + * + * This is the in-place model: every axis is local state, no remount. Our axes + * (Color, Material, Length) are all within-product variants, so chips fit; no + * axis needs promotion to a cross-product switch. + */ +export function VariantPicker({ + product, + selection, + onChange, +}: VariantPickerProps) { + return ( +
+ {product.options.map((option) => { + // Empty map: the axis does not apply to the current configuration. + const states = axisStates(product, option.id, selection); + if (states.size === 0) { + return null; + } + return ( +
+ + {option.label} + +
+ {option.values.map((value) => ( + + onChange( + applyChoice(product, selection, option.id, value.id), + ) + } + /> + ))} +
+
+ ); + })} +
+ ); +} diff --git a/examples/ecom-carousel/src/components/view-frame.css.ts b/examples/ecom-carousel/src/components/view-frame.css.ts new file mode 100644 index 000000000..e406e25e6 --- /dev/null +++ b/examples/ecom-carousel/src/components/view-frame.css.ts @@ -0,0 +1,22 @@ +import { globalStyle, style } from "@vanilla-extract/css"; +import { colors, primitives } from "../design/tokens"; + +/** + * Base frame for every view. Transparent so the widget blends into the host + * surface; it pins the design system's font + content color so descendants + * inherit from the DS, not from the host page (whose text color could be white + * on a dark host and make card text vanish). + */ +export const viewFrame = style({ + color: colors.content.intense, + fontFamily: primitives.font.family.primary, + fontSize: primitives.font.size.m, + lineHeight: primitives.font.lineHeight.normal, + WebkitFontSmoothing: "antialiased", + MozOsxFontSmoothing: "grayscale", +}); + +// Box-sizing reset scoped to the frame subtree. +globalStyle(`${viewFrame} *, ${viewFrame} *::before, ${viewFrame} *::after`, { + boxSizing: "border-box", +}); diff --git a/examples/ecom-carousel/src/components/view-frame.tsx b/examples/ecom-carousel/src/components/view-frame.tsx new file mode 100644 index 000000000..c3d2b64e6 --- /dev/null +++ b/examples/ecom-carousel/src/components/view-frame.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from "react"; +import { useLayout } from "skybridge/web"; +import { darkTheme, lightTheme } from "../design/tokens"; +import { cx } from "../lib/cx"; +import { viewFrame } from "./view-frame.css"; + +/** + * ViewFrame: the top-level wrapper every view mounts inside. It activates the + * design system by applying the active theme class (which sets the CSS + * variables the color contract declares) and paints the base surface + font + + * content color so descendants inherit from the DS instead of the host page. + * + * It follows the host theme via useLayout(). To lock the widget to one palette + * (e.g. always light), drop useLayout and hardcode the theme class. + * + * Keep view entry files thin: render at the root and let this carry + * the theme + base-style boilerplate. + */ +type ViewFrameProps = HTMLAttributes; + +export function ViewFrame({ className, ...rest }: ViewFrameProps) { + const { theme } = useLayout(); + const themeClass = theme === "dark" ? darkTheme : lightTheme; + + return
; +} +ViewFrame.displayName = "ViewFrame"; diff --git a/examples/ecom-carousel/src/config.ts b/examples/ecom-carousel/src/config.ts new file mode 100644 index 000000000..4afcc2561 --- /dev/null +++ b/examples/ecom-carousel/src/config.ts @@ -0,0 +1,12 @@ +// App-wide tuning knobs, shared by the server prompt and the tools. + +// Tiny 3-product catalog: one good search is enough, don't force busywork. +// ponytail: bump if the catalog grows. +export const MIN_SEARCH_ITERATIONS = 1; + +// Whole catalog is 3 products, so the carousel never shows more. +export const CAROUSEL_MAX_SIZE = 3; + +// A "min-max" string (e.g. "3-6") derived from CAROUSEL_MAX_SIZE, interpolated +// into the prompts to tell the model how many products to curate toward. +export const CAROUSEL_RANGE = `${Math.ceil(CAROUSEL_MAX_SIZE / 2)}-${CAROUSEL_MAX_SIZE}`; diff --git a/examples/ecom-carousel/src/design/contract.css.ts b/examples/ecom-carousel/src/design/contract.css.ts new file mode 100644 index 000000000..441152b25 --- /dev/null +++ b/examples/ecom-carousel/src/design/contract.css.ts @@ -0,0 +1,37 @@ +import { createThemeContract } from "@vanilla-extract/css"; + +/** + * Semantic color contract. Every field must be satisfied by both + * themes/light.css.ts and themes/dark.css.ts: TypeScript errors on drift. + * + * Adding a field here forces both themes to set a value. The compiler is the + * enforcement mechanism that keeps light and dark coherent. The template's slot + * set fits the Alpic UI as-is; names stay intent-based (surface.subtle, + * content.intense), so components reference meaning and the theme decides hex. + */ +export const colors = createThemeContract({ + surface: { + extraLight: null, + light: null, + subtle: null, + intense: null, + }, + content: { + intense: null, + subtle: null, + invertIntense: null, + invertSubtle: null, + }, + border: { + thin: null, + subtle: null, + intense: null, + }, + common: { + accent: null, + invertAccent: null, + highlight: null, + error: null, + success: null, + }, +}); diff --git a/examples/ecom-carousel/src/design/fonts.css b/examples/ecom-carousel/src/design/fonts.css new file mode 100644 index 000000000..3922f851b --- /dev/null +++ b/examples/ecom-carousel/src/design/fonts.css @@ -0,0 +1,6 @@ +/* Alpic brand font: Mozilla Text (Google font, variable 200–700 weight axis). + Loaded from Google Fonts; both hosts are allowed in the view CSP + (fonts.googleapis.com for this stylesheet, fonts.gstatic.com for the woff2). + Source: https://fonts.google.com/specimen/Mozilla+Text (latin, v1). */ + +@import url("https://fonts.googleapis.com/css2?family=Mozilla+Text:wght@200..700&display=swap"); diff --git a/examples/ecom-carousel/src/design/primitives.css.ts b/examples/ecom-carousel/src/design/primitives.css.ts new file mode 100644 index 000000000..e2d93b487 --- /dev/null +++ b/examples/ecom-carousel/src/design/primitives.css.ts @@ -0,0 +1,107 @@ +import { createGlobalTheme } from "@vanilla-extract/css"; + +/** + * Non-mode-aware design primitives: the raw scales every theme and recipe + * draws from. Values are the Alpic brand tokens, extracted from the live + * alpic.ai site (Framer inlines styles, so these are observed computed values, + * not a formal named ramp — see SPEC phase 5). Provenance: https://alpic.ai. + * Change values, not the shape: the space/radius/font keys are referenced by + * sprinkles.css.ts and the recipes. + */ +export const primitives = createGlobalTheme(":root", { + // Spacing scale (4px based). Used for padding, margin, and gap via sprinkles. + space: { + none: "0", + "5xs": "2px", + "4xs": "4px", + "3xs": "8px", + "2xs": "12px", + xs: "16px", + s: "24px", + m: "32px", + l: "40px", + xl: "48px", + "2xl": "56px", + "3xl": "64px", + }, + radius: { + none: "0", + xs: "2px", + s: "4px", + m: "8px", // Alpic buttons + l: "20px", // Alpic cards + xl: "32px", + full: "999px", + }, + font: { + family: { + // Alpic uses "Mozilla Text" for display + body (self-hosted, fonts.css). + primary: '"Mozilla Text", system-ui, sans-serif', + }, + weight: { + regular: "400", + medium: "500", + semibold: "600", + bold: "700", + }, + size: { + xs: "12px", + s: "14px", + m: "16px", + l: "20px", + xl: "24px", + "2xl": "32px", + "3xl": "40px", + }, + lineHeight: { + tight: "1.1", // Alpic headings run tight (1.1) + normal: "1.4", // Alpic body line-height + }, + letterSpacing: { + default: "0", + // Alpic headings carry negative tracking (~-0.02em on the hero). + tight: "-0.02em", + wide: "0.06em", // eyebrow labels are tracked out + uppercase + }, + }, + stroke: { + thin: "1px", + medium: "1.5px", + thick: "2px", + }, + // Neutral ramp — Alpic's "black" is a near-black teal-green, so the whole + // ramp is green-tinted (white/50/100/200 observed on the site; the mid steps + // are interpolated to fill the ramp coherently). + grey: { + white: "#ffffff", + "50": "#f5f9fa", // light page surface + "100": "#eaf4f3", // mint-tinted surface / image stage + "200": "#e6e8e6", // hairline border + "300": "#c6d2d0", + "400": "#9daba9", + "500": "#6e7c7a", // muted text on light + "600": "#465250", + "700": "#303837", // elevated panel on dark + "800": "#16211f", + "900": "#081c1b", // dark page surface + black: "#051413", // brand near-black (teal-green) + }, + // Magenta scale: the secondary highlight (themes map it to common.highlight). + // 500 resting; 600 hover/darker; 400 a lighter step that pops on dark. + accent: { + "400": "#f2477f", + "500": "#ed115e", + "600": "#c80850", + }, + // Mint/cyan scale: the primary brand accent / CTA fill (common.accent). + // 300 is the bright on-dark step; 700 a deepened step readable on light. + mint: { + "300": "#89f0ec", + "700": "#1e7a76", + }, + // Status colors, shared across themes. + status: { + error: "#c53929", + success: "#5c7d0b", + }, +}); diff --git a/examples/ecom-carousel/src/design/recipes/typography.css.ts b/examples/ecom-carousel/src/design/recipes/typography.css.ts new file mode 100644 index 000000000..122b8316a --- /dev/null +++ b/examples/ecom-carousel/src/design/recipes/typography.css.ts @@ -0,0 +1,75 @@ +import { recipe } from "@vanilla-extract/recipes"; +import { primitives } from "../primitives.css"; + +/** + * The one text style, driven by variants. Call it as: + * text({ style: "headingM", weight: "medium" }) + * + * `style` picks the size/line-height pair; `weight` picks the font weight. + * This is the workhorse recipe: use it for all typography rather than setting + * fontSize/fontWeight by hand. Headings carry Alpic's tight negative tracking. + */ +export const text = recipe({ + base: { + fontFamily: primitives.font.family.primary, + letterSpacing: primitives.font.letterSpacing.default, + fontWeight: primitives.font.weight.regular, + margin: 0, + }, + variants: { + style: { + display: { + fontSize: primitives.font.size["3xl"], + lineHeight: primitives.font.lineHeight.tight, + letterSpacing: primitives.font.letterSpacing.tight, + }, + headingL: { + fontSize: primitives.font.size["2xl"], + lineHeight: primitives.font.lineHeight.tight, + letterSpacing: primitives.font.letterSpacing.tight, + }, + headingM: { + fontSize: primitives.font.size.xl, + lineHeight: primitives.font.lineHeight.tight, + letterSpacing: primitives.font.letterSpacing.tight, + }, + headingS: { + fontSize: primitives.font.size.l, + lineHeight: primitives.font.lineHeight.tight, + letterSpacing: primitives.font.letterSpacing.tight, + }, + bodyM: { + fontSize: primitives.font.size.m, + lineHeight: primitives.font.lineHeight.normal, + }, + bodyS: { + fontSize: primitives.font.size.s, + lineHeight: primitives.font.lineHeight.normal, + }, + labelM: { + fontSize: primitives.font.size.m, + lineHeight: primitives.font.lineHeight.tight, + }, + labelS: { + fontSize: primitives.font.size.s, + lineHeight: primitives.font.lineHeight.tight, + }, + overline: { + fontSize: primitives.font.size.xs, + lineHeight: primitives.font.lineHeight.normal, + letterSpacing: primitives.font.letterSpacing.wide, + textTransform: "uppercase", + }, + }, + weight: { + regular: { fontWeight: primitives.font.weight.regular }, + medium: { fontWeight: primitives.font.weight.medium }, + semibold: { fontWeight: primitives.font.weight.semibold }, + bold: { fontWeight: primitives.font.weight.bold }, + }, + }, + defaultVariants: { + style: "bodyM", + weight: "regular", + }, +}); diff --git a/examples/ecom-carousel/src/design/sprinkles.css.ts b/examples/ecom-carousel/src/design/sprinkles.css.ts new file mode 100644 index 000000000..577bfa7c2 --- /dev/null +++ b/examples/ecom-carousel/src/design/sprinkles.css.ts @@ -0,0 +1,128 @@ +import { createSprinkles, defineProperties } from "@vanilla-extract/sprinkles"; +import { colors } from "./contract.css"; +import { primitives } from "./primitives.css"; + +/** + * Atomic style props built on the primitives + color contract. Use sprinkles + * for one-off layout/spacing/color on an element, e.g. + * sprinkles({ display: "flex", gap: "s", color: "intense" }) + * + * Structural component styling belongs in a co-located `.css.ts` `style()` + * block; sprinkles is the thin glue layer on top. + */ + +const spaceProperties = defineProperties({ + properties: { + padding: primitives.space, + paddingTop: primitives.space, + paddingRight: primitives.space, + paddingBottom: primitives.space, + paddingLeft: primitives.space, + margin: primitives.space, + marginTop: primitives.space, + marginRight: primitives.space, + marginBottom: primitives.space, + marginLeft: primitives.space, + gap: primitives.space, + rowGap: primitives.space, + columnGap: primitives.space, + }, + shorthands: { + p: ["padding"], + pt: ["paddingTop"], + pr: ["paddingRight"], + pb: ["paddingBottom"], + pl: ["paddingLeft"], + px: ["paddingLeft", "paddingRight"], + py: ["paddingTop", "paddingBottom"], + m: ["margin"], + mt: ["marginTop"], + mr: ["marginRight"], + mb: ["marginBottom"], + ml: ["marginLeft"], + mx: ["marginLeft", "marginRight"], + my: ["marginTop", "marginBottom"], + }, +}); + +const colorProperties = defineProperties({ + properties: { + backgroundColor: { + ...colors.surface, + accent: colors.common.accent, + invertAccent: colors.common.invertAccent, + highlight: colors.common.highlight, + transparent: "transparent", + }, + color: { + ...colors.content, + accent: colors.common.accent, + invertAccent: colors.common.invertAccent, + highlight: colors.common.highlight, + error: colors.common.error, + success: colors.common.success, + }, + borderColor: { + ...colors.border, + accent: colors.common.accent, + invertAccent: colors.common.invertAccent, + highlight: colors.common.highlight, + transparent: "transparent", + }, + }, +}); + +const radiusProperties = defineProperties({ + properties: { + borderRadius: primitives.radius, + borderTopLeftRadius: primitives.radius, + borderTopRightRadius: primitives.radius, + borderBottomLeftRadius: primitives.radius, + borderBottomRightRadius: primitives.radius, + }, +}); + +const typographyProperties = defineProperties({ + properties: { + fontFamily: primitives.font.family, + fontWeight: primitives.font.weight, + fontSize: primitives.font.size, + lineHeight: primitives.font.lineHeight, + letterSpacing: primitives.font.letterSpacing, + }, +}); + +const strokeProperties = defineProperties({ + properties: { + borderWidth: primitives.stroke, + }, +}); + +const layoutProperties = defineProperties({ + properties: { + display: ["none", "flex", "inline-flex", "block", "inline-block", "grid"], + flexDirection: ["row", "column", "row-reverse", "column-reverse"], + alignItems: ["flex-start", "center", "flex-end", "stretch", "baseline"], + justifyContent: [ + "flex-start", + "center", + "flex-end", + "space-between", + "space-around", + "space-evenly", + ], + flexWrap: ["wrap", "nowrap", "wrap-reverse"], + textAlign: ["left", "center", "right"], + }, +}); + +export const sprinkles = createSprinkles( + spaceProperties, + colorProperties, + radiusProperties, + typographyProperties, + strokeProperties, + layoutProperties, +); + +export type Sprinkles = Parameters[0]; diff --git a/examples/ecom-carousel/src/design/themes/dark.css.ts b/examples/ecom-carousel/src/design/themes/dark.css.ts new file mode 100644 index 000000000..2cbfca02a --- /dev/null +++ b/examples/ecom-carousel/src/design/themes/dark.css.ts @@ -0,0 +1,34 @@ +import { createTheme } from "@vanilla-extract/css"; +import { colors } from "../contract.css"; +import { primitives } from "../primitives.css"; + +/** + * Dark palette (Alpic dark-section treatment): teal-green near-black surfaces, + * white text, mint accent, magenta highlight. Same slots as light.css.ts. + */ +export const darkTheme = createTheme(colors, { + surface: { + extraLight: primitives.grey.black, // base (#051413) + light: primitives.grey["900"], // raised page (#081c1b) + subtle: primitives.grey["700"], // cards / panels (#303837) + intense: primitives.grey["500"], + }, + content: { + intense: primitives.grey["50"], // near-white text + subtle: primitives.grey["300"], // muted + invertIntense: primitives.grey.black, // text on light fills + invertSubtle: primitives.grey["500"], + }, + border: { + thin: primitives.grey["700"], + subtle: primitives.grey["600"], + intense: primitives.grey["400"], + }, + common: { + accent: primitives.mint["300"], // bright mint pops on dark + invertAccent: primitives.grey.black, // dark text on bright mint (white fails) + highlight: primitives.accent["400"], // lighter magenta highlight on dark + error: primitives.status.error, + success: primitives.status.success, + }, +}); diff --git a/examples/ecom-carousel/src/design/themes/light.css.ts b/examples/ecom-carousel/src/design/themes/light.css.ts new file mode 100644 index 000000000..b7ced1ed2 --- /dev/null +++ b/examples/ecom-carousel/src/design/themes/light.css.ts @@ -0,0 +1,34 @@ +import { createTheme } from "@vanilla-extract/css"; +import { colors } from "../contract.css"; +import { primitives } from "../primitives.css"; + +/** + * Light palette (Alpic light-section treatment): white cards on an off-white + * page, teal-green text, mint accent. Fills the same slots as dark.css.ts. + */ +export const lightTheme = createTheme(colors, { + surface: { + extraLight: primitives.grey.white, // cards + light: primitives.grey["50"], // page background (#f5f9fa) + subtle: primitives.grey["100"], // mint-tinted stage behind images + intense: primitives.grey["200"], + }, + content: { + intense: primitives.grey.black, // primary text (#051413) + subtle: primitives.grey["500"], // muted + invertIntense: primitives.grey["50"], // text on dark fills + invertSubtle: primitives.grey["300"], + }, + border: { + thin: primitives.grey["200"], // #e6e8e6 hairline + subtle: primitives.grey["300"], + intense: primitives.grey["400"], + }, + common: { + accent: primitives.mint["700"], // brand mint CTA fill (readable on light) + invertAccent: primitives.grey.white, // white text on mint + highlight: primitives.accent["500"], // magenta highlight + error: primitives.status.error, + success: primitives.status.success, + }, +}); diff --git a/examples/ecom-carousel/src/design/tokens.ts b/examples/ecom-carousel/src/design/tokens.ts new file mode 100644 index 000000000..e3d932325 --- /dev/null +++ b/examples/ecom-carousel/src/design/tokens.ts @@ -0,0 +1,8 @@ +// Barrel for the design system. Import tokens from here rather than reaching +// into individual files. +export { colors } from "./contract.css"; +export { primitives } from "./primitives.css"; +export { text } from "./recipes/typography.css"; +export { type Sprinkles, sprinkles } from "./sprinkles.css"; +export { darkTheme } from "./themes/dark.css"; +export { lightTheme } from "./themes/light.css"; diff --git a/examples/ecom-carousel/src/env.ts b/examples/ecom-carousel/src/env.ts deleted file mode 100644 index a5411ea7f..000000000 --- a/examples/ecom-carousel/src/env.ts +++ /dev/null @@ -1,12 +0,0 @@ -import "dotenv/config"; - -import { createEnv } from "@t3-oss/env-core"; -import { z } from "zod"; - -export const env = createEnv({ - server: { - STRIPE_SECRET_KEY: z.string().min(1), - }, - runtimeEnv: process.env, - emptyStringAsUndefined: true, -}); diff --git a/examples/ecom-carousel/src/helpers.ts b/examples/ecom-carousel/src/helpers.ts index 9fb4d6fa0..0f4e05387 100644 --- a/examples/ecom-carousel/src/helpers.ts +++ b/examples/ecom-carousel/src/helpers.ts @@ -1,4 +1,4 @@ import { generateHelpers } from "skybridge/web"; import type { AppType } from "./server.js"; -export const { useCallTool, useToolInfo } = generateHelpers(); +export const { useToolInfo, useCallTool } = generateHelpers(); diff --git a/examples/ecom-carousel/src/hooks/use-checkout-polling.ts b/examples/ecom-carousel/src/hooks/use-checkout-polling.ts deleted file mode 100644 index 7b34f1b2e..000000000 --- a/examples/ecom-carousel/src/hooks/use-checkout-polling.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useCallTool } from "../helpers.js"; - -export type CheckoutPhase = "idle" | "polling" | "complete" | "expired"; - -type CheckoutState = - | { phase: "idle" } - | { phase: "polling"; sessionId: string } - | { phase: "complete" } - | { phase: "expired" }; - -const POLL_INTERVAL_MS = 3000; -const POLL_TIMEOUT_MS = 5 * 60 * 1000; - -export function useCheckoutPolling() { - const [checkoutState, setCheckoutState] = useState({ - phase: "idle", - }); - - const { callToolAsync: checkStatus } = useCallTool("check-checkout-status"); - - const pollingRef = useRef | null>(null); - const timeoutRef = useRef | null>(null); - - const stopPolling = useCallback(() => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); - - useEffect(() => { - return () => stopPolling(); - }, [stopPolling]); - - const startPolling = useCallback( - (sessionId: string) => { - stopPolling(); - setCheckoutState({ phase: "polling", sessionId }); - - pollingRef.current = setInterval(async () => { - try { - const result = await checkStatus({ sessionId }); - const status = result.structuredContent?.status; - if (status === "complete") { - stopPolling(); - setCheckoutState({ phase: "complete" }); - } else if (status === "expired") { - stopPolling(); - setCheckoutState({ phase: "expired" }); - } - } catch { - // Stripe may temporarily error — keep polling - } - }, POLL_INTERVAL_MS); - - timeoutRef.current = setTimeout(() => { - stopPolling(); - setCheckoutState((prev) => - prev.phase === "polling" ? { phase: "expired" } : prev, - ); - }, POLL_TIMEOUT_MS); - }, - [stopPolling, checkStatus], - ); - - const reset = useCallback(() => { - stopPolling(); - setCheckoutState({ phase: "idle" }); - }, [stopPolling]); - - return { - phase: checkoutState.phase, - startPolling, - reset, - }; -} diff --git a/examples/ecom-carousel/src/i18n.ts b/examples/ecom-carousel/src/i18n.ts index 04060d5d2..46a1a8fa0 100644 --- a/examples/ecom-carousel/src/i18n.ts +++ b/examples/ecom-carousel/src/i18n.ts @@ -1,81 +1,34 @@ import { useUser } from "skybridge/web"; -const translations: Record> = { +// Centralized UI labels. The active locale comes from the host via useUser(); +// useLabels matches on the language subtag ("en-US" -> "en") and falls back to +// English for anything unlisted. English only for now; add a locale key (e.g. +// `fr`) with the same shape to support another language. +const LABELS = { en: { - loading: "Loading products...", - noProducts: "No product found", - addToCart: "Add to cart", - removeFromCart: "Remove", - checkout: "Checkout", - orderSummary: "Order summary", - total: "Total", - payWithStripe: "Pay with Stripe", - creatingSession: "Redirecting to checkout...", - waitingForPayment: "Waiting for payment...", - paymentSuccess: "Payment successful!", - itemCount_one: "1 item", - itemCount_other: "{count} items", - paymentExpired: "Checkout session expired", - backToProducts: "Back to products", + outOfStock: "Out of stock", + combinationUnavailable: "Combination unavailable", + noProducts: "No products to show.", + carousel: "carousel", + products: "Products", + previous: "Previous", + next: "Next", + // Detail view. + reference: "Ref.", + viewOnSite: "View on Skybridge", + priceOnRequest: "Price on request", + specifications: "Specifications", + readMore: "Read more", + readLess: "Read less", }, - fr: { - loading: "Chargement des produits...", - noProducts: "Aucun produit trouvé", - addToCart: "Ajouter", - removeFromCart: "Retirer", - checkout: "Payer", - orderSummary: "Récapitulatif de commande", - total: "Total", - payWithStripe: "Payer avec Stripe", - creatingSession: "Redirection vers le paiement...", - waitingForPayment: "En attente du paiement...", - paymentSuccess: "Paiement réussi !", - itemCount_one: "1 article", - itemCount_other: "{count} articles", - paymentExpired: "Session de paiement expirée", - backToProducts: "Retour aux produits", - }, - es: { - loading: "Cargando productos...", - noProducts: "No se encontraron productos", - addToCart: "Añadir", - removeFromCart: "Quitar", - checkout: "Pagar", - orderSummary: "Resumen del pedido", - total: "Total", - payWithStripe: "Pagar con Stripe", - creatingSession: "Redirigiendo al pago...", - waitingForPayment: "Esperando el pago...", - paymentSuccess: "Pago exitoso!", - itemCount_one: "1 artículo", - itemCount_other: "{count} artículos", - paymentExpired: "Sesión de pago expirada", - backToProducts: "Volver a productos", - }, - de: { - loading: "Produkte werden geladen...", - noProducts: "Keine Produkte gefunden", - addToCart: "Hinzufügen", - removeFromCart: "Entfernen", - checkout: "Zur Kasse", - orderSummary: "Bestellübersicht", - total: "Gesamt", - payWithStripe: "Mit Stripe bezahlen", - creatingSession: "Weiterleitung zur Kasse...", - waitingForPayment: "Warte auf Zahlung...", - paymentSuccess: "Zahlung erfolgreich!", - itemCount_one: "1 Artikel", - itemCount_other: "{count} Artikel", - paymentExpired: "Zahlungssitzung abgelaufen", - backToProducts: "Zurück zu Produkten", - }, -}; +} as const; -export function useTranslate() { - const { locale } = useUser(); - const lang = locale?.split("-")[0] ?? "en"; +const DEFAULT_LOCALE = "en"; + +export type Labels = (typeof LABELS)[typeof DEFAULT_LOCALE]; - return function t(key: string) { - return translations[lang]?.[key] ?? translations.en[key]; - }; +export function useLabels(): Labels { + const { locale } = useUser(); + const lang = locale.split("-")[0] ?? DEFAULT_LOCALE; + return lang in LABELS ? LABELS[lang as keyof typeof LABELS] : LABELS.en; } diff --git a/examples/ecom-carousel/src/index.css b/examples/ecom-carousel/src/index.css index 3cf34da19..08ed3c0de 100644 --- a/examples/ecom-carousel/src/index.css +++ b/examples/ecom-carousel/src/index.css @@ -1,337 +1,9 @@ -.light { - --bg: #fff; - --bg-alt: #f5f5f5; - --text: #333; - --text-muted: #666; - --shadow: rgba(0, 0, 0, 0.1); -} - -.dark { - --bg: #2a2a2a; - --bg-alt: #333; - --text: #eee; - --text-muted: #aaa; - --shadow: rgba(0, 0, 0, 0.3); -} - -.container, -.checkout { - --accent: mediumseagreen; - --accent-text: #fff; - display: flex; - flex-direction: column; - font-family: sans-serif; -} - -.carousel { - display: flex; - gap: 1rem; - padding: 1rem; - overflow-x: auto; - scrollbar-width: none; -} - -.product-wrapper { - flex: 0 0 150px; - max-width: 150px; - display: flex; - flex-direction: column; - gap: 0.5rem; -} +@import "./design/fonts.css"; -.product-card { - background: var(--bg); - border: none; - border-radius: 8px; - box-shadow: 0 2px 8px var(--shadow); - cursor: pointer; +/* The view mounts in a host iframe whose keeps the user-agent default + margin (~8px). Zero it out so the surface paints edge to edge. */ +html, +body { + margin: 0; padding: 0; - text-align: left; -} - -.product-card.selected { - outline: 2px solid var(--accent); -} - -.product-image { - width: 100%; - height: 100px; - object-fit: contain; - background: var(--bg-alt); - padding: 0.5rem; - box-sizing: border-box; - border-radius: 8px 8px 0 0; -} - -.product-info { - padding: 0.5rem; -} - -.product-title { - font-size: 0.75rem; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.product-price { - font-size: 0.875rem; - font-weight: bold; - color: var(--accent); - margin-top: 0.25rem; -} - -.product-detail { - margin: 0 1rem 1rem; - padding: 1rem; - background: var(--bg); - border-radius: 8px; - box-shadow: 0 2px 8px var(--shadow); -} - -.detail-title { - font-weight: bold; - color: var(--text); - margin-bottom: 0.5rem; -} - -.detail-rating { - font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 0.5rem; -} - -.detail-description { - font-size: 0.8rem; - color: var(--text-muted); - line-height: 1.4; -} - -.message { - margin: 1rem; - padding: 1rem; - background: var(--bg); - border-radius: 8px; - box-shadow: 0 2px 8px var(--shadow); - color: var(--text-muted); - text-align: center; -} - -.cart-indicator { - padding: 0.5rem 1rem; - align-self: flex-end; - color: var(--accent-text); - background: var(--accent); - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: bold; - margin-right: 1rem; -} - -.cart-indicator:disabled { - background: var(--bg-alt); - color: var(--text-muted); - cursor: default; -} - -.cart-button { - padding: 0.5rem; - border: none; - border-radius: 6px; - cursor: pointer; - background: var(--accent); - color: var(--accent-text); -} - -.cart-button.in-cart { - background: var(--bg-alt); - color: var(--text-muted); -} - -.checkout { - gap: 1rem; - padding: 1.25rem; -} - -.checkout-title { - font-weight: bold; - font-size: 1rem; - color: var(--text); -} - -.checkout-items { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.checkout-item { - display: flex; - justify-content: space-between; - font-size: 0.875rem; - color: var(--text); -} - -.checkout-total { - display: flex; - justify-content: space-between; - font-weight: bold; - color: var(--text); - border-top: 1px solid var(--bg-alt); - padding-top: 0.5rem; -} - -.checkout-button { - padding: 0.75rem; - border: none; - border-radius: 6px; - cursor: pointer; - background: var(--accent); - color: var(--accent-text); - font-weight: bold; -} - -.checkout-button:disabled { - opacity: 0.6; - cursor: default; -} - -.checkout-button.polling { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -/* Success card */ - -.success-card { - --success: #248a52; - --success-bg: #edf8f2; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - padding: 1.75rem 1.25rem 1.25rem; - background: var(--success-bg); - border-radius: 12px; -} - -.dark .success-card { - --success: #4cd68a; - --success-bg: #162e22; -} - -.success-icon { - color: var(--success); - animation: pop-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes pop-in { - 0% { - transform: scale(0); - opacity: 0; - } - 100% { - transform: scale(1); - opacity: 1; - } -} - -.success-header { - text-align: center; - margin-bottom: 0.25rem; -} - -.success-title { - font-size: 1.05rem; - font-weight: 700; - color: var(--success); -} - -.success-subtitle { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 0.15rem; -} - -.success-divider { - width: 100%; - height: 1px; - background: var(--success); - opacity: 0.15; -} - -.success-items { - width: 100%; - display: flex; - flex-direction: column; - gap: 0.4rem; - padding: 0.25rem 0; -} - -.success-item { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 0.8rem; - color: var(--text); -} - -.success-item-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-right: 0.75rem; -} - -.success-item-price { - flex-shrink: 0; - font-variant-numeric: tabular-nums; - color: var(--text-muted); -} - -.success-total { - width: 100%; - display: flex; - justify-content: space-between; - font-weight: 700; - font-size: 0.95rem; - color: var(--success); -} - -.checkout-status { - text-align: center; - padding: 1.5rem 1rem; - font-size: 1.1rem; - font-weight: bold; - border-radius: 8px; -} - -.checkout-status.expired { - color: #b45309; - background: #fef3c7; -} - -.dark .checkout-status.expired { - color: #fbbf24; - background: #3a2a1a; -} - -.spinner { - display: inline-block; - width: 14px; - height: 14px; - border: 2px solid var(--accent-text); - border-top-color: transparent; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } } diff --git a/examples/ecom-carousel/src/lib/cx.ts b/examples/ecom-carousel/src/lib/cx.ts new file mode 100644 index 000000000..db9011a01 --- /dev/null +++ b/examples/ecom-carousel/src/lib/cx.ts @@ -0,0 +1,9 @@ +/** + * Zero-dep class-name joiner. vanilla-extract's `recipe`/`style` return plain + * strings, so merging a recipe class with an optional consumer `className` + * just needs string concatenation that tolerates `undefined`/`false`/`null`. + * + * className={cx(text({ style: "bodyM" }), sprinkles({ color: "accent" }))} + */ +export const cx = (...classes: (string | false | null | undefined)[]) => + classes.filter(Boolean).join(" "); diff --git a/examples/ecom-carousel/src/lib/format.ts b/examples/ecom-carousel/src/lib/format.ts new file mode 100644 index 000000000..5059d03fd --- /dev/null +++ b/examples/ecom-carousel/src/lib/format.ts @@ -0,0 +1,9 @@ +import type { Price } from "../types.js"; + +// Pass useUser().locale from the view; omit for the runtime default. +export function formatPrice(price: Price, locale?: string): string { + return new Intl.NumberFormat(locale, { + style: "currency", + currency: price.currency, + }).format(price.amount); +} diff --git a/examples/ecom-carousel/src/lib/load-env.ts b/examples/ecom-carousel/src/lib/load-env.ts new file mode 100644 index 000000000..03aa8af0b --- /dev/null +++ b/examples/ecom-carousel/src/lib/load-env.ts @@ -0,0 +1,8 @@ +import { existsSync } from "node:fs"; + +// Load .env into process.env as an import side effect (native to Node, no +// dependency). Imported first so values are available to module-level code — +// e.g. a view's CSP domains — that runs before any tool handler. +if (existsSync(".env")) { + process.loadEnvFile(); +} diff --git a/examples/ecom-carousel/src/lib/medusa.ts b/examples/ecom-carousel/src/lib/medusa.ts new file mode 100644 index 000000000..2ef68aab4 --- /dev/null +++ b/examples/ecom-carousel/src/lib/medusa.ts @@ -0,0 +1,213 @@ +// Shared Medusa v2 Store API client + mapping helpers. Both tools go through +// here so id handling stays consistent between search-products and +// render-carousel. See docs/medusa-store-api.md for the verified API shape. + +// Read lazily: server.ts loads .env in its module body, which runs AFTER this +// module is imported, so reading at import time would see undefined. +function env(): { base: string; key: string } { + const base = process.env.MEDUSA_BASE_URL; + const key = process.env.MEDUSA_PUBLISHABLE_KEY; + if (!base || !key) { + throw new Error( + "MEDUSA_BASE_URL and MEDUSA_PUBLISHABLE_KEY must be set (see .env.template).", + ); + } + return { base, key }; +} + +// The imagery-driving axis (put first in options; drives color-image pairing). +export const COLOR_AXIS = "Color"; + +// --- raw shapes (only the fields we read; Medusa returns much more) --- + +type RawPrice = { calculated_amount: number; currency_code: string }; +type RawVariantOption = { value: string; option?: { title: string } }; +export type RawVariant = { + id: string; + title: string; + sku: string | null; + options: RawVariantOption[]; + calculated_price?: RawPrice | null; + metadata?: Record | null; +}; +type RawImage = { url: string; rank?: number }; +type RawOption = { title: string; values: { value: string }[] }; +export type RawProduct = { + id: string; + title: string; + handle: string; + description: string | null; + thumbnail: string | null; + images?: RawImage[]; + options?: RawOption[]; + variants?: RawVariant[]; + categories?: { id: string; name: string; handle: string }[]; + metadata?: Record | null; +}; + +// --- HTTP --- + +async function fetchJson(path: string, params?: URLSearchParams): Promise { + const { base, key } = env(); + const url = `${base}${path}${params ? `?${params}` : ""}`; + const res = await fetch(url, { headers: { "x-publishable-api-key": key } }); + if (!res.ok) { + throw new Error(`Medusa ${res.status} on ${path}: ${await res.text()}`); + } + return res.json() as Promise; +} + +// Single Europe/EUR region; fetched once and memoized. Prices require it — +// never invent the id (docs/medusa-store-api.md pricing quirk). +let regionIdPromise: Promise | undefined; +function getRegionId(): Promise { + regionIdPromise ??= fetchJson<{ regions: { id: string }[] }>( + "/store/regions", + ).then((r) => { + const id = r.regions[0]?.id; + if (!id) throw new Error("No Medusa region available for pricing."); + return id; + }); + return regionIdPromise; +} + +// Category handle -> id, fetched once and memoized. The handle (e.g. "skis") is +// the stable public identifier; the id is store-specific, so never hardcode it. +let categoryIdsPromise: Promise> | undefined; +function getCategoryId(handle: string): Promise { + categoryIdsPromise ??= fetchJson<{ + product_categories: { id: string; handle: string }[]; + }>( + "/store/product-categories", + new URLSearchParams({ fields: "id,handle", limit: "100" }), + ).then((r) => new Map(r.product_categories.map((c) => [c.handle, c.id]))); + return categoryIdsPromise.then((m) => m.get(handle)); +} + +const SEARCH_FIELDS = + "id,title,handle,description,thumbnail,metadata,*categories,*variants.calculated_price,*variants.metadata"; +const DETAIL_FIELDS = + "id,title,handle,description,thumbnail,metadata,*images,*options,*options.values,*variants,*variants.options,*variants.calculated_price,*variants.metadata"; + +export type ProductQuery = { keyword?: string; category?: string; order?: string }; + +export async function fetchSearch( + q: ProductQuery, +): Promise<{ products: RawProduct[]; count: number }> { + const region = await getRegionId(); + const params = new URLSearchParams({ + region_id: region, + fields: SEARCH_FIELDS, + limit: "50", + }); + if (q.keyword) params.set("q", q.keyword); + if (q.order) params.set("order", q.order); + if (q.category) { + const catId = await getCategoryId(q.category); + if (catId) params.append("category_id[]", catId); + } + return fetchJson<{ products: RawProduct[]; count: number }>( + "/store/products", + params, + ); +} + +export async function fetchByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + const region = await getRegionId(); + const params = new URLSearchParams({ + region_id: region, + fields: DETAIL_FIELDS, + limit: String(ids.length), + }); + for (const id of ids) params.append("id[]", id); + const { products } = await fetchJson<{ products: RawProduct[] }>( + "/store/products", + params, + ); + // Medusa doesn't preserve id[] order — restore the requested order. + const byId = new Map(products.map((p) => [p.id, p])); + return ids + .map((id) => byId.get(id)) + .filter((p): p is RawProduct => Boolean(p)); +} + +// --- mapping helpers --- + +export const slug = (s: string) => + s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + +// metadata readers: ratings/badges/specs live in metadata, not native fields. +type Meta = Record | null | undefined; + +export function readNumber(meta: Meta, key: string): number | undefined { + const v = meta?.[key]; + return typeof v === "number" ? v : undefined; +} + +export function readBadges(meta: Meta): string[] { + const v = meta?.tags; + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +export function readSpecs(meta: Meta): { label?: string; value: string }[] { + const v = meta?.specs; + if (!Array.isArray(v)) return []; + const out: { label?: string; value: string }[] = []; + for (const s of v) { + if (s && typeof s === "object" && typeof (s as { value?: unknown }).value === "string") { + const label = (s as { label?: unknown }).label; + out.push({ + value: (s as { value: string }).value, + ...(typeof label === "string" ? { label } : {}), + }); + } + } + return out; +} + +// A variant's value on a given option axis (matched by axis title). +export function variantValue(v: RawVariant, axisTitle: string): string | undefined { + return v.options.find((o) => o.option?.title === axisTitle)?.value; +} + +export function priceOf(v: RawVariant): number | undefined { + return v.calculated_price?.calculated_amount; +} + +export function currencyOf(v: RawVariant): string { + return (v.calculated_price?.currency_code ?? "eur").toUpperCase(); +} + +// "From" price: min across variants, falling back to metadata.from_price. +export function fromPrice(p: RawProduct): number | undefined { + const amounts = (p.variants ?? []) + .map(priceOf) + .filter((n): n is number => typeof n === "number"); + if (amounts.length) return Math.min(...amounts); + return readNumber(p.metadata, "from_price"); +} + +// Variant-image pairing (D4): product images whose filename encodes one of the +// variant's option values (`*-{value}-*.webp`), falling back to all images when +// none match. No structured variant↔image link exists — this is the filename +// heuristic. Matching ALL the variant's values (not just color) catches cases +// where the photo is named by another axis, e.g. Chapka's `chapka-fur-1.webp` +// (Material) for the Natural/Fur variant. Numeric axes (length 160/170) never +// appear in filenames, so they match nothing and cause no false positives. +export function imagesForValues( + images: RawImage[] | undefined, + values: (string | undefined)[], +): string[] { + const urls = (images ?? []).map((i) => i.url); + const slugs = values + .filter((v): v is string => Boolean(v)) + .map(slug) + .filter(Boolean); + if (!slugs.length) return urls; + const matched = urls.filter((u) => { + const lower = u.toLowerCase(); + return slugs.some((s) => lower.includes(`-${s}-`) || lower.includes(`-${s}.`)); + }); + return matched.length ? matched : urls; +} diff --git a/examples/ecom-carousel/src/lib/variants.ts b/examples/ecom-carousel/src/lib/variants.ts new file mode 100644 index 000000000..7e8dc7f54 --- /dev/null +++ b/examples/ecom-carousel/src/lib/variants.ts @@ -0,0 +1,150 @@ +import type { Product, Variant } from "../tools/render-carousel.js"; + +// Pure helpers that turn the client's option choices into a concrete variant. +// The `variants` list is SPARSE: only combinations that exist are present, and +// that is the whole contingency model. These helpers never encode rules; they +// filter the list. A variant may also skip an axis entirely (a one-size cap in +// a capacity x size catalog carries no `size`): that axis is simply "not +// applicable" to it, and the helpers treat the absence as its own value. +// +// Availability is computed TOP-DOWN (the Shopify convention): an axis is +// constrained only by the choices on the axes declared BEFORE it. So the first +// axis is never disabled by a later choice, and each later axis narrows under +// the ones above. Order the `options` array accordingly. + +// A selection is one chosen value per axis, keyed by Option.id -> OptionValue.id. +export type Selection = Record; + +/** + * The variant that exactly matches a selection, or undefined if none does. + * Strict per-axis equality with "missing" normalized to null: a partial + * selection resolves nothing, and a variant that skips an axis matches only a + * selection that also leaves it unset. A product with no options resolves to + * its single variant on an empty selection. + */ +export function resolveVariant( + product: Product, + selection: Selection, +): Variant | undefined { + return product.variants.find((variant) => + product.options.every( + (option) => + (variant.selection[option.id] ?? null) === + (selection[option.id] ?? null), + ), + ); +} + +/** + * The state of every value of `axisId` given the choices on the axes ABOVE it: + * - "inStock": at least one matching variant is purchasable → normal chip. + * - "soldOut": matching variants exist, none in stock → struck, clickable. + * - absent: a hole in the variant matrix → hard-disabled chip. + * An empty map means the axis does not apply to the current configuration (a + * one-size colorway has no Size), and the picker hides its row. + */ +export function axisStates( + product: Product, + axisId: string, + selection: Selection, +): Map { + const states = new Map(); + for (const variant of product.variants) { + let matchesEarlier = true; + for (const option of product.options) { + if (option.id === axisId) { + break; // only the axes above constrain this one + } + const chosen = selection[option.id]; + const carried = variant.selection[option.id]; + if (chosen != null && carried !== undefined && carried !== chosen) { + matchesEarlier = false; + break; + } + } + if (!matchesEarlier) { + continue; + } + // A variant to which this axis does not apply carries no value to offer. + const value = variant.selection[axisId]; + if (value !== undefined && states.get(value) !== "inStock") { + states.set(value, variant.outOfStock ? "soldOut" : "inStock"); + } + } + return states; +} + +/** + * The selection after picking `valueId` on `axisId`: the one state transition + * of the picker, rebuilt top-down. + * - An axis with no reachable value does not apply: its choice is dropped. + * - A prior choice that still EXISTS under the axes above is KEPT, even sold + * out: switching color never silently changes the size the user asked for. + * - Anything else fills with the first value in display order, preferring one + * that is in stock. + * The result resolves to a concrete variant, with one exception: a variant + * skipping an EARLIER axis keeps offering its later values under any choice on + * that axis, so picking one can compose a selection no variant matches. The + * detail renders that as "Combination unavailable". + */ +export function applyChoice( + product: Product, + selection: Selection, + axisId: string, + valueId: string, +): Selection { + const next: Selection = {}; + for (const option of product.options) { + const candidate = option.id === axisId ? valueId : selection[option.id]; + const states = axisStates(product, option.id, next); + if (states.size === 0) { + continue; // axis does not apply to the configuration above it + } + if (candidate != null && states.has(candidate)) { + next[option.id] = candidate; + continue; + } + let fill: string | undefined; + for (const value of option.values) { + if (states.get(value.id) === "inStock") { + fill = value.id; + break; + } + } + if (fill === undefined) { + for (const value of option.values) { + if (states.has(value.id)) { + fill = value.id; + break; + } + } + } + if (fill !== undefined) { + next[option.id] = fill; + } + } + return next; +} + +/** + * The selection to preselect when a product opens: the variant the client + * tapped (its id equals the opened product id), else the first IN-STOCK + * variant, else the first variant. A variant is always preselected when the + * product has any, so the buy CTA is live on open rather than starting + * disabled. + */ +export function initialSelection(product: Product): Selection { + let base: Variant | undefined; + let firstInStock: Variant | undefined; + for (const variant of product.variants) { + if (variant.id === product.id) { + base = variant; + break; + } + if (firstInStock === undefined && !variant.outOfStock) { + firstInStock = variant; + } + } + base ??= firstInStock ?? product.variants[0]; + return base ? { ...base.selection } : {}; +} diff --git a/examples/ecom-carousel/src/products.ts b/examples/ecom-carousel/src/products.ts deleted file mode 100644 index 40590e11e..000000000 --- a/examples/ecom-carousel/src/products.ts +++ /dev/null @@ -1,207 +0,0 @@ -export const products = [ - { - id: 1, - title: "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops", - price: 109.95, - description: - "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday", - category: "men's clothing", - image: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_t.png", - rating: { rate: 3.9, count: 120 }, - }, - { - id: 2, - title: "Mens Casual Premium Slim Fit T-Shirts ", - price: 22.3, - description: - "Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.", - category: "men's clothing", - image: "https://fakestoreapi.com/img/71YXzeOuslL._AC_UY879_t.png", - rating: { rate: 4.1, count: 259 }, - }, - { - id: 3, - title: "Mens Cotton Jacket", - price: 55.99, - description: - "great outerwear jackets for Spring/Autumn/Winter, suitable for many occasions, such as working, hiking, camping, mountain/rock climbing, cycling, traveling or other outdoors. Good gift choice for you or your family member. A warm hearted love to Father, husband or son in this thanksgiving or Christmas Day.", - category: "men's clothing", - image: "https://fakestoreapi.com/img/71li-ujtlUL._AC_UX679_t.png", - rating: { rate: 4.7, count: 500 }, - }, - { - id: 4, - title: "Mens Casual Slim Fit", - price: 15.99, - description: - "The color could be slightly different between on the screen and in practice. / Please note that body builds vary by person, therefore, detailed size information should be reviewed below on the product description.", - category: "men's clothing", - image: "https://fakestoreapi.com/img/71YXzeOuslL._AC_UY879_t.png", - rating: { rate: 2.1, count: 430 }, - }, - { - id: 5, - title: - "John Hardy Women's Legends Naga Gold & Silver Dragon Station Chain Bracelet", - price: 695, - description: - "From our Legends Collection, the Naga was inspired by the mythical water dragon that protects the ocean's pearl. Wear facing inward to be bestowed with love and abundance, or outward for protection.", - category: "jewelery", - image: "https://fakestoreapi.com/img/71pWzhdJNwL._AC_UL640_QL65_ML3_t.png", - rating: { rate: 4.6, count: 400 }, - }, - { - id: 6, - title: "Solid Gold Petite Micropave ", - price: 168, - description: - "Satisfaction Guaranteed. Return or exchange any order within 30 days.Designed and sold by Hafeez Center in the United States. Satisfaction Guaranteed. Return or exchange any order within 30 days.", - category: "jewelery", - image: "https://fakestoreapi.com/img/61sbMiUnoGL._AC_UL640_QL65_ML3_t.png", - rating: { rate: 3.9, count: 70 }, - }, - { - id: 7, - title: "White Gold Plated Princess", - price: 9.99, - description: - "Classic Created Wedding Engagement Solitaire Diamond Promise Ring for Her. Gifts to spoil your love more for Engagement, Wedding, Anniversary, Valentine's Day...", - category: "jewelery", - image: "https://fakestoreapi.com/img/71YAIFU48IL._AC_UL640_QL65_ML3_t.png", - rating: { rate: 3, count: 400 }, - }, - { - id: 8, - title: "Pierced Owl Rose Gold Plated Stainless Steel Double", - price: 10.99, - description: - "Rose Gold Plated Double Flared Tunnel Plug Earrings. Made of 316L Stainless Steel", - category: "jewelery", - image: "https://fakestoreapi.com/img/51UDEzMJVpL._AC_UL640_QL65_ML3_t.png", - rating: { rate: 1.9, count: 100 }, - }, - { - id: 9, - title: "WD 2TB Elements Portable External Hard Drive - USB 3.0 ", - price: 64, - description: - "USB 3.0 and USB 2.0 Compatibility Fast data transfers Improve PC Performance High Capacity; Compatibility Formatted NTFS for Windows 10, Windows 8.1, Windows 7; Reformatting may be required for other operating systems; Compatibility may vary depending on user’s hardware configuration and operating system", - category: "electronics", - image: "https://fakestoreapi.com/img/61IBBVJvSDL._AC_SY879_t.png", - rating: { rate: 3.3, count: 203 }, - }, - { - id: 10, - title: "SanDisk SSD PLUS 1TB Internal SSD - SATA III 6 Gb/s", - price: 109, - description: - "Easy upgrade for faster boot up, shutdown, application load and response (As compared to 5400 RPM SATA 2.5” hard drive; Based on published specifications and internal benchmarking tests using PCMark vantage scores) Boosts burst write performance, making it ideal for typical PC workloads The perfect balance of performance and reliability Read/write speeds of up to 535MB/s/450MB/s (Based on internal testing; Performance may vary depending upon drive capacity, host device, OS and application.)", - category: "electronics", - image: "https://fakestoreapi.com/img/61U7T1koQqL._AC_SX679_t.png", - rating: { rate: 2.9, count: 470 }, - }, - { - id: 11, - title: - "Silicon Power 256GB SSD 3D NAND A55 SLC Cache Performance Boost SATA III 2.5", - price: 109, - description: - "3D NAND flash are applied to deliver high transfer speeds Remarkable transfer speeds that enable faster bootup and improved overall system performance. The advanced SLC Cache Technology allows performance boost and longer lifespan 7mm slim design suitable for Ultrabooks and Ultra-slim notebooks. Supports TRIM command, Garbage Collection technology, RAID, and ECC (Error Checking & Correction) to provide the optimized performance and enhanced reliability.", - category: "electronics", - image: "https://fakestoreapi.com/img/71kWymZ+c+L._AC_SX679_t.png", - rating: { rate: 4.8, count: 319 }, - }, - { - id: 12, - title: - "WD 4TB Gaming Drive Works with Playstation 4 Portable External Hard Drive", - price: 114, - description: - "Expand your PS4 gaming experience, Play anywhere Fast and easy, setup Sleek design with high capacity, 3-year manufacturer's limited warranty", - category: "electronics", - image: "https://fakestoreapi.com/img/61mtL65D4cL._AC_SX679_t.png", - rating: { rate: 4.8, count: 400 }, - }, - { - id: 13, - title: "Acer SB220Q bi 21.5 inches Full HD (1920 x 1080) IPS Ultra-Thin", - price: 599, - description: - "21. 5 inches Full HD (1920 x 1080) widescreen IPS display And Radeon free Sync technology. No compatibility for VESA Mount Refresh Rate: 75Hz - Using HDMI port Zero-frame design | ultra-thin | 4ms response time | IPS panel Aspect ratio - 16: 9. Color Supported - 16. 7 million colors. Brightness - 250 nit Tilt angle -5 degree to 15 degree. Horizontal viewing angle-178 degree. Vertical viewing angle-178 degree 75 hertz", - category: "electronics", - image: "https://fakestoreapi.com/img/81QpkIctqPL._AC_SX679_t.png", - rating: { rate: 2.9, count: 250 }, - }, - { - id: 14, - title: - "Samsung 49-Inch CHG90 144Hz Curved Gaming Monitor (LC49HG90DMNXZA) – Super Ultrawide Screen QLED ", - price: 999.99, - description: - "49 INCH SUPER ULTRAWIDE 32:9 CURVED GAMING MONITOR with dual 27 inch screen side by side QUANTUM DOT (QLED) TECHNOLOGY, HDR support and factory calibration provides stunningly realistic and accurate color and contrast 144HZ HIGH REFRESH RATE and 1ms ultra fast response time work to eliminate motion blur, ghosting, and reduce input lag", - category: "electronics", - image: "https://fakestoreapi.com/img/81Zt42ioCgL._AC_SX679_t.png", - rating: { rate: 2.2, count: 140 }, - }, - { - id: 15, - title: "BIYLACLESEN Women's 3-in-1 Snowboard Jacket Winter Coats", - price: 56.99, - description: - "Note:The Jackets is US standard size, Please choose size as your usual wear Material: 100% Polyester; Detachable Liner Fabric: Warm Fleece. Detachable Functional Liner: Skin Friendly, Lightweigt and Warm.Stand Collar Liner jacket, keep you warm in cold weather. Zippered Pockets: 2 Zippered Hand Pockets, 2 Zippered Pockets on Chest (enough to keep cards or keys)and 1 Hidden Pocket Inside.Zippered Hand Pockets and Hidden Pocket keep your things secure. Humanized Design: Adjustable and Detachable Hood and Adjustable cuff to prevent the wind and water,for a comfortable fit. 3 in 1 Detachable Design provide more convenience, you can separate the coat and inner as needed, or wear it together. It is suitable for different season and help you adapt to different climates", - category: "women's clothing", - image: "https://fakestoreapi.com/img/51Y5NI-I5jL._AC_UX679_t.png", - rating: { rate: 2.6, count: 235 }, - }, - { - id: 16, - title: - "Lock and Love Women's Removable Hooded Faux Leather Moto Biker Jacket", - price: 29.95, - description: - "100% POLYURETHANE(shell) 100% POLYESTER(lining) 75% POLYESTER 25% COTTON (SWEATER), Faux leather material for style and comfort / 2 pockets of front, 2-For-One Hooded denim style faux leather jacket, Button detail on waist / Detail stitching at sides, HAND WASH ONLY / DO NOT BLEACH / LINE DRY / DO NOT IRON", - category: "women's clothing", - image: "https://fakestoreapi.com/img/51Y5NI-I5jL._AC_UX679_t.png", - rating: { rate: 2.9, count: 340 }, - }, - { - id: 17, - title: "Rain Jacket Women Windbreaker Striped Climbing Raincoats", - price: 39.99, - description: - "Lightweight perfet for trip or casual wear---Long sleeve with hooded, adjustable drawstring waist design. Button and zipper front closure raincoat, fully stripes Lined and The Raincoat has 2 side pockets are a good size to hold all kinds of things, it covers the hips, and the hood is generous but doesn't overdo it.Attached Cotton Lined Hood with Adjustable Drawstrings give it a real styled look.", - category: "women's clothing", - image: "https://fakestoreapi.com/img/71z3kpMAYsL._AC_UY879_t.png", - rating: { rate: 3.8, count: 679 }, - }, - { - id: 18, - title: "MBJ Women's Solid Short Sleeve Boat Neck V ", - price: 9.85, - description: - "95% RAYON 5% SPANDEX, Made in USA or Imported, Do Not Bleach, Lightweight fabric with great stretch for comfort, Ribbed on sleeves and neckline / Double stitching on bottom hem", - category: "women's clothing", - image: "https://fakestoreapi.com/img/71z3kpMAYsL._AC_UY879_t.png", - rating: { rate: 4.7, count: 130 }, - }, - { - id: 19, - title: "Opna Women's Short Sleeve Moisture", - price: 7.95, - description: - "100% Polyester, Machine wash, 100% cationic polyester interlock, Machine Wash & Pre Shrunk for a Great Fit, Lightweight, roomy and highly breathable with moisture wicking fabric which helps to keep moisture away, Soft Lightweight Fabric with comfortable V-neck collar and a slimmer fit, delivers a sleek, more feminine silhouette and Added Comfort", - category: "women's clothing", - image: "https://fakestoreapi.com/img/51eg55uWmdL._AC_UX679_t.png", - rating: { rate: 4.5, count: 146 }, - }, - { - id: 20, - title: "DANVOUY Womens T Shirt Casual Cotton Short", - price: 12.99, - description: - "95%Cotton,5%Spandex, Features: Casual, Short Sleeve, Letter Print,V-Neck,Fashion Tees, The fabric is soft and has some stretch., Occasion: Casual/Office/Beach/School/Home/Street. Season: Spring,Summer,Autumn,Winter.", - category: "women's clothing", - image: "https://fakestoreapi.com/img/61pHAEJ4NML._AC_UX679_t.png", - rating: { rate: 3.6, count: 145 }, - }, -]; diff --git a/examples/ecom-carousel/src/server.ts b/examples/ecom-carousel/src/server.ts index 9fecae159..bc6063e54 100644 --- a/examples/ecom-carousel/src/server.ts +++ b/examples/ecom-carousel/src/server.ts @@ -1,187 +1,35 @@ -import { intentMiddleware } from "@alpic-ai/insights"; +import "./lib/load-env.js"; // must run before the tool modules read process.env import { McpServer } from "skybridge/server"; -import { z } from "zod"; -import { products } from "./products.js"; -import { stripe } from "./stripe.js"; - -interface Product { - id: number; - title: string; - price: number; - description: string; - category: string; - image: string; - rating: { - rate: number; - count: number; - }; -} +import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "./config.js"; +import { + renderCarouselDefinition, + renderCarouselHandler, +} from "./tools/render-carousel.js"; +import { + searchProductsDefinition, + searchProductsHandler, +} from "./tools/search-products.js"; const server = new McpServer( { - name: "ecom-carousel-app", + name: "skybridge-shop", version: "0.0.1", }, - { capabilities: {} }, -) - .mcpMiddleware(intentMiddleware()) - .registerTool( - { - name: "browse-catalog", - description: "Display a carousel of products from the store.", - inputSchema: { - category: z - .enum([ - "electronics", - "jewelery", - "men's clothing", - "women's clothing", - ]) - .optional() - .describe("Filter by product category"), - maxPrice: z.number().optional().describe("Maximum price filter"), - }, - view: { - component: "browse-catalog", - description: "E-commerce Product Carousel", - csp: { - resourceDomains: ["https://fakestoreapi.com"], - redirectDomains: [ - "https://docs.skybridge.tech", - "https://checkout.stripe.com", - ], - }, - }, - }, - ({ category, maxPrice }) => { - try { - const filtered: Product[] = []; - - for (const product of products) { - if (category && product.category !== category) { - continue; - } - if (maxPrice !== undefined && product.price > maxPrice) { - continue; - } - filtered.push(product); - } - - return { - structuredContent: { products: filtered }, - content: [{ type: "text", text: JSON.stringify(filtered) }], - isError: false, - }; - } catch (error) { - return { - content: [{ type: "text", text: `Error: ${error}` }], - isError: true, - }; - } - }, - ) - .registerTool( - { - name: "create-checkout", - description: - "Create a Stripe Checkout session for the selected products and return the checkout URL.", - inputSchema: { - productIds: z - .array(z.number()) - .describe("Product IDs to include in the checkout"), - }, - annotations: { - readOnlyHint: false, - openWorldHint: true, - }, - }, - async ({ productIds }) => { - try { - const lineItems = productIds - .map((id) => products.find((p) => p.id === id)) - .filter((p): p is Product => p !== undefined) - .map((p) => ({ - price_data: { - currency: "usd", - product_data: { name: p.title }, - unit_amount: Math.round(p.price * 100), - }, - quantity: 1, - })); - - if (lineItems.length === 0) { - return { - content: [ - { type: "text", text: "Error: no valid products in the cart" }, - ], - isError: true, - }; - } - - const session = await stripe.checkout.sessions.create({ - mode: "payment", - line_items: lineItems, - success_url: "https://docs.skybridge.tech", - cancel_url: "https://docs.skybridge.tech", - }); + { + instructions: `\ +Skybridge is a winter-sports shop: skis, goggles, and cold-weather apparel. Two phases: - return { - structuredContent: { - checkoutUrl: session.url, - sessionId: session.id, - }, - content: [ - { - type: "text", - text: `Checkout session created: ${session.url}`, - }, - ], - }; - } catch (error) { - return { - content: [{ type: "text", text: `Error: ${error}` }], - isError: true, - }; - } - }, - ) - .registerTool( - { - name: "check-checkout-status", - description: - "Check the status of a Stripe Checkout session. Returns open, complete, or expired.", - inputSchema: { - sessionId: z.string().describe("The Stripe Checkout Session ID"), - }, - annotations: { - readOnlyHint: true, - openWorldHint: true, - }, - }, - async ({ sessionId }) => { - try { - const session = await stripe.checkout.sessions.retrieve(sessionId); +SEARCH: Call search-products (at least ${MIN_SEARCH_ITERATIONS}) before presenting. \ +Vary the keyword or scope with a category (apparel, goggles, skis) when the user narrows. \ +Stay silent while searching: emit NO text between calls. Speak only \ +once the carousel renders. - return { - structuredContent: { - status: session.status, - paymentStatus: session.payment_status, - }, - content: [ - { - type: "text", - text: `Checkout session ${sessionId}: status=${session.status}, payment=${session.payment_status}`, - }, - ], - }; - } catch (error) { - return { - content: [{ type: "text", text: `Error: ${error}` }], - isError: true, - }; - } - }, - ); +RENDER: After curating, call render-carousel with the chosen product IDs (aim for ${CAROUSEL_RANGE}). \ +Speak once it renders, then recommend products in carousel order.`, + }, +) + .registerTool(searchProductsDefinition, searchProductsHandler) + .registerTool(renderCarouselDefinition, renderCarouselHandler); export default await server.run(); diff --git a/examples/ecom-carousel/src/stripe.ts b/examples/ecom-carousel/src/stripe.ts deleted file mode 100644 index 335fc4876..000000000 --- a/examples/ecom-carousel/src/stripe.ts +++ /dev/null @@ -1,4 +0,0 @@ -import Stripe from "stripe"; -import { env } from "./env.js"; - -export const stripe = new Stripe(env.STRIPE_SECRET_KEY); diff --git a/examples/ecom-carousel/src/tools/render-carousel.ts b/examples/ecom-carousel/src/tools/render-carousel.ts new file mode 100644 index 000000000..fef13b074 --- /dev/null +++ b/examples/ecom-carousel/src/tools/render-carousel.ts @@ -0,0 +1,337 @@ +import "../lib/load-env.js"; // ensure MEDUSA_BASE_URL is set before it's read below +import { z } from "zod"; +import { CAROUSEL_MAX_SIZE, CAROUSEL_RANGE } from "../config.js"; +import { + COLOR_AXIS, + currencyOf, + fetchByIds, + imagesForValues, + priceOf, + type RawProduct, + type RawVariant, + readBadges, + readNumber, + readSpecs, + slug, + variantValue, +} from "../lib/medusa.js"; +import { type Price, PriceSchema, type Spec, SpecSchema } from "../types.js"; + +// The storefront every "View on site" CTA deep-links to (SPEC phase 3). +const STOREFRONT_URL = "https://skybridge.tech"; + +// Product images are served from the catalog backend origin (SPEC phase 2). +const IMAGE_HOST = process.env.MEDUSA_BASE_URL ?? ""; + +// The `render-carousel` tool: takes the IDs the model curated and returns the +// matching products for the carousel view to render. +// Everything this tool needs lives in this file. + +// --------------------------------------------------------------------------- +// Product model +// --------------------------------------------------------------------------- +// Model: variant-as-full-product. Each `Variant` is a complete, buyable product +// (its own title, price, media). A `Product` ties sibling variants together and +// declares the axes (`Option`s) they vary on. A product with no variations is +// just a product with a single variant and no options. + +// One selectable value on an axis, e.g. the "Black" choice on the "Color" axis. +type OptionValue = { + id: string; // stable key referenced by Variant.selection, e.g. "black" + label: string; // shown to the user, e.g. "Black" + media?: string; // optional swatch / image representing this value +}; + +// A variation axis the variants differ on, e.g. Color or Size. +type Option = { + id: string; // stable key, used as a key in Variant.selection, e.g. "color" + label: string; // shown to the user, e.g. "Color" + values: OptionValue[]; // in display order +}; + +// Display fields shared by a Variant and by a product's `card`. +type Meta = { + title: string; + description?: string; + price?: Price; + media: string[]; // images for this item; media[0] is the primary/cover + url?: string; // link to this item's external product page + outOfStock?: boolean; // true = not purchasable + // Objective, product-specific facts (material, dimensions, capacity, care…), + // rendered as-is. Each fact's label is optional. + specs: Spec[]; + + // Custom fields (from Medusa metadata): rating → stars, badges → chips. + rating?: number; // average review rating, 0–5 + reviewCount?: number; + badges?: string[]; // e.g. ["Bestseller"], ["New"] +}; + +// One buyable product: full display Meta plus which value it takes on each axis. +export type Variant = Meta & { + id: string; // SKU / article number; unique within the catalog + // The chosen value per axis: keys are Option.id, values are OptionValue.id. + // e.g. { color: "black", size: "40" } + selection: Record; +}; + +// A product: one carousel card backed by one or more variants and the axes they +// vary on (none for a single-variant product). +export type Product = { + id: string; // stable product key + // The axes the variants vary on, in display order. Order is semantic: the + // detail picker narrows availability top-down (each axis constrained by the + // ones before it), so put the imagery-driving axis (usually color) first. + options: Option[]; + // Only the variants that actually exist. A missing combination (e.g. no + // { color: "black", size: "40" }) is simply absent from this list — that is how + // contingent variations are expressed. Derive the selectable values for an axis + // by filtering this list on the choices already made. + variants: Variant[]; + // The product's carousel card. Surfaced both in the carousel (the view + // renders it) and to the model (structuredContent is projected from it). + // How you build it depends on your mapping strategy: see getProducts. + card: Meta; +}; + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +const inputSchema = { + ids: z + .array(z.string()) + .min(1) + .max(CAROUSEL_MAX_SIZE) + .describe("Product IDs to present, in display order."), +}; + +type RenderInput = z.infer>; + +// --------------------------------------------------------------------------- +// Output — model-facing grounding, for the LLM ONLY. The carousel view is NOT +// built from this; it renders from the full data in `_meta`. Keep it to what the +// model needs to reference and compare the displayed products afterward. +// --------------------------------------------------------------------------- + +const outputSchema = { + products: z + .array( + z.object({ + id: z.string().describe("Product ID."), + title: z.string(), + options: z + .array(z.object({ label: z.string(), values: z.array(z.string()) })) + .describe("Variations available (e.g. colors, sizes)."), + description: z.string().optional(), + price: PriceSchema.optional().describe('"From" price (cheapest variant).'), + rating: z.number().optional().describe("Average review rating, 0–5."), + reviewCount: z.number().optional(), + badges: z.array(z.string()).optional().describe('e.g. "Bestseller".'), + specs: z + .array(SpecSchema) + .describe("Product-specific facts (material, dimensions, care…)."), + }), + ) + .describe( + "The products shown in the carousel, in display order. For your reference only — to curate, compare, and answer follow-ups. Ground every claim in this data; never invent facts.", + ), +}; + +type RenderOutput = z.infer>; + +// --------------------------------------------------------------------------- +// Data access +// --------------------------------------------------------------------------- + +// Grouped strategy: one carousel card per Medusa product; the detail view lets +// the client switch between its variants. `card` is the "from" union of the +// product's variants. Images are color-filtered per variant (D4 heuristic). + +// Options with the Color axis first (imagery-driving; order is semantic). +function mapOptions(p: RawProduct): Option[] { + const options = (p.options ?? []).map((o) => ({ + id: slug(o.title), + label: o.title, + values: o.values.map((val) => ({ + id: slug(val.value), + label: val.value, + // Color values get a representative image as a swatch (no swatch data + // exists; reuse the color-matched product photo). + media: + o.title === COLOR_AXIS + ? imagesForValues(p.images, [val.value])[0] + : undefined, + })), + })); + return options.sort((a, b) => + a.label === COLOR_AXIS ? -1 : b.label === COLOR_AXIS ? 1 : 0, + ); +} + +function mapVariant(p: RawProduct, v: RawVariant): Variant { + const amount = priceOf(v); + const selection: Record = {}; + for (const opt of p.options ?? []) { + const value = variantValue(v, opt.title); + if (value) selection[slug(opt.title)] = slug(value); + } + return { + id: v.id, + title: p.title, // detail header stays the product identity; pickers show the variant + description: p.description ?? undefined, + price: amount != null ? { amount, currency: currencyOf(v) } : undefined, + // Color-filtered images (D4); matches any option value incl. material. + media: imagesForValues(p.images, v.options.map((o) => o.value)), + url: STOREFRONT_URL, + outOfStock: false, // no stock signal in the data — never lock the CTA + specs: readSpecs(v.metadata), + rating: readNumber(v.metadata, "rating") ?? readNumber(p.metadata, "rating"), + reviewCount: + readNumber(v.metadata, "review_count") ?? + readNumber(p.metadata, "review_count"), + badges: readBadges(p.metadata), + selection, + }; +} + +function mapProduct(p: RawProduct): Product { + const variants = (p.variants ?? []).map((v) => mapVariant(p, v)); + const prices = variants + .map((v) => v.price?.amount) + .filter((n): n is number => typeof n === "number"); + const thumbFirst = [ + ...(p.thumbnail ? [p.thumbnail] : []), + ...(p.images ?? []).map((i) => i.url).filter((u) => u !== p.thumbnail), + ]; + const card: Meta = { + title: p.title, + description: p.description ?? undefined, + price: prices.length + ? { amount: Math.min(...prices), currency: "EUR" } + : undefined, + media: thumbFirst, + url: STOREFRONT_URL, + outOfStock: false, + specs: readSpecs(p.variants?.[0]?.metadata), + rating: readNumber(p.metadata, "rating"), + reviewCount: readNumber(p.metadata, "review_count"), + badges: readBadges(p.metadata), + }; + return { id: p.id, options: mapOptions(p), variants, card }; +} + +async function getProducts(ids: string[]): Promise { + const raw = await fetchByIds(ids); + return raw.map(mapProduct); +} + +// --------------------------------------------------------------------------- +// Mapping: trim each product's `card` and `options` into the model-facing +// grounding (outputSchema), dropping presentational fields (media, url). The +// full data stays in `_meta` for the view. Grounding = id, title, price, +// rating, reviewCount, badges, options, specs — enough to compare and answer. +// --------------------------------------------------------------------------- + +function toStructuredContent(products: Product[]): RenderOutput { + const groundingProducts: RenderOutput["products"] = []; + + for (const product of products) { + const { card } = product; + + const options: { label: string; values: string[] }[] = []; + for (const option of product.options) { + const values: string[] = []; + for (const value of option.values) { + values.push(value.label); + } + options.push({ label: option.label, values }); + } + + groundingProducts.push({ + id: product.id, + title: card.title, + description: card.description, + price: card.price, + rating: card.rating, + reviewCount: card.reviewCount, + badges: card.badges, + options, + specs: card.specs, + }); + } + + return { products: groundingProducts }; +} + +// --------------------------------------------------------------------------- +// Tool (registered from server.ts to keep the typed tool chain intact) +// --------------------------------------------------------------------------- + +export const renderCarouselDefinition = { + name: "render-carousel" as const, + + description: `\ +Display the Skybridge products you curated as an inline carousel for the client. + +## When to call +Call this AFTER searching and curating, and BEFORE writing your recommendation. Avoid describing the products in text first since the carousel shows them. + +## What to pass +Pass the IDs of the ${CAROUSEL_RANGE} products you chose, in display order (most relevant first). Order is significant: the carousel shows them in this exact order and your recommendation must follow the same sequence. Pass distinct products, not several variants of the same one; the detail view lets the client explore a product's variants (colors, sizes, and so on). + +## After the carousel +Recommend in carousel order so the client can follow along. The cards already show image, title, price, and key facts, so do not repeat them: add useful analysis tied to the client's need. Suggest a refinement the client has not addressed yet (from the available filters), never one they already used. + +## Accuracy +Use only the data returned for each product. Never invent facts, materials, or availability. If the client asks about something not present, open that product's detail or search again before answering.`, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + }, + + _meta: { + "openai/toolInvocation/invoking": "Loading products", + "openai/toolInvocation/invoked": "Loaded products", + }, + + // The carousel and product details UI rendered inline in the conversation. + view: { + // `as const` keeps this a literal (like `name` above) so it matches the + // generated ViewNameRegistry; a bare string widens and fails the build. + component: "carousel" as const, + description: "Browse the curated products.", + csp: { + resourceDomains: [ + // Catalog image host (product photos), from MEDUSA_BASE_URL. + IMAGE_HOST, + // Google Fonts: stylesheet host + woff2 host (Mozilla Text brand font). + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + ].filter(Boolean), + // Storefront the detail CTA / "open in app" link points at. + redirectDomains: [STOREFRONT_URL], + }, + }, + + inputSchema, + outputSchema, +}; + +export async function renderCarouselHandler({ ids }: RenderInput) { + const products = await getProducts(ids); + + return { + // Full products (incl. variants) for the view; not in model context. + _meta: { products }, + structuredContent: toStructuredContent(products), + content: [ + { + type: "text" as const, + text: `Rendered ${products.length} product(s) in the carousel.`, + }, + ], + isError: false, + }; +} diff --git a/examples/ecom-carousel/src/tools/search-products.ts b/examples/ecom-carousel/src/tools/search-products.ts new file mode 100644 index 000000000..82639f9f6 --- /dev/null +++ b/examples/ecom-carousel/src/tools/search-products.ts @@ -0,0 +1,199 @@ +import { z } from "zod"; +import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "../config.js"; +import { + fetchSearch, + fromPrice, + readBadges, + readNumber, + readSpecs, +} from "../lib/medusa.js"; +import { PriceSchema, SpecSchema } from "../types.js"; + +// The `search-products` tool: keyword + filters in, matching products out as +// structured output for the model. It has NO view — include only what the model +// needs to curate (ids + properties), never presentational data (images, media); +// render-carousel handles that. Everything this tool needs lives in this file. + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +const inputSchema = { + keyword: z.string().describe( + `\ +Short noun phrases extracted from conversational input, matched against product titles, descriptions, and SKUs. Never pass full sentences. \ +Include color or material descriptors when the user mentions them (e.g. "cyan skis", "fur hat"). \ +This is a small winter-sports catalog: skis, goggles, and cold-weather apparel. For vague or broad requests use a plain category term.`, + ), + + sort: z + .enum(["price-asc", "price-desc", "newest", "name"]) + .optional() + .describe("Sort order. Price sorts are applied over the fetched results."), + + category: z + .enum(["apparel", "goggles", "skis"]) + .optional() + .describe( + "Restrict to one category. Only these three exist; omit to search all.", + ), +}; + +type SearchInput = z.infer>; + +// --------------------------------------------------------------------------- +// Output — model-facing grounding, returned in structuredContent. +// --------------------------------------------------------------------------- + +const productSchema = z.object({ + id: z.string().describe("Stable product ID; pass to render-carousel."), + title: z.string(), + category: z.string().optional().describe("apparel, goggles, or skis."), + description: z.string().optional(), + price: PriceSchema.optional().describe('"From" price (cheapest variant).'), + rating: z.number().optional().describe("Average review rating, 0–5."), + reviewCount: z.number().optional(), + badges: z + .array(z.string()) + .optional() + .describe('Display badges, e.g. "New", "Bestseller".'), + specs: z + .array(SpecSchema) + .describe("Product-specific facts to curate on (material, dimensions…)."), +}); + +const outputSchema = { + products: z.array(productSchema).describe("Matching products, sorted."), + pages: z + .object({ + current: z.number(), + total: z.number(), + }) + .optional() + .describe("Pagination: current page and total page count."), + totalHits: z + .number() + .optional() + .describe("Total matching products across all pages."), +}; + +type SearchOutput = z.infer>; + +// --------------------------------------------------------------------------- +// Data access +// --------------------------------------------------------------------------- + +// Server-side sort keys (price sort is unsupported by Medusa, applied below). +const ORDER: Record = { + name: "title", + newest: "-created_at", +}; + +async function search(input: SearchInput): Promise { + const { products: raw, count } = await fetchSearch({ + keyword: input.keyword, + category: input.category, + order: input.sort ? ORDER[input.sort] : undefined, + }); + + const products: SearchOutput["products"] = raw.map((p) => { + const amount = fromPrice(p); + return { + id: p.id, + title: p.title, + category: p.categories?.[0]?.name, + description: p.description ?? undefined, + price: amount != null ? { amount, currency: "EUR" } : undefined, + rating: readNumber(p.metadata, "rating"), + reviewCount: readNumber(p.metadata, "review_count"), + badges: readBadges(p.metadata), + // Specs live on the variant; use the first variant as a representative. + specs: readSpecs(p.variants?.[0]?.metadata), + }; + }); + + // Price sort is client-side (Medusa can't sort by calculated_price). + if (input.sort === "price-asc" || input.sort === "price-desc") { + const dir = input.sort === "price-asc" ? 1 : -1; + products.sort( + (a, b) => ((a.price?.amount ?? 0) - (b.price?.amount ?? 0)) * dir, + ); + } + + return { + products, + pages: { current: 1, total: 1 }, + totalHits: count, + }; +} + +// --------------------------------------------------------------------------- +// Narration — framing + next-step instructions for the model. The products +// themselves ride in structuredContent; this text carries NO result data. +// --------------------------------------------------------------------------- + +function narrate({ products }: SearchOutput): string { + const size = products.length; + + if (size === 0) { + return `\ +No products found. + +NEXT STEP: Broaden the keyword or relax filters, then search again.`; + } + + // Small catalog: any relevant hit is renderable. Only widen if these clearly + // miss the client's intent (e.g. a category with nothing for them). + return `\ +Results ready. + +NEXT STEPS: +1. Curate the matches that fit the client's intent from the structured results. If none fit, search again with a broader keyword or drop the category. +2. Call render-carousel with the selected IDs (display order). +3. Only after it renders, write your recommendation in carousel order.`; +} + +// --------------------------------------------------------------------------- +// Tool (registered from server.ts to keep the typed tool chain intact) +// --------------------------------------------------------------------------- + +export const searchProductsDefinition = { + name: "search-products" as const, + + description: `\ +Search the Skybridge winter-sports catalog: skis, goggles, and cold-weather apparel. Handles any query: a specific product, a category, a gift, or open browsing. Never assume something is unavailable — always search before responding. + +The response is data only: matching products (title, ID, "from" price, rating, badges, description, facts). The raw results are for your eyes only; the client never sees them. Avoid characterizing the raw results to the client (how many, what categories). + +Act on the response as follows: + +- SEARCH: pass a keyword. Optionally scope with \`category\` (apparel, goggles, or skis) or \`sort\`. This is a small catalog — one focused search usually surfaces everything relevant (${MIN_SEARCH_ITERATIONS}+ calls). +- REFINEMENT: if the user narrows to a category, re-search with that \`category\`. If they change intent, search with a new keyword. +- CURATION: pick the best matches for the client's intent, grounding your choice in each product's description. If zero results, broaden the keyword and search again; do NOT call render-carousel on an empty set. +- PRESENT: once you have ${CAROUSEL_RANGE} distinct, relevant products, call render-carousel with their IDs. Recommend products ONLY AFTER the carousel displays, and in the same order as in the carousel. + +The sweet spot is ${CAROUSEL_RANGE} products. +`, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + }, + + _meta: { + "openai/toolInvocation/invoking": "Searching the shop", + "openai/toolInvocation/invoked": "Searched the shop", + }, + + inputSchema, + outputSchema, +}; + +export async function searchProductsHandler(input: SearchInput) { + const results = await search(input); + return { + structuredContent: results, + content: [{ type: "text" as const, text: narrate(results) }], + isError: false, + }; +} diff --git a/examples/ecom-carousel/src/types.ts b/examples/ecom-carousel/src/types.ts index bcdd2f2cf..cdbda9fd0 100644 --- a/examples/ecom-carousel/src/types.ts +++ b/examples/ecom-carousel/src/types.ts @@ -1,5 +1,15 @@ -import type { useToolInfo } from "./helpers.js"; +import { z } from "zod"; -type ToolOutput = ReturnType>; +export const PriceSchema = z.object({ + amount: z.number(), + currency: z.string(), +}); +export type Price = z.infer; -export type Product = NonNullable["products"][number]; +// A product-specific fact (an objective spec: material, dimensions, capacity, +// care…). `label` is optional so a fact can be a bare value (e.g. "Waterproof"). +export const SpecSchema = z.object({ + label: z.string().optional(), + value: z.string(), +}); +export type Spec = z.infer; diff --git a/examples/ecom-carousel/src/views/browse-catalog.tsx b/examples/ecom-carousel/src/views/browse-catalog.tsx deleted file mode 100644 index cc11a6d9c..000000000 --- a/examples/ecom-carousel/src/views/browse-catalog.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import "@/index.css"; - -import { - useLayout, - useOpenExternal, - useRequestModal, - useViewState, -} from "skybridge/web"; -import { CheckoutSummary } from "../components/checkout-summary.js"; -import { PaymentSuccess } from "../components/payment-success.js"; -import { ProductCarousel } from "../components/product-carousel.js"; -import { useCallTool, useToolInfo } from "../helpers.js"; -import { useCheckoutPolling } from "../hooks/use-checkout-polling.js"; -import { useTranslate } from "../i18n.js"; - -function BrowseCatalog() { - const { theme } = useLayout(); - const t = useTranslate(); - const { open, isOpen } = useRequestModal(); - const openExternal = useOpenExternal(); - - const { output, isPending } = useToolInfo<"browse-catalog">(); - - const [cart, setCart] = useViewState<{ ids: number[] }>({ ids: [] }); - - const { callTool: createCheckout, isPending: checkoutPending } = - useCallTool("create-checkout"); - - const { phase, startPolling, reset } = useCheckoutPolling(); - - function toggleCart(productId: number) { - if (cart.ids.includes(productId)) { - setCart({ ids: cart.ids.filter((id) => id !== productId) }); - } else { - setCart({ ids: [...cart.ids, productId] }); - } - } - - function handlePay() { - createCheckout( - { productIds: cart.ids }, - { - onSuccess: (result) => { - const url = result.structuredContent?.checkoutUrl; - const sid = result.structuredContent?.sessionId; - if (typeof url === "string") { - openExternal(url); - } - if (typeof sid === "string") { - startPolling(sid); - } - }, - }, - ); - } - - function resetCheckout() { - reset(); - setCart({ ids: [] }); - } - - if (isPending) { - return ( -
-
{t("loading")}
-
- ); - } - - if (!output || output.products.length === 0) { - return ( -
-
{t("noProducts")}
-
- ); - } - - if (phase === "complete") { - const paidItems = output.products.filter((p) => cart.ids.includes(p.id)); - return ( -
- - -
- ); - } - - if (phase === "expired") { - return ( -
-
{t("paymentExpired")}
- -
- ); - } - - if (isOpen) { - const cartItems = output.products.filter((p) => cart.ids.includes(p.id)); - return ( -
- -
- ); - } - - return ( -
- open({ title: "Proceed to checkout ?" })} - /> -
- ); -} - -export default BrowseCatalog; diff --git a/examples/ecom-carousel/src/views/carousel/detail/detail.css.ts b/examples/ecom-carousel/src/views/carousel/detail/detail.css.ts new file mode 100644 index 000000000..8bac8bf0f --- /dev/null +++ b/examples/ecom-carousel/src/views/carousel/detail/detail.css.ts @@ -0,0 +1,141 @@ +import { style } from "@vanilla-extract/css"; +import { colors, primitives } from "../../../design/tokens"; + +// Two-column breakpoint. Layout is driven by the pane's own width (container +// query), never the viewport, so a phone and a resized desktop pane agree. +const TWO_COLUMN_MIN_WIDTH = "560px"; + +export const detail = style({ + containerType: "inline-size", + padding: primitives.space.s, + // Clear the notch / home indicator when the page bleeds to the screen edge. + paddingBottom: `calc(${primitives.space.s} + env(safe-area-inset-bottom))`, + // A fullscreen, text-heavy page owns its background so content.intense text + // stays readable regardless of the host backdrop (the inline carousel stays + // transparent via ViewFrame; only this fullscreen page paints a surface). + minHeight: "100dvh", + backgroundColor: colors.surface.light, +}); + +// Product / variant id, idiomatically top-right. +export const reference = style({ + textAlign: "right", + color: colors.content.subtle, + marginBottom: primitives.space["3xs"], +}); + +// Single column by default; two columns (gallery | info) once the pane is wide. +export const grid = style({ + display: "flex", + flexDirection: "column", + gap: primitives.space.s, + maxWidth: "1099px", + marginInline: "auto", + "@container": { + [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: { + display: "grid", + // `minmax(0, 1fr)` (not `1fr`) lets a column shrink below its content's + // intrinsic width, so a chip row scrolls/wraps instead of blowing out. + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + gridTemplateAreas: '"gallery info"', + alignItems: "start", + }, + }, +}); + +export const galleryCell = style({ + "@container": { + [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: { + gridArea: "gallery", + position: "sticky", + top: primitives.space.s, + }, + }, +}); + +// The info column owns all inter-section spacing; sections set no outer margin. +export const info = style({ + display: "flex", + flexDirection: "column", + gap: primitives.space.xs, + "@container": { + [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: { gridArea: "info" }, + }, +}); + +export const title = style({ color: colors.content.intense }); + +// D1/D3: rating + badges row directly under the title. +export const meta = style({ + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: primitives.space["3xs"], +}); + +export const rating = style({ + display: "inline-flex", + alignItems: "center", + gap: primitives.space["5xs"], + color: colors.content.intense, +}); + +export const ratingStar = style({ color: colors.common.accent }); + +export const ratingCount = style({ color: colors.content.subtle }); + +export const badge = style({ + padding: `${primitives.space["5xs"]} ${primitives.space["3xs"]}`, + borderRadius: primitives.radius.full, + backgroundColor: colors.content.intense, + color: colors.content.invertIntense, +}); + +export const price = style({ color: colors.content.intense }); + +// D5: product facts as a simple list, one line per fact "label: value" (or just +// the value when unlabeled). marginTop separates it from the section heading. +export const specList = style({ + display: "flex", + flexDirection: "column", + gap: primitives.space["3xs"], + marginTop: primitives.space["3xs"], + marginBottom: 0, +}); + +// One fact on a line: "label:" then value. +export const specRow = style({ + display: "flex", + gap: primitives.space["4xs"], +}); + +export const specLabel = style({ + margin: 0, + color: colors.content.subtle, +}); + +export const specValue = style({ + margin: 0, + color: colors.content.intense, +}); + +// Primary CTA (magenta fill, white label in both themes). +export const cta = style({ + minHeight: "44px", + padding: `${primitives.space["3xs"]} ${primitives.space.s}`, + borderRadius: primitives.radius.m, + border: "none", + backgroundColor: colors.common.accent, + color: colors.common.invertAccent, + fontFamily: primitives.font.family.primary, + fontSize: primitives.font.size.m, + fontWeight: primitives.font.weight.medium, + cursor: "pointer", + selectors: { + "&:disabled": { + backgroundColor: colors.surface.intense, + color: colors.content.subtle, + cursor: "not-allowed", + }, + }, +}); diff --git a/examples/ecom-carousel/src/views/carousel/detail/detail.stories.tsx b/examples/ecom-carousel/src/views/carousel/detail/detail.stories.tsx new file mode 100644 index 000000000..11fb36866 --- /dev/null +++ b/examples/ecom-carousel/src/views/carousel/detail/detail.stories.tsx @@ -0,0 +1,126 @@ +import type { Product } from "../../../tools/render-carousel.js"; +import { DetailView } from "./index"; + +function shot(fill: string) { + return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect width='400' height='400' fill='%23${fill}'/%3E%3Ccircle cx='200' cy='200' r='110' fill='%23ffffff' fill-opacity='0.5'/%3E%3C/svg%3E`; +} + +// A long media set (primary shot + several accents) so the desktop thumbnail +// rail is taller than the image and overflows past its bottom. +const ACCENT_FILLS = [ + "c9d4f5", + "f5d4c9", + "d4f5c9", + "f5f0c9", + "e0c9f5", + "c9f5f0", + "f5c9e0", + "d9d9d9", +]; + +function galleryMedia(primaryFill: string): string[] { + const media = [shot(primaryFill)]; + for (const fill of ACCENT_FILLS) { + media.push(shot(fill)); + } + return media; +} + +const DESCRIPTION = + "A relaxed-fit jacket in water-repellent cotton.\n\nDropped shoulders, a two-way zip, and ribbed cuffs. Fully lined, with two zip pockets at the front and one inside."; + +// One story, every picker behavior: +// - Hole in the matrix: {sand,L} does not exist, so under Sand, L is +// hard-disabled; switching to Sand while L is selected snaps Size to M. +// - Sold out: {black,L} renders struck but selectable; the CTA locks as +// "Out of stock". +// - Axis not applicable: the indigo colorway is one-size (no `size` key), so +// choosing "Indigo" hides the Size row; indigo resolves and stays buyable. +function jacket( + id: string, + color: string, + colorLabel: string, + size: string | null, + outOfStock = false, +): Product["variants"][number] { + const fill = + color === "sand" ? "d8c7a8" : color === "indigo" ? "3f4a8a" : "2b2b2b"; + return { + id, + selection: size == null ? { color } : { color, size }, + title: `Field jacket — ${colorLabel}`, + description: DESCRIPTION, + price: { amount: color === "black" ? 229 : 249, currency: "EUR" }, + media: galleryMedia(fill), + url: "https://example.com/jacket", + outOfStock, + specs: [ + { label: "Material", value: "Water-repellent cotton" }, + { label: "Fit", value: "Relaxed" }, + { value: "Machine washable" }, // label-less fact (renders value only) + ], + }; +} + +const PRODUCT: Product = { + id: "field-jacket", + options: [ + { + id: "color", + label: "Color", + values: [ + { id: "black", label: "Black", media: shot("2b2b2b") }, + { id: "sand", label: "Sand", media: shot("d8c7a8") }, + { id: "indigo", label: "Indigo", media: shot("3f4a8a") }, + ], + }, + { + id: "size", + label: "Size", + values: [ + { id: "m", label: "M" }, + { id: "l", label: "L" }, + ], + }, + ], + variants: [ + jacket("fj-black-m", "black", "Black", "m"), + jacket("fj-black-l", "black", "Black", "l", true), + jacket("fj-sand-m", "sand", "Sand", "m"), + jacket("fj-indigo", "indigo", "Indigo", null), + ], + card: { + title: "Field jacket", + price: { amount: 229, currency: "EUR" }, + media: [shot("2b2b2b")], + specs: [], + }, +}; + +export const Default = () => ; + +// Single-variant product: no picker, buyable immediately. +const SIMPLE: Product = { + id: "tote", + options: [], + variants: [ + { + id: "tote", + selection: {}, + title: "Canvas tote", + description: "A sturdy everyday canvas tote.", + price: { amount: 39, currency: "EUR" }, + media: [shot("e1e1e1")], + url: "https://example.com/tote", + specs: [{ label: "Material", value: "Canvas" }], + }, + ], + card: { + title: "Canvas tote", + price: { amount: 39, currency: "EUR" }, + media: [shot("e1e1e1")], + specs: [], + }, +}; + +export const SingleVariant = () => ; diff --git a/examples/ecom-carousel/src/views/carousel/detail/index.tsx b/examples/ecom-carousel/src/views/carousel/detail/index.tsx new file mode 100644 index 000000000..3a9490863 --- /dev/null +++ b/examples/ecom-carousel/src/views/carousel/detail/index.tsx @@ -0,0 +1,275 @@ +import { useEffect, useState } from "react"; +import { useOpenExternal, useSetOpenInAppUrl, useUser } from "skybridge/web"; +import { ExpandableText } from "../../../components/expandable-text"; +import { ImageGallery } from "../../../components/image-gallery"; +import { VariantPicker } from "../../../components/variant-picker"; +import { sprinkles, text } from "../../../design/tokens"; +import { type Labels, useLabels } from "../../../i18n"; +import { cx } from "../../../lib/cx"; +import { formatPrice } from "../../../lib/format"; +import { + initialSelection, + resolveVariant, + type Selection, +} from "../../../lib/variants.js"; +import type { Product, Variant } from "../../../tools/render-carousel.js"; +import * as styles from "./detail.css"; + +// Price to show: the resolved variant's price, else the range across variants +// (or a single price when they agree), else the card price, else a fallback. +function priceText( + product: Product, + variantPrice: Product["card"]["price"], + locale: string, + labels: Labels, +): string { + if (variantPrice) { + return formatPrice(variantPrice, locale); + } + const amounts: number[] = []; + let currency = ""; + for (const variant of product.variants) { + if (variant.price) { + amounts.push(variant.price.amount); + currency = variant.price.currency; + } + } + if (amounts.length > 0) { + const min = Math.min(...amounts); + const max = Math.max(...amounts); + if (min === max) { + return formatPrice({ amount: min, currency }, locale); + } + return `${formatPrice({ amount: min, currency }, locale)} – ${formatPrice({ amount: max, currency }, locale)}`; + } + if (product.card.price) { + return formatPrice(product.card.price, locale); + } + return labels.priceOnRequest; +} + +// data-llm narrates the variant the user is currently looking at. The full +// product spec (every variant) is pushed to view state by the carousel +// orchestrator, so the model can answer beyond what is on screen; this stays +// scoped to the visible selection. +function grounding( + product: Product, + variant: Variant | undefined, + title: string, + price: string, + selection: Selection, + unpurchasable: boolean, + labels: Labels, +): string { + const parts = [`The user is viewing "${title}" (product id: ${product.id}).`]; + const chosen: string[] = []; + for (const option of product.options) { + const valueId = selection[option.id]; + let label: string | undefined; + for (const value of option.values) { + if (value.id === valueId) { + label = value.label; + break; + } + } + if (label === undefined) { + // Axis skipped by the shown variant vs simply not picked yet. + label = + variant != null && variant.selection[option.id] == null + ? "not applicable" + : "not selected"; + } + chosen.push(`${option.label}: ${label}`); + } + if (chosen.length > 0) { + parts.push(`Selected — ${chosen.join(", ")}.`); + } + parts.push(`Price: ${price}.`); + if (unpurchasable) { + // Sold-out real variant vs a combination that does not exist. + parts.push( + variant != null ? labels.outOfStock : labels.combinationUnavailable, + ); + } + return parts.join(" "); +} + +/** + * Product detail view, rendered fullscreen over the carousel (see the carousel + * orchestrator). Reads a single product from the payload already in `_meta`; + * does no fetch. Option choices resolve against the product's sparse variant + * list in-place (no remount). + */ +export function DetailView({ product }: { product: Product }) { + const { locale } = useUser(); + const labels = useLabels(); + const openExternal = useOpenExternal(); + const setOpenInAppUrl = useSetOpenInAppUrl(); + const [selection, setSelection] = useState(() => + initialSelection(product), + ); + + // The exact variant for the selection; card fields fill in when none + // resolves (the applyChoice edge case, or a product with no variants). + const shown = resolveVariant(product, selection); + const displayTitle = shown?.title ?? product.card.title; + const description = shown?.description ?? product.card.description; + const media = shown?.media.length ? shown.media : product.card.media; + const specs = shown?.specs ?? product.card.specs; + // Sold out, or no variant resolved. + const unpurchasable = shown ? (shown.outOfStock ?? false) : true; + const url = shown?.url ?? product.card.url; + // The shown item's id: the resolved variant's SKU, else the product id. + const reference = shown?.id ?? product.id; + // Custom Meta fields (variant-first, then card): rating by the title (D1), + // badges as chips by the title. + const rating = shown?.rating ?? product.card.rating; + const reviewCount = shown?.reviewCount ?? product.card.reviewCount; + const badges = shown?.badges ?? product.card.badges ?? []; + + const price = priceText(product, shown?.price, locale, labels); + // Buy CTA is enabled only for an exact, in-stock variant with a real link + // (an empty url would leave the button enabled but dead). + const canBuy = shown != null && Boolean(url) && !unpurchasable; + + // Point the host's fullscreen "Open in app" affordance at the selected + // variant's page. Apps-SDK only; MCP Apps hosts reject, so ignore that. + useEffect(() => { + if (url) { + setOpenInAppUrl(url).catch(() => {}); + } + }, [url, setOpenInAppUrl]); + + return ( +
+ {/* Product / variant reference, idiomatically top-right. */} +

+ {labels.reference} {reference} +

+ +
+
+ {media.length > 0 ? ( + // Key on the media set so switching to a variant with different + // images remounts the gallery fresh (index reset to the first image). + + ) : null} +
+ +
+

+ {displayTitle} +

+ + {rating != null || badges.length > 0 ? ( +
+ {rating != null ? ( + + + {rating.toFixed(1)} + {reviewCount != null ? ( + + {" "} + ({reviewCount}) + + ) : null} + + ) : null} + {badges.map((b) => ( + + {b} + + ))} +
+ ) : null} + +

+ {price} +

+ + + + {description ? {description} : null} + + {/* Sold-out and unresolved selections stay composable; the CTA locks + and its label names the cause. */} + + + {/* D5: product facts as a simple "label: value" list after the CTA + (label-less facts show the value alone). Visual only — the full + spec is already in view state for the model, so this never hides + facts from the assistant. */} + {specs.length > 0 ? ( +
+

+ {labels.specifications} +

+
+ {specs.map((spec) => ( +
+ {spec.label ? ( +
+ {spec.label}: +
+ ) : null} +
+ {spec.value} +
+
+ ))} +
+
+ ) : null} +
+
+
+ ); +} diff --git a/examples/ecom-carousel/src/views/carousel/index.tsx b/examples/ecom-carousel/src/views/carousel/index.tsx new file mode 100644 index 000000000..107bdc39e --- /dev/null +++ b/examples/ecom-carousel/src/views/carousel/index.tsx @@ -0,0 +1,237 @@ +import "../../index.css"; + +import { + type ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useDisplayMode, useViewState } from "skybridge/web"; +import { EmptyState } from "../../components/empty-state"; +import { + ProductCard, + ProductCardSkeleton, +} from "../../components/product-card"; +import * as cardStyles from "../../components/product-card.css"; +import { ProductCarousel } from "../../components/product-carousel"; +import { ViewFrame } from "../../components/view-frame"; +import { sprinkles } from "../../design/tokens"; +import { useToolInfo } from "../../helpers.js"; +import { useLabels } from "../../i18n"; +import { formatPrice } from "../../lib/format"; +import type { Product } from "../../tools/render-carousel.js"; +import type { Price, Spec } from "../../types.js"; +import { DetailView } from "./detail"; + +const SKELETON_COUNT = 4; + +// One narration line per on-screen product. `id` ties the card back to its full +// record in structuredContent. +function narrate(product: Product, index: number): string { + const { card } = product; + const price = card.price ? ` - ${formatPrice(card.price)}` : ""; + const oos = card.outOfStock ? " [out of stock]" : ""; + return `${index + 1}. ${card.title} (id: ${product.id})${price}${oos}`; +} + +// View state, persisted on the host so an open detail survives a remount (e.g. +// after a follow-up message). scrollLeft restores the carousel position on the +// way back; spec is the full product spec (every variant) the model can answer +// from while the detail is open. +type VariantSpec = { + selection: Record; // option label -> chosen value label + price?: Price; + available: boolean; + specs: Spec[]; +}; +type ProductSpec = { id: string; title: string; variants: VariantSpec[] }; +type ViewState = { + selectedId: string | null; + scrollLeft: number; + spec: ProductSpec | null; +}; + +// The complete spec of the open product — every variant, not just the visible +// one — pushed to view state so the model can answer any detail question. The +// on-screen variant is narrated separately (data-llm, in the detail view). Keep +// it to a single product; a very large spec trips the view-state size warning. +function buildProductSpec(product: Product): ProductSpec { + const variants: VariantSpec[] = []; + for (const variant of product.variants) { + const selection: Record = {}; + for (const option of product.options) { + const valueId = variant.selection[option.id]; + if (!valueId) { + continue; + } + let label = valueId; + for (const value of option.values) { + if (value.id === valueId) { + label = value.label; + break; + } + } + selection[option.label] = label; + } + variants.push({ + selection, + price: variant.price, + available: !variant.outOfStock, + specs: variant.specs, + }); + } + return { id: product.id, title: product.card.title, variants }; +} + +/** + * Carousel view + product detail, in one view. The carousel is the inline + * surface; tapping a card opens the detail fullscreen over it (the carousel is + * hidden, not unmounted). Both read the full products from `_meta`; the detail + * needs no extra fetch. + */ +function Carousel() { + const { responseMetadata } = useToolInfo<"render-carousel">(); + const labels = useLabels(); + const trackRef = useRef(null); + const [visibleIndices, setVisibleIndices] = useState([]); + const [mode, setMode] = useDisplayMode(); + const [nav, setNav] = useViewState({ + selectedId: null, + scrollLeft: 0, + spec: null, + }); + // True between requesting fullscreen and the host applying it, so the + // collapse-is-back effect below does not fire mid-transition. + const enteringRef = useRef(false); + + const selectedId = nav.selectedId; + // The detail only mounts once the host is actually fullscreen: rendering the + // tall page inside the small inline frame would flash a cramped layout. + const showDetail = selectedId != null && mode === "fullscreen"; + + // A host-driven exit from fullscreen (the user used host chrome) means "back". + useEffect(() => { + if (mode === "fullscreen") { + enteringRef.current = false; + return; + } + if (selectedId != null && !enteringRef.current) { + setNav((prev) => ({ ...prev, selectedId: null, spec: null })); + } + }, [mode, selectedId, setNav]); + + // Restore carousel scroll when back on the carousel. display:none resets + // scrollLeft, so re-apply once it is visible again (layout effect, not mount). + useLayoutEffect(() => { + if (!showDetail && trackRef.current) { + trackRef.current.scrollLeft = nav.scrollLeft; + } + }, [showDetail, nav.scrollLeft]); + + function openProduct(id: string) { + enteringRef.current = true; + const list = responseMetadata?.products ?? []; + let spec: ProductSpec | null = null; + for (const product of list) { + if (product.id === id) { + spec = buildProductSpec(product); + break; + } + } + setNav({ + selectedId: id, + scrollLeft: trackRef.current?.scrollLeft ?? 0, + spec, + }); + setMode("fullscreen"); + } + + // Tool still resolving: reserve the layout with skeleton cards. + if (responseMetadata == null) { + const skeletons: ReactNode[] = []; + for (let i = 0; i < SKELETON_COUNT; i++) { + skeletons.push(); + } + return ( + +
+ {skeletons} +
+
+ ); + } + + const products = responseMetadata.products ?? []; + + if (products.length === 0) { + return ( + + + + ); + } + + // Guard against a stale id (e.g. the model rendered a new carousel while a + // detail was open): fall back to the carousel rather than an empty page. + const selectedProduct = + selectedId != null + ? products.find((product) => product.id === selectedId) + : undefined; + // The detail actually renders only when we have a product to show. Everything + // that hides the carousel keys off this, so a stale id shows the carousel, not + // a blank page. + const detailProduct = showDetail ? selectedProduct : undefined; + + const cards: ReactNode[] = []; + for (const [index, product] of products.entries()) { + const { card } = product; + cards.push( +
+ +
, + ); + } + + const narration = `Carousel of ${products.length} product(s); the user scrolls horizontally. On screen now:`; + + return ( + +
+ + {cards} + +
+ {detailProduct ? : null} +
+ ); +} + +export default Carousel; diff --git a/examples/ecom-carousel/tsconfig.json b/examples/ecom-carousel/tsconfig.json index 0b2c66f70..de0a684ef 100644 --- a/examples/ecom-carousel/tsconfig.json +++ b/examples/ecom-carousel/tsconfig.json @@ -2,9 +2,9 @@ "extends": "skybridge/tsconfig", "compilerOptions": { - "paths": { - "@/*": ["./src/*"] - } + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node", "vite/client"] }, "include": ["src", ".skybridge/**/*.d.ts"] diff --git a/examples/ecom-carousel/vite.config.ts b/examples/ecom-carousel/vite.config.ts index 5f9a70659..158dec4f2 100644 --- a/examples/ecom-carousel/vite.config.ts +++ b/examples/ecom-carousel/vite.config.ts @@ -1,9 +1,8 @@ -import path from "node:path"; +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; import react from "@vitejs/plugin-react"; import { skybridge } from "skybridge/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type PluginOption } from "vite"; -// https://vite.dev/config/ export default defineConfig({ server: { forwardConsole: { @@ -11,10 +10,5 @@ export default defineConfig({ logLevels: ["error"], }, }, - plugins: [skybridge(), react()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, + plugins: [skybridge() as PluginOption, react(), vanillaExtractPlugin()], }); diff --git a/packages/create-skybridge/src/index.ts b/packages/create-skybridge/src/index.ts index 87c89bf76..7f415c9da 100644 --- a/packages/create-skybridge/src/index.ts +++ b/packages/create-skybridge/src/index.ts @@ -12,7 +12,7 @@ const DEFAULT_PROJECT_NAME = "skybridge-project"; const PACKAGE_MANAGERS = ["bun", "deno", "npm", "pnpm", "yarn"] as const; type PackageManager = (typeof PACKAGE_MANAGERS)[number]; -const TEMPLATES = ["demo", "blank"] as const; +const TEMPLATES = ["demo", "blank", "ecom"] as const; type Template = (typeof TEMPLATES)[number]; const pkg = JSON.parse( @@ -32,6 +32,7 @@ Arguments: Options: --blank scaffold a minimal project without demo tools and views + --ecom scaffold the ecommerce template (search products, render carousel) --overwrite remove existing files if target directory is not empty --pm package manager to use (choices: ${PACKAGE_MANAGERS.join(", ")}. default to npm when none is provided or infered) --skip-skills skip installing coding agent skills @@ -74,13 +75,22 @@ export async function init(args: string[] = process.argv.slice(2)) { const argv = mri<{ help?: boolean; blank?: boolean; + ecom?: boolean; overwrite?: boolean; pm?: string; "skip-skills"?: boolean; start?: boolean; yes?: boolean; }>(args, { - boolean: ["help", "blank", "overwrite", "skip-skills", "start", "yes"], + boolean: [ + "help", + "blank", + "ecom", + "overwrite", + "skip-skills", + "start", + "yes", + ], string: ["pm"], alias: { h: "help" }, }); @@ -153,32 +163,46 @@ export async function init(args: string[] = process.argv.slice(2)) { } // 3. Template - let template: Template; + let template: Template | undefined; if (argv.blank) { template = "blank"; - } else if (yes) { - template = "demo"; - } else { - const choice = await prompts.select