Freeframe

Documentation

Market

v1.0.4Updated Oct 16, 2026A complete online store for a handmade homeware brand, with a working cart and checkout flow.

Overview#

Market is a complete storefront for Kiln & Co., a fictional handmade-ceramics studio: home, a filterable shop, product pages with glaze and size variants, a cart saved in the browser, a slide-out mini cart, a validated demo checkout, an about page and a searchable FAQ with a contact form. Product images are drawn in SVG from the product data, so there are no photos to replace before launch. This is the **Astro 7

  • TypeScript + Tailwind CSS v4** version, version 1.0.2. It builds to static HTML files with no UI framework, and can be hosted anywhere. See it live at freeframe.dev/templates/market.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:4321

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/market into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page. Each page passes a page prop to <Layout>, which becomes <body data-page="…">; src/scripts/main.ts reads it to decide which page features to start, so keep it when you copy a page.

URL File page What's on it
/ src/pages/index.astro home Hero with product art, shop by shape, bestsellers (rendered from featured products), story, reasons to buy, reviews, newsletter band
/shop src/pages/shop.astro shop Product grid with category, price and sort filters and a live result count
/product src/pages/product.astro product One product, chosen by ?id=: gallery with four views, glaze and size options, quantity, details, related products
/cart src/pages/cart.astro cart Cart lines with quantity steppers, free-shipping progress, promo code, order summary
/checkout src/pages/checkout.astro checkout Contact, shipping address, delivery method and card fields, order summary, confirmation
/about src/pages/about.astro about Studio story, timeline, team and visit info
/faq src/pages/faq.astro faq Searchable FAQ grouped by topic, and a contact form (#contact)
404 src/pages/404.astro 404 Not-found page (most hosts show 404.html automatically)

Project structure#

market/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), <body data-page>, header, footer, cart drawer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    announcement bar, header, cart button, mobile menu
│  │  ├─ SiteFooter.astro    footer with newsletter form and links
│  │  └─ CartDrawer.astro    slide-out cart, its overlay and the toast
│  ├─ data/
│  │  ├─ products.ts         product catalogue, categories, shipping/tax/promo settings, link helpers
│  │  ├─ faq.ts              FAQ groups and questions
│  │  └─ nav.ts              header, mobile menu and footer links
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             SVG product art, cart, page scripts, forms and FAQ search
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (brand tokens, fonts), components, animations
├─ public/img/               favicon.svg, og-image.png (1200×630), og-image.svg (its source)
├─ astro.config.mjs          site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

src/scripts/main.ts is split into numbered sections: 1 Product catalogue, 2 SVG product art, 3 Helpers, 4 Cart, 5 Cart drawer and toast, 6 Header and mobile menu, 7 Home, 8 Shop, 9 Product page, 10 Cart page, 11 Checkout, 12 Forms and FAQ. It imports the catalogue from src/data/products.ts, so the shop, product, cart and checkout pages all read the same data. The pages themselves are rendered at build time; the product grids, product page details, art and cart are filled in the browser.

Customising#

Brand and colours#

All colours are tokens in the @theme block of src/styles/global.css:

Token Default Used for
--color-paper #f6f1ea Page background
--color-sand / --color-oat #ebe3d6 / #e2d7c6 Panels and image tiles
--color-line #d9cebd Borders
--color-ink #1f1c19 Text and primary buttons
--color-stone #645d54 Muted text
--color-clay / --color-clay-dark #a1441d / #7f3414 Accent, links, focus rings, badges
--color-moss #56653f Secondary accent (shipping progress bar)
--color-cream #fbf8f3 Light surfaces and form fields

Tailwind rebuilds automatically in pnpm dev and pnpm build. Also update site.themeColor in src/lib/site.ts (the browser theme colour) and the colours inside public/img/favicon.svg. Button styles are .btn-primary, .btn-accent, .btn-outline and .btn-light.

Typography#

Fraunces (display, font-display) and Inter (body, font-sans) load from Google Fonts by a <link> in src/layouts/Layout.astro. To change them, edit that <link> and the --font-display / --font-sans tokens. To avoid Google Fonts, put the font files in public/fonts/, add @font-face rules to global.css and remove the <link> and the two preconnect tags.

Content#

Products are PRODUCTS in src/data/products.ts, typed by Product:

ts
{
  id: "ridge-vase", name: "Ridge Vase", category: "Vases", price: 68, shape: "vase",
  rating: 4.9, reviews: 214, badge: "Bestseller", featured: 1,
  blurb: "A tall, generous vase thrown in one pull…",
  glazes: [{ name: "Oat", color: "#e6d9c2" }, { name: "Moss", color: "#76855a" }],
  sizes: [{ name: "Medium · 24cm", delta: 0 }, { name: "Large · 32cm", delta: 26 }],
  details: ["Stoneware, fired to 1260°C", "Fully glazed inside — watertight"],
  dims: "Medium: 24cm tall, 14cm wide. Large: 32cm tall, 18cm wide.",
},
Field Notes
id Unique, used in /product?id=… and in saved carts. Changing an id drops it from visitors' carts
category Must be one of CATEGORIES (the type checker enforces it). The shop's category radios are built from CATEGORIES
price Base price in dollars; each size adds its delta (which can be negative)
shape vase, bud, mug, cup, tumbler, bowl, plate, pitcher or planter (drives the SVG art)
badge Optional label such as Bestseller, New or Limited
featured Optional; products with it appear in the home page bestsellers, sorted by the number
glazes Colour options; color is the glaze colour in the art
sizes Size options; the one with delta: 0 is preselected

