create-skybridge 0.0.0-dev.06d207f → 0.0.0-dev.06f809c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/index.js +46 -24
  2. package/package.json +2 -2
  3. package/templates/blank/Dockerfile +2 -2
  4. package/templates/blank/node_modules/.bin/alpic +2 -2
  5. package/templates/blank/node_modules/.bin/tsx +2 -2
  6. package/templates/blank/node_modules/.bin/vite +2 -2
  7. package/templates/blank/package.json +6 -6
  8. package/templates/demo/Dockerfile +2 -2
  9. package/templates/demo/node_modules/.bin/alpic +2 -2
  10. package/templates/demo/node_modules/.bin/tsx +2 -2
  11. package/templates/demo/node_modules/.bin/vite +2 -2
  12. package/templates/demo/package.json +11 -11
  13. package/templates/ecom/.dockerignore +4 -0
  14. package/templates/ecom/.env.template +2 -0
  15. package/templates/ecom/.ladle/components.tsx +26 -0
  16. package/templates/ecom/.ladle/config.mjs +11 -0
  17. package/templates/ecom/.ladle/vite.config.ts +11 -0
  18. package/templates/ecom/AGENTS.md +2 -0
  19. package/templates/ecom/Dockerfile +53 -0
  20. package/templates/ecom/README.md +92 -0
  21. package/templates/ecom/_gitignore +9 -0
  22. package/templates/ecom/alpic.json +3 -0
  23. package/templates/ecom/node_modules/.bin/alpic +21 -0
  24. package/templates/ecom/node_modules/.bin/ladle +21 -0
  25. package/templates/ecom/node_modules/.bin/sb +21 -0
  26. package/templates/ecom/node_modules/.bin/skybridge +21 -0
  27. package/templates/ecom/node_modules/.bin/tsc +21 -0
  28. package/templates/ecom/node_modules/.bin/tsserver +21 -0
  29. package/templates/ecom/node_modules/.bin/tsx +21 -0
  30. package/templates/ecom/node_modules/.bin/vite +21 -0
  31. package/templates/ecom/package.json +41 -0
  32. package/templates/ecom/src/components/chip.css.ts +56 -0
  33. package/templates/ecom/src/components/chip.stories.tsx +26 -0
  34. package/templates/ecom/src/components/chip.tsx +41 -0
  35. package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
  36. package/templates/ecom/src/components/empty-state.tsx +12 -0
  37. package/templates/ecom/src/components/expandable-text.css.ts +49 -0
  38. package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
  39. package/templates/ecom/src/components/expandable-text.tsx +53 -0
  40. package/templates/ecom/src/components/image-gallery.css.ts +172 -0
  41. package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
  42. package/templates/ecom/src/components/image-gallery.tsx +163 -0
  43. package/templates/ecom/src/components/product-card.css.ts +159 -0
  44. package/templates/ecom/src/components/product-card.stories.tsx +58 -0
  45. package/templates/ecom/src/components/product-card.tsx +102 -0
  46. package/templates/ecom/src/components/product-carousel.css.ts +134 -0
  47. package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
  48. package/templates/ecom/src/components/product-carousel.tsx +202 -0
  49. package/templates/ecom/src/components/variant-picker.css.ts +27 -0
  50. package/templates/ecom/src/components/variant-picker.stories.tsx +64 -0
  51. package/templates/ecom/src/components/variant-picker.tsx +82 -0
  52. package/templates/ecom/src/components/view-frame.css.ts +22 -0
  53. package/templates/ecom/src/components/view-frame.tsx +27 -0
  54. package/templates/ecom/src/config.ts +12 -0
  55. package/templates/ecom/src/design/contract.css.ts +39 -0
  56. package/templates/ecom/src/design/fonts.css +15 -0
  57. package/templates/ecom/src/design/primitives.css.ts +101 -0
  58. package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
  59. package/templates/ecom/src/design/sprinkles.css.ts +128 -0
  60. package/templates/ecom/src/design/themes/dark.css.ts +36 -0
  61. package/templates/ecom/src/design/themes/light.css.ts +36 -0
  62. package/templates/ecom/src/design/tokens.ts +8 -0
  63. package/templates/ecom/src/helpers.ts +4 -0
  64. package/templates/ecom/src/i18n.ts +35 -0
  65. package/templates/ecom/src/index.css +9 -0
  66. package/templates/ecom/src/lib/cx.ts +9 -0
  67. package/templates/ecom/src/lib/format.ts +9 -0
  68. package/templates/ecom/src/lib/variants.ts +79 -0
  69. package/templates/ecom/src/server.ts +43 -0
  70. package/templates/ecom/src/tools/render-carousel.ts +241 -0
  71. package/templates/ecom/src/tools/search-products.ts +185 -0
  72. package/templates/ecom/src/types.ts +15 -0
  73. package/templates/ecom/src/views/carousel/detail/detail.css.ts +115 -0
  74. package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +115 -0
  75. package/templates/ecom/src/views/carousel/detail/index.tsx +240 -0
  76. package/templates/ecom/src/views/carousel/index.tsx +234 -0
  77. package/templates/ecom/tsconfig.json +11 -0
  78. package/templates/ecom/vite.config.ts +8 -0
