Skip to content

feat(docs): add Cart recipe - #2722

Merged
Maciej D (mdanilowicz) merged 5 commits into
mainfrom
docs/recipe-cart
Sep 16, 2026
Merged

Maciej D (mdanilowicz) merged 5 commits into
mainfrom
docs/recipe-cart

Conversation

@mdanilowicz

Copy link
Copy Markdown
Contributor

closes #2721

This pull request adds initial documentation and navigation for the Checkout section in the frontend recipes of the docs app. It introduces a new sidebar entry, a landing page for Checkout, and updates the main recipes index to reference this new section.

Documentation and Navigation Additions:

  • Added a new "Checkout" section to the sidebar navigation, including a nested "Cart" page link (apps/docs/.vitepress/sidebar.ts).
  • Created a new checkout index page introducing checkout-related recipes and referencing the "Cart" documentation (apps/docs/src/frontends-recipes/checkout/index.md).
  • Updated the main frontend recipes index to include a summary and link to the new "Checkout" section (apps/docs/src/frontends-recipes/index.md).

Component Addition:

  • Added a new CodeExample.vue component for displaying code examples with features like copy-to-clipboard, expandable/collapsible code blocks, and accessibility improvements (apps/docs/src/components/CodeExample.vue).

- add apps/docs/src/frontends-recipes/checkout/cart.md
- add apps/docs/src/frontends-recipes/checkout/index.md
- register the Checkout area in frontends-recipes/index.md
- add the Checkout area and Cart entry to .vitepress/sidebar.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Security review

No medium, high, or critical vulnerabilities in this PR.

Scope is documentation only: Checkout recipes navigation, the Cart recipe, and a CodeExample wrapper for copy/expand. There is no production storefront or API change and no dependency update.

CodeExample.vue interpolates title and status as text, sanitizes bodyId to [a-z0-9-], and copies textContent only. The cart Vue snippet is a fenced code block (not executed on the docs site). Recipe diagram/tooltip inputs are author-static and Vue-escaped.

No prior automation security threads to re-validate.

Open in Web View Automation 

Sent by Cursor Automation: Review pull requests for exploitable security issues and flag only validated findings before merge

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One moderate accessibility issue and several documentation inconsistencies remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds Checkout and Cart frontend recipe documentation, navigation, and a reusable interactive code-example component.

Changes:

  • Adds Checkout/Cart navigation and landing pages.
  • Documents cart flows, APIs, state, errors, and examples.
  • Adds copyable, collapsible code examples with accessibility support.
File summaries
File Summary and review notes
apps/docs/src/frontends-recipes/index.md Links to Checkout recipes.
apps/docs/src/frontends-recipes/checkout/index.md Adds the Checkout landing page.
apps/docs/src/frontends-recipes/checkout/cart.md Adds comprehensive Cart documentation. Four nit findings remain regarding SSR behavior, error normalization, deleteCart responses, and contradictory refreshCart() guidance.
apps/docs/src/components/CodeExample.vue Adds expandable, copyable code examples. Moderate (3 votes): generate per-instance IDs instead of deriving IDs from display titles.
apps/docs/.vitepress/sidebar.ts Adds Checkout and Cart navigation.
Review details

Suppressed comments (2)

apps/docs/src/frontends-recipes/checkout/cart.md:233

  • This comment gives the wrong reason for client-only loading. The repository's caching guidance states that createSharedComposable falls back to per-request state during SSR (apps/docs/src/best-practices/caching.md:261), so the risk here is personalized cart data being embedded in SSR/ISR HTML, not a module-global cart leaking between requests. Please keep the client-only recommendation if desired, but describe the SSR behavior accurately.
