create-skybridge 0.0.0-dev.7914624 → 0.0.0-dev.793efe5
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 +107 -17
- package/package.json +2 -2
- package/templates/blank/README.md +2 -1
- package/templates/blank/node_modules/.bin/alpic +2 -2
- package/templates/blank/node_modules/.bin/tsx +2 -2
- package/templates/blank/node_modules/.bin/vite +2 -2
- package/templates/blank/package.json +8 -7
- package/templates/blank/src/index.ts +3 -0
- package/templates/blank/src/server.ts +7 -11
- package/templates/blank/vite.config.ts +7 -1
- package/templates/demo/README.md +3 -1
- package/templates/demo/evals/start.eval.ts +11 -0
- package/templates/demo/node_modules/.bin/alpic +2 -2
- package/templates/demo/node_modules/.bin/tsx +2 -2
- package/templates/demo/node_modules/.bin/vite +2 -2
- package/templates/demo/node_modules/.bin/vitest +21 -0
- package/templates/demo/package.json +19 -13
- package/templates/demo/src/index.ts +3 -0
- package/templates/demo/src/server.ts +86 -88
- package/templates/demo/src/views/onboarding.tsx +3 -3
- package/templates/demo/tsconfig.evals.json +11 -0
- package/templates/demo/vite.config.ts +9 -3
- package/templates/ecom/.env.template +5 -0
- package/templates/ecom/README.md +2 -1
- package/templates/ecom/node_modules/.bin/alpic +2 -2
- package/templates/ecom/node_modules/.bin/ladle +2 -2
- package/templates/ecom/node_modules/.bin/vite +2 -2
- package/templates/ecom/package.json +3 -2
- 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 +15 -8
- package/templates/ecom/src/components/chip.stories.tsx +2 -1
- package/templates/ecom/src/components/chip.tsx +16 -8
- package/templates/ecom/src/components/image-gallery.css.ts +2 -2
- package/templates/ecom/src/components/image-gallery.tsx +5 -4
- package/templates/ecom/src/components/product-card.css.ts +9 -5
- package/templates/ecom/src/components/product-card.tsx +5 -4
- package/templates/ecom/src/components/variant-picker.stories.tsx +9 -6
- package/templates/ecom/src/components/variant-picker.tsx +17 -29
- package/templates/ecom/src/components/view-frame.tsx +4 -4
- package/templates/ecom/src/i18n.ts +2 -1
- package/templates/ecom/src/index.ts +3 -0
- package/templates/ecom/src/lib/variants.ts +100 -29
- package/templates/ecom/src/server.ts +17 -21
- package/templates/ecom/src/tools/render-carousel.ts +11 -92
- package/templates/ecom/src/tools/search-products.ts +40 -26
- package/templates/ecom/src/types.ts +77 -3
- package/templates/ecom/src/views/carousel/detail/detail.css.ts +25 -10
- package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +33 -14
- package/templates/ecom/src/views/carousel/detail/index.tsx +83 -44
- package/templates/ecom/src/views/carousel/index.tsx +3 -4
- package/templates/ecom/vite.config.ts +1 -1
|
@@ -7,11 +7,21 @@ const TWO_COLUMN_MIN_WIDTH = "560px";
|
|
|
7
7
|
|
|
8
8
|
export const detail = style({
|
|
9
9
|
containerType: "inline-size",
|
|
10
|
+
maxHeight: "100dvh",
|
|
11
|
+
overflowY: "auto",
|
|
10
12
|
padding: primitives.space.s,
|
|
11
13
|
// Clear the notch / home indicator when the page bleeds to the screen edge.
|
|
12
14
|
paddingBottom: `calc(${primitives.space.s} + env(safe-area-inset-bottom))`,
|
|
13
15
|
});
|
|
14
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
|
+
|
|
15
25
|
// Single column by default; two columns (gallery | info) once the pane is wide.
|
|
16
26
|
export const grid = style({
|
|
17
27
|
display: "flex",
|
|
@@ -55,26 +65,31 @@ export const title = style({ color: colors.content.intense });
|
|
|
55
65
|
|
|
56
66
|
export const price = style({ color: colors.content.intense });
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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({
|
|
61
72
|
display: "flex",
|
|
62
73
|
flexDirection: "column",
|
|
63
|
-
gap: primitives.space["
|
|
74
|
+
gap: primitives.space["3xs"],
|
|
75
|
+
marginTop: primitives.space["3xs"],
|
|
76
|
+
marginBottom: 0,
|
|
64
77
|
});
|
|
65
78
|
|
|
79
|
+
// One fact on a line: "label:" then value.
|
|
66
80
|
export const specRow = style({
|
|
67
81
|
display: "flex",
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
gap: primitives.space["4xs"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const specLabel = style({
|
|
86
|
+
margin: 0,
|
|
87
|
+
color: colors.content.subtle,
|
|
72
88
|
});
|
|
73
89
|
|
|
74
|
-
export const specName = style({ color: colors.content.subtle });
|
|
75
90
|
export const specValue = style({
|
|
91
|
+
margin: 0,
|
|
76
92
|
color: colors.content.intense,
|
|
77
|
-
textAlign: "right",
|
|
78
93
|
});
|
|
79
94
|
|
|
80
95
|
// Primary CTA. @todo: tune to your brand; add a secondary action beside it
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Product } from "../../../
|
|
1
|
+
import type { Product } from "../../../types.js";
|
|
2
2
|
import { DetailView } from "./index";
|
|
3
3
|
|
|
4
4
|
function shot(fill: string) {
|
|
@@ -29,25 +29,42 @@ function galleryMedia(primaryFill: string): string[] {
|
|
|
29
29
|
const DESCRIPTION =
|
|
30
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
31
|
|
|
32
|
-
//
|
|
33
|
-
// so
|
|
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.
|
|
34
39
|
function jacket(
|
|
35
40
|
id: string,
|
|
36
41
|
color: string,
|
|
37
42
|
colorLabel: string,
|
|
38
|
-
size: string,
|
|
43
|
+
size: string | null,
|
|
44
|
+
outOfStock = false,
|
|
39
45
|
): Product["variants"][number] {
|
|
46
|
+
const fill =
|
|
47
|
+
color === "sand" ? "d8c7a8" : color === "indigo" ? "3f4a8a" : "2b2b2b";
|
|
40
48
|
return {
|
|
41
49
|
id,
|
|
42
|
-
selection: { color, size },
|
|
50
|
+
selection: size == null ? { color } : { color, size },
|
|
43
51
|
title: `Field jacket — ${colorLabel}`,
|
|
44
52
|
description: DESCRIPTION,
|
|
45
|
-
price: { amount: color === "
|
|
46
|
-
media: galleryMedia(
|
|
53
|
+
price: { amount: color === "black" ? 229 : 249, currency: "EUR" },
|
|
54
|
+
media: galleryMedia(fill),
|
|
47
55
|
url: "https://example.com/jacket",
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{
|
|
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)
|
|
51
68
|
],
|
|
52
69
|
};
|
|
53
70
|
}
|
|
@@ -61,6 +78,7 @@ const PRODUCT: Product = {
|
|
|
61
78
|
values: [
|
|
62
79
|
{ id: "black", label: "Black", media: shot("2b2b2b") },
|
|
63
80
|
{ id: "sand", label: "Sand", media: shot("d8c7a8") },
|
|
81
|
+
{ id: "indigo", label: "Indigo", media: shot("3f4a8a") },
|
|
64
82
|
],
|
|
65
83
|
},
|
|
66
84
|
{
|
|
@@ -74,14 +92,15 @@ const PRODUCT: Product = {
|
|
|
74
92
|
],
|
|
75
93
|
variants: [
|
|
76
94
|
jacket("fj-black-m", "black", "Black", "m"),
|
|
77
|
-
jacket("fj-black-l", "black", "Black", "l"),
|
|
95
|
+
jacket("fj-black-l", "black", "Black", "l", true),
|
|
78
96
|
jacket("fj-sand-m", "sand", "Sand", "m"),
|
|
97
|
+
jacket("fj-indigo", "indigo", "Indigo", null),
|
|
79
98
|
],
|
|
80
99
|
card: {
|
|
81
100
|
title: "Field jacket",
|
|
82
101
|
price: { amount: 229, currency: "EUR" },
|
|
83
102
|
media: [shot("2b2b2b")],
|
|
84
|
-
|
|
103
|
+
specs: [],
|
|
85
104
|
},
|
|
86
105
|
};
|
|
87
106
|
|
|
@@ -100,14 +119,14 @@ const SIMPLE: Product = {
|
|
|
100
119
|
price: { amount: 39, currency: "EUR" },
|
|
101
120
|
media: [shot("e1e1e1")],
|
|
102
121
|
url: "https://example.com/tote",
|
|
103
|
-
|
|
122
|
+
specs: [{ label: "Material", value: "Canvas" }],
|
|
104
123
|
},
|
|
105
124
|
],
|
|
106
125
|
card: {
|
|
107
126
|
title: "Canvas tote",
|
|
108
127
|
price: { amount: 39, currency: "EUR" },
|
|
109
128
|
media: [shot("e1e1e1")],
|
|
110
|
-
|
|
129
|
+
specs: [],
|
|
111
130
|
},
|
|
112
131
|
};
|
|
113
132
|
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
resolveVariant,
|
|
13
13
|
type Selection,
|
|
14
14
|
} from "../../../lib/variants.js";
|
|
15
|
-
import type { Product } from "../../../
|
|
15
|
+
import type { Product, Variant } from "../../../types.js";
|
|
16
16
|
import * as styles from "./detail.css";
|
|
17
17
|
|
|
18
18
|
// Price to show: the resolved variant's price, else the range across variants
|
|
@@ -54,31 +54,42 @@ function priceText(
|
|
|
54
54
|
// scoped to the visible selection.
|
|
55
55
|
function grounding(
|
|
56
56
|
product: Product,
|
|
57
|
+
variant: Variant | undefined,
|
|
57
58
|
title: string,
|
|
58
59
|
price: string,
|
|
59
60
|
selection: Selection,
|
|
60
|
-
|
|
61
|
+
unpurchasable: boolean,
|
|
61
62
|
labels: Labels,
|
|
62
63
|
): string {
|
|
63
64
|
const parts = [`The user is viewing "${title}" (product id: ${product.id}).`];
|
|
64
65
|
const chosen: string[] = [];
|
|
65
66
|
for (const option of product.options) {
|
|
66
|
-
let label = "not selected";
|
|
67
67
|
const valueId = selection[option.id];
|
|
68
|
+
let label: string | undefined;
|
|
68
69
|
for (const value of option.values) {
|
|
69
70
|
if (value.id === valueId) {
|
|
70
71
|
label = value.label;
|
|
71
72
|
break;
|
|
72
73
|
}
|
|
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
|
+
}
|
|
74
82
|
chosen.push(`${option.label}: ${label}`);
|
|
75
83
|
}
|
|
76
84
|
if (chosen.length > 0) {
|
|
77
85
|
parts.push(`Selected — ${chosen.join(", ")}.`);
|
|
78
86
|
}
|
|
79
87
|
parts.push(`Price: ${price}.`);
|
|
80
|
-
if (
|
|
81
|
-
|
|
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
|
+
);
|
|
82
93
|
}
|
|
83
94
|
return parts.join(" ");
|
|
84
95
|
}
|
|
@@ -98,19 +109,27 @@ export function DetailView({ product }: { product: Product }) {
|
|
|
98
109
|
initialSelection(product),
|
|
99
110
|
);
|
|
100
111
|
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
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…).
|
|
109
128
|
|
|
110
|
-
const price = priceText(product,
|
|
111
|
-
// Buy CTA is enabled only
|
|
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
|
|
112
131
|
// (an empty url would leave the button enabled but dead).
|
|
113
|
-
const canBuy =
|
|
132
|
+
const canBuy = shown != null && Boolean(url) && !unpurchasable;
|
|
114
133
|
|
|
115
134
|
// Point the host's fullscreen "Open in app" affordance at the selected
|
|
116
135
|
// variant's page. Apps-SDK only; MCP Apps hosts reject, so ignore that.
|
|
@@ -125,13 +144,21 @@ export function DetailView({ product }: { product: Product }) {
|
|
|
125
144
|
className={styles.detail}
|
|
126
145
|
data-llm={grounding(
|
|
127
146
|
product,
|
|
147
|
+
shown,
|
|
128
148
|
displayTitle,
|
|
129
149
|
price,
|
|
130
150
|
selection,
|
|
131
|
-
|
|
151
|
+
unpurchasable,
|
|
132
152
|
labels,
|
|
133
153
|
)}
|
|
134
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
|
+
|
|
135
162
|
<div className={styles.grid}>
|
|
136
163
|
<div className={styles.galleryCell}>
|
|
137
164
|
{media.length > 0 ? (
|
|
@@ -159,12 +186,6 @@ export function DetailView({ product }: { product: Product }) {
|
|
|
159
186
|
{price}
|
|
160
187
|
</p>
|
|
161
188
|
|
|
162
|
-
{outOfStock ? (
|
|
163
|
-
<p className={cx(text({ style: "bodyS" }), styles.oos)}>
|
|
164
|
-
{labels.outOfStock}
|
|
165
|
-
</p>
|
|
166
|
-
) : null}
|
|
167
|
-
|
|
168
189
|
<VariantPicker
|
|
169
190
|
product={product}
|
|
170
191
|
selection={selection}
|
|
@@ -173,39 +194,57 @@ export function DetailView({ product }: { product: Product }) {
|
|
|
173
194
|
|
|
174
195
|
{description ? <ExpandableText>{description}</ExpandableText> : null}
|
|
175
196
|
|
|
197
|
+
{/* Sold-out and unresolved selections stay composable; the CTA locks
|
|
198
|
+
and its label names the cause. */}
|
|
176
199
|
<button
|
|
177
200
|
type="button"
|
|
178
201
|
className={cx(styles.cta, sprinkles({ mt: "3xs" }))}
|
|
179
202
|
disabled={!canBuy}
|
|
180
203
|
onClick={canBuy && url ? () => openExternal(url) : undefined}
|
|
181
204
|
>
|
|
182
|
-
{
|
|
205
|
+
{shown
|
|
206
|
+
? unpurchasable
|
|
207
|
+
? labels.outOfStock
|
|
208
|
+
: labels.viewOnSite
|
|
209
|
+
: labels.combinationUnavailable}
|
|
183
210
|
</button>
|
|
184
211
|
|
|
185
|
-
{/*
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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>
|
|
192
221
|
<h2 className={text({ style: "labelM", weight: "medium" })}>
|
|
193
222
|
{labels.specifications}
|
|
194
223
|
</h2>
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
{attribute.name}
|
|
201
|
-
</span>
|
|
202
|
-
<span
|
|
203
|
-
className={cx(text({ style: "bodyS" }), styles.specValue)}
|
|
224
|
+
<dl className={styles.specList}>
|
|
225
|
+
{specs.map((spec) => (
|
|
226
|
+
<div
|
|
227
|
+
key={`${spec.label ?? ""}:${spec.value}`}
|
|
228
|
+
className={styles.specRow}
|
|
204
229
|
>
|
|
205
|
-
{
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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>
|
|
209
248
|
</section>
|
|
210
249
|
) : null}
|
|
211
250
|
</div>
|
|
@@ -20,8 +20,7 @@ import { sprinkles } from "../../design/tokens";
|
|
|
20
20
|
import { useToolInfo } from "../../helpers.js";
|
|
21
21
|
import { useLabels } from "../../i18n";
|
|
22
22
|
import { formatPrice } from "../../lib/format";
|
|
23
|
-
import type { Product } from "../../
|
|
24
|
-
import type { Attribute, Price } from "../../types.js";
|
|
23
|
+
import type { Price, Product, Spec } from "../../types.js";
|
|
25
24
|
import { DetailView } from "./detail";
|
|
26
25
|
|
|
27
26
|
const SKELETON_COUNT = 4;
|
|
@@ -43,7 +42,7 @@ type VariantSpec = {
|
|
|
43
42
|
selection: Record<string, string>; // option label -> chosen value label
|
|
44
43
|
price?: Price;
|
|
45
44
|
available: boolean;
|
|
46
|
-
|
|
45
|
+
specs: Spec[];
|
|
47
46
|
};
|
|
48
47
|
type ProductSpec = { id: string; title: string; variants: VariantSpec[] };
|
|
49
48
|
type ViewState = {
|
|
@@ -78,7 +77,7 @@ function buildProductSpec(product: Product): ProductSpec {
|
|
|
78
77
|
selection,
|
|
79
78
|
price: variant.price,
|
|
80
79
|
available: !variant.outOfStock,
|
|
81
|
-
|
|
80
|
+
specs: variant.specs,
|
|
82
81
|
});
|
|
83
82
|
}
|
|
84
83
|
return { id: product.id, title: product.card.title, variants };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { skybridge } from "@skybridge/vite-plugin";
|
|
1
2
|
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
|
|
2
3
|
import react from "@vitejs/plugin-react";
|
|
3
|
-
import { skybridge } from "skybridge/vite";
|
|
4
4
|
import { defineConfig, type PluginOption } from "vite";
|
|
5
5
|
|
|
6
6
|
export default defineConfig({
|