@wix/agent-skills 1.16.1 → 1.16.2
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/package.json +1 -1
- package/skills/wix-app/SKILL.md +72 -8
- package/skills/wix-app/references/CODE_QUALITY.md +0 -1
- package/skills/wix-app/references/DASHBOARD_MODAL.md +21 -14
- package/skills/wix-app/references/DASHBOARD_PAGE.md +30 -184
- package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +1 -0
- package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +172 -0
- package/skills/wix-app/references/dashboard-page/WDS_LAYOUT.md +187 -0
- package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +31 -2
- package/skills/wix-app/references/editor-react-component/COMPONENT-API.md +6 -1
- package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +167 -0
- package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +2 -2
- package/skills/wix-base44-connector/SKILL.md +185 -0
- package/skills/wix-base44-connector/scripts/utils.js +212 -0
- package/skills/wix-design-system/SKILL.md +19 -15
- package/skills/wix-docs/SKILL.md +75 -1
- package/skills/wix-manage/SKILL.md +12 -16
- package/skills/wix-manage/references/domains/domain-search-purchase-and-connect.md +2603 -0
- package/skills/wix-manage/references/events/create-wix-event.md +112 -90
- package/skills/wix-manage/references/events/manage-wix-events.md +33 -94
- package/skills/wix-manage/references/sites/create-headless-site.md +1 -0
- package/skills/wix-manage/references/sites/manage-oauth-apps.md +154 -0
- package/skills/wix-manage/references/sites/read-site-context.md +125 -0
- package/skills/wix-manage/references/stores/create-product-catalog-v3.md +273 -0
- package/skills/wix-vibe-headless/install/deploy.cjs +1 -2
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +104 -0
- package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +74 -0
- package/skills/wix-vibe-headless/platforms/base44.md +3 -3
- package/skills/wix-vibe-headless/references/blog/seed/SEED.md +1 -1
- package/skills/wix-vibe-headless/references/bookings/seed/SEED.md +1 -1
- package/skills/wix-vibe-headless/references/events/seed/SEED.md +1 -1
- package/skills/wix-vibe-headless/references/portfolio/seed/SEED.md +1 -1
- package/skills/wix-vibe-headless/references/pricing-plans/seed/SEED.md +1 -1
- package/skills/wix-vibe-headless/references/restaurants/seed/SEED.md +1 -1
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +68 -2
- package/skills/wix-vibe-headless/references/storefront/app/components/ProductCard.jsx +29 -86
- package/skills/wix-vibe-headless/references/storefront/app/components/ProductGrid.jsx +3 -8
- package/skills/wix-vibe-headless/references/storefront/app/components/VariantPicker.jsx +78 -105
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +92 -0
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useVariantOptions.js +76 -0
- package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +1 -1
- package/skills/wix-app/references/AUTO_PATTERNS_DASHBOARD.md +0 -326
- package/skills/wix-app/references/auto-patterns-dashboard/action-cell.md +0 -115
- package/skills/wix-app/references/auto-patterns-dashboard/app-config-structure.md +0 -264
- package/skills/wix-app/references/auto-patterns-dashboard/app-context.md +0 -37
- package/skills/wix-app/references/auto-patterns-dashboard/bulk-actions.md +0 -86
- package/skills/wix-app/references/auto-patterns-dashboard/collection-page-actions.md +0 -119
- package/skills/wix-app/references/auto-patterns-dashboard/collection-page.md +0 -103
- package/skills/wix-app/references/auto-patterns-dashboard/custom-actions-override.md +0 -76
- package/skills/wix-app/references/auto-patterns-dashboard/custom-columns-override.md +0 -82
- package/skills/wix-app/references/auto-patterns-dashboard/custom-components-override.md +0 -89
- package/skills/wix-app/references/auto-patterns-dashboard/custom-header-override.md +0 -124
- package/skills/wix-app/references/auto-patterns-dashboard/custom-sections-override.md +0 -91
- package/skills/wix-app/references/auto-patterns-dashboard/custom-slots-override.md +0 -71
- package/skills/wix-app/references/auto-patterns-dashboard/entity-page-actions.md +0 -70
- package/skills/wix-app/references/auto-patterns-dashboard/entity-page-view-actions.md +0 -73
- package/skills/wix-app/references/auto-patterns-dashboard/entity-page.md +0 -98
- package/skills/wix-app/references/auto-patterns-dashboard/example-patterns.json +0 -278
- package/skills/wix-app/references/auto-patterns-dashboard/pages-configuration.md +0 -94
- package/skills/wix-app/references/auto-patterns-dashboard/resolved-action.md +0 -43
- package/skills/wix-app/references/auto-patterns-dashboard/sdk-utilities.md +0 -66
- package/skills/wix-app/references/auto-patterns-dashboard/views.md +0 -76
- package/skills/wix-app/scripts/generate-auto-patterns.js +0 -510
- package/skills/wix-manage/references/domains/domain-search-and-purchase.md +0 -437
- package/skills/wix-manage/references/stores/create-product-from-image.md +0 -1031
- package/skills/wix-manage/references/stores/create-product-with-options-catalog-v3.md +0 -623
- package/skills/wix-manage/references/stores/setup-online-store-catalog-v3.md +0 -106
|
@@ -9,7 +9,7 @@ plain data.
|
|
|
9
9
|
|
|
10
10
|
```js
|
|
11
11
|
// build-time exec_tool
|
|
12
|
-
const { accessToken } = await base44.asServiceRole.connectors.getConnection("wix");
|
|
12
|
+
const { accessToken } = await base44.asServiceRole.connectors.getConnection("wix");
|
|
13
13
|
const fs = require("fs");
|
|
14
14
|
// exec_tool's require can return EMPTY exports for these build-time modules — load the file itself:
|
|
15
15
|
const seed = (() => { const m = { exports: {} };
|
|
@@ -17,7 +17,7 @@ across exec calls. Online ordering and table reservations run only when the plan
|
|
|
17
17
|
|
|
18
18
|
```js
|
|
19
19
|
// build-time exec_tool
|
|
20
|
-
const { accessToken } = await base44.asServiceRole.connectors.getConnection("wix");
|
|
20
|
+
const { accessToken } = await base44.asServiceRole.connectors.getConnection("wix");
|
|
21
21
|
const fs = require("fs");
|
|
22
22
|
// exec_tool's require can return EMPTY exports for these build-time modules — load the file itself:
|
|
23
23
|
const seed = (() => { const m = { exports: {} };
|
|
@@ -24,12 +24,15 @@ so you don't need to open them:**
|
|
|
24
24
|
| `context/CartContext.jsx` | `useCart()` provider: server cart, add/update/remove, checkout |
|
|
25
25
|
| `hooks/useProductDetail.js` | PDP data — product + variant resolution for a slug, plus load/add state |
|
|
26
26
|
| `hooks/useShop.js` | catalog listing — category menu, cursor paging, sort, failure state |
|
|
27
|
-
| `
|
|
27
|
+
| `hooks/useProductCard.js` | headless data layer for a grid tile — returns `leftBadges`, `promoBadge`, `priceDisplay`, `compareAtDisplay`, `colors`, `optionLabel`, `isQuickAddable`, `image`, `hoverImage`; **always use this to build your own product card UI on the grid** |
|
|
28
|
+
| `components/ProductCard.jsx` | reference implementation of a grid tile built on `useProductCard` — read it for inspiration, build your own component rather than using it directly |
|
|
29
|
+
| `components/ProductGrid.jsx` | reference grid layout (2-col mobile → auto-fill desktop) with skeleton + empty state — read it for the skeleton/empty-state patterns, build your own layout rather than using it directly |
|
|
28
30
|
| `components/ProductGallery.jsx` | PDP main image + thumbnails |
|
|
29
31
|
| `lib/storeImage.js` | `productImage()` / `productGallery()` / `storeImage()` — normalise Wix image urls |
|
|
30
32
|
| `components/CartButton.jsx` | header cart **icon** button with a live-count badge |
|
|
31
33
|
| `components/CartDrawer.jsx` | slide-over cart (mount once; opens from `useCart`) |
|
|
32
|
-
| `
|
|
34
|
+
| `hooks/useVariantOptions.js` | headless data layer for options/modifiers — returns `optionGroups` + `modifierGroups` (normalised, render-agnostic); **always use this to build your own variant UI on the PDP** |
|
|
35
|
+
| `components/VariantPicker.jsx` | reference implementation of a variant selector built on `useVariantOptions` (pills + colour swatches) — read it for inspiration, but build your own component rather than using it directly |
|
|
33
36
|
| `components/WixManageBanner.jsx` | preview-only manage banner — drop it into your Layout (STEP 3) |
|
|
34
37
|
| `pages/Shop.jsx`, `pages/ProductDetail.jsx` | the two shipped routes (`/shop`, `/product/:slug`) |
|
|
35
38
|
| `rest/wix-config.js` | the two ids, written by the install step |
|
|
@@ -70,6 +73,69 @@ replace it.
|
|
|
70
73
|
banner is dismissed.
|
|
71
74
|
- Routes under the Layout: `/shop` → `Shop`, `/product/:slug` → `ProductDetail` (both shipped, as-is).
|
|
72
75
|
**You add `/` → your own Home** page.
|
|
76
|
+
- **Build your own variant selector on the PDP — this is required, not optional, and it's your chance to be creative.** `ProductDetail.jsx` ships with a `VariantPicker` import — remove it and replace with your own component built on `useVariantOptions`. Design the controls to fit the brief: the business type, the tone, the audience. A fashion brand might want large colour swatches and a size chart link; a tech store might want a compact dropdown. `VariantPicker.jsx` is in `src/` for reference — read it, don't use it:
|
|
77
|
+
- **Build your own product card for the grid — this is required, not optional, and it's your chance to be creative.** `ProductGrid.jsx` ships with a `ProductCard` import — replace it with your own component built on `useProductCard`. The hook hands you everything the tile needs (badges, price display, colour dots, quick-add flag, images) — you decide the layout, shape, hover behaviour, and CTA style. A lifestyle brand might want full-bleed images with an overlay gradient; a tech store might want a compact horizontal list item. `ProductCard.jsx` is in `src/` for reference — read it, don't use it:
|
|
78
|
+
- **Build your own grid layout — this is required, not optional, and it's your chance to be creative.** `ProductGrid.jsx` ships as a reference (2-col mobile → auto-fill desktop, 220px min) — replace it with an arrangement that fits the brief. A curated boutique might want a 3-col asymmetric editorial layout; a high-volume store might want a dense 4-col grid; a featured strip on the home page might want horizontal scroll. Keep the skeleton and empty-state patterns from `ProductGrid.jsx` (copy them into your own component) — the states themselves are correct, just the layout is yours to choose.
|
|
79
|
+
|
|
80
|
+
```jsx
|
|
81
|
+
import { Link } from "react-router-dom";
|
|
82
|
+
import { useCart } from "@/context/CartContext";
|
|
83
|
+
import { useProductCard } from "@/hooks/useProductCard";
|
|
84
|
+
|
|
85
|
+
export default function MyProductCard({ product }) {
|
|
86
|
+
const { addToCart } = useCart();
|
|
87
|
+
const {
|
|
88
|
+
isSoldOut, isPreorder,
|
|
89
|
+
leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }] — render left side of image
|
|
90
|
+
promoBadge, // { type: 'discount'|'ribbon', label } | null — render right side
|
|
91
|
+
priceDisplay, // "€10" or "€10 – €20" (range when variants differ)
|
|
92
|
+
compareAtDisplay, // original price string | null
|
|
93
|
+
colors, // hex strings → render as dots (slice to how many you want)
|
|
94
|
+
optionLabel, // "3 sizes · 2 materials" or empty string
|
|
95
|
+
isQuickAddable, // true for single-variant, in-stock products
|
|
96
|
+
image, // primary image URL | null
|
|
97
|
+
hoverImage, // second image URL | null
|
|
98
|
+
} = useProductCard(product);
|
|
99
|
+
|
|
100
|
+
// Then render however you want:
|
|
101
|
+
return (
|
|
102
|
+
<div>
|
|
103
|
+
{/* image, badges, price, colour dots, quick-add or "Choose options" CTA */}
|
|
104
|
+
{isQuickAddable && <button onClick={() => addToCart(product.id)}>Quick add</button>}
|
|
105
|
+
{!isQuickAddable && !isSoldOut && <Link to={`/product/${product.slug}`}>Choose options</Link>}
|
|
106
|
+
{isSoldOut && isPreorder && <Link to={`/product/${product.slug}`}>Pre-order</Link>}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```jsx
|
|
113
|
+
import { useVariantOptions } from "@/hooks/useVariantOptions";
|
|
114
|
+
|
|
115
|
+
// options/modifiers/selectedOptions/modifierValues come from useProductDetail:
|
|
116
|
+
const { optionGroups, modifierGroups } = useVariantOptions(options, modifiers, selectedOptions, modifierValues);
|
|
117
|
+
|
|
118
|
+
// optionGroups: [{ id, name, isColor, choices: [{ choiceId, name, colorCode, isColorSwatch, inStock, selected }] }]
|
|
119
|
+
// modifierGroups: [{ key, name, mandatory, type: 'choices'|'text', choices?: [{ key, name, selected }], value?: string }]
|
|
120
|
+
|
|
121
|
+
// Then render however you want:
|
|
122
|
+
optionGroups.map((group) =>
|
|
123
|
+
group.choices.map((c) =>
|
|
124
|
+
c.isColorSwatch
|
|
125
|
+
? <MySwatch key={c.choiceId} color={c.colorCode} active={c.selected} disabled={!c.inStock}
|
|
126
|
+
onClick={() => selectOption(group.id, c.choiceId)} />
|
|
127
|
+
: <MyPill key={c.choiceId} active={c.selected} disabled={!c.inStock}
|
|
128
|
+
onClick={() => selectOption(group.id, c.choiceId)}>{c.name}</MyPill>
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
modifierGroups.map((m) =>
|
|
132
|
+
m.type === "text"
|
|
133
|
+
? <MyInput key={m.key} label={m.name} value={m.value} onChange={(v) => setModifier(m.key, v)} />
|
|
134
|
+
: m.choices.map((c) =>
|
|
135
|
+
<MyPill key={c.key} active={c.selected} onClick={() => setModifier(m.key, c.key)}>{c.name}</MyPill>
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
```
|
|
73
139
|
|
|
74
140
|
```jsx
|
|
75
141
|
import { useRef, useState, useEffect } from "react";
|
|
@@ -1,72 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// grid, the PDP gallery and the cart all normalise them the same way.
|
|
5
|
-
//
|
|
6
|
-
// Everything here comes from the list query — the tile costs no extra request. What the list query
|
|
7
|
-
// does NOT carry is a stock COUNT (inventory has availabilityStatus only), so "Limited stock" is as
|
|
8
|
-
// precise as a tile can honestly get; "Only 2 left" would need per-variant inventory per product.
|
|
1
|
+
// Reference implementation of a product grid tile built on useProductCard.
|
|
2
|
+
// Styled with base44 design tokens (shadcn Tailwind classes: bg-card / text-foreground / border-border).
|
|
3
|
+
// Build your own card on useProductCard instead — read this for inspiration, don't use it directly.
|
|
9
4
|
import { useState } from "react";
|
|
10
5
|
import { Link } from "react-router-dom";
|
|
11
|
-
import { productGallery, productImage } from "@/lib/storeImage";
|
|
12
6
|
import { useCart } from "@/context/CartContext";
|
|
7
|
+
import { useProductCard } from "@/hooks/useProductCard";
|
|
13
8
|
|
|
14
|
-
// Percent off, from the raw amounts — the formatted strings ("€129.00") can't be subtracted.
|
|
15
|
-
function discountPercent(product) {
|
|
16
|
-
const now = Number(product?.actualPriceRange?.minValue?.amount);
|
|
17
|
-
const was = Number(product?.compareAtPriceRange?.minValue?.amount);
|
|
18
|
-
if (!now || !was || was <= now) return null;
|
|
19
|
-
return Math.round(((was - now) / was) * 100);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// What the buyer gets to choose, previewed on the tile: colour options become real dots (the colours
|
|
23
|
-
// say more than "3 colors" could), everything else becomes "4 sizes". Pluralised from the option's own
|
|
24
|
-
// name, so the wording follows the merchant's catalogue rather than a hardcoded English word.
|
|
25
|
-
function optionsPreview(product) {
|
|
26
|
-
const labels = [], colors = [];
|
|
27
|
-
for (const o of product?.options || []) {
|
|
28
|
-
const choices = (o.choicesSettings?.choices || []).filter((c) => c.visible !== false);
|
|
29
|
-
if (!choices.length) continue;
|
|
30
|
-
const swatches = choices.map((c) => c.colorCode).filter(Boolean);
|
|
31
|
-
if (swatches.length) { colors.push(...swatches); continue; }
|
|
32
|
-
const n = o.name.toLowerCase();
|
|
33
|
-
labels.push(`${choices.length} ${choices.length === 1 || n.endsWith("s") ? n : `${n}s`}`);
|
|
34
|
-
}
|
|
35
|
-
return { label: labels.join(" · "), colors };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// One rule for every chip on the tile, so a new badge has an obvious home instead of a fresh colour:
|
|
39
|
-
// promotional shouts in the brand colour, informational sits quietly on a card surface, and only a
|
|
40
|
-
// blocking state is destructive. Each background travels with its own foreground token.
|
|
41
9
|
const badge = "px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide rounded-full";
|
|
42
|
-
const badgePromo
|
|
43
|
-
const badgeInfo
|
|
44
|
-
const badgeBlocked
|
|
10
|
+
const badgePromo = `${badge} bg-primary text-primary-foreground`;
|
|
11
|
+
const badgeInfo = `${badge} bg-card text-foreground border border-border`;
|
|
12
|
+
const badgeBlocked = `${badge} bg-destructive text-destructive-foreground`;
|
|
13
|
+
|
|
14
|
+
const badgeClass = { "pre-order": badgeInfo, "sold-out": badgeBlocked, "limited-stock": badgeInfo };
|
|
45
15
|
|
|
46
16
|
export default function ProductCard({ product }) {
|
|
47
17
|
const { addToCart } = useCart();
|
|
48
18
|
const [adding, setAdding] = useState(false);
|
|
49
19
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const status = product?.inventory?.availabilityStatus;
|
|
57
|
-
const soldOut = status === "OUT_OF_STOCK";
|
|
58
|
-
const preorder = product?.inventory?.preorderStatus === "ENABLED";
|
|
59
|
-
const discount = discountPercent(product);
|
|
60
|
-
// A merchant-set ribbon ("New", "Best Seller") is the catalogue's own badge, so it beats anything
|
|
61
|
-
// derived here — a "new" label computed from createdDate would flag the whole catalogue at once
|
|
62
|
-
// right after seeding. Suppressed when a discount badge is showing, since that ribbon is "Sale".
|
|
63
|
-
const ribbon = product?.ribbon?.name;
|
|
64
|
-
const { label: optionLabel, colors } = optionsPreview(product);
|
|
65
|
-
const hasOptions = (product?.options?.length || 0) > 0;
|
|
20
|
+
const {
|
|
21
|
+
isSoldOut, isPreorder, leftBadges, promoBadge,
|
|
22
|
+
priceDisplay, compareAtDisplay,
|
|
23
|
+
colors, optionLabel,
|
|
24
|
+
isQuickAddable, image, hoverImage,
|
|
25
|
+
} = useProductCard(product);
|
|
66
26
|
|
|
67
|
-
// No options means a single variant, which the cart resolves from the product id alone — so the
|
|
68
|
-
// tile can add straight to the cart with no extra request. The drawer opening is the confirmation
|
|
69
|
-
// (and carries the reason when it fails), which is why there's no "Added ✓" state to fake here.
|
|
70
27
|
const quickAdd = async () => {
|
|
71
28
|
setAdding(true);
|
|
72
29
|
await addToCart(product.id);
|
|
@@ -79,11 +36,8 @@ export default function ProductCard({ product }) {
|
|
|
79
36
|
<Link to={`/product/${product.slug}`} aria-label={product.name} className="block w-full h-full">
|
|
80
37
|
{image ? (
|
|
81
38
|
<>
|
|
82
|
-
{/* Fade out only when there IS a second shot behind it — a one-photo product would
|
|
83
|
-
otherwise hover to an empty frame. */}
|
|
84
39
|
<img src={image} alt={product.name} loading="lazy"
|
|
85
40
|
className={`w-full h-full object-cover ${hoverImage ? "transition-opacity duration-300 md:group-hover:opacity-0" : ""}`} />
|
|
86
|
-
{/* Second catalogue shot on hover. `hidden` below md so phones never fetch it. */}
|
|
87
41
|
{hoverImage && (
|
|
88
42
|
<img src={hoverImage} alt="" aria-hidden="true" loading="lazy"
|
|
89
43
|
className="hidden md:block absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
|
@@ -99,51 +53,40 @@ export default function ProductCard({ product }) {
|
|
|
99
53
|
</Link>
|
|
100
54
|
|
|
101
55
|
<div className="absolute top-2 left-2 flex flex-col items-start gap-1 pointer-events-none">
|
|
102
|
-
{
|
|
103
|
-
|
|
104
|
-
|
|
56
|
+
{leftBadges.map((b) => (
|
|
57
|
+
<span key={b.type} className={badgeClass[b.type]}>{b.label}</span>
|
|
58
|
+
))}
|
|
105
59
|
</div>
|
|
106
|
-
{/* Discount and ribbon are mutually exclusive, so both can claim the promotional slot. */}
|
|
107
60
|
<div className="absolute top-2 right-2 pointer-events-none">
|
|
108
|
-
{
|
|
109
|
-
: ribbon ? <span className={badgePromo}>{ribbon}</span> : null}
|
|
61
|
+
{promoBadge && <span className={badgePromo}>{promoBadge.label}</span>}
|
|
110
62
|
</div>
|
|
111
63
|
|
|
112
|
-
{
|
|
113
|
-
{(!soldOut || preorder) && (
|
|
64
|
+
{(!isSoldOut || isPreorder) && (
|
|
114
65
|
<div className="absolute inset-x-2 bottom-2 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
|
|
115
|
-
{
|
|
116
|
-
CTA is the brand colour rather than "whatever the text colour is". */}
|
|
117
|
-
{hasOptions || soldOut ? (
|
|
118
|
-
<Link to={`/product/${product.slug}`}
|
|
119
|
-
className="block w-full text-center py-2.5 px-4 rounded-full bg-primary text-primary-foreground no-underline text-sm font-semibold">
|
|
120
|
-
{soldOut ? "Pre-order" : "Choose options"}
|
|
121
|
-
</Link>
|
|
122
|
-
) : (
|
|
66
|
+
{isQuickAddable ? (
|
|
123
67
|
<button onClick={quickAdd} disabled={adding}
|
|
124
68
|
className="w-full py-2.5 px-4 rounded-full bg-primary text-primary-foreground border-none text-sm font-semibold cursor-pointer transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-wait">
|
|
125
69
|
{adding ? "Adding…" : "Quick add"}
|
|
126
70
|
</button>
|
|
71
|
+
) : (
|
|
72
|
+
<Link to={`/product/${product.slug}`}
|
|
73
|
+
className="block w-full text-center py-2.5 px-4 rounded-full bg-primary text-primary-foreground no-underline text-sm font-semibold">
|
|
74
|
+
{isSoldOut ? "Pre-order" : "Choose options"}
|
|
75
|
+
</Link>
|
|
127
76
|
)}
|
|
128
77
|
</div>
|
|
129
78
|
)}
|
|
130
79
|
</div>
|
|
131
80
|
|
|
132
81
|
<div className="p-3 flex flex-col gap-1">
|
|
133
|
-
{/* Side by side once the tile is wide enough; stacked on a phone's two-up grid, where a long
|
|
134
|
-
name wrapping to three lines against a top-right price reads as broken. */}
|
|
135
82
|
<div className="flex flex-col gap-1 md:flex-row md:items-baseline md:justify-between md:gap-3">
|
|
136
|
-
{/* Clamped to two lines: a 220px tile turns a long product name into three or four ragged
|
|
137
|
-
lines beside a top-right price. The full name is on the tile's link and the PDP. */}
|
|
138
83
|
<h3 className="m-0 font-display text-[15px] font-semibold line-clamp-2">
|
|
139
84
|
<Link to={`/product/${product.slug}`} className="no-underline text-foreground">{product.name}</Link>
|
|
140
85
|
</h3>
|
|
141
86
|
<div className="flex gap-2 items-baseline flex-wrap md:justify-end">
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
{compareAt && compareAt !== price && (
|
|
146
|
-
<span className="text-muted-foreground line-through text-[13px] whitespace-nowrap">{compareAt}</span>
|
|
87
|
+
<span className="font-semibold whitespace-nowrap">{priceDisplay}</span>
|
|
88
|
+
{compareAtDisplay && (
|
|
89
|
+
<span className="text-muted-foreground line-through text-[13px] whitespace-nowrap">{compareAtDisplay}</span>
|
|
147
90
|
)}
|
|
148
91
|
</div>
|
|
149
92
|
</div>
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
// empty.
|
|
3
|
-
//
|
|
4
|
-
// Two columns on phones — a 220px minmax leaves room for only one, and a one-up catalog scrolls
|
|
5
|
-
// forever — then auto-fill from md up.
|
|
6
|
-
//
|
|
7
|
-
// The empty state carries weight here — a catalog is seeded separately from the build, so it is
|
|
8
|
-
// routinely empty when the merchant first opens the page. Say why, rather than showing a bare line.
|
|
1
|
+
// Reference implementation of a product grid — 2-col mobile, auto-fill desktop, with a skeleton
|
|
2
|
+
// loading state and a descriptive empty state. Build your own layout instead (see INSTRUCTIONS.md
|
|
3
|
+
// STEP 3) — read this for the skeleton/empty-state patterns, don't use it directly.
|
|
9
4
|
import ProductCard from "./ProductCard";
|
|
10
5
|
|
|
11
6
|
// Skeleton tiles match ProductCard's aspect-square + two text lines, so nothing shifts when the real
|
|
@@ -1,118 +1,91 @@
|
|
|
1
|
-
// PDP choice controls
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// PDP choice controls built on useVariantOptions — swap this component for your own if you want
|
|
2
|
+
// a different look; the data contract (optionGroups / modifierGroups) stays the same.
|
|
3
|
+
// Styled with base44 design tokens (shadcn Tailwind classes).
|
|
4
|
+
import { useVariantOptions } from "@/hooks/useVariantOptions";
|
|
4
5
|
|
|
5
6
|
const chipBase = "py-1.5 px-3 cursor-pointer text-sm font-body border rounded-sm";
|
|
6
7
|
const chipIdle = "border-border bg-card text-foreground";
|
|
7
8
|
const chipActive = "border-primary bg-primary text-primary-foreground";
|
|
8
9
|
const label = "block mb-1.5 text-[13px] font-semibold text-muted-foreground";
|
|
9
10
|
|
|
10
|
-
// A colour/swatch option renders as swatches, a text option as pills — the render type is on the
|
|
11
|
-
// option (`optionRenderType`) and mirrored per choice (`choiceType`: ONE_COLOR vs CHOICE_TEXT).
|
|
12
|
-
// Colour choices carry `colorCode` (hex) and usually `linkedMedia`, so a swatch is real colour, and
|
|
13
|
-
// picking one moves the gallery to that colour's photo.
|
|
14
|
-
const isColorChoice = (option, choice) =>
|
|
15
|
-
option?.optionRenderType === "COLOR_CHOICES" ||
|
|
16
|
-
option?.optionRenderType === "SWATCH_CHOICES" ||
|
|
17
|
-
choice?.choiceType === "ONE_COLOR";
|
|
18
|
-
|
|
19
|
-
function OptionSelector({ option, selected, onSelect }) {
|
|
20
|
-
// `visible: false` is a choice the merchant retired — it stays in the payload, so filter it out or
|
|
21
|
-
// the buyer can pick something the catalogue no longer offers.
|
|
22
|
-
const choices = (option.choicesSettings?.choices || []).filter((c) => c.visible !== false);
|
|
23
|
-
if (!choices.length) return null;
|
|
24
|
-
const swatches = choices.some((c) => isColorChoice(option, c) && c.colorCode);
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<div className="mb-4">
|
|
28
|
-
<label className={label}>
|
|
29
|
-
{option.name}
|
|
30
|
-
{/* With swatches the chosen colour's name isn't otherwise on screen — echo it. */}
|
|
31
|
-
{swatches && selected && (
|
|
32
|
-
<span className="ml-1.5 font-normal text-foreground">
|
|
33
|
-
{choices.find((c) => c.choiceId === selected)?.name}
|
|
34
|
-
</span>
|
|
35
|
-
)}
|
|
36
|
-
</label>
|
|
37
|
-
<div className="flex flex-wrap gap-2">
|
|
38
|
-
{choices.map((c) => {
|
|
39
|
-
const active = selected === c.choiceId;
|
|
40
|
-
const out = c.inStock === false;
|
|
41
|
-
if (swatches && c.colorCode) {
|
|
42
|
-
return (
|
|
43
|
-
<button
|
|
44
|
-
key={c.choiceId}
|
|
45
|
-
type="button"
|
|
46
|
-
disabled={out}
|
|
47
|
-
onClick={() => onSelect(option.id, c.choiceId)}
|
|
48
|
-
aria-pressed={active}
|
|
49
|
-
aria-label={`${c.name}${out ? " (out of stock)" : ""}`}
|
|
50
|
-
title={c.name}
|
|
51
|
-
className={`relative w-9 h-9 rounded-full cursor-pointer transition-shadow ring-1 ring-inset ring-black/15 ${
|
|
52
|
-
active ? "ring-2 ring-offset-2 ring-offset-background ring-primary" : ""
|
|
53
|
-
} ${out ? "opacity-40 cursor-not-allowed" : "hover:ring-2 hover:ring-offset-1 hover:ring-offset-background hover:ring-border"}`}
|
|
54
|
-
style={{ backgroundColor: c.colorCode }}
|
|
55
|
-
>
|
|
56
|
-
{/* A struck line reads as "unavailable" without hiding which colour it was. */}
|
|
57
|
-
{out && (
|
|
58
|
-
<span aria-hidden="true" className="absolute inset-0 grid place-items-center">
|
|
59
|
-
<span className="w-full h-px bg-destructive rotate-45" />
|
|
60
|
-
</span>
|
|
61
|
-
)}
|
|
62
|
-
</button>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
return (
|
|
66
|
-
<button
|
|
67
|
-
key={c.choiceId}
|
|
68
|
-
type="button"
|
|
69
|
-
disabled={out}
|
|
70
|
-
aria-pressed={active}
|
|
71
|
-
onClick={() => onSelect(option.id, c.choiceId)}
|
|
72
|
-
className={`${chipBase} ${active ? chipActive : chipIdle} ${out ? "opacity-40 line-through" : ""}`}
|
|
73
|
-
>
|
|
74
|
-
{c.name}
|
|
75
|
-
</button>
|
|
76
|
-
);
|
|
77
|
-
})}
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function ModifierSelector({ modifier, value, onChange }) {
|
|
84
|
-
const key = modifier.modifierRenderType === "FREE_TEXT" ? modifier.freeTextSettings?.key : modifier.key;
|
|
85
|
-
if (modifier.modifierRenderType === "FREE_TEXT") {
|
|
86
|
-
return (
|
|
87
|
-
<div className="mb-4">
|
|
88
|
-
<label className={label}>{modifier.name}{modifier.mandatory && " *"}</label>
|
|
89
|
-
<input value={value || ""} onChange={(e) => onChange(key, e.target.value)}
|
|
90
|
-
className="w-full py-2 px-3 font-body border border-input rounded-sm bg-background text-foreground" />
|
|
91
|
-
</div>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
return (
|
|
95
|
-
<div className="mb-4">
|
|
96
|
-
<label className={label}>{modifier.name}{modifier.mandatory && " *"}</label>
|
|
97
|
-
<div className="flex flex-wrap gap-2">
|
|
98
|
-
{modifier.choicesSettings?.choices?.map((c) => (
|
|
99
|
-
<button key={c.key} aria-pressed={value === c.key} onClick={() => onChange(key, c.key)}
|
|
100
|
-
className={`${chipBase} ${value === c.key ? chipActive : chipIdle}`}>{c.name}</button>
|
|
101
|
-
))}
|
|
102
|
-
</div>
|
|
103
|
-
</div>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
11
|
export default function VariantPicker({ options, modifiers, selectedOptions, selectOption, modifierValues, setModifier }) {
|
|
12
|
+
const { optionGroups, modifierGroups } = useVariantOptions(options, modifiers, selectedOptions, modifierValues);
|
|
13
|
+
|
|
108
14
|
return (
|
|
109
15
|
<>
|
|
110
|
-
{
|
|
111
|
-
<
|
|
16
|
+
{optionGroups.map((group) => (
|
|
17
|
+
<div key={group.id} className="mb-4">
|
|
18
|
+
<label className={label}>
|
|
19
|
+
{group.name}
|
|
20
|
+
{group.isColor && (
|
|
21
|
+
<span className="ml-1.5 font-normal text-foreground">
|
|
22
|
+
{group.choices.find((c) => c.selected)?.name}
|
|
23
|
+
</span>
|
|
24
|
+
)}
|
|
25
|
+
</label>
|
|
26
|
+
<div className="flex flex-wrap gap-2">
|
|
27
|
+
{group.choices.map((c) =>
|
|
28
|
+
c.isColorSwatch ? (
|
|
29
|
+
<button
|
|
30
|
+
key={c.choiceId}
|
|
31
|
+
type="button"
|
|
32
|
+
disabled={!c.inStock}
|
|
33
|
+
onClick={() => selectOption(group.id, c.choiceId)}
|
|
34
|
+
aria-pressed={c.selected}
|
|
35
|
+
aria-label={`${c.name}${!c.inStock ? " (out of stock)" : ""}`}
|
|
36
|
+
title={c.name}
|
|
37
|
+
className={`relative w-9 h-9 rounded-full cursor-pointer transition-shadow ring-1 ring-inset ring-black/15 ${
|
|
38
|
+
c.selected ? "ring-2 ring-offset-2 ring-offset-background ring-primary" : ""
|
|
39
|
+
} ${!c.inStock ? "opacity-40 cursor-not-allowed" : "hover:ring-2 hover:ring-offset-1 hover:ring-offset-background hover:ring-border"}`}
|
|
40
|
+
style={{ backgroundColor: c.colorCode }}
|
|
41
|
+
>
|
|
42
|
+
{!c.inStock && (
|
|
43
|
+
<span aria-hidden="true" className="absolute inset-0 grid place-items-center">
|
|
44
|
+
<span className="w-full h-px bg-destructive rotate-45" />
|
|
45
|
+
</span>
|
|
46
|
+
)}
|
|
47
|
+
</button>
|
|
48
|
+
) : (
|
|
49
|
+
<button
|
|
50
|
+
key={c.choiceId}
|
|
51
|
+
type="button"
|
|
52
|
+
disabled={!c.inStock}
|
|
53
|
+
aria-pressed={c.selected}
|
|
54
|
+
onClick={() => selectOption(group.id, c.choiceId)}
|
|
55
|
+
className={`${chipBase} ${c.selected ? chipActive : chipIdle} ${!c.inStock ? "opacity-40 line-through" : ""}`}
|
|
56
|
+
>
|
|
57
|
+
{c.name}
|
|
58
|
+
</button>
|
|
59
|
+
)
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
112
63
|
))}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
64
|
+
|
|
65
|
+
{modifierGroups.map((m) => (
|
|
66
|
+
<div key={m.key} className="mb-4">
|
|
67
|
+
<label className={label}>{m.name}{m.mandatory && " *"}</label>
|
|
68
|
+
{m.type === "text" ? (
|
|
69
|
+
<input
|
|
70
|
+
value={m.value}
|
|
71
|
+
onChange={(e) => setModifier(m.key, e.target.value)}
|
|
72
|
+
className="w-full py-2 px-3 font-body border border-input rounded-sm bg-background text-foreground"
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="flex flex-wrap gap-2">
|
|
76
|
+
{m.choices.map((c) => (
|
|
77
|
+
<button
|
|
78
|
+
key={c.key}
|
|
79
|
+
aria-pressed={c.selected}
|
|
80
|
+
onClick={() => setModifier(m.key, c.key)}
|
|
81
|
+
className={`${chipBase} ${c.selected ? chipActive : chipIdle}`}
|
|
82
|
+
>
|
|
83
|
+
{c.name}
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
116
89
|
))}
|
|
117
90
|
</>
|
|
118
91
|
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Headless data layer for a product grid tile.
|
|
2
|
+
// Normalises raw Wix product fields into render-agnostic structures so you can build
|
|
3
|
+
// whatever card UI you want (image layout, badge style, price display, quick-add trigger)
|
|
4
|
+
// without touching the badge-priority logic, price-range maths, or colour-dot extraction.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// const { isSoldOut, leftBadges, promoBadge, priceDisplay, compareAtDisplay,
|
|
8
|
+
// colors, optionLabel, isQuickAddable, image, hoverImage } = useProductCard(product);
|
|
9
|
+
// // then render however you want, call addToCart(product.id) from useCart() for quick-add.
|
|
10
|
+
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { productImage, productGallery } from "@/lib/storeImage";
|
|
13
|
+
|
|
14
|
+
function discountPercent(product) {
|
|
15
|
+
const now = Number(product?.actualPriceRange?.minValue?.amount);
|
|
16
|
+
const was = Number(product?.compareAtPriceRange?.minValue?.amount);
|
|
17
|
+
if (!now || !was || was <= now) return null;
|
|
18
|
+
return Math.round(((was - now) / was) * 100);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useProductCard(product) {
|
|
22
|
+
return useMemo(() => {
|
|
23
|
+
const status = product?.inventory?.availabilityStatus;
|
|
24
|
+
const isSoldOut = status === "OUT_OF_STOCK";
|
|
25
|
+
// preorderStatus is only meaningful when the product is out of stock
|
|
26
|
+
const isPreorder = isSoldOut && product?.inventory?.preorderStatus === "ENABLED";
|
|
27
|
+
const isPartiallyOutOfStock = status === "PARTIALLY_OUT_OF_STOCK";
|
|
28
|
+
|
|
29
|
+
// Left-side badges (stacked, top-left of the image): stock / pre-order state.
|
|
30
|
+
const leftBadges = [];
|
|
31
|
+
if (isPreorder) leftBadges.push({ type: "pre-order", label: "Pre-order" });
|
|
32
|
+
else if (isSoldOut) leftBadges.push({ type: "sold-out", label: "Sold out" });
|
|
33
|
+
if (isPartiallyOutOfStock) leftBadges.push({ type: "limited-stock", label: "Limited stock" });
|
|
34
|
+
|
|
35
|
+
// Right-side promo badge: discount % beats a merchant ribbon (a "Sale" ribbon is redundant
|
|
36
|
+
// when the % is already showing). Only one at a time.
|
|
37
|
+
const discount = discountPercent(product);
|
|
38
|
+
const ribbon = product?.ribbon?.name;
|
|
39
|
+
const promoBadge = discount
|
|
40
|
+
? { type: "discount", label: `-${discount}%` }
|
|
41
|
+
: ribbon
|
|
42
|
+
? { type: "ribbon", label: ribbon }
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
// Price: show a min–max range when variants span different prices so the PDP doesn't
|
|
46
|
+
// appear to raise the price once a variant is selected.
|
|
47
|
+
const priceMin = product?.actualPriceRange?.minValue?.formattedAmount;
|
|
48
|
+
const priceMax = product?.actualPriceRange?.maxValue?.formattedAmount;
|
|
49
|
+
const compareAt = product?.compareAtPriceRange?.minValue?.formattedAmount;
|
|
50
|
+
const priceDisplay = priceMax && priceMax !== priceMin ? `${priceMin} – ${priceMax}` : priceMin;
|
|
51
|
+
const compareAtDisplay = compareAt && compareAt !== priceMin ? compareAt : null;
|
|
52
|
+
|
|
53
|
+
// Options preview for the tile summary row.
|
|
54
|
+
// Colour options → real hex dots (more informative than "3 colours").
|
|
55
|
+
// Non-colour options → "3 sizes · 2 materials" (pluralised from the merchant's own name).
|
|
56
|
+
const labels = [], colors = [];
|
|
57
|
+
for (const o of product?.options || []) {
|
|
58
|
+
const choices = (o.choicesSettings?.choices || []).filter((c) => c.visible !== false);
|
|
59
|
+
if (!choices.length) continue;
|
|
60
|
+
const swatches = choices.map((c) => c.colorCode).filter(Boolean);
|
|
61
|
+
if (swatches.length) { colors.push(...swatches); continue; }
|
|
62
|
+
const n = o.name.toLowerCase();
|
|
63
|
+
labels.push(`${choices.length} ${choices.length === 1 || n.endsWith("s") ? n : `${n}s`}`);
|
|
64
|
+
}
|
|
65
|
+
const optionLabel = labels.join(" · ");
|
|
66
|
+
const hasOptions = (product?.options?.length || 0) > 0;
|
|
67
|
+
|
|
68
|
+
// Quick-add is only safe for single-variant products (no option choices to resolve).
|
|
69
|
+
// Sold-out with pre-order still shows a CTA, but it links to the PDP, not quick-add.
|
|
70
|
+
const isQuickAddable = !hasOptions && !isSoldOut;
|
|
71
|
+
|
|
72
|
+
// Images: normalised through lib/storeImage so URLs are consistent across the tile,
|
|
73
|
+
// the PDP gallery, and the cart. Hover image is the second gallery shot (if one exists).
|
|
74
|
+
const image = productImage(product);
|
|
75
|
+
const hoverImage = productGallery(product)[1]?.url ?? null;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
isSoldOut,
|
|
79
|
+
isPreorder,
|
|
80
|
+
isPartiallyOutOfStock,
|
|
81
|
+
leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }]
|
|
82
|
+
promoBadge, // { type: 'discount'|'ribbon', label } | null
|
|
83
|
+
priceDisplay, // formatted price (range or single value)
|
|
84
|
+
compareAtDisplay, // formatted compare-at price | null
|
|
85
|
+
colors, // hex strings — render as dots; the tile shows up to however many you want
|
|
86
|
+
optionLabel, // "3 sizes · 2 materials" or empty string
|
|
87
|
+
isQuickAddable,
|
|
88
|
+
image, // primary image URL | null
|
|
89
|
+
hoverImage, // second image URL for hover effect | null
|
|
90
|
+
};
|
|
91
|
+
}, [product]);
|
|
92
|
+
}
|