// Client-side only. useCart is a shared composable whose state is module-scoped
// on the server, so fetching the cart during SSR would leak it between requests.
onMounted(async () => {

apps/docs/src/frontends-recipes/checkout/cart.md:399

  • This is correct for the current useCart implementation, but the related guide linked below still says refreshCart() is called automatically after every cart action (apps/docs/src/guides/e-commerce/cart.md:39). Because this recipe links to that page, the docs now give contradictory instructions about whether writes require a second GET; update the guide in the same change or explicitly reconcile the difference.
- Do not call `refreshCart()` after every write. The write response already is the new cart.
  • Files reviewed: 5/5 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/docs/src/components/CodeExample.vue Outdated
Comment thread apps/docs/src/frontends-recipes/checkout/cart.md
Comment thread apps/docs/src/frontends-recipes/checkout/cart.md Outdated

@mkucmus Maciek Kucmus (mkucmus) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice recipe. The operation table, the type notes and the useUser await details all match the source.

A few suggestions inline. The main ones are the SSR explanation and the empty-cart state in the example.

One more small thing: apps/docs is outside the lint CI paths, so pnpm format was not enforced here. It would change trailing commas in CodeExample.vue and cart.md. Could you run it once before merge?


`useCart` is wrapped in `createSharedComposable`, so every call in the application returns the same instance. The cart itself lives in the `swCart` context value and the collected errors in `swCartErrors`, which is what makes a mini cart in the header and a cart page stay in sync without any prop passing or store of your own.

That shared instance is module-scoped, and on the server nothing tears it down between requests — component scopes are never stopped after a render. **Never fetch the cart during SSR.** Load it from `onMounted` (or behind `import.meta.client`), as the example does; a `useAsyncData` wrapper around `refreshCart()` would write one customer's cart into process-global state, and under a template's `isr` route rules that HTML is then cached and served to everyone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this part is not accurate. createSharedComposable in the pinned @vueuse/shared 14.4.0 returns the plain composable on the server (if (!isClient) return composable), and useContext provides state per app instance. So each request gets its own cart. The advice to load on the client is still right, but for the caching reason. Maybe something like:

Suggested change
That shared instance is module-scoped, and on the server nothing tears it down between requests — component scopes are never stopped after a render. **Never fetch the cart during SSR.** Load it from `onMounted` (or behind `import.meta.client`), as the example does; a `useAsyncData` wrapper around `refreshCart()` would write one customer's cart into process-global state, and under a template's `isr` route rules that HTML is then cached and served to everyone.
`useCart` is not shared on the server. `createSharedComposable` returns the plain composable there, and `useContext` provides state per app instance, so each request gets its own cart. **Still, load the cart on the client.** Templates cache pages with `isr` route rules, so a cart rendered on the server would land in cached HTML and be served to everyone. Loading it from `onMounted` (or behind `import.meta.client`), as the example does, keeps personalized data out of the cache.

Comment on lines +231 to +232
// Client-side only. useCart is a shared composable whose state is module-scoped
// on the server, so fetching the cart during SSR would leak it between requests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same point as below in the State And Session section. Could the comment name the caching reason instead?

Suggested change
// Client-side only. useCart is a shared composable whose state is module-scoped
// on the server, so fetching the cart during SSR would leak it between requests.
// Client-side only. Templates cache pages with isr route rules, so a cart
// rendered on the server would end up in HTML served to everyone.


<p v-if="isLoading">Loading your cart…</p>

<p v-else-if="isEmpty">Your cart is empty.</p>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the first refreshCart() rejects, isLoading turns false and isEmpty is still true. So the customer sees the error and "Your cart is empty." together. The testing checklist below asks for the opposite. Maybe a separate loadError ref, rendered as its own branch before isEmpty?


The errors are rendered inline rather than pushed through `codeErrorsNotification()`, so the example stands on its own. `codeErrorsNotification()` only writes into `useNotifications()` state — it renders nothing by itself, so it needs a notification outlet mounted somewhere above it, as `vue-starter-template` does with `<LayoutNotifications />` in its layouts.

The example owns its initial load. `vue-starter-template` already calls `refreshCart()` once in `app.vue`, so inside that template you should drop the `onMounted` block here rather than fetching the cart twice on hydration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small trap here: isLoading starts as true and only the onMounted block sets it to false. If a reader drops the whole block, the page shows "Loading your cart…" forever. Maybe:

Suggested change
The example owns its initial load. `vue-starter-template` already calls `refreshCart()` once in `app.vue`, so inside that template you should drop the `onMounted` block here rather than fetching the cart twice on hydration.
The example owns its initial load. `vue-starter-template` already calls `refreshCart()` once in `app.vue`. Inside that template, drop only the `refreshCart()` call and keep the rest of the `onMounted` block, or `isLoading` never turns false.

- A line item with `stackable: false` must not render a quantity input, and one with `removable: false` must not render a remove button. Both flags come from the cart response.
- Adding a product that is already in the cart increases the existing line item instead of creating a second one, so `addProduct` can change `count` by more than the quantity you sent.
- `consumeCartErrors()` clears `swCartErrors`. If two components call it for the same response, only the first one sees the errors — and `codeErrorsNotification()` and `getErrorsCodes()` both call it, so calling one after the other for the same response leaves the second empty.
- `Cart["errors"]` is a union of `CartError[]` and a keyed map. `codeErrorsNotification()` returns early on the array branch and pushes nothing, so do not rely on it as your only path — `getErrorsCodes()` normalises both.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both consumers return early on the array branch, getErrorsCodes() gives [] too. In practice it does not matter, because setCartErrors runs Object.assign and turns an array into an index-keyed object. Maybe describe that instead?

Suggested change
- `Cart["errors"]` is a union of `CartError[]` and a keyed map. `codeErrorsNotification()` returns early on the array branch and pushes nothing, so do not rely on it as your only path — `getErrorsCodes()` normalises both.
- `Cart["errors"]` is a union of `CartError[]` and a keyed map, but `setCartErrors` merges either shape into one keyed object with `Object.assign`. Both `codeErrorsNotification()` and `getErrorsCodes()` read that object, so you never handle the array shape yourself.


- `useCartItem` takes a `Ref<LineItem>` and derives the whole row from it — `itemTotalPrice`, `itemStock`, `itemImageThumbnailUrl`, `isStackable`, `isRemovable` and the rest — so a row component needs no props beyond that one ref.
- `useAddToCart` takes a `Ref<Product | undefined>`. The `undefined` is deliberate: it lets you call the composable at the top level of setup while the product is still loading.
- `useCartNotification` gives you two ways to handle the same errors — `codeErrorsNotification()` pushes them as notifications, `getErrorsCodes()` returns them as `CartError[]`. Both call `consumeCartErrors()`, so the first one you call clears them for the other. Pick one per response.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One detail worth adding: getErrorsCodes() drops the promotion-discount-added entry, while codeErrorsNotification() shows it as a success.

Suggested change
- `useCartNotification` gives you two ways to handle the same errors`codeErrorsNotification()` pushes them as notifications, `getErrorsCodes()` returns them as `CartError[]`. Both call `consumeCartErrors()`, so the first one you call clears them for the other. Pick one per response.
- `useCartNotification` gives you two ways to handle the same errors. `codeErrorsNotification()` pushes them as notifications, `getErrorsCodes()` returns them as `CartError[]`. Both call `consumeCartErrors()`, so the first one you call clears them for the other. Pick one per response. Note that `getErrorsCodes()` drops the `promotion-discount-added` entry, while `codeErrorsNotification()` shows it as a success.


const changeLineItemQuantity = (item: Schemas["LineItem"], value: string) => {
// min="1" constrains the stepper and validation, not the value you read here:
// a cleared field yields "" and Number("") is 0.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code uses parseInt, which gives NaN for "". Maybe align the comment:

Suggested change
// a cleared field yields "" and Number("") is 0.
// a cleared field yields "", and parseInt("") is NaN.

@@ -0,0 +1,211 @@
<script setup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sibling components (RecipeFlowDiagram.vue, SchemaTypeTooltip.vue) use lang="ts". Would you like to match them here?

border-radius: 0;
}

.code-example__body :deep(button.copy) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VitePress already ships a copy button on every code block. Could we keep only the collapse part and drop the custom copy code? That would remove about half of the script.

expanded: { type: Boolean, default: false },
});

