Skip to content

Commit dcd4030

Browse files
authored
feat(plugin-ecommerce): new hooks, cart logic moved to the server and fixed several bugs (#15142)
This PR fixes several bugs reported here or on our Discord server as well as adds some new features to further improve some of the workflows and DX. ### New React Hooks & Config Added `useEcommerceConfig` hook and config property on the ecommerce context for accessing collection slugs and API settings. This will be used in the future in the template to ensure that collection slugs remain consistent across our components. - `onLogin` hook to handle cart state after user authentication (merges guest cart with user's cart) - `onLogout` hook to clear ecommerce session data during logout - `clearSession` utility to reset all ecommerce state (cart, addresses, user) - `mergeCart` utility for manually merging carts (useful for custom authentication flows) - `refreshCart` hook to sync cart state with the server ### Cart logic is now server side The cart logic has been moved to the server side and carts as a collection now have new REST API endpoints, this allows to provide better support for customising the flows and support additional logic, it also simplifies the React Context provider. - Exported cart operations for server-side use: addItem, clearCart, removeItem, updateItem, defaultCartItemMatcher - `update-item` endpoint supports MongoDB-style `{ $inc: number }` operator for quantity changes - `update-item` endpoint has `removeOnZero` option (defaults to `true`) - `merge` can merge a new guest cart with a user's saved cart when logging in - Added a custom cartItemMatcher utility Example ```ts /** * Custom cart item matcher that includes fulfillment option. * This ensures the same product with different fulfillment options * are listed as separate items in the cart. */ const fulfillmentCartItemMatcher: CartItemMatcher = ({ existingItem, newItem }) => { const existingProductID = typeof existingItem.product === 'object' ? existingItem.product.id : existingItem.product const existingVariantID = existingItem.variant && typeof existingItem.variant === 'object' ? existingItem.variant.id : existingItem.variant const productMatches = existingProductID === newItem.product // Variant matching: both must have same variant or both must have no variant const variantMatches = newItem.variant ? existingVariantID === newItem.variant : !existingVariantID // Fulfillment matching: items with different fulfillment options are separate const existingFulfillment = existingItem.fulfillment as string | undefined const newFulfillment = newItem.fulfillment as string | undefined const fulfillmentMatches = existingFulfillment === newFulfillment return productMatches && variantMatches && fulfillmentMatches } ``` ### Bug fixes - Fixes #14125 - Fixed an issue with hardcoded collection slugs in some areas of the code (including VariantOptionsSelector) - Fixed a problem with guest checkouts no longer working, closes #14645 - Fixed addresses beforeChange hook not returning data - Fixed an issue with .toArray implementation, closes #14608 - Fixed an issue with the type for cart array items being wrong, closes #14646 - Added all missing translations and fixed structure so it's consistent with other plugins Documentation - Added new "Hooks" section documenting onLogin, onLogout, clearSession, mergeCart, and refreshCart - Added "Session Management" section explaining authentication flows ### Chores - Deprecated `customerFieldAccess` function in favour of `isCustomer` to improve clarity for how it's used - will be removed in V4 - Guest cart secret is now passed via request context instead of query parameters for improved compatibility with other hooks or plugins modifications (backwards compatible with query params) ### Future enhancements Once this PR is merged we can then allow automatic merging of carts if a user adds an item and then logs in, currently it will lose the previous guest cart.
1 parent ec6bba5 commit dcd4030

82 files changed

Lines changed: 8262 additions & 352 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/ecommerce/advanced.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ Use this to create the `addresses` collection. This collection is used to store
3636

3737
The access object can contain the following properties:
3838

39-
| Property | Type | Description |
40-
| ------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41-
| `isAdmin` | `Access` | Access control to check if the user has `admin` permissions. |
42-
| `isAuthenticated` | `Access` | Access control to check if the user is authenticated. Use on the `create` access to allow any customer to create a new address. |
43-
| `isDocumentOwner` | `Access` | Access control to check if the user owns the document via the `customer` field. Used to limit read, update or delete to only the customers that own this address. |
44-
| `customerOnlyFieldAccess` | `FieldAccess` | Field level access control to check if the user has `customer` permissions. |
39+
| Property | Type | Description |
40+
| ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41+
| `isAdmin` | `Access` | Access control to check if the user has `admin` permissions. |
42+
| `isAuthenticated` | `Access` | Access control to check if the user is authenticated. Use on the `create` access to allow any customer to create a new address. |
43+
| `isCustomer` | `FieldAccess` | Checks if the user is a customer (authenticated but not admin). Used to auto-assign customer ID when creating addresses. |
44+
| `isDocumentOwner` | `Access` | Access control to check if the user owns the document via the `customer` field. Used to limit read, update or delete to only the customers that own this address. |
4545

4646
See the [access control section](./plugin#access) for more details on each of these functions.
4747

@@ -54,8 +54,8 @@ const Addresses = createAddressesCollection({
5454
access: {
5555
isAdmin,
5656
isAuthenticated,
57+
isCustomer,
5758
isDocumentOwner,
58-
customerOnlyFieldAccess,
5959
},
6060
addressFields: [
6161
{

docs/ecommerce/frontend.mdx

Lines changed: 224 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ The package provides a set of React utilities to help you manage your ecommerce
1010

1111
The following hooks and components are available:
1212

13-
| Hook / Component | Description |
14-
| ------------------- | ------------------------------------------------------------------------------ |
15-
| `EcommerceProvider` | A context provider to wrap your application and provide the ecommerce context. |
16-
| `useCart` | A hook to manage the cart state and actions. |
17-
| `useAddresses` | A hook to fetch and manage addresses. |
18-
| `usePayments` | A hook to manage the checkout process. |
19-
| `useCurrency` | A hook to format prices based on the selected currency. |
20-
| `useEcommerce` | A hook that encompasses all of the above in one. |
13+
| Hook / Component | Description |
14+
| -------------------- | ------------------------------------------------------------------------------ |
15+
| `EcommerceProvider` | A context provider to wrap your application and provide the ecommerce context. |
16+
| `useCart` | A hook to manage the cart state and actions. |
17+
| `useAddresses` | A hook to fetch and manage addresses. |
18+
| `usePayments` | A hook to manage the checkout process. |
19+
| `useCurrency` | A hook to format prices based on the selected currency. |
20+
| `useEcommerceConfig` | A hook to access the ecommerce configuration (collection slugs, API settings). |
21+
| `useEcommerce` | A hook that encompasses all of the above in one. |
2122

2223
### EcommerceProvider
2324

@@ -267,19 +268,232 @@ const PriceComponent = ({ amount }) => {
267268
}
268269
```
269270

271+
### useEcommerceConfig
272+
273+
The `useEcommerceConfig` hook provides access to the ecommerce configuration. This is useful when you need to build custom API calls or queries using the correct collection slugs and API settings.
274+
275+
| Property | Type | Description |
276+
| --------------- | -------- | -------------------------------------- |
277+
| `addressesSlug` | `string` | The slug for the addresses collection. |
278+
| `cartsSlug` | `string` | The slug for the carts collection. |
279+
| `customersSlug` | `string` | The slug for the customers collection. |
280+
| `api.apiRoute` | `string` | The base API route (e.g., `/api`). |
281+
282+
Example usage:
283+
284+
```tsx
285+
import { useEcommerceConfig } from '@payloadcms/plugin-ecommerce/client/react'
286+
287+
const CustomComponent = () => {
288+
const { cartsSlug, customersSlug, api } = useEcommerceConfig()
289+
290+
// Build custom API URLs
291+
const cartsEndpoint = `${api.apiRoute}/${cartsSlug}`
292+
293+
// Your component logic here
294+
}
295+
```
296+
270297
### useEcommerce
271298

272-
The `useEcommerce` hook encompasses all of the above hooks in one. It provides access to the cart, addresses, and payments hooks, along with a unified `isLoading` state that tracks any async operations across all these features.
299+
The `useEcommerce` hook encompasses all of the above hooks in one. It provides access to the cart, addresses, and payments hooks, along with a unified `isLoading` state that tracks any async operations across all these features. It also includes the `config` property for accessing collection slugs and API settings.
273300

274301
Example usage:
275302

276303
```tsx
277304
import { useEcommerce } from '@payloadcms/plugin-ecommerce/client/react'
278305

279306
const EcommerceComponent = () => {
280-
const { cart, addresses, isLoading, selectedPaymentMethod } = useEcommerce()
307+
const {
308+
cart,
309+
addresses,
310+
clearSession,
311+
config,
312+
isLoading,
313+
selectedPaymentMethod,
314+
} = useEcommerce()
281315

282316
// Your component logic here
283317
// isLoading tracks loading states for cart, addresses, and payment operations
318+
// config provides access to collection slugs and API settings
319+
}
320+
```
321+
322+
## Hooks
323+
324+
The ecommerce provider exposes several hooks for handling authentication events, session management, and cart operations. These hooks are accessible via `useEcommerce()`.
325+
326+
### onLogin
327+
328+
Called after a successful login to properly set up cart state. This hook handles:
329+
330+
- Fetching the authenticated user's data
331+
- Merging any guest cart items into the user's existing cart
332+
- Transferring a guest cart to the user if they don't have one
333+
- Clearing guest cart secrets (authenticated users don't need them)
334+
335+
```tsx
336+
import { useEcommerce } from '@payloadcms/plugin-ecommerce/client/react'
337+
338+
const LoginForm = () => {
339+
const { onLogin } = useEcommerce()
340+
341+
const handleLogin = async (credentials) => {
342+
// Perform your login logic
343+
const response = await fetch('/api/users/login', {
344+
method: 'POST',
345+
body: JSON.stringify(credentials),
346+
})
347+
348+
if (response.ok) {
349+
// Set up ecommerce state after successful login
350+
await onLogin()
351+
}
352+
}
353+
354+
return <form onSubmit={handleLogin}>{/* form fields */}</form>
284355
}
285356
```
357+
358+
### onLogout
359+
360+
Called during logout to clear all ecommerce session data. Currently this is just an alias for `clearSession()` but named for semantic clarity when used in logout flows and it could change in the future.
361+
362+
```tsx
363+
import { useEcommerce } from '@payloadcms/plugin-ecommerce/client/react'
364+
365+
const LogoutButton = () => {
366+
const { onLogout } = useEcommerce()
367+
368+
const handleLogout = async () => {
369+
// Perform your logout logic
370+
await fetch('/api/users/logout', { method: 'POST' })
371+
372+
// Clear ecommerce session data
373+
onLogout()
374+
}
375+
376+
return <button onClick={handleLogout}>Logout</button>
377+
}
378+
```
379+
380+
### clearSession
381+
382+
Clears all ecommerce session data from state and localStorage. This includes:
383+
384+
- Cart data and cart ID
385+
- Cart secret (for guest carts)
386+
- User addresses
387+
- User state
388+
389+
Use this when you need to reset the ecommerce state, such as during logout or when switching users.
390+
391+
```tsx
392+
import { useEcommerce } from '@payloadcms/plugin-ecommerce/client/react'
393+
394+
const ResetButton = () => {
395+
const { clearSession } = useEcommerce()
396+
397+
return <button onClick={clearSession}>Reset Session</button>
398+
}
399+
```
400+
401+
### mergeCart
402+
403+
Merges items from a source cart into a target cart. This is useful when you need to manually merge a guest cart into an authenticated user's cart, or when implementing custom cart merge logic.
404+
405+
| Parameter | Type | Description |
406+
| -------------- | -------- | --------------------------------------------------------- |
407+
| `targetCartID` | `string` | The ID of the cart to merge items into |
408+
| `sourceCartID` | `string` | The ID of the cart to merge items from |
409+
| `sourceSecret` | `string` | The secret for the source cart (required for guest carts) |
410+
411+
When items are merged:
412+
413+
- Matching items (same product and variant) have their quantities combined
414+
- Non-matching items are added to the target cart
415+
- The source cart is deleted after successful merge
416+
417+
```tsx
418+
import { useEcommerce } from '@payloadcms/plugin-ecommerce/client/react'
419+
420+
const MergeCartsButton = () => {
421+
const { mergeCart, isLoading } = useEcommerce()
422+
423+
const handleMerge = async () => {
424+
try {
425+
const mergedCart = await mergeCart(
426+
'user-cart-id',
427+
'guest-cart-id',
428+
'guest-cart-secret',
429+
)
430+
console.log('Merged cart:', mergedCart)
431+
} catch (error) {
432+
console.error('Failed to merge carts:', error)
433+
}
434+
}
435+
436+
return (
437+
<button onClick={handleMerge} disabled={isLoading}>
438+
Merge Carts
439+
</button>
440+
)
441+
}
442+
```
443+
444+
### refreshCart
445+
446+
Fetches the latest cart data from the server and updates the local state. Use this when you need to ensure the cart is in sync with the server, such as after external cart modifications.
447+
448+
```tsx
449+
import { useEcommerce } from '@payloadcms/plugin-ecommerce/client/react'
450+
451+
const RefreshCartButton = () => {
452+
const { refreshCart, isLoading } = useEcommerce()
453+
454+
return (
455+
<button onClick={refreshCart} disabled={isLoading}>
456+
Refresh Cart
457+
</button>
458+
)
459+
}
460+
```
461+
462+
## Session Management
463+
464+
The provider includes built-in session management for handling user authentication flows. This ensures cart data, addresses, and user state are properly managed when users log in or out.
465+
466+
### Handling Login
467+
468+
When a user logs in, call `onLogin()` to set up the ecommerce state. This automatically:
469+
470+
1. Fetches the authenticated user's data
471+
2. Merges any guest cart items into the user's existing cart (if both exist)
472+
3. Transfers the guest cart to the user (if they don't have an existing cart)
473+
4. Clears guest cart secrets (authenticated users don't need them)
474+
475+
```tsx
476+
const handleLogin = async (credentials) => {
477+
const response = await fetch('/api/users/login', {
478+
method: 'POST',
479+
body: JSON.stringify(credentials),
480+
})
481+
482+
if (response.ok) {
483+
await onLogin() // Set up ecommerce state
484+
}
485+
}
486+
```
487+
488+
### Handling Logout
489+
490+
When a user logs out, call `onLogout()` or `clearSession()` to clear all ecommerce data:
491+
492+
```tsx
493+
const handleLogout = async () => {
494+
await fetch('/api/users/logout', { method: 'POST' })
495+
onLogout() // or clearSession()
496+
}
497+
```
498+
499+
Both methods clear cart data, cart ID, cart secret, addresses, and user state from memory and localStorage.

docs/ecommerce/overview.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ const config = buildConfig({
6868
access: {
6969
adminOnlyFieldAccess,
7070
adminOrPublishedStatus,
71-
customerOnlyFieldAccess,
7271
isAdmin,
7372
isAuthenticated,
73+
isCustomer,
7474
isDocumentOwner,
7575
},
7676
customers: { slug: 'users' },

0 commit comments

Comments
 (0)