@@ -0,0 +1,185 @@
1
+ import { z } from "zod";
2
+ import {
3
+ CAROUSEL_MAX_SIZE,
4
+ CAROUSEL_RANGE,
5
+ MIN_SEARCH_ITERATIONS,
6
+ } from "../config.js";
7
+ import { PriceSchema, SpecSchema } from "../types.js";
8
+
9
+ // The `search-products` tool: keyword + filters in, matching products out as
10
+ // structured output for the model. It has NO view — include only what the model
11
+ // needs to curate (ids + properties), never presentational data (images, media);
12
+ // render-carousel handles that. Everything this tool needs lives in this file.
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Input
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const inputSchema = {
19
+ // @todo: tune this guidance to your catalog's vocabulary. The model reads
20
+ // it to turn conversational input into a good keyword.
21
+ keyword: z.string().describe(
22
+ `\
23
+ Short noun phrases extracted from conversational input. Never pass full sentences. \
24
+ ALWAYS use the singular form, not the plural. \
25
+ Include color, material, and style descriptors for accuracy. \
26
+ For vague gift or occasion queries without a clear product type, use broad category terms. \
27
+ Preserve the existing keyword across filter refinements; only replace when the user makes a completely different request.`,
28
+ ),
29
+
30
+ // @todo: set the sort options your backend supports (or remove).
31
+ sort: z.enum(["price-asc", "price-desc"]).optional().describe("Sort order."),
32
+
33
+ // @todo: declare the filters your catalog supports. Add one param per
34
+ // facet (category, color, size...), each optional so the model only sends
35
+ // what the user asked for. `priceRange` below is just an example, remove it.
36
+ priceRange: z
37
+ .string()
38
+ .optional()
39
+ .describe(
40
+ "Price range in dollars. Format: 'min-max' e.g. '0-500', '1000-2000'.",
41
+ ),
42
+ };
43
+
44
+ type SearchInput = z.infer<z.ZodObject<typeof inputSchema>>;
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Output — model-facing grounding, returned in structuredContent.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const productSchema = z.object({
51
+ id: z.string().describe("Stable product ID; pass to render-carousel."),
52
+ title: z.string(),
53
+ description: z.string().optional(),
54
+ price: PriceSchema.optional(),
55
+ outOfStock: z
56
+ .boolean()
57
+ .optional()
58
+ .describe("True when the product is not purchasable."),
59
+ specs: z
60
+ .array(SpecSchema)
61
+ .describe("Product-specific facts to curate on (material, dimensions…)."),
62
+
63
+ // @todo: Add whatever custom fields the model should curate on as real types,
64
+ // not `specs` strings (e.g. `rating`, `discountPct`, `badges`).
65
+ });
66
+
67
+ const outputSchema = {
68
+ products: z.array(productSchema).describe("Matching products, sorted."),
69
+ pages: z
70
+ .object({
71
+ current: z.number(),
72
+ total: z.number(),
73
+ })
74
+ .optional()
75
+ .describe("Pagination: current page and total page count."),
76
+ totalHits: z
77
+ .number()
78
+ .optional()
79
+ .describe("Total matching products across all pages."),
80
+ };
81
+
82
+ type SearchOutput = z.infer<z.ZodObject<typeof outputSchema>>;
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Data access
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function search(_input: SearchInput): SearchOutput {
89
+ // @todo: plug in your product API / DB. Query it with the input params and map
90
+ // each result into `products` below. `pages` and `totalHits` are optional.
91
+ return {
92
+ products: [], // { id: string; properties: { name: PropertyName; value: string[] }[] }[]
93
+ pages: { current: 1, total: 1 },
94
+ totalHits: 0,
95
+ };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Narration — framing + next-step instructions for the model. The products
100
+ // themselves ride in structuredContent; this text carries NO result data.
101
+ // @todo: customize the NEXT STEPS guidance below for your flow (keep searching
102
+ // vs. curate and render). Remind the model to ground claims in the structured
103
+ // results and never invent facts.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function narrate({ products }: SearchOutput): string {
107
+ const size = products.length;
108
+
109
+ if (size === 0) {
110
+ return `\
111
+ No products found.
112
+
113
+ NEXT STEP: Broaden the keyword or relax filters, then search again.`;
114
+ }
115
+
116
+ if (size < CAROUSEL_MAX_SIZE) {
117
+ return `\
118
+ Only a few results.
119
+
120
+ NEXT STEPS:
121
+ 1. If the client asked for a specific product by name, these are fine: curate and call render-carousel with the selected IDs.
122
+ 2. Otherwise, search again with broader terms before rendering.`;
123
+ }
124
+
125
+ return `\
126
+ Enough results to present.
127
+
128
+ NEXT STEPS:
129
+ 1. Curate the best matches for the client's intent from the structured results.
130
+ 2. Write your recommendation mentioning the selected products.
131
+ 3. Call render-carousel with the selected IDs.`;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Tool (registered from server.ts to keep the typed tool chain intact)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export const searchProductsDefinition = {
139
+ name: "search-products" as const,
140
+
141
+ // @todo: describe YOUR catalog and how the agent should search and curate
142
+ // it. This is the model's main guide: be specific about your categories,
143
+ // the multi-call search loop, and when to render vs. keep searching.
144
+ description: `\
145
+ Search the product catalog. Handles any query: specific products, broad categories, gifts, occasions, or open discovery.
146
+ Never assume a category is unavailable: always search before responding.
147
+
148
+ The response is data only: a list of matching products (name, ID, price, description, and any product-specific facts) plus pagination and the available filters. The raw results are for your eyes only: the client never sees them.
149
+
150
+ NEVER describe or characterize the raw results to the client: do not mention how many there are, what categories they fall into, or that they look off-topic. While searching, stay silent: do not narrate what you are doing, announce searches, or report progress between tool calls. Speak only once the carousel is rendered.
151
+
152
+ Act on the response as follows:
153
+
154
+ - FIRST SEARCH: use the keyword only (plus sort if needed). Read the available filters in the response; these are the only valid filter options for follow-ups.
155
+ - REFINEMENT: if the user's intent matches an available filter, apply it. If it doesn't match any filter, start a new keyword search.
156
+ - ITERATE: a single search is never enough. Run several searches (${MIN_SEARCH_ITERATIONS} minimum) before rendering: vary the keyword, apply a filter, or page deeper, and compare results across them. Quality comes from this multi-call exploration; one call then render gives shallow results.
157
+ - CURATION: read results and pick the best matches for the client's intent, grounding your choice in each product's description. If zero results, broaden the keyword or relax filters and search again: do NOT call render-carousel. If fewer than 3 results and the user didn't ask for a specific product, search again with broader terms. Only after exploring across several searches, call render-carousel with the selected IDs.
158
+ - PRESENT: Once you have ${CAROUSEL_RANGE} distinct, relevant products, call render-carousel with their IDs. Recommend ONLY AFTER the carousel displays, in carousel order.
159
+
160
+ The sweet spot is ${CAROUSEL_RANGE} products.
161
+ `,
162
+ annotations: {
163
+ readOnlyHint: true,
164
+ openWorldHint: false,
165
+ destructiveHint: false,
166
+ },
167
+
168
+ // @todo: customize the status messages shown in ChatGPT while the tool runs.
169
+ _meta: {
170
+ "openai/toolInvocation/invoking": "Searching the catalog",
171
+ "openai/toolInvocation/invoked": "Searched the catalog",
172
+ },
173
+
174
+ inputSchema,
175
+ outputSchema,
176
+ };
177
+
178
+ export function searchProductsHandler(input: SearchInput) {
179
+ const results = search(input);
180
+ return {
181
+ structuredContent: results,
182
+ content: [{ type: "text" as const, text: narrate(results) }],
183
+ isError: false,
184
+ };
185
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+
3
+ export const PriceSchema = z.object({
4
+ amount: z.number(),
5
+ currency: z.string(),
6
+ });
7
+ export type Price = z.infer<typeof PriceSchema>;
8
+
9
+ // A product-specific fact (an objective spec: material, dimensions, capacity,
10
+ // care…). `label` is optional so a fact can be a bare value (e.g. "Waterproof").
11
+ export const SpecSchema = z.object({
12
+ label: z.string().optional(),
13
+ value: z.string(),
14
+ });
15
+ export type Spec = z.infer<typeof SpecSchema>;
@@ -0,0 +1,115 @@
1
+ import { style } from "@vanilla-extract/css";
2
+ import { colors, primitives } from "../../../design/tokens";
3
+
4
+ // Two-column breakpoint. Layout is driven by the pane's own width (container
5
+ // query), never the viewport, so a phone and a resized desktop pane agree.
6
+ const TWO_COLUMN_MIN_WIDTH = "560px";
7
+
8
+ export const detail = style({
9
+ containerType: "inline-size",
10
+ padding: primitives.space.s,
11
+ // Clear the notch / home indicator when the page bleeds to the screen edge.
12
+ paddingBottom: `calc(${primitives.space.s} + env(safe-area-inset-bottom))`,
13
+ });
14
+
15
+ // Product / variant id, idiomatically top-right. @todo: reposition (drop the
16
+ // right align, or move it in the JSX) or remove it.
17
+ export const reference = style({
18
+ textAlign: "right",
19
+ color: colors.content.subtle,
20
+ marginBottom: primitives.space["3xs"],
21
+ });
22
+
23
+ // Single column by default; two columns (gallery | info) once the pane is wide.
24
+ export const grid = style({
25
+ display: "flex",
26
+ flexDirection: "column",
27
+ gap: primitives.space.s,
28
+ maxWidth: "1099px",
29
+ marginInline: "auto",
30
+ "@container": {
31
+ [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: {
32
+ display: "grid",
33
+ // `minmax(0, 1fr)` (not `1fr`) lets a column shrink below its content's
34
+ // intrinsic width, so a chip row scrolls/wraps instead of blowing out.
35
+ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
36
+ gridTemplateAreas: '"gallery info"',
37
+ alignItems: "start",
38
+ },
39
+ },
40
+ });
41
+
42
+ export const galleryCell = style({
43
+ "@container": {
44
+ [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: {
45
+ gridArea: "gallery",
46
+ position: "sticky",
47
+ top: primitives.space.s,
48
+ },
49
+ },
50
+ });
51
+
52
+ // The info column owns all inter-section spacing; sections set no outer margin.
53
+ export const info = style({
54
+ display: "flex",
55
+ flexDirection: "column",
56
+ gap: primitives.space.xs,
57
+ "@container": {
58
+ [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: { gridArea: "info" },
59
+ },
60
+ });
61
+
62
+ export const title = style({ color: colors.content.intense });
63
+
64
+ export const price = style({ color: colors.content.intense });
65
+
66
+ export const oos = style({ color: colors.common.error });
67
+
68
+ // Product facts as a simple list: one line per fact, "label: value" (or just
69
+ // the value when unlabeled). marginTop separates it from the section heading.
70
+ // @todo: reshape freely (a table, grouped sections, inline chips…)
71
+ export const specList = style({
72
+ display: "flex",
73
+ flexDirection: "column",
74
+ gap: primitives.space["3xs"],
75
+ marginTop: primitives.space["3xs"],
76
+ marginBottom: 0,
77
+ });
78
+
79
+ // One fact on a line: "label:" then value.
80
+ export const specRow = style({
81
+ display: "flex",
82
+ gap: primitives.space["4xs"],
83
+ });
84
+
85
+ export const specLabel = style({
86
+ margin: 0,
87
+ color: colors.content.subtle,
88
+ });
89
+
90
+ export const specValue = style({
91
+ margin: 0,
92
+ color: colors.content.intense,
93
+ });
94
+
95
+ // Primary CTA. @todo: tune to your brand; add a secondary action beside it
96
+ // (ask-the-assistant, contact) as a per-catalog extension.
97
+ export const cta = style({
98
+ minHeight: "44px",
99
+ padding: `${primitives.space["3xs"]} ${primitives.space.s}`,
100
+ borderRadius: primitives.radius.m,
101
+ border: "none",
102
+ backgroundColor: colors.common.accent,
103
+ color: colors.content.invertIntense,
104
+ fontFamily: primitives.font.family.primary,
105
+ fontSize: primitives.font.size.m,
106
+ fontWeight: primitives.font.weight.medium,
107
+ cursor: "pointer",
108
+ selectors: {
109
+ "&:disabled": {
110
+ backgroundColor: colors.surface.intense,
111
+ color: colors.content.subtle,
112
+ cursor: "not-allowed",
113
+ },
114
+ },
115
+ });
@@ -0,0 +1,115 @@
1
+ import type { Product } from "../../../tools/render-carousel.js";
2
+ import { DetailView } from "./index";
3
+
4
+ function shot(fill: string) {
5
+ 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`;
6
+ }
7
+
8
+ // A long media set (primary shot + several accents) so the desktop thumbnail
9
+ // rail is taller than the image and overflows past its bottom.
10
+ const ACCENT_FILLS = [
11
+ "c9d4f5",
12
+ "f5d4c9",
13
+ "d4f5c9",
14
+ "f5f0c9",
15
+ "e0c9f5",
16
+ "c9f5f0",
17
+ "f5c9e0",
18
+ "d9d9d9",
19
+ ];
20
+
21
+ function galleryMedia(primaryFill: string): string[] {
22
+ const media = [shot(primaryFill)];
23
+ for (const fill of ACCENT_FILLS) {
24
+ media.push(shot(fill));
25
+ }
26
+ return media;
27
+ }
28
+
29
+ const DESCRIPTION =
30
+ "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.";
31
+
32
+ // Two axes with a sparse variant set: {black,M}, {black,L}, {sand,M} exist —
33
+ // so choosing "Sand" disables "L".
34
+ function jacket(
35
+ id: string,
36
+ color: string,
37
+ colorLabel: string,
38
+ size: string,
39
+ ): Product["variants"][number] {
40
+ return {
41
+ id,
42
+ selection: { color, size },
43
+ title: `Field jacket — ${colorLabel}`,
44
+ description: DESCRIPTION,
45
+ price: { amount: color === "sand" ? 249 : 229, currency: "EUR" },
46
+ media: galleryMedia(color === "sand" ? "d8c7a8" : "2b2b2b"),
47
+ url: "https://example.com/jacket",
48
+ specs: [
49
+ { label: "Material", value: "Water-repellent cotton" },
50
+ { label: "Fit", value: "Relaxed" },
51
+ { value: "Machine washable" }, // label-less fact (renders value only)
52
+ ],
53
+ };
54
+ }
55
+
56
+ const PRODUCT: Product = {
57
+ id: "field-jacket",
58
+ options: [
59
+ {
60
+ id: "color",
61
+ label: "Color",
62
+ values: [
63
+ { id: "black", label: "Black", media: shot("2b2b2b") },
64
+ { id: "sand", label: "Sand", media: shot("d8c7a8") },
65
+ ],
66
+ },
67
+ {
68
+ id: "size",
69
+ label: "Size",
70
+ values: [
71
+ { id: "m", label: "M" },
72
+ { id: "l", label: "L" },
73
+ ],
74
+ },
75
+ ],
76
+ variants: [
77
+ jacket("fj-black-m", "black", "Black", "m"),
78
+ jacket("fj-black-l", "black", "Black", "l"),
79
+ jacket("fj-sand-m", "sand", "Sand", "m"),
80
+ ],
81
+ card: {
82
+ title: "Field jacket",
83
+ price: { amount: 229, currency: "EUR" },
84
+ media: [shot("2b2b2b")],
85
+ specs: [],
86
+ },
87
+ };
88
+
89
+ export const Default = () => <DetailView product={PRODUCT} />;
90
+
91
+ // Single-variant product: no picker, buyable immediately.
92
+ const SIMPLE: Product = {
93
+ id: "tote",
94
+ options: [],
95
+ variants: [
96
+ {
97
+ id: "tote",
98
+ selection: {},
99
+ title: "Canvas tote",
100
+ description: "A sturdy everyday canvas tote.",
101
+ price: { amount: 39, currency: "EUR" },
102
+ media: [shot("e1e1e1")],
103
+ url: "https://example.com/tote",
104
+ specs: [{ label: "Material", value: "Canvas" }],
105
+ },
106
+ ],
107
+ card: {
108
+ title: "Canvas tote",
109
+ price: { amount: 39, currency: "EUR" },
110
+ media: [shot("e1e1e1")],
111
+ specs: [],
112
+ },
113
+ };
114
+
115
+ export const SingleVariant = () => <DetailView product={SIMPLE} />;
@@ -0,0 +1,240 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useOpenExternal, useSetOpenInAppUrl, useUser } from "skybridge/web";
3
+ import { ExpandableText } from "../../../components/expandable-text";
4
+ import { ImageGallery } from "../../../components/image-gallery";
5
+ import { VariantPicker } from "../../../components/variant-picker";
6
+ import { sprinkles, text } from "../../../design/tokens";
7
+ import { type Labels, useLabels } from "../../../i18n";
8
+ import { cx } from "../../../lib/cx";
9
+ import { formatPrice } from "../../../lib/format";
10
+ import {
11
+ initialSelection,
12
+ resolveVariant,
13
+ type Selection,
14
+ } from "../../../lib/variants.js";
15
+ import type { Product } from "../../../tools/render-carousel.js";
16
+ import * as styles from "./detail.css";
17
+
18
+ // Price to show: the resolved variant's price, else the range across variants
19
+ // (or a single price when they agree), else the card price, else a fallback.
20
+ function priceText(
21
+ product: Product,
22
+ variantPrice: Product["card"]["price"],
23
+ locale: string,
24
+ labels: Labels,
25
+ ): string {
26
+ if (variantPrice) {
27
+ return formatPrice(variantPrice, locale);
28
+ }
29
+ const amounts: number[] = [];
30
+ let currency = "";
31
+ for (const variant of product.variants) {
32
+ if (variant.price) {
33
+ amounts.push(variant.price.amount);
34
+ currency = variant.price.currency;
35
+ }
36
+ }
37
+ if (amounts.length > 0) {
38
+ const min = Math.min(...amounts);
39
+ const max = Math.max(...amounts);
40
+ if (min === max) {
41
+ return formatPrice({ amount: min, currency }, locale);
42
+ }
43
+ return `${formatPrice({ amount: min, currency }, locale)} – ${formatPrice({ amount: max, currency }, locale)}`;
44
+ }
45
+ if (product.card.price) {
46
+ return formatPrice(product.card.price, locale);
47
+ }
48
+ return labels.priceOnRequest;
49
+ }
50
+
51
+ // data-llm narrates the variant the user is currently looking at. The full
52
+ // product spec (every variant) is pushed to view state by the carousel
53
+ // orchestrator, so the model can answer beyond what is on screen; this stays
54
+ // scoped to the visible selection.
55
+ function grounding(
56
+ product: Product,
57
+ title: string,
58
+ price: string,
59
+ selection: Selection,
60
+ outOfStock: boolean,
61
+ labels: Labels,
62
+ ): string {
63
+ const parts = [`The user is viewing "${title}" (product id: ${product.id}).`];
64
+ const chosen: string[] = [];
65
+ for (const option of product.options) {
66
+ let label = "not selected";
67
+ const valueId = selection[option.id];
68
+ for (const value of option.values) {
69
+ if (value.id === valueId) {
70
+ label = value.label;
71
+ break;
72
+ }
73
+ }
74
+ chosen.push(`${option.label}: ${label}`);
75
+ }
76
+ if (chosen.length > 0) {
77
+ parts.push(`Selected — ${chosen.join(", ")}.`);
78
+ }
79
+ parts.push(`Price: ${price}.`);
80
+ if (outOfStock) {
81
+ parts.push(labels.outOfStock);
82
+ }
83
+ return parts.join(" ");
84
+ }
85
+
86
+ /**
87
+ * Product detail view, rendered fullscreen over the carousel (see the carousel
88
+ * orchestrator). Reads a single product from the payload already in `_meta`;
89
+ * does no fetch. Option choices resolve against the product's sparse variant
90
+ * list in-place (no remount).
91
+ */
92
+ export function DetailView({ product }: { product: Product }) {
93
+ const { locale } = useUser();
94
+ const labels = useLabels();
95
+ const openExternal = useOpenExternal();
96
+ const setOpenInAppUrl = useSetOpenInAppUrl();
97
+ const [selection, setSelection] = useState<Selection>(() =>
98
+ initialSelection(product),
99
+ );
100
+
101
+ const variant = resolveVariant(product, selection);
102
+ // Fall back to the card for display fields until a variant resolves.
103
+ const displayTitle = variant?.title ?? product.card.title;
104
+ const description = variant?.description ?? product.card.description;
105
+ const media = variant?.media.length ? variant.media : product.card.media;
106
+ const specs = variant?.specs ?? product.card.specs;
107
+ const outOfStock = variant?.outOfStock ?? product.card.outOfStock ?? false;
108
+ const url = variant?.url ?? product.card.url;
109
+ // The shown item's id: the resolved variant's SKU, else the product id.
110
+ const reference = variant?.id ?? product.id;
111
+ // @todo: read any custom Meta fields you added the same way (variant first,
112
+ // then card), e.g. `const rating = variant?.rating ?? product.card.rating;`,
113
+ // then render them in the agreed spot (rating by the title, discount by the
114
+ // price, badges as chips…).
115
+
116
+ const price = priceText(product, variant?.price, locale, labels);
117
+ // Buy CTA is enabled only once a variant resolves and carries a real link
118
+ // (an empty url would leave the button enabled but dead).
119
+ const canBuy = variant != null && Boolean(url);
120
+
121
+ // Point the host's fullscreen "Open in app" affordance at the selected
122
+ // variant's page. Apps-SDK only; MCP Apps hosts reject, so ignore that.
123
+ useEffect(() => {
124
+ if (url) {
125
+ setOpenInAppUrl(url).catch(() => {});
126
+ }
127
+ }, [url, setOpenInAppUrl]);
128
+
129
+ return (
130
+ <div
131
+ className={styles.detail}
132
+ data-llm={grounding(
133
+ product,
134
+ displayTitle,
135
+ price,
136
+ selection,
137
+ outOfStock,
138
+ labels,
139
+ )}
140
+ >
141
+ {/* Product / variant reference, idiomatically top-right. @todo: move it
142
+ (e.g. into the info column under the title) or drop it; restyle in
143
+ detail.css.ts (`reference`). */}
144
+ <p className={cx(text({ style: "bodyS" }), styles.reference)}>
145
+ {labels.reference} {reference}
146
+ </p>
147
+
148
+ <div className={styles.grid}>
149
+ <div className={styles.galleryCell}>
150
+ {media.length > 0 ? (
151
+ // Key on the media set so switching to a variant with different
152
+ // images remounts the gallery fresh (index reset to the first image).
153
+ <ImageGallery
154
+ key={media.join("|")}
155
+ media={media}
156
+ alt={displayTitle}
157
+ />
158
+ ) : null}
159
+ </div>
160
+
161
+ <div className={styles.info}>
162
+ <h1
163
+ className={cx(
164
+ text({ style: "headingS", weight: "medium" }),
165
+ styles.title,
166
+ )}
167
+ >
168
+ {displayTitle}
169
+ </h1>
170
+
171
+ <p className={cx(text({ style: "headingS" }), styles.price)}>
172
+ {price}
173
+ </p>
174
+
175
+ {outOfStock ? (
176
+ <p className={cx(text({ style: "bodyS" }), styles.oos)}>
177
+ {labels.outOfStock}
178
+ </p>
179
+ ) : null}
180
+
181
+ <VariantPicker
182
+ product={product}
183
+ selection={selection}
184
+ onChange={setSelection}
185
+ />
186
+
187
+ {description ? <ExpandableText>{description}</ExpandableText> : null}
188
+
189
+ <button
190
+ type="button"
191
+ className={cx(styles.cta, sprinkles({ mt: "3xs" }))}
192
+ disabled={!canBuy}
193
+ onClick={canBuy && url ? () => openExternal(url) : undefined}
194
+ >
195
+ {variant ? labels.viewOnSite : labels.selectOptions}
196
+ </button>
197
+
198
+ {/* Product facts as a simple list, after the CTA: one "label: value"
199
+ line per fact (label-less facts show the value alone). This is the
200
+ visual treatment only; the full spec is already in view state for
201
+ the model (see the carousel orchestrator), so restyling or dropping
202
+ it never hides facts from the assistant. @todo: pick the shape that
203
+ best presents your specs: this list (default), a two-column table,
204
+ grouped sections, inline chips, etc. */}
205
+ {specs.length > 0 ? (
206
+ <section>
207
+ <h2 className={text({ style: "labelM", weight: "medium" })}>
208
+ {labels.specifications}
209
+ </h2>
210
+ <dl className={styles.specList}>
211
+ {specs.map((spec) => (
212
+ <div
213
+ key={`${spec.label ?? ""}:${spec.value}`}
214
+ className={styles.specRow}
215
+ >
216
+ {spec.label ? (
217
+ <dt
218
+ className={cx(
219
+ text({ style: "bodyS" }),
220
+ styles.specLabel,
221
+ )}
222
+ >
223
+ {spec.label}:
224
+ </dt>
225
+ ) : null}
226
+ <dd
227
+ className={cx(text({ style: "bodyS" }), styles.specValue)}
228
+ >
229
+ {spec.value}
230
+ </dd>
231
+ </div>
232
+ ))}
233
+ </dl>
234
+ </section>
235
+ ) : null}
236
+ </div>
237
+ </div>
238
+ </div>
239
+ );
240
+ }