Components, Blocks and Styling
The Astro components and Portable Text blocks Vendwick gives your theme, the injected pages, the stylesheet’s CSS custom properties, the Appearance settings and content gating.
Everything lives in @gatilab/emdash-vendwick/astro.
Components
---
import { ProductList, Product, Price, BuyButton, CartButton, CartDrawer, Cart, Checkout, Portal, ProductGallery, VendwickAssets, AccessGate, MyPages } from "@gatilab/emdash-vendwick/astro";
---
<ProductList type="plugin" columns={3} />
<Product product="my-plugin" />
<Price product="my-plugin" plan="pro-annual" />
<BuyButton product="my-plugin" plan="pro-annual" label="Buy Pro" />
Every component imports the stylesheet and the small client script, so a page with any of them is styled and enhanced. Everything works without JavaScript: buttons are links or forms, and the server prices every submit.
| Component | Props | What it renders |
|---|---|---|
ProductList | type, limit (1-100, default 12), columns (1-4; default the Appearance setting), showPrice (default true), orderby (name, created, updated, version), order (ASC, DESC), page, headingLevel (2 or 3), emptyMessage, summaries | A card grid of active products: the first gallery image as a cover, a type badge, the lowest price ("From $19.00", "Free to start"), each plan as a row with its terms and a buy button, and "View details". An empty state when there is nothing to show. |
Product | product (slug), value, headingLevel (1-3), showDescription, showVersion, showPrice, showGallery, link, layout (card or page) | One product as a card with its plans. layout="page" renders the full product page layout (below). |
Price | product, plan, value, showBilling | A plan’s price ("$49.00", "From $5.00", "Pay what you want", "Free") and billing term. |
BuyButton | product, plan, label, variant (primary, secondary), cart, value | A link to the checkout for that plan. cart makes it "Add to cart". An out-of-stock physical plan renders a disabled "Out of stock" button. |
CartButton | label (default "Cart"), drawerId, showLabel (default true) | A cart icon, label and count badge. A link to the checkout without JS; it opens the drawer with JS. |
CartDrawer | id, title, side (left or right; default the Appearance setting) | The cart as a modal side sheet. Put it once per layout. |
Cart | label | A cart button and its drawer together (what the vendwick-cart block renders). |
ProductGallery | product, images, columns (1-6), label | A product’s gallery images, with their dimensions (no layout shift). |
Checkout | product, plan, coupon, view, headingLevel (1 or 2) | The checkout form. Standalone it loads its own state and posts to the store’s checkout page. |
Portal | view, heading, headingLevel (1 or 2) | The customer sign-in form; the account pages pass a view with the signed-in screens. |
VendwickAssets | none | The stylesheet, the Appearance tokens and the browser script, for a layout that renders Vendwick markup some other way. Put it in <head>. |
AccessGate | collection + id or slug, or rule, or path; headingLevel (2-4) | Renders its slot only for visitors with access (see below). |
MyPages | data, headingLevel (2-4), showLabel | The gated pages the signed-in visitor can open. |
One example each:
<ProductList type="theme" limit={6} columns={2} />
<Product product="my-plugin" layout="page" />
<Price product="my-plugin" plan="pro-annual" showBilling={false} />
<BuyButton product="my-plugin" plan="pro-annual" cart variant="secondary" />
<CartButton label="Basket" /> <!-- in your header -->
<CartDrawer side="left" /> <!-- once, before </body> -->
<ProductGallery product="my-plugin" columns={4} />
<Checkout product="my-plugin" plan="pro-annual" coupon="LAUNCH" />
<Portal heading="My account" />
<head><VendwickAssets /></head>
<AccessGate path="/members/"><slot /></AccessGate>
<MyPages data={pages} headingLevel={2} />
If a theme renders Vendwick markup without any component and forgets VendwickAssets, the markup still works but is unstyled; the client script then logs one hint in the browser console ("the storefront stylesheet is not on this page").
The package also exports the view loaders the injected pages use (loadCheckoutView, loadReturnView, loadScreen, parsePortalPath) and link helpers (checkoutHref, portalHref, productHref), so you can build your own checkout or account pages around the components.
The product page layout
<Product layout="page"> and the injected product page show: a breadcrumb, the gallery (or the icon) on the left, a buy box on the right (type badge, version, name, lead, price, the plan picker as selectable cards with billing, sites, what a bundle includes and stock, quantity for shipped goods, "Buy now" and "Add to cart", what the buyer gets, a secure checkout and terms row), the description below, and "Compare plans" for software with several plans. The picker is a GET form to the checkout (cf_product, cf_plan, qty, and cart=1 for "Add to cart"), so it works without JavaScript.
It emits schema.org Product structured data (JSON-LD, one Offer per plan with the price, currency and availability) from the same data the page shows. It is a data block, not a script, so it needs no CSP exception.
The checkout
Two columns on wide screens: the steps in one card on the left (items in a cart, plan, your price, contact, shipping address, billing details, questions, add-ons, payment), and a sticky order summary on the right with the coupon, totals and renewal amount. On phones the summary folds into a bar that shows the total. The Appearance’s "Single column" layout puts the summary above the form.
- The shipping address asks for the country first; the region and postcode fields are named for it ("State" and "ZIP code", "County" and "Postcode") and required where the server requires them.
- With JavaScript, the totals, the shipping methods, add-ons and line totals update as the buyer types, a cart line can be removed with an undo, and the applied coupon shows as a chip with "Remove". Without it, the "Update" buttons price the page on the server.
- Errors show next to their fields and in a summary at the top that links to each one. A cart quantity above the stock left is a problem on that line.
- The submit button shows a busy label and cannot submit twice.
The cart drawer
A native <dialog>: the page behind is inert while it is open, Escape or a click outside closes it, focus goes back to the button that opened it, and the page does not scroll behind it. It slides in from the side set in the Appearance (no animation with reduced motion). Lines have quantity steppers capped by stock, a price field for pay-what-you-want lines, and remove with undo. The footer shows the subtotal and says what is calculated at checkout.
Injected pages
vendwickSite() injects the checkout page, the account pages, the product index and product pages, and the page access page. They render inside Vendwick’s own minimal HTML document (Shell), not your theme’s layout. The product pages add a cart button to the header and the cart drawer.
There are no slots for your own header and footer in the injected pages. To put your site’s header and footer around them, build your own pages from the components and the view loaders, for example:
---
import Layout from "../layouts/Site.astro";
import { Checkout, loadCheckoutView } from "@gatilab/emdash-vendwick/astro";
---
<Layout><Checkout product="my-plugin" plan="pro-annual" headingLevel={1} /></Layout>
Portable Text blocks
Editors can drop these blocks into any Portable Text field. EmDash renders them with blockComponents, which the descriptor’s componentsEntry points at.
| Block type | Label | Fields |
|---|---|---|
vendwick-product-list | Product list | productType (select), limit (1-100), columns (1-4), showPrice |
vendwick-product | Product | product, showDescription, showVersion |
vendwick-price | Price | product, plan |
vendwick-buy-button | Buy button | product, plan, label, cart |
vendwick-product-gallery | Product gallery | product, columns (1-6) |
vendwick-checkout | Checkout | product, plan |
vendwick-portal | Customer account | heading |
vendwick-cart | Cart | label (a cart button and its drawer) |
product and plan are slugs. There’s no block for AccessGate or MyPages; use them in templates.
Styling
Every class starts with vw-. The stylesheet (src/styles/commerce.css, also exported as @gatilab/emdash-vendwick/styles/commerce.css) has no framework, no web fonts and no resets outside Vendwick markup, and its layouts hold down to 320px wide.
Restyle it with these custom properties. The defaults sit on :where(:root) at zero specificity, so any rule you write on :root, body or a container wins. The colors switch with prefers-color-scheme. The admin Appearance page writes the same properties (see src/core/appearance/css.ts).
| Property | Light default | Used for |
|---|---|---|
--vw-font, --vw-font-heading, --vw-font-mono | system stacks | Text, headings, keys and coupon codes |
--vw-text, --vw-muted | #14161a, #5b616e | Text and secondary text |
--vw-bg, --vw-surface | #ffffff, #f4f5f7 | Cards and the page canvas |
--vw-border, --vw-border-strong | #dcdfe5, #8b919b | Hairlines and field outlines |
--vw-accent, --vw-accent-hover, --vw-accent-text, --vw-accent-soft, --vw-accent-strong | indigo | Actions, links and selection |
--vw-success, --vw-warning, --vw-error and their -bg | Status pills, notices, errors | |
--vw-info, --vw-info-bg | accent on its tint | Type badges, "Shipped" and mail states (0.2.0) |
--vw-badge-bg, --vw-badge-text | accent | The cart count and quantity badges (0.2.0) |
--vw-addon-bg, --vw-addon-border | a faint accent tint | Order bump cards (0.2.0) |
--vw-selected-bg | --vw-accent-soft | A chosen plan, shipping method or payment method (0.2.0) |
--vw-stepper-bg | --vw-surface | Quantity stepper buttons (0.2.0) |
--vw-overlay | text at 45% | The backdrop behind the cart drawer (0.2.0) |
--vw-drawer-width | 28rem | The cart drawer’s width (0.2.0) |
--vw-sticky-top | 1.5rem | How far below the top the order summary and buy box stick (0.2.0) |
--vw-focus | accent | Focus rings |
--vw-radius-small, --vw-radius, --vw-radius-large, --vw-button-radius | 8px, 12px, 16px | Corners |
--vw-gap, --vw-pad, --vw-section-gap, --vw-field-gap, --vw-control-height | Spacing and the 44px controls | |
--vw-max-width, --vw-columns | 72rem, 3 | Page width and the product grid |
--vw-shadow, --vw-shadow-lg | Card and summary shadows |
:root {
--vw-accent: #0f766e;
--vw-accent-hover: #115e59;
--vw-radius: 4px;
--vw-drawer-width: 24rem;
}
Appearance component styles
Added in 0.2.0. The Appearance record has a components group: badge (accent or neutral), addOn (tinted or outlined) and drawer (right or left). Each preset sets them (Studio and Bold: accent, tinted; Editorial, Terminal and Quiet: neutral, outlined; all open on the right). A record saved by 0.1.0 has no components and takes its preset’s, so it renders as before plus the new parts. They reach the page as data-vw-badge, data-vw-addon and data-vw-drawer on the page root.
The Appearance admin page has no controls for these three choices yet: choose them through the preset, override the --vw-badge-*, --vw-addon-* and --vw-drawer-width properties in your CSS, or pass side to <CartDrawer>.
The contrast check on the Appearance page also covers the status pills on their own tint (success, warning, error) and text on a selected option, and auto-fix repairs them.
Gating content: AccessGate and hasAccess
---
import { getEmDashEntry } from "emdash";
import { AccessGate, hasAccess } from "@gatilab/emdash-vendwick/astro";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
const open = await hasAccess(Astro, { collection: "posts", id: post.data.id });
---
<AccessGate collection="posts" id={post.data.id}>
<Content />
<p slot="teaser">{post.data.excerpt}</p>
</AccessGate>
- A target is
{ collection, id },{ collection, slug },{ rule }(an access rule id) or{ path }. - Without access,
AccessGateshows theteaserslot (or the rule’s teaser text), what’s locked, buy links for the products that unlock it, an "Already bought?" access-link form and a sign-in link. A signed-in customer without the product sees the gate without the form. - Call
hasAccess()(oraccessFor()) in the page frontmatter too. Astro only applies response headers set before the page starts streaming, and the helper setsCache-Control: private, no-store,Vary: Cookieand, when the rule asks for it,X-Robots-Tag: noindex. - A component can’t redirect. If the store or rule says to redirect instead of showing the gate, call
accessRedirect(decision, Astro.url)in the frontmatter andreturn Astro.redirect(url)when it returns a URL. - Signed-in EmDash editors and admins always see gated content.
- When the plugin can’t be reached, both the helper and the component treat the content as locked.
- A prerendered page, or one cached at the edge for everyone, can’t be gated.
MyPages lists the gated pages the visitor has unlocked. It reads the access cookie, or the portal cookie on account pages. See Page Access for Buyers for rules and settings.
What isn’t there
- No helper renders Vendwick blocks into raw HTML strings outside Astro. Use the components or
blockComponents. - The injected pages have no slots for a site’s own header and footer; build pages from the components for that.