From 723aaa39db776222cb5fc17ff4e9474091a52bd6 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 8 Oct 2026 20:05:32 -0700 Subject: [PATCH 1/4] feat(commerce): Next.js Commerce drawn by a PopCraft design Next.js Commerce with its pages rendering components exported from PopCraft's Fieldwork storefront: Shopify's products, cart and menu in, its cart actions behind the design's forms, and npm run design:sync to export the design again. --- .github/workflows/examples.yml | 2 +- README.md | 2 + commerce/.env.example | 5 + commerce/.gitignore | 39 + commerce/README.md | 80 ++ commerce/app/actions.ts | 40 + commerce/app/api/revalidate/route.ts | 6 + commerce/app/cart/page.tsx | 17 + commerce/app/error.tsx | 13 + commerce/app/favicon.ico | Bin 0 -> 15086 bytes commerce/app/globals.css | 13 + commerce/app/layout.tsx | 26 + commerce/app/page.tsx | 18 + commerce/app/product/[handle]/page.tsx | 92 +++ commerce/app/robots.ts | 13 + commerce/app/search/[collection]/page.tsx | 38 + commerce/app/search/page.tsx | 24 + commerce/app/sitemap.ts | 52 ++ commerce/components/cart/actions.ts | 106 +++ commerce/components/popcraft/CartPage.tsx | 365 ++++++++++ commerce/components/popcraft/HomePage.tsx | 730 +++++++++++++++++++ commerce/components/popcraft/ProductPage.tsx | 526 +++++++++++++ commerce/components/popcraft/SearchPage.tsx | 555 ++++++++++++++ commerce/components/popcraft/index.ts | 7 + commerce/components/popcraft/popcraft.css | 26 + commerce/lib/constants.ts | 51 ++ commerce/lib/popcraft.ts | 24 + commerce/lib/shopify/fragments/cart.ts | 53 ++ commerce/lib/shopify/fragments/image.ts | 10 + commerce/lib/shopify/fragments/product.ts | 64 ++ commerce/lib/shopify/fragments/seo.ts | 8 + commerce/lib/shopify/index.ts | 543 ++++++++++++++ commerce/lib/shopify/mutations/cart.ts | 45 ++ commerce/lib/shopify/queries/cart.ts | 10 + commerce/lib/shopify/queries/collection.ts | 56 ++ commerce/lib/shopify/queries/menu.ts | 10 + commerce/lib/shopify/queries/page.ts | 41 ++ commerce/lib/shopify/queries/product.ts | 36 + commerce/lib/shopify/types.ts | 272 +++++++ commerce/lib/type-guards.ts | 31 + commerce/lib/utils.ts | 51 ++ commerce/license.md | 21 + commerce/next.config.ts | 17 + commerce/package.json | 25 + commerce/tsconfig.json | 34 + commerce/types/css.d.ts | 3 + 46 files changed, 4199 insertions(+), 1 deletion(-) create mode 100644 commerce/.env.example create mode 100644 commerce/.gitignore create mode 100644 commerce/README.md create mode 100644 commerce/app/actions.ts create mode 100644 commerce/app/api/revalidate/route.ts create mode 100644 commerce/app/cart/page.tsx create mode 100644 commerce/app/error.tsx create mode 100644 commerce/app/favicon.ico create mode 100644 commerce/app/globals.css create mode 100644 commerce/app/layout.tsx create mode 100644 commerce/app/page.tsx create mode 100644 commerce/app/product/[handle]/page.tsx create mode 100644 commerce/app/robots.ts create mode 100644 commerce/app/search/[collection]/page.tsx create mode 100644 commerce/app/search/page.tsx create mode 100644 commerce/app/sitemap.ts create mode 100644 commerce/components/cart/actions.ts create mode 100644 commerce/components/popcraft/CartPage.tsx create mode 100644 commerce/components/popcraft/HomePage.tsx create mode 100644 commerce/components/popcraft/ProductPage.tsx create mode 100644 commerce/components/popcraft/SearchPage.tsx create mode 100644 commerce/components/popcraft/index.ts create mode 100644 commerce/components/popcraft/popcraft.css create mode 100644 commerce/lib/constants.ts create mode 100644 commerce/lib/popcraft.ts create mode 100644 commerce/lib/shopify/fragments/cart.ts create mode 100644 commerce/lib/shopify/fragments/image.ts create mode 100644 commerce/lib/shopify/fragments/product.ts create mode 100644 commerce/lib/shopify/fragments/seo.ts create mode 100644 commerce/lib/shopify/index.ts create mode 100644 commerce/lib/shopify/mutations/cart.ts create mode 100644 commerce/lib/shopify/queries/cart.ts create mode 100644 commerce/lib/shopify/queries/collection.ts create mode 100644 commerce/lib/shopify/queries/menu.ts create mode 100644 commerce/lib/shopify/queries/page.ts create mode 100644 commerce/lib/shopify/queries/product.ts create mode 100644 commerce/lib/shopify/types.ts create mode 100644 commerce/lib/type-guards.ts create mode 100644 commerce/lib/utils.ts create mode 100644 commerce/license.md create mode 100644 commerce/next.config.ts create mode 100644 commerce/package.json create mode 100644 commerce/tsconfig.json create mode 100644 commerce/types/css.d.ts diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index e40739f..e1e8b76 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - example: [templates, kit/social-cards, kit/design-check, kit/typed-script, cli/build-a-design, cli/translate, runtime/expressions, runtime/site-backend] + example: [templates, kit/social-cards, kit/design-check, kit/typed-script, cli/build-a-design, cli/translate, runtime/expressions, runtime/site-backend, commerce] defaults: run: working-directory: ${{ matrix.example }} diff --git a/README.md b/README.md index 704a42d..4e19a9b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ here works with what you can install today. | | [cli/translate](cli/translate) | A translated copy of a design for every language, layouts reflowing round the new words | | [`@popcraft/runtime`](https://www.npmjs.com/package/@popcraft/runtime) | [runtime/expressions](runtime/expressions) | A weekly digest worked out with an exported app's own expression functions, so the numbers match | | | [runtime/site-backend](runtime/site-backend) | An exported site's forms answered from a plain Node server, kept in a JSON file | +| | [commerce](commerce) | **Next.js Commerce drawn by a PopCraft design**: Shopify's products, cart and checkout rendered by components exported from a storefront template | | [`@popcraft/plugins`](https://www.npmjs.com/package/@popcraft/plugins) | [getpopcraft/plugins](https://github.com/getpopcraft/plugins) | Plugins, widgets, themes, brushes, shaders and templates | Node 25 or later for the CLI examples, 24 or later for the rest. @@ -25,5 +26,6 @@ Node 25 or later for the CLI examples, 24 or later for the rest. - [Making templates](https://popcraft.app/docs/api/templates): templates as PopCraft's own are made - [Command line](https://popcraft.app/docs/api/cli) · [Designing with an AI agent](https://popcraft.app/docs/api/agents) - [Exporting to Next.js](https://popcraft.app/docs/exporting/nextjs): what `@popcraft/runtime` is part of +- [Storefront components](https://popcraft.app/docs/exporting/commerce): a design as a Next.js Commerce store's pages MIT licensed. Copy anything here into your own projects. diff --git a/commerce/.env.example b/commerce/.env.example new file mode 100644 index 0000000..f7fa990 --- /dev/null +++ b/commerce/.env.example @@ -0,0 +1,5 @@ +COMPANY_NAME="Vercel Inc." +SITE_NAME="Next.js Commerce" +SHOPIFY_REVALIDATION_SECRET="" +SHOPIFY_STOREFRONT_ACCESS_TOKEN="" +SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" diff --git a/commerce/.gitignore b/commerce/.gitignore new file mode 100644 index 0000000..e82a0f9 --- /dev/null +++ b/commerce/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage +.playwright + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local diff --git a/commerce/README.md b/commerce/README.md new file mode 100644 index 0000000..c9c7403 --- /dev/null +++ b/commerce/README.md @@ -0,0 +1,80 @@ +# Next.js Commerce, drawn by a PopCraft design + +[Next.js Commerce](https://github.com/vercel/commerce) with its pages rendered by components exported from +[PopCraft](https://popcraft.app). The store does what Next.js Commerce does — Shopify's Storefront API, the cart +cookie, server actions, checkout, search, revalidation — and the design is PopCraft's: the **Fieldwork** storefront +template, its layout at every width, its type, colours and motion. + +A designer changes the shop in PopCraft; `npm run design:sync` exports it again, and the store renders the new design +with its own products. No component is rewritten by hand. + +## How it fits together + +| Path | What it is | +| --- | --- | +| `design/fieldwork.popcraft` | The design: open it in PopCraft (**Import as new file**), change it, save it back here | +| `design/media/` | Its pictures (the hero, the story, the sample products), by content hash | +| `components/popcraft/` | The design exported as components: `HomePage`, `SearchPage`, `ProductPage`, `CartPage` | +| `public/popcraft/` | The pictures they draw | +| `app/` | Next.js Commerce's routes, each fetching from Shopify and rendering a PopCraft component | +| `app/actions.ts` | The design's forms (add to cart, remove, checkout, search) as server actions over Next.js Commerce's cart | +| `lib/shopify/`, `components/cart/actions.ts` | Next.js Commerce's Shopify client and cart actions, unchanged | +| `lib/popcraft.ts` | What every page passes: the visitor's cart (an empty one when there is none) and the menu | + +A page is Next.js Commerce's data handed to a component: + +```tsx +export default async function Product(props: { params: Promise<{ handle: string }> }) { + const product = await getProduct((await props.params).handle); + if (!product) return notFound(); + const [related, frame] = await Promise.all([getProductRecommendations(product.id), storeFrame()]); + return ; +} +``` + +The components take Next.js Commerce's own types (`Product`, `Cart`, `CartItem`, `Menu`) from +`@popcraft/runtime/commerce`, so nothing is mapped by hand. See +[Storefront components](https://popcraft.app/docs/exporting/commerce) for how a design is bound. + +## Run it + +You need a Shopify store with the Headless channel (or the Storefront API) and Node 24 or later. + +```bash +cp .env.example .env.local # your store's domain and Storefront access token +npm install +npm run dev +``` + +| Variable | What it is | +| --- | --- | +| `SHOPIFY_STORE_DOMAIN` | `your-store.myshopify.com` | +| `SHOPIFY_STOREFRONT_ACCESS_TOKEN` | From the Headless channel or a custom app with Storefront API access | +| `SHOPIFY_REVALIDATION_SECRET` | Any secret: Shopify's webhooks call `/api/revalidate?secret=…` when products change | +| `SITE_NAME`, `COMPANY_NAME` | The store's name in titles and the footer | + +The footer draws Shopify's menu with the handle `next-js-frontend-footer-menu`, as Next.js Commerce does. Deploy it like +any Next.js app; on Vercel, add the variables to the project. + +## Change the design + +1. Open `design/fieldwork.popcraft` in PopCraft, or start from another storefront template (**New file › Web › + Kiln storefront**) and save it here. +2. Change anything: the layout, type, colours, motion, copy. Keep the lists and forms bound (Collections, Repeat and + the forms' **Sending it**): they are how the store's data gets in. +3. Export again: + +```bash +npm run design:sync # popcraft components design/fieldwork.popcraft --out . +``` + +Product pictures, titles and prices come from Shopify; what the design draws itself (the hero, the story) comes from +`design/media`. + +## What stays Next.js Commerce's + +Variant selection, the cart's optimistic updates, sorting and filtering, and checkout are the store's. The design's add +to cart sends the first available variant; a store that sells options renders Next.js Commerce's variant selector +beside the component. + +Next.js Commerce is MIT licensed (`license.md`). diff --git a/commerce/app/actions.ts b/commerce/app/actions.ts new file mode 100644 index 0000000..3c21b86 --- /dev/null +++ b/commerce/app/actions.ts @@ -0,0 +1,40 @@ +"use server"; + +// The design's storefront forms (add to cart, remove, checkout, search) as server actions. Each takes the form's own +// fields — the names the design gave them — and calls Next.js Commerce's cart actions. +import { + addItem, + createCartAndSetCookie, + redirectToCheckout, + removeItem, + updateItemQuantity, +} from "components/cart/actions"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +const field = (form: FormData, name: string) => String(form.get(name) ?? ""); + +export async function addToCart(form: FormData) { + if (!(await cookies()).get("cartId")) await createCartAndSetCookie(); + await addItem(null, field(form, "merchandiseId")); +} + +export async function removeCartLine(form: FormData) { + await removeItem(null, field(form, "merchandiseId")); +} + +export async function updateCartLine(form: FormData) { + await updateItemQuantity(null, { + merchandiseId: field(form, "merchandiseId"), + quantity: Number(field(form, "quantity")), + }); +} + +export async function checkout() { + await redirectToCheckout(); +} + +export async function search(form: FormData) { + const q = field(form, "q").trim(); + redirect(q ? `/search?q=${encodeURIComponent(q)}` : "/search"); +} diff --git a/commerce/app/api/revalidate/route.ts b/commerce/app/api/revalidate/route.ts new file mode 100644 index 0000000..e146fa0 --- /dev/null +++ b/commerce/app/api/revalidate/route.ts @@ -0,0 +1,6 @@ +import { revalidate } from "lib/shopify"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest): Promise { + return revalidate(req); +} diff --git a/commerce/app/cart/page.tsx b/commerce/app/cart/page.tsx new file mode 100644 index 0000000..9dc1ca2 --- /dev/null +++ b/commerce/app/cart/page.tsx @@ -0,0 +1,17 @@ +import { checkout, removeCartLine } from "app/actions"; +import { CartPage } from "components/popcraft"; +import { storeFrame } from "lib/popcraft"; + +export const metadata = { title: "Your cart" }; + +export default async function Cart() { + const frame = await storeFrame(); + return ( + + ); +} diff --git a/commerce/app/error.tsx b/commerce/app/error.tsx new file mode 100644 index 0000000..c7575d0 --- /dev/null +++ b/commerce/app/error.tsx @@ -0,0 +1,13 @@ +"use client"; + +export default function Error({ reset }: { reset: () => void }) { + return ( +
+

Something went wrong

+

There was an issue with the store. Please try again.

+ +
+ ); +} diff --git a/commerce/app/favicon.ico b/commerce/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dc7d8431e47ee24c71eebaffe30786769b348353 GIT binary patch literal 15086 zcmd^GTWAzX6m4-g@sXH`@A%578(qZ@6^)AEx+seJA)+9DC<(qIf}bu*kok~BOho(? zMKOx(j~|Mt_~Qfw1wjdF^h0Abh?s;#^B@nC+`Xsl>g}%X>Y3@8NoLrAo9cSpd(W+R z)vXxDC}Xs-bg98%*!ZW!FiH)>2#3vh$S@i>EyN-6D}FJIzq!EgT!vYUGA5*p-)}#I zSbt~DniZNndGh+g!ot4_3JPi_Oqfv1RG*)p-^A3y^o^;Dshg=MNrDW>f=tM6VqLGH zlXYJQ24DfEIB@4poH((Jd82uGdF|uJj~^U2ZXAsy0RylA6Jsl9JMz8km_B`a5$c-D z_l+c8Ki$9vjKFHQVa}X6c}Vx;&7?eF1!idj+9i|gU9CG{1!mx%J$rVD`_?11Os2$z>2Y9|^LwUa)#1Z9=6+PG%g^+5U}t)r={R-x zfF0|#EWG+X@0z8$BxzV>Er=BQk>gsAbd-kl0s;a7J+qP{2r=1trX3UsDCr_Rvlu>yhZ_l1R#B*XoHUK+Q zw_TUtH|npVqJsMR`bhVC{`@)3pFiJa18l!^>5?Z~M@I+k+__VgtT#Sa_{7cQiiFJF3MM`C+>J8j*%)zt>5=d!Xg z>hJHT+S*#0Idi6~uc%4VKI}V|@87>qQ>RXK;KR6>GGz+gym^y$?b_wC zPlwejJsJEG$KAVkwQYcT>cD{mE{x#iy*q_MAxHo6!swTl41UN(8*JRTky=_>v^HQq zoi}futBwSQh%~xs_+8`aBbSO ziCSA*J#A=gY!quU`dhNNC4VaMLng-Zg$oy4_y-3E>BNZ>t~p5J2pXmuKk9bz;>A>7 zU+=Kt@#DwD_e_p;EGT9>U#js#Hu}-gqesR507i6nb|$^f+A-U|(}5pjdD^sT^x(k* z(FQd&HN^KEDX)9jVd9Vb{9?D=?;CBfWy=#$y%dyaI!pQ)E?Q>^zh1o-@?7h$ns zG$PD3j3~m<|Ai66M;V41BKiOPi@15(!Z+@7x&I0b&;j2u{Os`IqYT2md-qaNQIRhO)-UEYsZR|d8-7{bv)X?j zK763%%a>dCPq@>$diAQk2;I7MOW^XW1J*C*R8_BxXhS+0KmZK&g+Qf13kPAl8D|Q`oV*Cz&P*j?sj0n+m9tnmRNNl<;MyWu^+NvBl;qI?Dk(>T^*H_ zl%!q1tb;Xc)>va5eQ^K&{Z=20WwSj$QTrt?>H%+Y@VkBcwlxN2e!QWnKjC-j=!@{Z zWBkKUkA4xaPJ21lFZQYSv_!t=&Yhc-@0Rt8J#r#C?PY_EIXge=7yEO2+HiieesMM! zPMvmLtY4f<>}kXKZJvonbpJil=Z}#-f8h)`lIKt8$8VU<-&N}Q`eXWHL&)=p@a3;v zyH=zvSg^pcA7g$|SCXIO;=OWDPY-?m{8{j@jdbP875e)1t7Bge9xgBHS@HuP_FHe? zzP0uM*cai + {children} + + ); +} diff --git a/commerce/app/page.tsx b/commerce/app/page.tsx new file mode 100644 index 0000000..10d5663 --- /dev/null +++ b/commerce/app/page.tsx @@ -0,0 +1,18 @@ +import { HomePage } from "components/popcraft"; +import { getProducts } from "lib/shopify"; +import { storeFrame } from "lib/popcraft"; + +export const metadata = { + description: "Workwear cut for work and worn for years.", + openGraph: { + type: "website", + }, +}; + +export default async function Home() { + const [products, frame] = await Promise.all([ + getProducts({ sortKey: "CREATED_AT", reverse: true }), + storeFrame(), + ]); + return ; +} diff --git a/commerce/app/product/[handle]/page.tsx b/commerce/app/product/[handle]/page.tsx new file mode 100644 index 0000000..75bdaf7 --- /dev/null +++ b/commerce/app/product/[handle]/page.tsx @@ -0,0 +1,92 @@ +import { addToCart } from "app/actions"; +import { ProductPage } from "components/popcraft"; +import { storeFrame } from "lib/popcraft"; +import { HIDDEN_PRODUCT_TAG } from "lib/constants"; +import { getProduct, getProductRecommendations } from "lib/shopify"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +export async function generateMetadata(props: { + params: Promise<{ handle: string }>; +}): Promise { + const params = await props.params; + const product = await getProduct(params.handle); + + if (!product) return notFound(); + + const { url, width, height, altText: alt } = product.featuredImage || {}; + const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG); + + return { + title: product.seo.title || product.title, + description: product.seo.description || product.description, + robots: { + index: indexable, + follow: indexable, + googleBot: { + index: indexable, + follow: indexable, + }, + }, + openGraph: url + ? { + images: [ + { + url, + width, + height, + alt, + }, + ], + } + : null, + }; +} + +export default async function Product(props: { + params: Promise<{ handle: string }>; +}) { + const params = await props.params; + const product = await getProduct(params.handle); + + if (!product) return notFound(); + + const productJsonLd = { + "@context": "https://schema.org", + "@type": "Product", + name: product.title, + description: product.description, + image: product.featuredImage.url, + offers: { + "@type": "AggregateOffer", + availability: product.availableForSale + ? "https://schema.org/InStock" + : "https://schema.org/OutOfStock", + priceCurrency: product.priceRange.minVariantPrice.currencyCode, + highPrice: product.priceRange.maxVariantPrice.amount, + lowPrice: product.priceRange.minVariantPrice.amount, + }, + }; + + const [related, frame] = await Promise.all([ + getProductRecommendations(product.id), + storeFrame(), + ]); + + return ( + <> +