Skip to content

fix(place-order): keep the button disabled until privacy & terms are accepted - #825

Open
acasazza wants to merge 1 commit into
fix/adyen-shopper-locale-v5from
fix/privacy-terms-gate-v5
Open

fix(place-order): keep the button disabled until privacy & terms are accepted#825
acasazza wants to merge 1 commit into
fix/adyen-shopper-locale-v5from
fix/privacy-terms-gate-v5

Conversation

@acasazza

Copy link
Copy Markdown
Member

Stacked on #824.

The bug

Selecting a payment method could enable PlaceOrderButton while the privacy & terms checkbox was still unchecked.

Root cause

Acceptance travelled from <PrivacyAndTermsCheckbox> to PlaceOrderButton through localStorage["privacy-terms"], and the write that happens at mount notified nobody — the checkbox only signalled from inside handleChange.

  1. A "true" left behind by an earlier visit survives: the cleanup that does removeItem runs only on a React unmount, never on a tab close or a hard navigation.
  2. PlaceOrderContainer reads that stale "true" and computes isPermitted: true.
  3. The checkbox mounts and correctly writes "false" — but none of the container's effect dependencies ([order, include, includeLoaded, organizationConfig]) changed, so isPermitted stays stale.
  4. The button's own effect does re-run on paymentSource?.id and currentPaymentMethodType — i.e. exactly when a payment method is picked — reads the stale isPermitted: true, sees card.brand, and enables.

Because it depends on mount order, it does not reproduce every time.

The fix

Acceptance now lives in a module-level store keyed by order id, following the usePrices / useSkus idiom already in this repo. The store notifies its subscribers, which is the thing that was missing. It is deliberately not persisted: a reload starts from "not accepted", so what the shopper sees can no longer diverge from what gates the button. Keying by order id also stops consent given on one order from leaking into another.

  • add termsAcceptanceStore — in-memory, per-order, notifying
  • add the public useTermsAndConditions() hook: with localStorage gone, a custom consent control needs a supported channel rather than an unofficial one
  • placeOrderPermitted takes termsAccepted as a parameter instead of reading a global, and reports termsBlocking
  • warn in development when acceptance is required but nothing collects it — the one state where the gate leaves a dead button with no explanation. The check lives in an effect: child effects run before parent effects, so a checkbox mounting in the same commit cannot raise a false alarm, and no timer is needed
  • drop PLACE_ORDER_RECHECK_EVENT, made redundant by the store
  • drop a dead isFree && !isPermitted line: setNotPermitted is a state setter, so the branches below always overwrote it within the same effect pass. Verified by removing it against the full suite — no observable behaviour changes

Two tests that covered nothing

The suite mocks getCardDetails to { brand: "" } and never provides currentPaymentMethodRef, which already falsifies the first factor of the enabling condition ((isFree && isPermitted) || onsubmit || card.brand) && isPermitted. So stays disabled when paymentMethodErrors clear but privacy/terms checkbox is not checked and is enabled for free order when permitted passed on the wrong factor and would have passed with the gate deleted entirely. Both now run with the condition live, and the free-order describe asserts a real matrix (missing billing address, missing shipping address, unaccepted terms).

Verification

  • 1050 tests pass (was 1049); typecheck and lint identical to baseline
  • Non-vacuity proved by mutation: deleting the gate fails 8 tests, including the two above; removing termsAccepted from the container's deps fails 2; removing the diagnostic's count guard fails 2
  • Confirmed in dist/: no localStorage access for privacy/terms remains; the input's name="privacy-terms" attribute is unchanged

One mutation is not caught: flipping the termsAccepted = false default to true. It is unreachable because every caller passes the value explicitly — left at false as the safe direction, but no test protects it.

Breaking changes

  • PLACE_ORDER_RECHECK_EVENT removed
  • localStorage["privacy-terms"] no longer read or written

Neither had consumers in this repo outside its own tests. mfe-checkout needs no change — it uses PlaceOrderContainer + PrivacyAndTermsCheckbox, both covered — but it pins pkg.pr.new@5884410, so it will not pick this up until published, and core-components / react-hooks-components are pinned to the same commit.

🤖 Generated with Claude Code

…accepted

Selecting a payment method could enable `PlaceOrderButton` while the privacy &
terms checkbox was still unchecked.

Acceptance travelled from `<PrivacyAndTermsCheckbox>` to `PlaceOrderButton`
through `localStorage["privacy-terms"]`, and the write that happens at *mount*
notified nobody. A `"true"` left behind by an earlier visit — the unmount
cleanup only runs on a React unmount, never on a tab close or a hard navigation
— was read by `PlaceOrderContainer` as `isPermitted: true`. The checkbox then
mounted and correctly wrote `"false"`, but none of the container's effect
dependencies changed, so `isPermitted` stayed stale. The button's own effect
does re-run on `paymentSource?.id`, i.e. exactly when a payment method is
picked, and it read that stale value.

Acceptance now lives in a module-level store keyed by order id, which notifies
its subscribers. It is deliberately not persisted: a reload starts from
"not accepted", so what the shopper sees can no longer diverge from what gates
the button.

- add `termsAcceptanceStore`: in-memory, per-order, notifying
- add the public `useTermsAndConditions()` hook, so a custom consent control has
  a supported channel now that `localStorage` is gone
- `placeOrderPermitted` takes `termsAccepted` as a parameter instead of reading
  a global, and reports `termsBlocking`
- warn in development when acceptance is required but no control collects it,
  from an effect so a late-mounting checkbox cannot raise a false alarm
- drop `PLACE_ORDER_RECHECK_EVENT`, made redundant by the store
- drop a dead `isFree && !isPermitted` line: `setNotPermitted` is a state
  setter, so the branches below always overwrote it in the same effect pass

Two existing tests claimed to cover this and covered nothing: the suite mocks
`getCardDetails` to `{ brand: "" }`, which already falsifies the first factor of
the enabling condition, so they passed on the wrong factor and would have passed
with the gate deleted. Both now run with the condition live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@acasazza acasazza added bug Something isn't working breaking-change This potentially causes other components to fail labels Aug 28, 2026
@acasazza acasazza self-assigned this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change This potentially causes other components to fail bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant