Subchapter 7.143
references/storefront/INSTRUCTIONS.mdMarkdown33 KBView on GitHub
The commerce machinery ships as files — data layer, hooks, cart, checkout, SEO plumbing, typed end-to-end. The presentation doesn’t ship — you build it on the shipped hooks/DTOs: the shop, category, and product (PDP) pages with their islands, the home page, and the brand. The skeletons below carry each page’s contract — including the SSR and SEO machinery, which must be exact. You never write commerce code; you never skip designing the store.
Don’t read the shipped files — this table and the contracts below are everything you
need. Open a shipped file’s source only on a real fallback: a runtime error, or a field
this playbook doesn’t cover. Files you edit: SiteLayout.astro and styles/global.css.
Files you create (skeletons below): the shop, category, and PDP pages with their island
components, plus your home page.
| file | what it is |
|---|---|
wix/config.ts · wix/sdk.ts | shared auth seam (deploy configures it — nothing to set by hand) |
wix/media.ts · wix/money.ts | imgSrc() / imgSrcSet() / formatMoney() — already used by everything shipped; imgSrcSet(p.imageUrl) + sizes for responsive tiles (it takes the DTO’s resolved URLs) — always alongside src={p.imageUrl} |
wix/storefront/types.ts | the DTOs (ProductSummary, ProductDetail, Cart, Category, Facet) — contracts inlined below |
wix/storefront/catalog.ts | searchCatalog (sort/filter/facets/search + cursor paging + result count, all server-side), fetchFacets, fetchProducts, fetchProductsByCategory, fetchProductBySlug, fetchCategories, fetchCategoryBySlug, resolveVariant |
wix/storefront/cart.ts · cart-store.ts | Cart V2 + shared cart state (module store — spans Astro islands) |
hooks/storefront/useCart.ts | cart state + actions — contract below |
hooks/storefront/useShop.ts | listing: category scope, sort, filters, option facets, result count, paging — contract below |
hooks/storefront/useProductDetail.ts | option selection → variant resolution → add-to-cart — contract below |
components/storefront/CartButton.tsx · CartDrawer.tsx | header badge + slide-over cart — wire as-is (drawer once per page) |
components/storefront/FilterPanel.tsx | the gallery’s filter LAYOUT — toolbar (result count, sort), active chips, then a 16rem sidebar of collapsible groups (price as a two-handle slider bounded by the catalog’s real prices, availability, one group per option facet with swatches/pills) beside YOUR results; a bottom sheet under md — wire as-is in your ShopView, your grid as its children: <FilterPanel shop={shop}>…grid…</FilterPanel> |
components/storefront/QuickAdd.tsx | the tile’s purchase control — one click for a product with no options, a picker anchored to the tile (bottom sheet on small screens) for one with options, the product page for free-text customization — wire as-is as the last row of every tile’s text block (<QuickAdd product={p} />) |
components/storefront/ShopView.tsx · ProductDetailView.tsx | don’t ship — YOU create them (skeletons below): the client islands your shop, category, and PDP pages mount |
styles/global.css | the design system: Tailwind v4 + the @theme token block (colors, radii, fonts — same token family as the official Wix templates). Everything, shipped and yours, styles from these tokens |
Astro stack additionally gets:
| file | what it is |
|---|---|
layouts/SiteLayout.astro | the site chrome — yours to brand: header, footer, nav. Keep the <slot name="seo-tags" />, the global.css import, and the CartButton/CartDrawer mounts |
pages/shop.astro · pages/category/[slug].astro · pages/products/[slug].astro | don’t ship — YOU create them (skeletons below): thin SSR pages that fetch server-side and mount your islands; the category and PDP pages carry the owner-editable SEO machinery, exactly as their skeletons show |
You implement three surfaces yourself — each an SSR page plus its island, per the
skeletons below — styled with Tailwind utilities on the @theme tokens, designed to fit the
brief (the business, the tone, the audience — a toy brand and a jewelry house should not get
the same store):
ShopView (skeletons below) — category links, the shipped
FilterPanel, and your grid of your tiles: image treatment, badges, price/sale
presentation, hover behavior, grid rhythm (columns, density, maybe an editorial featured
tile), each tile carrying the shipped QuickAdd. products === null → skeleton tiles,
[] → an honest empty state. Decompose into ProductCard/ProductGrid files if you like —
your call, nothing prescribes it. The category page (/category/[slug]) mounts the same
ShopView scoped to one category — one island, two pages.ProductDetailView (skeletons below) — gallery, price/sale,
description, your option-selection UI (color options = real swatches), quantity,
add-to-cart — on useProductDetail, which owns ALL selection/variant logic; you own how it
looks. This is the surface that most often looks generic — make the layout the brand’s: an
editorial split, a sticky buy column, a full-bleed gallery.Plus the theme (edit the @theme block in styles/global.css — one edit; a dark brand is
flipped token values; add brand fonts as extra tokens) and the chrome (header/footer in
SiteLayout.astro, one edit pass; mount the shipped CartButton in your header).
These are the recommended defaults for a store whose brief says nothing about them. They are not requirements: when the user’s prompt asks for something different — a cart page instead of a drawer, no shop page, a particular layout or look — the prompt wins, and the item here that conflicts with it is dropped without discussion. Look at the catalog before designing (categories, assortment size, media, options, sales, ribbons) and design for this store, not for a stereotype of its category. Then, by default:
/category/<slug>) —
never to an anchor on the shop page that every tile shares.FilterPanel around the grid (toolbar, then a filter sidebar beside the results on desktop and
a sheet on phones) — a store with any filterable catalog ships it, not “when it fits”; the
shipped QuickAdd as the last row of every tile; each
category reachable by a real link (/category/<slug>) — from the nav, the home page, or the
shop’s category row; loading, empty, no-results, and error states that look different.blockedReason
in the first screen at 390px wide too — on a phone the image is a bounded band
(max-h-[45vh]), not a full-screen hero that pushes the price below the fold; every ribbon;
every image reachable in the gallery.backdrop-blur header gets clipped), lock background scroll, close on Escape,
return focus on close — as the shipped CartDrawer does.Pre-order ships (isPreorder). Subscriptions, product groups, promotions, and notify-me are built
only when the catalog has them — never fabricated; wix-docs has their contracts.
// ProductSummary (grid tiles) — all display-ready: prices formatted, images https URLs:
// { id, slug, name, price, maxPrice, compareAtPrice|null, ribbon|null, ribbons: string[],
// minPriceVariantId|null, availability: "IN_STOCK"|"OUT_OF_STOCK"|"PARTIALLY_OUT_OF_STOCK",
// preorder: boolean, imageUrl, hoverImageUrl, optionsSummary /* "2 colors · 3 sizes" */,
// swatches: string[] /* hex colors of a color option's choices — dots on the tile, not a picker */,
// quickAddable: boolean }
// price !== maxPrice → the product is a RANGE: render "price – maxPrice" (compareAtPrice is null
// then — never a struck price beside a range). Otherwise price is what the buyer pays (a discount
// already applied) and compareAtPrice, when present, is the labelled "was".
// ribbons = EVERY merchant ribbon, primary first — render all, one shared style; a ribbon is a
// label, never proof of a discount. The tile's buy control is the shipped <QuickAdd product={p} />:
// it reads quickAddable / minPriceVariantId / preorder itself and routes to a direct add, an
// anchored option picker, or the product page — don't rebuild that decision in the tile.
// useShop({ initialProducts?, initialCategories?, initialCategoryId?, pageSize? /* 24 */ }) →
// { products: ProductSummary[]|null /* null = loading → skeletons */, total: number|null,
// categories: Category[], activeCategoryId: string|null, setActiveCategoryId(id|null),
Nothing renders until you write these — the store IS your work. Each page is a thin SSR shell
(fetch → DTO props → island); each island is a thin view over a hook. The pages’ frontmatter
is machinery, not design — reproduce it as the skeletons show, exactly. Hooks first,
branches after (an early return above a hook changes hook order between renders and React
throws). The islands render on the server too (client:load SSRs), and by then the 200 and
headers are already sent — a render throw truncates the body mid-stream and surfaces to the
visitor as ERR_HTTP2_PROTOCOL_ERROR, not an error page. Render every state totally; nothing
in a render path may throw.
---
// src/pages/shop.astro — YOU create it. Products and categories are fetched SERVER-SIDE
// (SEO: view-source shows product names) and handed to the island as serialized DTO props;
// category switches then filter live on the client.
import SiteLayout from "../layouts/SiteLayout.astro";
import ShopView from "../components/storefront/ShopView";
import { fetchProducts, fetchCategories } from "../wix/storefront/catalog";
import type { Category, ProductSummary } from "../wix/storefront/types";
let products: ProductSummary[] = [];
let categories: Category[] = [];
try {
[products, categories] = await Promise.all([fetchProducts({ limit: 24 }), fetchCategories()]);
} catch {
// Guarded: an unhandled SSR throw truncates the response mid-stream; the island
// renders your empty state instead.
}
---
<SiteLayout title="Shop">
<!-- your page heading / intro, then: -->
<ShopView client:load initialProducts={products} initialCategories={categories} />
</SiteLayout>---
// src/pages/category/[slug].astro — YOU create it. A category is a Wix ITEM PAGE like a product:
// its own URL, its SEO owner-editable in the dashboard, registered in the sitemap. Same SEO
// machinery as the PDP with the CATEGORY identifiers. It mounts the SAME ShopView, scoped.
import SiteLayout from "../../layouts/SiteLayout.astro";
import ShopView from "../../components/storefront/ShopView";
import { fetchCategoryBySlug, fetchProductsByCategory, fetchCategories } from "../../wix/storefront/catalog";
import type { Category, ProductSummary } from "../../wix/storefront/types";
import { WIX_APPS } from "@wix/essentials";
import { SEO } from "@wix/seo/components";
import { loadSEOTagsServiceConfig } from "@wix/seo/services";
import { seoTags } from "@wix/seo";
export const wixMetadata = {
appDefId: WIX_APPS.checkoutAndOrders.id,
pageIdentifier: WIX_APPS.checkoutAndOrders.categoryPageMetadata.pageIdentifier,
identifiers: { slug: WIX_APPS.checkoutAndOrders.categoryPageMetadata.identifiers.handle },
};
const slug = Astro.params.slug!;
const forwardedUrl = Astro.request.headers.get("x-wix-forwarded-url");
const pageUrl =
forwardedUrl && URL.canParse(forwardedUrl) && /^https?:$/.test(new URL(forwardedUrl).protocol)
? forwardedUrl
: Astro.url.href;
let category: Category | null = null;
let products: ProductSummary[] = [];
let categories: Category[] = [];
let seoTagsServiceConfig = null;
try {
[category, categories, seoTagsServiceConfig] = await Promise.all([
fetchCategoryBySlug(slug),
fetchCategories(),
loadSEOTagsServiceConfig({ pageUrl, itemType: seoTags.ItemType.STORES_CATEGORY, itemData: { slug } }),
]);
if (category) products = await fetchProductsByCategory(category.id, { limit: 24 });
} catch {
// Guarded: an unhandled SSR throw truncates the response mid-stream.
}
// A missing or hidden category is a real 404 — never a fallback to all products.
if (!category) {
return new Response(null, { status: 404 });
}
---
<SiteLayout title={category.name}>
<SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" />
<!-- your heading: category.name, category.description when present, then: -->
<ShopView client:load initialProducts={products} initialCategories={categories} initialCategoryId={category.id} />
</SiteLayout>---
// src/pages/products/[slug].astro — YOU create it. This is a Wix ITEM PAGE: the wixMetadata
// export + <SEO.Tags> are what let the site owner edit this page's title/description/OG in
// the dashboard and register the route in the sitemap. All three SEO pieces are REQUIRED,
// exactly as here.
import SiteLayout from "../../layouts/SiteLayout.astro";
import ProductDetailView from "../../components/storefront/ProductDetailView";
import { fetchProductBySlug } from "../../wix/storefront/catalog";
import { WIX_APPS } from "@wix/essentials";
import { SEO } from "@wix/seo/components";
import { loadSEOTagsServiceConfig } from "@wix/seo/services";
import { seoTags } from "@wix/seo";
export const wixMetadata = {
appDefId: WIX_APPS.checkoutAndOrders.id,
pageIdentifier: WIX_APPS.checkoutAndOrders.productPageMetadata.pageIdentifier,
identifiers: { slug: WIX_APPS.checkoutAndOrders.productPageMetadata.identifiers.handle },
};
const slug = Astro.params.slug!;
// Behind Wix's proxy the request URL is the internal one — the real public page URL arrives
// on x-wix-forwarded-url, and the SEO service needs the public one.
const forwardedUrl = Astro.request.headers.get("x-wix-forwarded-url");
const pageUrl =
forwardedUrl && URL.canParse(forwardedUrl) && /^https?:$/.test(new URL(forwardedUrl).protocol)
? forwardedUrl
: Astro.url.href;
let product = null;
let seoTagsServiceConfig = null;
try {
[product, seoTagsServiceConfig] = await Promise.all([
fetchProductBySlug(slug),
loadSEOTagsServiceConfig({
pageUrl,
itemType: seoTags.ItemType.STORES_PRODUCT,
itemData: { slug },
}),
]);
} catch {
// Guarded: an unhandled SSR throw truncates the response mid-stream.
}
if (!product) {
return new Response(null, { status: 404 });
}
---
<SiteLayout title={product.name}>
<SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" />
<ProductDetailView client:load initial={product} />
</SiteLayout>// src/components/storefront/ShopView.tsx — YOU build it; shop.astro AND category/[slug].astro mount it.
import { useShop } from "../../hooks/storefront/useShop";
import FilterPanel from "./FilterPanel";
import QuickAdd from "./QuickAdd";
import type { Category, ProductSummary } from "../../wix/storefront/types";
export default function ShopView(props: {
initialProducts?: ProductSummary[]; // SSR props from your page — pass straight to useShop;
initialCategories?: Category[];
// src/components/storefront/ProductDetailView.tsx — YOU build the whole PDP surface;
// your [slug].astro mounts it with the server-fetched product.
import { useProductDetail } from "../../hooks/storefront/useProductDetail";
import type { ProductDetail } from "../../wix/storefront/types";
export default function ProductDetailView(props: {
initial?: ProductDetail | null; // SSR (Astro); a SPA passes { slug } instead
slug?: string;
}) {
const d = useProductDetail(props); // full contract above — selection lives HERE
// …you implement the render. Handle in order:
// d.notFound → a "doesn't exist (anymore)" message
// !d.product → a loading placeholder
// else the product view, laid out for the brand:
// • gallery from d.product.gallery (urls, main first): show ONE primary
// image at full size, and the rest as a small thumbnail strip (or a
// scrollable row) that swaps the primary — never map the whole gallery
// to full-width images stacked down the column (products with per-color
// linked media carry several gallery urls, so that stacks big duplicates).
// A single-image gallery is just the one primary — no empty strip.
// • name, EVERY ribbon (d.product.ribbons), live d.price (the range until every option
// is picked) with d.compareAtPrice as a labelled "was" when present — never invent one;
// descriptionHtml rendered as HTML, then d.product.infoSections as sections/accordions
// • option controls from d.optionGroups → d.selectOption(optionName, choiceName)
// (isColor → real swatches via colorCode; disable out-of-stock choices);
// modifiers from d.product.modifiers → d.setModifier(key, value), "*" = mandatory
// • quantity (d.quantity / d.setQuantity), then the buy button gated by d.canAdd
// ONLY — never resolve variants yourself — calling d.add(); label it "Pre-order" when
// d.isPreorder; while disabled, render d.blockedReason beside it as neutral guidance
// (not error styling); d.adding disables, d.error renders inline
// • in the first screen at mobile AND desktop: the image, name, price, the first choice,
// and the button with its reason — a shopper decides without scrolling. On a phone that
// means the primary image is a bounded band, not a full-height hero: e.g. the gallery
// column `max-h-[45vh] md:max-h-none` with `object-contain`, thumbnails as a row under
// it, and the two-column split only from md (`md:grid md:grid-cols-2`); description and
// infoSections come AFTER the buy button, never between the price and the action.
}@theme tokens (one edit); brand SiteLayout.astro (one pass).client:load with the
SSR props; browser-state widgets (cart) are client:only="react".pages/index.astro (home) on SiteLayout.Import ./styles/global.css once at the app entry (needs @tailwindcss/vite in the vite
config plugins — deploy already added the dep). Write route wrappers in the project’s router:
/shop → your shop component; /category/:slug → the same shop component with
initialCategoryId resolved from fetchCategoryBySlug(slug) (null → your 404 view);
/products/:slug → your detail component (useProductDetail({ slug }) — components fetch
client-side when no initial is passed).
Mount the shipped CartButton in the header and CartDrawer once. Deploy wrote the public
client id into wix/config.ts; nothing else to configure.
wix/storefront/ for what they don’t cover (API contracts: the wix-docs skill).useProductDetail — never add a product with options by
picking variants[0], and never gate canAdd yourself. In the gallery that is the shipped
QuickAdd — a tile never adds a product with options itself, and never hides the buy path
behind “go to the product page” for a product that has no options.FilterPanel — mounted in the gallery whenever the store has a
catalog to filter; not rebuilt with fewer controls, not dropped because the brief didn’t ask./category/[slug] with its SEO block, linked from the chrome; a
category that exists only as a state toggle on /shop has no URL to share or index.@theme tokens; your markup uses Tailwind utilities on the same tokens. No
parallel theme files, no hardcoded palette values in components.checkout()) — never a hand-built checkout URL.compareAtPrice, no struck price beside a range (the DTO already withholds it).cart — never summed or hardcoded in the client; shipping and tax say
“calculated at checkout” (the drawer already does).wixMetadata + loadSEOTagsServiceConfig +
<SEO.Tags>) exactly as the skeleton shows — owners edit those tags in their dashboard.notFound/loading above its useState/useEffect changes hook order between renders and
React throws. Hooks first, branches after.useShop‘s sort/filters/search and
searchCatalog run on Wix across the whole catalog before cursor paging; re-ordering the
array a hook already returned only sorts the slice you happen to have. Never sort/filter
client-side, and never raise the page limit instead of paging (hasMore/loadMore).Give the owner the dashboard, products, and categories links — the deploy step’s JSON
output already printed them ready-made (dashboardUrl, productsUrl, categoriesUrl);
copy, don’t re-derive. Real payments additionally need a premium plan + a connected payment
method (dashboard) — mention it, don’t treat it as a code failure.
Per seed/SEED.md — a plain-data plan.json into seed-store.mjs, run from the project
root. Independent of the frontend work; seed a catalog that exercises the UI (≥1 product with
a color option, ≥1 on sale, an image per product) unless the brief says otherwise.
/shop renders live products SSR (view-source shows product names) through your
grid/card; the FilterPanel shows the result count, and on desktop a sidebar beside the
grid with the price slider and this catalog’s facets (a Color option → swatches); category links lead to /category/<slug>, which renders scoped and
carries the SEO tags in view-source; an unknown slug is a 404; empty catalog shows your
honest empty state.blockedReason (“Choose Size”) until every option is picked, the price is the range until then and the variant’s
price after, a sale shows the labelled “was”, a sold-out combination reads “Out of stock”,
a pre-orderable one reads “Pre-order”.