const bodyId = computed(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two examples with the same title on one page would get the same id, and aria-controls would point to the first one. Maybe add an instance counter or use useId()?

@vercel

vercel Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated
frontends-starter-template-extended Skipped Skipped Sep 15, 2026 5:12pm UTC
frontends-vue-starter-template Skipped Skipped Sep 15, 2026 5:12pm UTC

Request Review

@vercel
vercel Bot temporarily deployed to Preview – old-frontends-demo September 15, 2026 16:18 Inactive
@vercel
vercel Bot temporarily deployed to Preview – frontends-starter-template-extended September 15, 2026 16:18 Inactive
@vercel
vercel Bot temporarily deployed to Preview – frontends-vue-starter-template September 15, 2026 16:18 Inactive

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Security review

No medium, high, or critical vulnerabilities in this PR.

Re-reviewed after feat: changes after CR (d99f595). Still documentation only: Checkout recipes navigation, the Cart recipe, and a CodeExample expand/collapse wrapper. No production storefront or API change and no dependency update.

CodeExample.vue interpolates title as text, uses Vue useId() for aria-controls, and binds peek as a style-object maxHeight only. The custom clipboard path is gone. The cart Vue snippet remains a fenced code block (not executed on the docs site). Recipe diagram/tooltip inputs are author-static and Vue-escaped.

Prior automation run also reported no findings; there were no security finding threads to re-open.

Open in Web View Automation 

Sent by Cursor Automation: Review pull requests for exploitable security issues and flag only validated findings before merge

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate correctness, accessibility, and documentation issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (7)

apps/docs/src/components/CodeExample.vue:58

  • The PR description advertises copy-to-clipboard as a CodeExample feature, but this component exposes no copy control or clipboard handler; its only interaction is Show more/Show less. As written, readers cannot copy code from this wrapper, so either implement that promised action or remove it from the stated scope.
    <button
      v-if="needsToggle"
      type="button"
      class="code-example__more"
      :aria-expanded="isExpanded"

apps/docs/src/components/CodeExample.vue:51

  • When collapsed, this only applies max-height/overflow: hidden to the body. The full slotted code remains in the accessibility tree, so aria-expanded="false" does not actually hide the collapsed content from assistive technology. The disclosure needs to make hidden lines inaccessible, or avoid exposing a collapsed state.
    <div
      :id="bodyId"
      ref="body"
      class="code-example__body"
      :class="{ 'is-clipped': isClipped }"
      :style="isClipped ? { maxHeight: peek + 'px' } : null"
    >
      <slot />
      <span v-if="isClipped" class="code-example__fade" aria-hidden="true" />

apps/docs/src/frontends-recipes/checkout/cart.md:270

  • The generated cart operations expose 401 for unauthorized responses, but this branch only recognizes 403. A rejected cart write with status 401 therefore shows the generic update/remove message instead of the session-expired guidance; handle the cart endpoint's unauthorized status (or both statuses) here.
      error instanceof ApiClientError && error.status === 403

apps/docs/src/frontends-recipes/checkout/cart.md:194

  • These lines imply that useCart() normalizes Cart.errors to the keyed map, but useCart().cart stores the raw response; only the separate swCartErrors accumulator is merged. Code reading cart.errors can still see the array form, so the example's “always the map” guidance is incorrect.
// a raw response; through useCart it is always the map, and getErrorsCodes()
// hands you a CartError[].

apps/docs/src/frontends-recipes/checkout/cart.md:313

  • The example renders error.message directly, bypassing useCartErrorParamsResolver and the application's errors.* translations. Stock and shipping errors can therefore show backend text instead of the localized message, which also contradicts the later instruction not to render raw messages; resolve and translate each error before rendering.
      <li v-for="error in cartErrors" :key="error.key">{{ error.message }}</li>

apps/docs/src/frontends-recipes/checkout/cart.md:397

  • This paragraph says the starter opts only /checkout and /checkout/** out of ISR, but its route rules also set ssr: false for /account, /account/**, and /wishlist. The “only” wording makes the caching guidance inaccurate; limit it to cart-related routes or remove that qualifier.
**Fetch the cart on the client anyway.** Load it from `onMounted` (or behind `import.meta.client`), as the example and the starter's own `app.vue` do. The reason is caching, not leakage. `vue-starter-template` applies `isr` to `/**` and opts only `/checkout` and `/checkout/**` out of it with `ssr: false`, so the cart page itself is safe — but a mini cart in the header renders on every catalog and CMS route, and those responses are cached and served to every other visitor. Personalized data does not belong in an ISR-cached response.

apps/docs/src/frontends-recipes/checkout/cart.md:59

  • DELETE /checkout/cart is also listed as a cart write in this recipe, but it returns a 204 SuccessResponse and does not return the recalculated cart. This statement makes the flow diagram inaccurate; scope it to line-item writes.
      "Every write operation on the cart responds with the complete recalculated cart, not with the line item that changed. Prices, deliveries and errors come back together.",
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread apps/docs/src/frontends-recipes/checkout/cart.md Outdated
@vercel
vercel Bot temporarily deployed to Preview – old-frontends-demo September 15, 2026 17:11 Inactive
@vercel
vercel Bot temporarily deployed to Preview – frontends-starter-template-extended September 15, 2026 17:12 Inactive
@vercel
vercel Bot temporarily deployed to Preview – frontends-vue-starter-template September 15, 2026 17:12 Inactive

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review

No medium, high, or critical vulnerabilities in this PR.

Re-reviewed after fix(cart): remove radix parameter from Number.parseInt (75c4eaa). Still documentation only: Checkout recipes navigation, the Cart recipe, and a CodeExample expand/collapse wrapper. No production storefront or API change and no dependency update.

CodeExample.vue interpolates title as text, uses Vue useId() for aria-controls, and binds peek as a style-object maxHeight only. The cart Vue snippet remains a fenced code block (not executed on the docs site); the latest parseInt radix change is inside that example only. Recipe diagram/tooltip inputs are author-static and Vue-escaped.

Prior automation runs also reported no findings, and there were no unresolved security-review threads to re-open.

Open in Web View Automation 

Sent by Cursor Automation: Review pull requests for exploitable security issues and flag only validated findings before merge

@mkucmus Maciek Kucmus (mkucmus) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📓 🔍

@mdanilowicz
Maciej D (mdanilowicz) merged commit 01d676a into main Sep 16, 2026
10 checks passed
@mdanilowicz
Maciej D (mdanilowicz) deleted the docs/recipe-cart branch September 16, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cart recipes

3 participants