create-skybridge 0.0.0-dev.08be486 → 0.0.0-dev.09043e8
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.
- package/dist/index.js +52 -26
- package/package.json +1 -1
- package/templates/blank/package.json +2 -1
- package/templates/blank/vite.config.ts +1 -1
- package/templates/demo/package.json +2 -1
- package/templates/demo/vite.config.ts +1 -1
- package/templates/ecom/.dockerignore +4 -0
- package/templates/ecom/.env.template +7 -0
- package/templates/ecom/.ladle/components.tsx +26 -0
- package/templates/ecom/.ladle/config.mjs +11 -0
- package/templates/ecom/.ladle/vite.config.ts +11 -0
- package/templates/ecom/AGENTS.md +2 -0
- package/templates/ecom/Dockerfile +53 -0
- package/templates/ecom/README.md +92 -0
- package/templates/ecom/_gitignore +9 -0
- package/templates/ecom/alpic.json +3 -0
- package/templates/ecom/node_modules/.bin/alpic +21 -0
- package/templates/ecom/node_modules/.bin/ladle +21 -0
- package/templates/ecom/node_modules/.bin/sb +21 -0
- package/templates/ecom/node_modules/.bin/skybridge +21 -0
- package/templates/ecom/node_modules/.bin/tsc +21 -0
- package/templates/ecom/node_modules/.bin/tsserver +21 -0
- package/templates/ecom/node_modules/.bin/tsx +21 -0
- package/templates/ecom/node_modules/.bin/vite +21 -0
- package/templates/ecom/package.json +42 -0
- package/templates/ecom/src/catalog/index.ts +19 -0
- package/templates/ecom/src/catalog/mock.ts +134 -0
- package/templates/ecom/src/catalog/shopify.ts +264 -0
- package/templates/ecom/src/components/chip.css.ts +63 -0
- package/templates/ecom/src/components/chip.stories.tsx +27 -0
- package/templates/ecom/src/components/chip.tsx +49 -0
- package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
- package/templates/ecom/src/components/empty-state.tsx +12 -0
- package/templates/ecom/src/components/expandable-text.css.ts +49 -0
- package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
- package/templates/ecom/src/components/expandable-text.tsx +53 -0
- package/templates/ecom/src/components/image-gallery.css.ts +172 -0
- package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
- package/templates/ecom/src/components/image-gallery.tsx +163 -0
- package/templates/ecom/src/components/product-card.css.ts +159 -0
- package/templates/ecom/src/components/product-card.stories.tsx +58 -0
- package/templates/ecom/src/components/product-card.tsx +102 -0
- package/templates/ecom/src/components/product-carousel.css.ts +134 -0
- package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
- package/templates/ecom/src/components/product-carousel.tsx +202 -0
- package/templates/ecom/src/components/variant-picker.css.ts +27 -0
- package/templates/ecom/src/components/variant-picker.stories.tsx +67 -0
- package/templates/ecom/src/components/variant-picker.tsx +70 -0
- package/templates/ecom/src/components/view-frame.css.ts +22 -0
- package/templates/ecom/src/components/view-frame.tsx +27 -0
- package/templates/ecom/src/config.ts +12 -0
- package/templates/ecom/src/design/contract.css.ts +39 -0
- package/templates/ecom/src/design/fonts.css +15 -0
- package/templates/ecom/src/design/primitives.css.ts +101 -0
- package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
- package/templates/ecom/src/design/sprinkles.css.ts +128 -0
- package/templates/ecom/src/design/themes/dark.css.ts +36 -0
- package/templates/ecom/src/design/themes/light.css.ts +36 -0
- package/templates/ecom/src/design/tokens.ts +8 -0
- package/templates/ecom/src/helpers.ts +4 -0
- package/templates/ecom/src/i18n.ts +35 -0
- package/templates/ecom/src/index.css +9 -0
- package/templates/ecom/src/lib/cx.ts +9 -0
- package/templates/ecom/src/lib/format.ts +9 -0
- package/templates/ecom/src/lib/variants.ts +150 -0
- package/templates/ecom/src/server.ts +43 -0
- package/templates/ecom/src/tools/render-carousel.ts +161 -0
- package/templates/ecom/src/tools/search-products.ts +196 -0
- package/templates/ecom/src/types.ts +87 -0
- package/templates/ecom/src/views/carousel/detail/detail.css.ts +115 -0
- package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +133 -0
- package/templates/ecom/src/views/carousel/detail/index.tsx +254 -0
- package/templates/ecom/src/views/carousel/index.tsx +233 -0
- package/templates/ecom/tsconfig.json +11 -0
- package/templates/ecom/vite.config.ts +8 -0
|
@@ -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
|
+
maxHeight: "100dvh",
|
|
11
|
+
overflowY: "auto",
|
|
12
|
+
padding: primitives.space.s,
|
|
13
|
+
// Clear the notch / home indicator when the page bleeds to the screen edge.
|
|
14
|
+
paddingBottom: `calc(${primitives.space.s} + env(safe-area-inset-bottom))`,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Product / variant id, idiomatically top-right. @todo: reposition (drop the
|
|
18
|
+
// right align, or move it in the JSX) or remove it.
|
|
19
|
+
export const reference = style({
|
|
20
|
+
textAlign: "right",
|
|
21
|
+
color: colors.content.subtle,
|
|
22
|
+
marginBottom: primitives.space["3xs"],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Single column by default; two columns (gallery | info) once the pane is wide.
|
|
26
|
+
export const grid = style({
|
|
27
|
+
display: "flex",
|
|
28
|
+
flexDirection: "column",
|
|
29
|
+
gap: primitives.space.s,
|
|
30
|
+
maxWidth: "1099px",
|
|
31
|
+
marginInline: "auto",
|
|
32
|
+
"@container": {
|
|
33
|
+
[`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: {
|
|
34
|
+
display: "grid",
|
|
35
|
+
// `minmax(0, 1fr)` (not `1fr`) lets a column shrink below its content's
|
|
36
|
+
// intrinsic width, so a chip row scrolls/wraps instead of blowing out.
|
|
37
|
+
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
|
|
38
|
+
gridTemplateAreas: '"gallery info"',
|
|
39
|
+
alignItems: "start",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const galleryCell = style({
|
|
45
|
+
"@container": {
|
|
46
|
+
[`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: {
|
|
47
|
+
gridArea: "gallery",
|
|
48
|
+
position: "sticky",
|
|
49
|
+
top: primitives.space.s,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The info column owns all inter-section spacing; sections set no outer margin.
|
|
55
|
+
export const info = style({
|
|
56
|
+
display: "flex",
|
|
57
|
+
flexDirection: "column",
|
|
58
|
+
gap: primitives.space.xs,
|
|
59
|
+
"@container": {
|
|
60
|
+
[`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: { gridArea: "info" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const title = style({ color: colors.content.intense });
|
|
65
|
+
|
|
66
|
+
export const price = style({ color: colors.content.intense });
|
|
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,133 @@
|
|
|
1
|
+
import type { Product } from "../../../types.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
|
+
// One story, every picker behavior:
|
|
33
|
+
// - Hole in the matrix: {sand,L} does not exist, so under Sand, L is
|
|
34
|
+
// hard-disabled; switching to Sand while L is selected snaps Size to M.
|
|
35
|
+
// - Sold out: {black,L} renders struck but selectable; the CTA locks as
|
|
36
|
+
// "Out of stock".
|
|
37
|
+
// - Axis not applicable: the indigo colorway is one-size (no `size` key), so
|
|
38
|
+
// choosing "Indigo" hides the Size row; indigo resolves and stays buyable.
|
|
39
|
+
function jacket(
|
|
40
|
+
id: string,
|
|
41
|
+
color: string,
|
|
42
|
+
colorLabel: string,
|
|
43
|
+
size: string | null,
|
|
44
|
+
outOfStock = false,
|
|
45
|
+
): Product["variants"][number] {
|
|
46
|
+
const fill =
|
|
47
|
+
color === "sand" ? "d8c7a8" : color === "indigo" ? "3f4a8a" : "2b2b2b";
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
selection: size == null ? { color } : { color, size },
|
|
51
|
+
title: `Field jacket — ${colorLabel}`,
|
|
52
|
+
description: DESCRIPTION,
|
|
53
|
+
price: { amount: color === "black" ? 229 : 249, currency: "EUR" },
|
|
54
|
+
media: galleryMedia(fill),
|
|
55
|
+
url: "https://example.com/jacket",
|
|
56
|
+
outOfStock,
|
|
57
|
+
specs: [
|
|
58
|
+
{ label: "Material", value: "Water-repellent cotton" },
|
|
59
|
+
{ label: "Lining", value: "Recycled polyester" },
|
|
60
|
+
{ label: "Fit", value: "Relaxed" },
|
|
61
|
+
{ label: "Closure", value: "Two-way zip" },
|
|
62
|
+
{ label: "Pockets", value: "Two zip front, one inside" },
|
|
63
|
+
{ label: "Collar", value: "Stand-up" },
|
|
64
|
+
{ label: "Cuffs", value: "Ribbed" },
|
|
65
|
+
{ label: "Weight", value: "780 g" },
|
|
66
|
+
{ label: "Origin", value: "Made in Portugal" },
|
|
67
|
+
{ value: "Machine washable" }, // label-less fact (renders value only)
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PRODUCT: Product = {
|
|
73
|
+
id: "field-jacket",
|
|
74
|
+
options: [
|
|
75
|
+
{
|
|
76
|
+
id: "color",
|
|
77
|
+
label: "Color",
|
|
78
|
+
values: [
|
|
79
|
+
{ id: "black", label: "Black", media: shot("2b2b2b") },
|
|
80
|
+
{ id: "sand", label: "Sand", media: shot("d8c7a8") },
|
|
81
|
+
{ id: "indigo", label: "Indigo", media: shot("3f4a8a") },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "size",
|
|
86
|
+
label: "Size",
|
|
87
|
+
values: [
|
|
88
|
+
{ id: "m", label: "M" },
|
|
89
|
+
{ id: "l", label: "L" },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
variants: [
|
|
94
|
+
jacket("fj-black-m", "black", "Black", "m"),
|
|
95
|
+
jacket("fj-black-l", "black", "Black", "l", true),
|
|
96
|
+
jacket("fj-sand-m", "sand", "Sand", "m"),
|
|
97
|
+
jacket("fj-indigo", "indigo", "Indigo", null),
|
|
98
|
+
],
|
|
99
|
+
card: {
|
|
100
|
+
title: "Field jacket",
|
|
101
|
+
price: { amount: 229, currency: "EUR" },
|
|
102
|
+
media: [shot("2b2b2b")],
|
|
103
|
+
specs: [],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const Default = () => <DetailView product={PRODUCT} />;
|
|
108
|
+
|
|
109
|
+
// Single-variant product: no picker, buyable immediately.
|
|
110
|
+
const SIMPLE: Product = {
|
|
111
|
+
id: "tote",
|
|
112
|
+
options: [],
|
|
113
|
+
variants: [
|
|
114
|
+
{
|
|
115
|
+
id: "tote",
|
|
116
|
+
selection: {},
|
|
117
|
+
title: "Canvas tote",
|
|
118
|
+
description: "A sturdy everyday canvas tote.",
|
|
119
|
+
price: { amount: 39, currency: "EUR" },
|
|
120
|
+
media: [shot("e1e1e1")],
|
|
121
|
+
url: "https://example.com/tote",
|
|
122
|
+
specs: [{ label: "Material", value: "Canvas" }],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
card: {
|
|
126
|
+
title: "Canvas tote",
|
|
127
|
+
price: { amount: 39, currency: "EUR" },
|
|
128
|
+
media: [shot("e1e1e1")],
|
|
129
|
+
specs: [],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const SingleVariant = () => <DetailView product={SIMPLE} />;
|
|
@@ -0,0 +1,254 @@
|
|
|
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, Variant } from "../../../types.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
|
+
variant: Variant | undefined,
|
|
58
|
+
title: string,
|
|
59
|
+
price: string,
|
|
60
|
+
selection: Selection,
|
|
61
|
+
unpurchasable: boolean,
|
|
62
|
+
labels: Labels,
|
|
63
|
+
): string {
|
|
64
|
+
const parts = [`The user is viewing "${title}" (product id: ${product.id}).`];
|
|
65
|
+
const chosen: string[] = [];
|
|
66
|
+
for (const option of product.options) {
|
|
67
|
+
const valueId = selection[option.id];
|
|
68
|
+
let label: string | undefined;
|
|
69
|
+
for (const value of option.values) {
|
|
70
|
+
if (value.id === valueId) {
|
|
71
|
+
label = value.label;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (label === undefined) {
|
|
76
|
+
// Axis skipped by the shown variant vs simply not picked yet.
|
|
77
|
+
label =
|
|
78
|
+
variant != null && variant.selection[option.id] == null
|
|
79
|
+
? "not applicable"
|
|
80
|
+
: "not selected";
|
|
81
|
+
}
|
|
82
|
+
chosen.push(`${option.label}: ${label}`);
|
|
83
|
+
}
|
|
84
|
+
if (chosen.length > 0) {
|
|
85
|
+
parts.push(`Selected — ${chosen.join(", ")}.`);
|
|
86
|
+
}
|
|
87
|
+
parts.push(`Price: ${price}.`);
|
|
88
|
+
if (unpurchasable) {
|
|
89
|
+
// Sold-out real variant vs a combination that does not exist.
|
|
90
|
+
parts.push(
|
|
91
|
+
variant != null ? labels.outOfStock : labels.combinationUnavailable,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return parts.join(" ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Product detail view, rendered fullscreen over the carousel (see the carousel
|
|
99
|
+
* orchestrator). Reads a single product from the payload already in `_meta`;
|
|
100
|
+
* does no fetch. Option choices resolve against the product's sparse variant
|
|
101
|
+
* list in-place (no remount).
|
|
102
|
+
*/
|
|
103
|
+
export function DetailView({ product }: { product: Product }) {
|
|
104
|
+
const { locale } = useUser();
|
|
105
|
+
const labels = useLabels();
|
|
106
|
+
const openExternal = useOpenExternal();
|
|
107
|
+
const setOpenInAppUrl = useSetOpenInAppUrl();
|
|
108
|
+
const [selection, setSelection] = useState<Selection>(() =>
|
|
109
|
+
initialSelection(product),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// The exact variant for the selection; card fields fill in when none
|
|
113
|
+
// resolves (the applyChoice edge case, or a product with no variants).
|
|
114
|
+
const shown = resolveVariant(product, selection);
|
|
115
|
+
const displayTitle = shown?.title ?? product.card.title;
|
|
116
|
+
const description = shown?.description ?? product.card.description;
|
|
117
|
+
const media = shown?.media.length ? shown.media : product.card.media;
|
|
118
|
+
const specs = shown?.specs ?? product.card.specs;
|
|
119
|
+
// Sold out, or no variant resolved.
|
|
120
|
+
const unpurchasable = shown ? (shown.outOfStock ?? false) : true;
|
|
121
|
+
const url = shown?.url ?? product.card.url;
|
|
122
|
+
// The shown item's id: the resolved variant's SKU, else the product id.
|
|
123
|
+
const reference = shown?.id ?? product.id;
|
|
124
|
+
// @todo: read any custom Meta fields you added the same way (shown first,
|
|
125
|
+
// then card), e.g. `const rating = shown?.rating ?? product.card.rating;`,
|
|
126
|
+
// then render them in the agreed spot (rating by the title, discount by the
|
|
127
|
+
// price, badges as chips…).
|
|
128
|
+
|
|
129
|
+
const price = priceText(product, shown?.price, locale, labels);
|
|
130
|
+
// Buy CTA is enabled only for an exact, in-stock variant with a real link
|
|
131
|
+
// (an empty url would leave the button enabled but dead).
|
|
132
|
+
const canBuy = shown != null && Boolean(url) && !unpurchasable;
|
|
133
|
+
|
|
134
|
+
// Point the host's fullscreen "Open in app" affordance at the selected
|
|
135
|
+
// variant's page. Apps-SDK only; MCP Apps hosts reject, so ignore that.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (url) {
|
|
138
|
+
setOpenInAppUrl(url).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
}, [url, setOpenInAppUrl]);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
className={styles.detail}
|
|
145
|
+
data-llm={grounding(
|
|
146
|
+
product,
|
|
147
|
+
shown,
|
|
148
|
+
displayTitle,
|
|
149
|
+
price,
|
|
150
|
+
selection,
|
|
151
|
+
unpurchasable,
|
|
152
|
+
labels,
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{/* Product / variant reference, idiomatically top-right. @todo: move it
|
|
156
|
+
(e.g. into the info column under the title) or drop it; restyle in
|
|
157
|
+
detail.css.ts (`reference`). */}
|
|
158
|
+
<p className={cx(text({ style: "bodyS" }), styles.reference)}>
|
|
159
|
+
{labels.reference} {reference}
|
|
160
|
+
</p>
|
|
161
|
+
|
|
162
|
+
<div className={styles.grid}>
|
|
163
|
+
<div className={styles.galleryCell}>
|
|
164
|
+
{media.length > 0 ? (
|
|
165
|
+
// Key on the media set so switching to a variant with different
|
|
166
|
+
// images remounts the gallery fresh (index reset to the first image).
|
|
167
|
+
<ImageGallery
|
|
168
|
+
key={media.join("|")}
|
|
169
|
+
media={media}
|
|
170
|
+
alt={displayTitle}
|
|
171
|
+
/>
|
|
172
|
+
) : null}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className={styles.info}>
|
|
176
|
+
<h1
|
|
177
|
+
className={cx(
|
|
178
|
+
text({ style: "headingS", weight: "medium" }),
|
|
179
|
+
styles.title,
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{displayTitle}
|
|
183
|
+
</h1>
|
|
184
|
+
|
|
185
|
+
<p className={cx(text({ style: "headingS" }), styles.price)}>
|
|
186
|
+
{price}
|
|
187
|
+
</p>
|
|
188
|
+
|
|
189
|
+
<VariantPicker
|
|
190
|
+
product={product}
|
|
191
|
+
selection={selection}
|
|
192
|
+
onChange={setSelection}
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
{description ? <ExpandableText>{description}</ExpandableText> : null}
|
|
196
|
+
|
|
197
|
+
{/* Sold-out and unresolved selections stay composable; the CTA locks
|
|
198
|
+
and its label names the cause. */}
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className={cx(styles.cta, sprinkles({ mt: "3xs" }))}
|
|
202
|
+
disabled={!canBuy}
|
|
203
|
+
onClick={canBuy && url ? () => openExternal(url) : undefined}
|
|
204
|
+
>
|
|
205
|
+
{shown
|
|
206
|
+
? unpurchasable
|
|
207
|
+
? labels.outOfStock
|
|
208
|
+
: labels.viewOnSite
|
|
209
|
+
: labels.combinationUnavailable}
|
|
210
|
+
</button>
|
|
211
|
+
|
|
212
|
+
{/* Product facts as a simple list, after the CTA: one "label: value"
|
|
213
|
+
line per fact (label-less facts show the value alone). This is the
|
|
214
|
+
visual treatment only; the full spec is already in view state for
|
|
215
|
+
the model (see the carousel orchestrator), so restyling or dropping
|
|
216
|
+
it never hides facts from the assistant. @todo: pick the shape that
|
|
217
|
+
best presents your specs: this list (default), a two-column table,
|
|
218
|
+
grouped sections, inline chips, etc. */}
|
|
219
|
+
{specs.length > 0 ? (
|
|
220
|
+
<section>
|
|
221
|
+
<h2 className={text({ style: "labelM", weight: "medium" })}>
|
|
222
|
+
{labels.specifications}
|
|
223
|
+
</h2>
|
|
224
|
+
<dl className={styles.specList}>
|
|
225
|
+
{specs.map((spec) => (
|
|
226
|
+
<div
|
|
227
|
+
key={`${spec.label ?? ""}:${spec.value}`}
|
|
228
|
+
className={styles.specRow}
|
|
229
|
+
>
|
|
230
|
+
{spec.label ? (
|
|
231
|
+
<dt
|
|
232
|
+
className={cx(
|
|
233
|
+
text({ style: "bodyS" }),
|
|
234
|
+
styles.specLabel,
|
|
235
|
+
)}
|
|
236
|
+
>
|
|
237
|
+
{spec.label}:
|
|
238
|
+
</dt>
|
|
239
|
+
) : null}
|
|
240
|
+
<dd
|
|
241
|
+
className={cx(text({ style: "bodyS" }), styles.specValue)}
|
|
242
|
+
>
|
|
243
|
+
{spec.value}
|
|
244
|
+
</dd>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</dl>
|
|
248
|
+
</section>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|