Store settings are exported from the same file:

Constant Default Meaning
FREE_SHIPPING 75 Subtotal for free standard shipping
STANDARD_SHIPPING / EXPRESS_SHIPPING 8 / 18 Delivery prices
TAX_RATE 0.08 Tax applied after the discount
PROMO { code: "WELCOME10", rate: 0.1 } The demo promo code

MAX_QTY (99, the largest quantity per cart line) is in section 4 of src/scripts/main.ts. Prices are formatted with Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) (the money helper in section 3); change the locale and currency there. The "Free shipping on orders over $75" announcement bar (SiteHeader.astro) and the shipping answers in src/data/faq.ts are plain text, so update them when you change FREE_SHIPPING.

FAQ questions are FAQ in src/data/faq.ts: groups with an id (the section's anchor, e.g. /faq#care), a title and items of { q, a }. The topic list beside the FAQ is built from the same groups.

Images and artwork#

Every product image is generated by art(product, glazeIndex, view) in section 2 of src/scripts/main.ts, from the product's shape and glaze colour. Views are 0 front, 1 glaze close-up, 2 on a shelf and 3 a pair. Place art anywhere with:

html
<div class="art aspect-square overflow-hidden rounded-[1.75rem]" data-art="terrace-planter" data-glaze="1" data-view="2"></div>

To use photos instead, add an image field to Product and each product, put the files in public/img/, and return an <img src="${url(product.image)}"> from art() (or change productCard() and the product gallery). The social image is public/img/og-image.png (1200×630); its source is og-image.svg.

  • Links come from src/data/nav.ts: mainNav (header and mobile menu), footerShop and footerHelp (footer columns) and socials. Category links use shopHref("Mugs & Cups") from products.ts, which writes /shop?category=Mugs%20%26%20Cups.
  • A mainNav item with a key gets aria-current="page" when the page's current prop matches it (current="shop", "about" or "faq").
  • src/components/SiteHeader.astro is the announcement bar, header and mobile menu. src/components/SiteFooter.astro is the footer with its newsletter form. src/components/CartDrawer.astro is the cart drawer (#cart-drawer, #cart-overlay) and toast (#toast); the layout includes all three on every page.

To add a page, create src/pages/journal.astro:

html
---
import Layout from "../layouts/Layout.astro";
---

<Layout title="Journal — Kiln & Co." description="Notes from the studio." page="journal">
  <main id="main" tabindex="-1" class="flex-1 focus:outline-none">
    <section class="container-x py-16">
      <h1 class="font-display text-5xl">Journal</h1>
    </section>
  </main>
</Layout>

Then add a link to mainNav or a footer list. Always write internal links as href={url("/journal")} (from src/lib/site.ts) so they keep working under a base path.

Features#

Cart and mini cart#

The cart is saved in localStorage and synced across tabs. Any element with data-cart-open opens the slide-out cart; product cards add with data-add="<product id>". The drawer traps focus, closes with Escape, the close button or the backdrop, and returns focus to where it was. Adding an item bumps the header badge (data-cart-count) and shows a toast with a "View cart" button.

Each cart line (in the drawer and on the cart page) carries data-line="<id>:<glaze>:<size>". After −, + or Remove, keyboard focus stays on the same button of the same line, and the line is updated in place so the quantity's live region reads out the new number. When a line goes (Remove, or − at quantity 1), focus moves to the same button on the next line, or on the previous line if it was the last; when the cart is then empty, it moves to the empty cart's "Shop" link ("Shop the collection" in the drawer, "Shop all ceramics" on the cart page).

Shop filters#

/shop filters by category (radio buttons, counts from data-count-for), maximum price (#price-max) and sort (#sort: featured, price up/down, rating, name). The result count and heading update live, and the category is kept in the URL (?category=Vases) so filtered views can be linked. On small screens the filters fold behind #filters-toggle.

Product page#

/product?id=daily-mug shows that product (an unknown id shows the first product). The page title, breadcrumb and text fill from the product. Glaze swatches recolour the gallery; sizes change the price; the quantity stepper is limited to 1–99; four thumbnails switch views. Related products prefer the same category.

Promo code and shipping progress#

On the cart page, WELCOME10 takes 10% off. The free-shipping progress bar appears in the drawer and on the cart page.

Checkout#

The checkout validates every field, formats the card number and expiry as you type, checks the card number with the Luhn algorithm (use 4242 4242 4242 4242) and rejects expired dates. On success it shows a confirmation with a random KC- order number and clears the cart. No payment is processed. Replace the setupValidation(form, …) callback in initCheckout() with your payment provider; the comment there marks the spot. For real payments, use a hosted checkout such as Stripe Checkout or Shopify, and don't collect card numbers in your own form.

Typing in #faq-search filters the details[data-faq] items, opens matches, hides empty [data-faq-group] sections and announces the number of answers.

Back to top button#

src/scripts/back-to-top.ts adds a button that appears after scrolling about one screen, returns to the top (instantly with reduced motion), moves focus to #main, lifts above the toast and hides while an in-page "Back to top" link is visible. Add data-back-to-top-avoid (lift above) or data-back-to-top-hide (hide while covering) to your own fixed UI. To remove it, delete the file, its import in Layout.astro and the .back-to-top styles in global.css.

Forms#

All forms are validated by setupValidation() in src/scripts/main.ts: errors appear on blur and submit, the first invalid field is focused, and each error is linked with aria-describedby. Rules come from attributes:

Attribute Rule
required Must not be empty
type="email" Must look like an email address
minlength Minimum length
data-label Name used in the error message
data-validate card, expiry, cvc or postcode
Form Where Behaviour
Newsletter (data-form="newsletter") Every footer, home page band Replaced with a thank-you message
Contact (data-form="contact") /faq#contact Replaced with a thank-you message
Promo (#promo-form) /cart Applies WELCOME10
Checkout (#checkout-form) /checkout Shows the order confirmation

Nothing is sent. To collect submissions, post the form in its onValid callback in section 12, for example to Formspree:

ts
$$<HTMLFormElement>('[data-form="contact"]').forEach((form) => {
  setupValidation(form, () => {
    fetch("https://formspree.io/f/your-id", {
      method: "POST", body: new FormData(form), headers: { Accept: "application/json" },
    }).then((res) => {
      successSwap(form, res.ok ? "<p>Thank you for writing.</p>" : "<p>Something went wrong. Please email us.</p>");
    });
  });
});

FormData only includes fields with a name. Every field already has one (email, name, topic, order, message, and the checkout fields); keep a name on any field you add. To handle submissions with your own code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/contact.ts with export const prerender = false, then post to it.

Saved data (localStorage)#

Key Value Used for
kiln-cart JSON array of { id, glaze, size, qty } Cart contents
kiln-promo "WELCOME10" or null Applied promo code

The cart is validated every time it is read (normalizeCart()): unknown products and bad entries are dropped, glaze and size indexes are range-checked, quantities are clamped to 1–MAX_QTY, duplicate lines are merged, and tampered data is overwritten with the cleaned version. Prices are never stored; totals are always calculated from PRODUCTS. If storage is blocked (some private windows), the store still works, but the cart is empty after a reload. To reset, run localStorage.removeItem("kiln-cart") in the browser console.

SEO and sharing#

  • Each page passes title and description to <Layout>, which outputs <title>, the meta description, og:title, og:description and a summary_large_image Twitter card. The product page also sets document.title from the product.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://market.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build
  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • /product is one page filled in by JavaScript from ?id=, so search engines that don't run scripts see the default content. If product search traffic matters, generate a page per product with getStaticPaths() from PRODUCTS (for example src/pages/products/[id].astro) and write the product text into it.

  • For a sitemap, add @astrojs/sitemap (pnpm astro add sitemap); it uses the same site. Add a public/robots.txt that points to it.

Accessibility#

Built in: a skip link, landmarks, visible focus rings, a focus-trapped cart drawer that closes with Escape, a mobile menu with aria-expanded that closes with Escape, a live cart count in the cart button's label, polite toasts, aria-pressed gallery thumbnails, glaze names for screen readers, an ARIA progress bar for free shipping, inline form errors linked to fields, a live FAQ result count, reduced-motion support and WCAG AA contrast. Content still shows without JavaScript except the parts drawn from the catalogue (art, grids and the cart).

When editing, keep one <h1> per page, give each glaze a meaningful name (it's the image's text alternative) and keep the data-label attributes on form fields.

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as shop.html rather than shop/index.html (build.format: "file" in astro.config.mjs), and links point to /shop without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /shop from shop.html automatically; Vercel does with cleanUrls (below).

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /shop serves shop.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /shop to shop.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/market pnpm build. It becomes Astro's base, and every link written with url(), including the product and shop links the script builds, includes it.

Tailwind finds class names in src/ automatically, including the ones inside strings in src/scripts/main.ts, as long as they're written in full. demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.

Troubleshooting#

  • A new class has no effect. Write class names in full (not built from pieces) so Tailwind can find them.
  • A product shows the Ridge Vase instead. The ?id= doesn't match any product id.
  • A category filter shows nothing. The product's category must be one of CATEGORIES.
  • The cart forgets items. localStorage is blocked, or the product's id changed.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links with url() (in .astro files and in src/scripts/main.ts) rather than plain "/..." strings.
  • Words run together after editing. Astro removes line breaks between text and a following inline element. Keep the text and the element on one line, or add {" "}.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.
  • Totals look wrong after changing prices. Totals always use current PRODUCTS prices; carts don't store prices, so this is expected.

License#

MIT. The code and artwork are free for personal and commercial use; the download includes the license text in LICENSE.txt. Fraunces and Inter are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation