Right of withdrawal
Since version 3.22
Front-Commerce ships a customer area where shoppers exercise their right of withdrawal, list the orders still eligible, submit a request on selected order lines, and follow its status. This guide explains how to enable the feature, how to implement its GraphQL contract on your own backend, and how to customize the user interface.
The right of withdrawal is mandated by the
EU directive 2011/83:
consumers may withdraw from a distance sale within a legal period, without
justification. Because every European merchant is concerned, the GraphQL
contract lives in @front-commerce/core and its user interface in
@front-commerce/theme-chocolatine, while each backend extension provides its
own implementation.
Enable the feature
The whole area is gated by the withdrawal extension
feature flag. While it is inactive:
/user/withdrawals,/user/withdrawals/newand/user/withdrawals/:idanswer404;- no entry is rendered in the account navigation, on desktop and mobile alike;
- the order detail page renders neither the pending request recap nor the action opening a new request.
@front-commerce/gezy enables the flag automatically. To enable it from another
extension, register the feature in unstable_lifecycleHooks.onFeaturesInit:
return defineRemixExtension({
// ...
unstable_lifecycleHooks: {
onFeaturesInit: (hooks) => {
hooks.registerFeature("withdrawal", { flags: { enabled: true } });
},
},
});
Raising the flag tells the storefront that your backend implements the
Front-Commerce/Withdrawal contract. Enable it only once the resolvers
described below are in place: the default core resolvers answer an empty list on
the list fields, and throw on the single-item ones.
What the customer sees
| Route | Content |
|---|---|
/user/withdrawals | The orders still eligible, and the requests already submitted with their status |
/user/withdrawals/new | The withdrawal form for a given order: pick the lines and quantities, then state the reason |
/user/withdrawals/:id | A request with its items, its status history, and a cancellation action while it is still pending |
The order detail page gets two additional slots of the orderDetails
components map:
OrderWithdrawalrecaps a pending request, right under the order summary;OrderWithdrawalActionadds an entry in the order actions bar, opening a new request.
Both are filled by theme-chocolatine and hide themselves when the flag is
down, so you have nothing to register to get them.
Implement the contract on your backend
The Front-Commerce/Withdrawal GraphQL module defines the schema and leaves
every eligibility rule to the backend: the withdrawal period, the per-line
quantities and the statuses are exposed as already-computed values. The
storefront never recomputes them.
The schema
extend type Customer {
withdrawableOrders: [WithdrawableOrder!]!
withdrawableOrder(orderId: ID!): WithdrawableOrderDetail!
withdrawals: [Withdrawal!]!
orderWithdrawals(orderId: ID!): [Withdrawal!]!
withdrawal(id: ID!): WithdrawalDetail!
}
extend type Mutation {
createWithdrawal(
input: CreateWithdrawalInput!
): CreateWithdrawalMutationSuccess!
cancelWithdrawal(id: ID!): CancelWithdrawalMutationSuccess!
}
A request goes through four statuses, exposed as the WithdrawalStatus enum:
REQUESTED, PROCESSED, REFUSED and CANCELED. Only a REQUESTED one may
be cancelled by the customer, and only a REFUSED one carries a
refusalReason.
Two fields deserve attention when mapping your own backend:
WithdrawableOrderDetail.linesmust contain all the order lines, including the ones that cannot be withdrawn. The form displays them disabled, so the customer understands why an item is missing rather than wondering about it. Mark them withisEligible: false.WithdrawableOrderLine.maxWithdrawableQuantitybounds the quantity input of the form. It should already account for the quantities withdrawn by previous requests, reported separately aswithdrawnQuantity.
The module
Declare a GraphQL module depending on the core contract, and implement its resolvers against your own loaders:
import { createGraphQLModule } from "@front-commerce/core/graphql";
export default createGraphQLModule({
namespace: "Acme/Withdrawal",
// The schema lives in Front-Commerce/Withdrawal: this module only implements
// it against the Acme backend.
dependencies: ["Front-Commerce/Withdrawal", "Acme/Customer"],
loadRuntime: () => import("./runtime"),
});
import { createGraphQLRuntime } from "@front-commerce/core/graphql";
import WithdrawalLoader from "./loaders/WithdrawalLoader";
export default createGraphQLRuntime({
contextEnhancer: ({ config, services }) => ({
Withdrawal: new WithdrawalLoader(services, config.shop.currency),
}),
resolvers: {
Customer: {
withdrawableOrders: (_customer, _args, { loaders }) =>
loaders.Withdrawal.loadEligibleOrders(),
// ...
},
Mutation: {
createWithdrawal: async (_, { input }, { loaders }) => {
try {
const withdrawalId = await loaders.Withdrawal.createWithdrawal(input);
return { success: true, withdrawalId };
} catch (error) {
return {
success: false,
errorMessage:
error instanceof Error ? error.message : "Unknown error",
};
}
},
// ...
},
},
});
Both mutations implement MutationSuccessInterface: a rejected request is
reported through success: false and an errorMessage rendered as-is to the
customer, rather than through a GraphQL error. Surface the message of your
backend when it carries one — it usually explains the refusal better than a
generic sentence.
Do not revalidate the quantities in your loader if your backend already rejects an ineligible line or an excessive quantity. Duplicating the rule lets the two implementations drift apart, and the customer would end up with a form that accepts what the backend refuses.
Customize the user interface
Every component of the area lives under theme/modules/Withdrawal/ and
theme/pages/Account/Withdrawals/, and can be
overridden as usual:
| Component | Role |
|---|---|
theme/modules/Withdrawal/EligibleOrdersTable | The orders a request may still be opened on |
theme/modules/Withdrawal/WithdrawalsTable | The requests already submitted |
theme/modules/Withdrawal/WithdrawalForm | Line selection, quantities, reason and confirmation modal |
theme/modules/Withdrawal/WithdrawalStatus | The translated label of a status |
theme/pages/Account/Withdrawals/Withdrawal | A request with its items and history |
The form holds only the line selection in React state: the quantity of a
selected line lives in its NumberInput and travels with the form data. If you
override WithdrawalFormTable, keep naming the quantity inputs
items[<lineId>].quantity — the route action reads them back with that pattern,
so that a line and its quantity cannot be mismatched.