feat(docs): add Cart recipe - #2722
Conversation
- 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
There was a problem hiding this comment.
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
CodeExamplewrapper for copy/expand. There is no production storefront or API change and no dependency update.
CodeExample.vueinterpolates title and status as text, sanitizesbodyIdto[a-z0-9-], and copiestextContentonly. 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.
Sent by Cursor Automation: Review pull requests for exploitable security issues and flag only validated findings before merge
There was a problem hiding this comment.
🟡 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
createSharedComposablefalls 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
useCartimplementation, but the related guide linked below still saysrefreshCart()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.
Maciek Kucmus (mkucmus)
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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:
| 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. |
| // 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. |
There was a problem hiding this comment.
Same point as below in the State And Session section. Could the comment name the caching reason instead?
| // 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> |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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:
| 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. |
There was a problem hiding this comment.
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?
| - `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. |
There was a problem hiding this comment.
One detail worth adding: getErrorsCodes() drops the promotion-discount-added entry, while codeErrorsNotification() shows it as a success.
| - `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. |
There was a problem hiding this comment.
The code uses parseInt, which gives NaN for "". Maybe align the comment:
| // a cleared field yields "" and Number("") is 0. | |
| // a cleared field yields "", and parseInt("") is NaN. |
| @@ -0,0 +1,211 @@ | |||
| <script setup> | |||
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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()?
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
There was a problem hiding this comment.
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 aCodeExampleexpand/collapse wrapper. No production storefront or API change and no dependency update.
CodeExample.vueinterpolatestitleas text, uses VueuseId()foraria-controls, and bindspeekas a style-objectmaxHeightonly. 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.
Sent by Cursor Automation: Review pull requests for exploitable security issues and flag only validated findings before merge
There was a problem hiding this comment.
🟡 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
CodeExamplefeature, 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: hiddento the body. The full slotted code remains in the accessibility tree, soaria-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
401for unauthorized responses, but this branch only recognizes403. 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()normalizesCart.errorsto the keyed map, butuseCart().cartstores the raw response; only the separateswCartErrorsaccumulator is merged. Code readingcart.errorscan 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.messagedirectly, bypassinguseCartErrorParamsResolverand the application'serrors.*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
/checkoutand/checkout/**out of ISR, but its route rules also setssr: falsefor/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/cartis also listed as a cart write in this recipe, but it returns a 204SuccessResponseand 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
There was a problem hiding this comment.
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.
Sent by Cursor Automation: Review pull requests for exploitable security issues and flag only validated findings before merge


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:
apps/docs/.vitepress/sidebar.ts).checkoutindex page introducing checkout-related recipes and referencing the "Cart" documentation (apps/docs/src/frontends-recipes/checkout/index.md).apps/docs/src/frontends-recipes/index.md).Component Addition:
CodeExample.vuecomponent for displaying code examples with features like copy-to-clipboard, expandable/collapsible code blocks, and accessibility improvements (apps/docs/src/components/CodeExample.vue).