From 41ccf1497a77a2f28b2cb77ad1bfa619ebd55010 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 17:11:16 +0100 Subject: [PATCH 1/4] refactor(my-account): extract v2-demo snackbar helper to util/snackbar.js Pre-Phase 5 cleanup. Newsletters / donations / subscriptions all carried identical copies of `ensureSnackbarContainer` + `snackbar`; Phase 5 modals make a third caller. Per the cross-phase devlog rule-of-three commitment, factor into `src/my-account/v2-demo/util/snackbar.js` and import from there. No behaviour change. --- src/my-account/v2-demo/donations.js | 60 ++-------------------- src/my-account/v2-demo/newsletters.js | 60 +--------------------- src/my-account/v2-demo/subscriptions.js | 59 ++-------------------- src/my-account/v2-demo/util/snackbar.js | 66 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 169 deletions(-) create mode 100644 src/my-account/v2-demo/util/snackbar.js diff --git a/src/my-account/v2-demo/donations.js b/src/my-account/v2-demo/donations.js index 1f2ffb6f13..d1c0e7e483 100644 --- a/src/my-account/v2-demo/donations.js +++ b/src/my-account/v2-demo/donations.js @@ -13,66 +13,14 @@ * - Dropdown for the "More" menu auto-wires via newspack-ui's own * `js/dropdowns.js` — no work needed here. * - * Snackbar helpers are duplicated from `newsletters.js` for now; we'll - * factor them into a shared util when the third caller lands (Phase 5 - * modals). See devlog for the rationale. + * Snackbar helper is shared with newsletters.js / subscriptions.js via + * `./util/snackbar` (factored out at the rule-of-three threshold ahead of + * the Phase 5 modals). */ import { __ } from '@wordpress/i18n'; -const SNACKBAR_LIFETIME_MS = 5000; - -/** - * Lazily create a top-right snackbar container if the page has none yet. - * Mirrors the helper in newsletters.js — same justification (page may have - * no PHP-rendered notice mount on first paint). - * - * @return {HTMLElement} Snackbar container element. - */ -function ensureSnackbarContainer() { - let container = document.querySelector( '.newspack-ui__snackbar--top-right' ); - if ( container ) { - return container; - } - const wrap = document.createElement( 'div' ); - wrap.className = 'newspack-ui'; - container = document.createElement( 'div' ); - container.className = 'newspack-ui__snackbar newspack-ui__snackbar--top-right'; - wrap.appendChild( container ); - document.body.appendChild( wrap ); - return container; -} - -/** - * Show a transient snackbar. Uses newspack-ui markup directly rather than - * `newspackUI.notices.openNotice`, which posts an AJAX dismissal nonce we - * don't have in the demo (matches the same call in newsletters.js). - * - * @param {string} message Pre-translated copy. - * @param {string} type 'success' | 'error' (default 'success'). - */ -function snackbar( message, type = 'success' ) { - const container = ensureSnackbarContainer(); - const item = document.createElement( 'div' ); - item.className = `newspack-ui__snackbar__item newspack-ui__snackbar__item--${ type } active`; - item.dataset.autohide = 'true'; - item.setAttribute( 'role', 'status' ); - item.setAttribute( 'aria-live', 'polite' ); - const content = document.createElement( 'div' ); - content.className = 'newspack-ui__snackbar__content'; - content.textContent = message; - item.appendChild( content ); - container.appendChild( item ); - - window.setTimeout( () => { - item.classList.remove( 'active' ); - window.setTimeout( () => { - if ( item.parentNode ) { - item.parentNode.removeChild( item ); - } - }, 300 ); - }, SNACKBAR_LIFETIME_MS ); -} +import { snackbar } from './util/snackbar'; /** * Wire row-click + keyboard navigation for the previous-donations table on diff --git a/src/my-account/v2-demo/newsletters.js b/src/my-account/v2-demo/newsletters.js index e81cac6601..7286a4cb1f 100644 --- a/src/my-account/v2-demo/newsletters.js +++ b/src/my-account/v2-demo/newsletters.js @@ -8,67 +8,11 @@ import { __, sprintf } from '@wordpress/i18n'; +import { snackbar } from './util/snackbar'; + const SUBSCRIBE = 'subscribe'; const UNSUBSCRIBE = 'unsubscribe'; const UNSUBSCRIBE_FROM_ALL = 'unsubscribe-from-all'; -const SNACKBAR_LIFETIME_MS = 5000; - -/** - * Show a transient snackbar for this demo screen. - * - * Renders newspack-ui's snackbar markup directly and removes the node after - * a short delay. We intentionally do _not_ go through - * `newspackUI.notices.openNotice` because its close path always sends an - * AJAX dismissal carrying a server-issued nonce — the demo has neither, so - * routing through it would fire a 403 admin-ajax request on every toast. - * - * @param {string} message Pre-translated message. - * @param {string} type 'success' | 'error' (default 'success'). - */ -function snackbar( message, type = 'success' ) { - const container = ensureSnackbarContainer(); - const item = document.createElement( 'div' ); - item.className = `newspack-ui__snackbar__item newspack-ui__snackbar__item--${ type } active`; - item.dataset.autohide = 'true'; - item.setAttribute( 'role', 'status' ); - item.setAttribute( 'aria-live', 'polite' ); - const content = document.createElement( 'div' ); - content.className = 'newspack-ui__snackbar__content'; - content.textContent = message; - item.appendChild( content ); - container.appendChild( item ); - - window.setTimeout( () => { - item.classList.remove( 'active' ); - // Give the CSS transition (~250ms) time to finish before detaching. - window.setTimeout( () => { - if ( item.parentNode ) { - item.parentNode.removeChild( item ); - } - }, 300 ); - }, SNACKBAR_LIFETIME_MS ); -} - -/** - * Lazily create a top-right snackbar container if the page has none yet. - * On a fresh request with no PHP-rendered notices, the .newspack-ui__snackbar - * markup is absent and the toast needs somewhere to live. - * - * @return {HTMLElement} Snackbar container element. - */ -function ensureSnackbarContainer() { - let container = document.querySelector( '.newspack-ui__snackbar--top-right' ); - if ( container ) { - return container; - } - const wrap = document.createElement( 'div' ); - wrap.className = 'newspack-ui'; - container = document.createElement( 'div' ); - container.className = 'newspack-ui__snackbar newspack-ui__snackbar--top-right'; - wrap.appendChild( container ); - document.body.appendChild( wrap ); - return container; -} /** * Flip a single newsletter row between subscribed and unsubscribed. Updates diff --git a/src/my-account/v2-demo/subscriptions.js b/src/my-account/v2-demo/subscriptions.js index e9f2608299..99d7938120 100644 --- a/src/my-account/v2-demo/subscriptions.js +++ b/src/my-account/v2-demo/subscriptions.js @@ -11,65 +11,14 @@ * doesn't appear inert. * - Dropdown for "More" auto-wires via newspack-ui's own js/dropdowns.js. * - * Snackbar helpers are duplicated from `donations.js` for now; once the - * Phase 5 modals add a third caller we'll factor them into a shared util - * (per the cross-phase devlog decision — rule of three threshold reached - * then, not now). + * Snackbar helper is shared with newsletters.js / donations.js via + * `./util/snackbar` (factored out at the rule-of-three threshold ahead of + * the Phase 5 modals). */ import { __ } from '@wordpress/i18n'; -const SNACKBAR_LIFETIME_MS = 5000; - -/** - * Lazily create a top-right snackbar container if the page has none yet. - * - * @return {HTMLElement} Snackbar container element. - */ -function ensureSnackbarContainer() { - let container = document.querySelector( '.newspack-ui__snackbar--top-right' ); - if ( container ) { - return container; - } - const wrap = document.createElement( 'div' ); - wrap.className = 'newspack-ui'; - container = document.createElement( 'div' ); - container.className = 'newspack-ui__snackbar newspack-ui__snackbar--top-right'; - wrap.appendChild( container ); - document.body.appendChild( wrap ); - return container; -} - -/** - * Show a transient snackbar. Uses newspack-ui markup directly rather than - * `newspackUI.notices.openNotice`, which posts an AJAX dismissal nonce we - * don't have in the demo (matches donations.js / newsletters.js). - * - * @param {string} message Pre-translated copy. - * @param {string} type 'success' | 'error' (default 'success'). - */ -function snackbar( message, type = 'success' ) { - const container = ensureSnackbarContainer(); - const item = document.createElement( 'div' ); - item.className = `newspack-ui__snackbar__item newspack-ui__snackbar__item--${ type } active`; - item.dataset.autohide = 'true'; - item.setAttribute( 'role', 'status' ); - item.setAttribute( 'aria-live', 'polite' ); - const content = document.createElement( 'div' ); - content.className = 'newspack-ui__snackbar__content'; - content.textContent = message; - item.appendChild( content ); - container.appendChild( item ); - - window.setTimeout( () => { - item.classList.remove( 'active' ); - window.setTimeout( () => { - if ( item.parentNode ) { - item.parentNode.removeChild( item ); - } - }, 300 ); - }, SNACKBAR_LIFETIME_MS ); -} +import { snackbar } from './util/snackbar'; /** * Map a `data-action` value to its stub snackbar message. `change-subscription` diff --git a/src/my-account/v2-demo/util/snackbar.js b/src/my-account/v2-demo/util/snackbar.js new file mode 100644 index 0000000000..69a7888f41 --- /dev/null +++ b/src/my-account/v2-demo/util/snackbar.js @@ -0,0 +1,66 @@ +/** + * Shared snackbar helper for the v2-demo prototype screens. + * + * Extracted on the third caller (Phase 5 modals) per the cross-phase devlog + * rule-of-three. Newsletters / donations / subscriptions used to each carry + * a private copy; they now import from here. + * + * Renders newspack-ui's snackbar markup directly rather than going through + * `newspackUI.notices.openNotice`: that helper posts an AJAX dismissal with + * a server-issued nonce we don't have in the demo, so routing through it + * would fire a 403 admin-ajax request on every toast. + */ + +const SNACKBAR_LIFETIME_MS = 5000; +const SNACKBAR_FADEOUT_MS = 300; + +/** + * Lazily create a top-right snackbar container if the page has none yet. + * On a fresh request with no PHP-rendered notices, the snackbar markup is + * absent and the toast needs somewhere to live. + * + * @return {HTMLElement} Snackbar container element. + */ +function ensureSnackbarContainer() { + let container = document.querySelector( '.newspack-ui__snackbar--top-right' ); + if ( container ) { + return container; + } + const wrap = document.createElement( 'div' ); + wrap.className = 'newspack-ui'; + container = document.createElement( 'div' ); + container.className = 'newspack-ui__snackbar newspack-ui__snackbar--top-right'; + wrap.appendChild( container ); + document.body.appendChild( wrap ); + return container; +} + +/** + * Show a transient snackbar. + * + * @param {string} message Pre-translated message copy. + * @param {string} type 'success' | 'error' (default 'success'). + */ +export function snackbar( message, type = 'success' ) { + const container = ensureSnackbarContainer(); + const item = document.createElement( 'div' ); + item.className = `newspack-ui__snackbar__item newspack-ui__snackbar__item--${ type } active`; + item.dataset.autohide = 'true'; + item.setAttribute( 'role', 'status' ); + item.setAttribute( 'aria-live', 'polite' ); + const content = document.createElement( 'div' ); + content.className = 'newspack-ui__snackbar__content'; + content.textContent = message; + item.appendChild( content ); + container.appendChild( item ); + + window.setTimeout( () => { + item.classList.remove( 'active' ); + // Give the CSS transition time to finish before detaching. + window.setTimeout( () => { + if ( item.parentNode ) { + item.parentNode.removeChild( item ); + } + }, SNACKBAR_FADEOUT_MS ); + }, SNACKBAR_LIFETIME_MS ); +} From ed9ef64548aa24797c2b790731f1e08b3444e2cd Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 17:11:58 +0100 Subject: [PATCH 2/4] feat(my-account): build v2 prototype Phase 5 modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five remaining modal flows from the brief, each as its own partial under `templates/v2-demo/partials/`: - cancel-subscription-modal.php — confirmation pattern (init + success). - renew-subscription-modal.php — transaction pattern + success state. - cancel-donation-modal.php — same shape as cancel-subscription. - restart-donation-modal.php — transaction pattern (no success state). - modify-donation-modal.php — frequency + amount editor with live totals. Detail templates load partials behind the same status guards their headers already branch on. The list-page renders the renew modal alongside any expiring sub in the active bucket so the inline 'renew now' anchor opens the modal once Phase 6 fixtures swap one in. donations.js / subscriptions.js trade the per-action snackbar switch for a slug-map dispatcher (`tryOpenModal` / `tryOpenDonationModal`); each opens `newspack-my-account__-` and toggles `data-state` to 'open'. Confirmation modals reset to init via the `closeModal` event. Modify-donation recomputes totals declaratively as the amount changes. `update-payment-method` stays a stub snackbar — the brief lumps it with the v1 checkout flow, not a Phase 5 modal. Devlog Phase 5 entry + cross-phase decision log rows added. --- docs/my-account-v2-prototype-devlog.md | 67 +++- .../templates/v2-demo/donation-details.php | 47 +++ .../partials/cancel-donation-modal.php | 88 ++++++ .../partials/cancel-subscription-modal.php | 125 ++++++++ .../partials/modify-donation-modal.php | 260 ++++++++++++++++ .../partials/renew-subscription-modal.php | 179 +++++++++++ .../partials/restart-donation-modal.php | 134 ++++++++ .../v2-demo/subscription-details.php | 34 ++ .../templates/v2-demo/subscriptions.php | 30 ++ src/my-account/v2-demo/donations.js | 293 +++++++++++++++--- src/my-account/v2-demo/subscriptions.js | 293 ++++++++++++------ 11 files changed, 1409 insertions(+), 141 deletions(-) create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-donation-modal.php create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-subscription-modal.php create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/partials/restart-donation-modal.php diff --git a/docs/my-account-v2-prototype-devlog.md b/docs/my-account-v2-prototype-devlog.md index 62b2bad227..0da02dd765 100644 --- a/docs/my-account-v2-prototype-devlog.md +++ b/docs/my-account-v2-prototype-devlog.md @@ -220,9 +220,65 @@ The five detail variants felt like they wanted five files until the second-pass ## Phase 5 — Modals -> See [brief §10 → Phase 5](my-account-v2-prototype-brief.md#phase-5--modals-1-day). All six flows. +> See [brief §10 → Phase 5](my-account-v2-prototype-brief.md#phase-5--modals-1-day). Five remaining flows (Change subscription shipped in Phase 4). -_(empty)_ +**Date:** 2026-04-28 +**By:** thomas@a8c.com +**PR:** _pending — phase branch `prototype/my-account-demo-phase-5`, draft PR targets `prototype/my-account-demo` (umbrella tracker is #4679)_ +**Commits:** _pending — code is staged on `prototype/my-account-demo-phase-5`_ +**Figma:** [`2636:46259`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46259), [`2636:46262`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46262), [`2636:46276`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46276), [`2636:46269`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46269), [`2636:46578`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46578), [`2636:46591`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46591), [`2636:46550`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46550), [`2636:46530`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46530) + +**What I built** + +The five remaining modal flows from the brief, each rendered as its own partial under `templates/v2-demo/partials/` and loaded by the relevant detail (or list, in the renew-on-list case) template behind a status guard. (a) `cancel-subscription-modal.php` — confirmation pattern: `data-step="init"` body with "Are you sure?" + the active-until date pulled from the sub's `next_payment` (or `expires_on` for the expiring variant) + destructive Cancel + ghost Keep; flips to `data-step="success"` with the canonical newspack-ui success box (`__box--success.--text-center` + `__icon--success` check) + "We have just sent a confirmation email to " + Done. (b) `renew-subscription-modal.php` — transaction pattern: `__box--text-center` summary line ("Patron: $101.70 / year"), Pay-now primary button, `
` billing readout reusing `tiers.billing` from the fake-data payload, full Stripe-style payment form (`wc_payment_methods` radio + `payment_box` with form-row inputs + cover-fees checkbox); flips to a success step on Pay. (c) `cancel-donation-modal.php` — same confirmation shape as cancel-subscription, donation copy. (d) `restart-donation-modal.php` — transaction pattern, single step, terminates on snackbar (no Figma success state). (e) `modify-donation-modal.php` — frequency segmented control + currency input + cover-fees checkbox + recurring-totals breakdown (subtotal / VAT / transaction fee / total + next-donation date) inside a `__box`, all driven by JS that recomputes on input change. + +The dispatcher refactor turns each detail-page click handler into a small router: `tryOpenModal(action, id, root)` looks up `newspack-my-account__-` and toggles `data-state="open"` if it exists, otherwise the click falls through. `update-payment-method` (Phase 5 leaves it as a snackbar — see the brief note) is the only fall-through that surfaces copy. The list-page renew-now anchor inside the expiring active card uses the same handler: it'll find a renew modal and open it whenever Phase 6 fixtures swap an expiring sub into the `active` bucket; today the list bucket only holds sub-001 (active), so the anchor doesn't render. The expiring-detail-page inline anchor (``) opens the same modal the header Renew button does — both trigger the same dispatcher. + +The snackbar utility was extracted into `src/my-account/v2-demo/util/snackbar.js` per the rule-of-three commitment in the cross-phase decision log: newsletters / donations / subscriptions had identical copies of `ensureSnackbarContainer` + `snackbar`. The util is the third caller's prerequisite, not a fourth. All three files now import the shared helper. **First commit on this branch is the pure refactor (no behaviour change); second commit adds the modals + routing on top, so the diff stays reviewable.** + +Detail-template loaders pick which partials to render based on the same status flags the page header already branches on: +- subscription detail: `change-subscription-modal` for active|renewed (existing); `cancel-subscription-modal` for active|renewed|expiring (Cancel trigger appears in the More dropdown for the first two and as the inline header link for expiring); `renew-subscription-modal` for cancelled|expiring. +- donation detail: `modify-donation-modal` + `cancel-donation-modal` for active recurring; `restart-donation-modal` for cancelled recurring. +- subscriptions list: `renew-subscription-modal` for any expiring sub in the `active` bucket — guards the inline anchor for Phase 6 fixtures without blocking Phase 5. + +`get_fake_data()` carries everything the modals need without extending the shape: subscription `next_payment` / `expires_on` for the cancel-confirmation date, the existing reader email block (`$data['reader']['email']`) for the success copy, and `$data['subscriptions']['tiers']['billing']` (added in Phase 4 for the change-subscription transaction step) reused by both renew-subscription and restart-donation. No new endpoints, no `ENDPOINTS_VERSION` bump. + +Verified via `wp eval-file` rendering each detail variant + list-page directly: 13/13 markup-presence checks pass — modify + cancel for active-recurring donations, restart for cancelled-recurring donations, change + cancel (no renew) for active subscriptions, renew (no change) for cancelled subscriptions, renew + cancel for expiring subscriptions, two-step modals carry both `data-step="init"` and `data-step="success"`, modify-donation modal carries the `data-currency-symbol` attribute the JS reads. Lint clean across `:php`, `:js`, `:scss`. Build passes. + +**What I learned** + +The Figma success-state frames are noisy: the "Cancel subscription – Success" frame (Figma `2636:46262`) renders with stub buttons labelled "Button" / "Continue" / "Secondary" / "Cancel" stacked below the success box. Same for cancel-donation and renew-subscription success frames. None of these are real action labels — the designer left placeholder Newspack/Button instances in the success layout. Reaching for the canonical newspack-ui success-modal pattern (one CTA labelled "Done" closing the modal, success box does the rest) renders the design intent without inventing copy. Codified the pattern by referencing `class-newspack-ui.php`'s `Newspack_UI::generate_modal()` — the exact `__box--success` + `__icon--success` + `

` structure the modal-checkout flow already uses. + +The action-router pattern collapses three near-clones into one. Phase 4's change-subscription wiring had a one-off `if 'change-subscription' === action` branch ahead of the snackbar fallback. With Phase 5 adding cancel + renew (subscription) and modify + cancel + restart (donation), the slug map kills the branching: each flow gets a one-line entry in the map; modal lookup + dropdown-close + state-flip is a single helper. `update-payment-method` is the only outlier today (no modal, snackbar fallback) — when Phase 6 (or v1 productisation) wires it to a real Stripe form, it just gets a slug entry too and the fallback can come out. + +The single-modal-instance-per-resource pattern compounds. With six modal flows and each detail page potentially showing two or three, a naive "always render every modal" would emit ~6× the markup. Status-guarding the loaders (active recurring → modify + cancel; cancelled recurring → restart) means each donation page renders 2 modals at most; each subscription page 1–2. The only place this breaks is the list-page renew anchor: it has to render a modal alongside any expiring sub in `active`, even though that anchor never fires when no expiring sub is there. The forward-compat is cheap (one foreach + one load_template) and lets Phase 6 swap fixtures without touching the dispatcher. + +The modify-donation totals math is intentionally approximate — `subtotal = amount / (1 + vatRate)`, `vat = amount - subtotal`, `fee = amount * 0.02 if covered`. Real WC arithmetic would query the cart's tax classes and Stripe's exact fee rate. The prototype's job is to demonstrate that the totals box updates as the reader edits the amount, not to match accounting precision; coordinating with real WC totals is productisation work. JSON-encoded data attributes (`data-unit-labels`, `data-next-dates`, `data-recurring-total-labels`) keep the PHP→JS boundary declarative — no separate `wp_localize_script` payload needed since each modal's params are small enough to ship inline. + +The `closeModal` event newspack-ui's `modals.js` dispatches on every state→closed transition is the right hook for "reset state on every close." Each modal that has reset semantics (re-opening should show init, not the previous step or input) listens there. Cleaner than tracking a "last open time" or watching the `data-state` attribute ourselves. + +**Decisions and why** + +- **Snackbar refactor lands in its own commit.** The brief and Phase 4 devlog both flagged this as the rule-of-three threshold. Keeping the refactor diff isolated means PR review can verify it's a pure no-op (three identical helpers → one shared file) before evaluating modal logic on top. Newsletters didn't strictly need to change — but folding it in alongside donations and subscriptions keeps the project from carrying a fourth copy waiting to be retired. +- **Modal partials, not inline blocks.** Each modal is a separate file under `partials/` matching the Phase 4 `change-subscription-modal.php` precedent. Two reasons: detail templates already have enough variant logic without adding 50–200 lines of modal markup at the bottom; and partials pass through `load_template` with explicit `$args`, which is the canonical Newspack pattern (see `My_Account_UI_V1::load_template_with_args` and the way subscription-details loads change-subscription-modal). Single-file-per-flow + `__DIR__ . '/partials/.php'` is the contract. +- **Modals render outside the detail-page wrap.** The detail container has `data-newspack-my-account-v2-demo=""` and a single click listener. Modals rendered _inside_ that wrap would bubble their internal clicks through the listener — harmless today (the `confirm` data-action isn't in the slug map), but a foot-gun for any future addition that reuses `data-action`. Subscription-details already loaded the change-subscription modal after ``; donation-details now follows the same convention. +- **Action buttons live directly inside `.newspack-ui__modal__content`, never `__footer`.** Phase 4 lesson held: the footer's `--newspack-ui-color-neutral-5` background visibly clashes with the design. Every Phase 5 modal terminates with a wide primary button + (where Figma shows one) a wide ghost cancel button, both directly inside the content section. +- **Confirmation modals use `data-step="init"` + `data-step="success"` toggles.** Two `__modal__content` divs, one with `hidden`, JS flips them on Confirm. Same pattern across cancel-subscription, cancel-donation, renew-subscription. Discoverable from the markup, no separate "is this in success state?" boolean to track. +- **Update-payment-method stays a snackbar.** Brief §10 lumps it with the v1 checkout flow (real Stripe form, real billing readout sourced from WC). Building a fake "payment-method updated" modal in Phase 5 would either duplicate the renew/restart payment forms (cheap) or pretend to mutate state (lying). The phase ends with a confirmed gap. +- **Restart donation has no Figma success state, so it terminates on snackbar.** Cancel/Renew show explicit success frames — Restart only shows the init transaction. Adding a fictional success step would invent design that isn't in Figma. Snackbar termination is consistent with what Phase 4's change-subscription does after its transaction step. +- **Modify donation recomputes totals in JS.** The Figma frame shows totals updating as the amount is edited. Two options: freeze the totals to match the donation's current numbers (declarative + simple) or recompute (matches Figma intent). Picked recompute because it's ~30 lines and the design is clearly interactive. Math is approximate — the comment in the JS says so. +- **Renew + Restart reuse the shared `tiers.billing` fixture from `get_fake_data()['subscriptions']`.** Donations don't carry their own billing block in the fake data, and the demo address is the same one Change subscription already uses. Reusing keeps the modals visually consistent with each other (one fictional reader, one fictional address) without proliferating fixture data. +- **Action-router lookup is keyed by data-action → slug.** A literal slug map (`'cancel-subscription' → 'cancel-subscription'`) reads as identity right now, but it's the seam where a future flow could route two actions to one modal (e.g. "renew-subscription" and "renew-now-from-list-anchor" both pointing at "renew-subscription"). Cheaper to leave the map than to flatten it and refactor on first divergence. +- **List-page renew modal renders only for expiring subs in active.** Today there are none — the modal's a no-op. Phase 6 scenario fixtures (`?v2-demo=expiring`) are expected to swap an expiring sub into `active`; rendering the modal alongside there means the anchor opens the modal in place, no further code changes. Cheap forward-compat for Phase 6. +- **`The News Paper` stays hardcoded in modal copy.** The change-subscription modal already used it (cover-fees helper text); cancel-subscription / renew-subscription / etc. follow. Phase 6 polish can sweep these to `get_bloginfo('name')` or a fake-data field — until then, consistency over real-site reflection. +- **Reserved-globals trap: every modal partial uses `$donation_id`, `$subscription_id`, `$frequency_unit`, `$reader_email` etc. — never `$id`, `$status`, `$frequency`.** Same call as Phase 4's subscription-details template; saved a PHPCS round-trip across six new files. + +**Open questions** + +- **Designed but stub-labelled buttons.** Cancel/Renew/Cancel-donation success frames in Figma all carry placeholder buttons ("Button", "Continue", "Secondary", "Cancel") below the success box. I picked the simplest Newspack-canonical interpretation (success box + a single Done CTA), but if those stub buttons are meant to be real (e.g. "Subscribe to a newsletter", "View receipt"), the success states need a second pass with copy from the designer. +- **Modify-donation totals precision.** The math is approximate. If the design review surfaces specific totals (e.g. exact VAT rate per region, exact Stripe fee tiers), Phase 6 should swap in either a static fixture matching Figma byte-for-byte or a proper `wc_get_price_excluding_tax`-style helper. +- **Update-payment-method.** Brief §10 says "v1 checkout flow." Confirm with Thomas before Phase 6: do we render a stub modal mirroring the renew-subscription payment form, or stay pointed at real WC infrastructure? +- **Inline `` etc.** The detail-page header links carry hash fragments so they look like real links. Once the modals are universally wired, the hashes are dead — they don't navigate, they don't get bookmarked. Worth scrubbing in Phase 6. --- @@ -265,3 +321,10 @@ A flat list of decisions that span phases or that future-you will want to find w | 2026-04-28 | Change subscription modal pulled forward from Phase 5 into Phase 4. One modal partial (`partials/change-subscription-modal.php`), two visual steps (`select` and `transaction`), four Figma frames collapsed onto status flags. Tier catalogue + billing fixture live in `subscriptions.tiers` on `get_fake_subscriptions()`; each `active`/`renewed` subscription row carries a `current_tier` id. v1's `Subscriptions_Tiers::render_modal` was instructive but not directly reusable — it depends on real `WC_Product` and `WC_Subscription` objects and renders a single-screen tier picker, not the four-step Figma flow. Other 5 modal flows (cancel sub, renew sub, modify donation, cancel donation, restart donation) still ship as Phase 5. | Phase 4 | | 2026-04-28 | When v1 already has class names + SCSS for a surface v2 is rebuilding (subscription detail header, modal tier cards), **reuse the v1 class names** rather than composing fresh utility-class layouts. The v2-demo body class chain (`.woocommerce-account.newspack-my-account.newspack-ui` + `.newspack-my-account--v2-demo`) inherits the entire `_subscriptions.scss` ruleset for free, so reusing `--header / --title / --back-link / --actions-container / --actions-dropdown` and `Newspack_UI_Icons::print_svg('chevronLeft')` gets the right gaps, the right chevron, and the right responsive behaviour without writing any v2 SCSS. Same call for the modal tier cards — the v1 `Subscriptions_Tiers::render_product_card` markup (`

+ + $donation, + 'currency_symbol' => $currency_symbol, + ] + ); + load_template( + __DIR__ . '/partials/cancel-donation-modal.php', + false, + [ + 'donation' => $donation, + 'reader_email' => $reader_email, + ] + ); +} +if ( $is_recurring && $is_cancelled ) { + load_template( + __DIR__ . '/partials/restart-donation-modal.php', + false, + [ + 'donation' => $donation, + 'billing' => $shared_billing, + 'currency_symbol' => $currency_symbol, + ] + ); +} +?> diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-donation-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-donation-modal.php new file mode 100644 index 0000000000..2c92078682 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-donation-modal.php @@ -0,0 +1,88 @@ + the donation row (id). + * - `reader_email` => email surfaced in the success copy. + */ + +defined( 'ABSPATH' ) || exit; + +$donation = isset( $args['donation'] ) ? $args['donation'] : []; +$reader_email = isset( $args['reader_email'] ) ? (string) $args['reader_email'] : ''; +$donation_id = isset( $donation['id'] ) ? (string) $donation['id'] : ''; +?> + diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-subscription-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-subscription-modal.php new file mode 100644 index 0000000000..8b2c8cecf6 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/cancel-subscription-modal.php @@ -0,0 +1,125 @@ + the subscription row this modal is for + * (id, status, expires_on, next_payment). + * - `reader_email` => email address surfaced in the success + * copy (matches the page-level fake data). + */ + +defined( 'ABSPATH' ) || exit; + +$subscription = isset( $args['subscription'] ) ? $args['subscription'] : []; +$reader_email = isset( $args['reader_email'] ) ? (string) $args['reader_email'] : ''; +$subscription_id = isset( $subscription['id'] ) ? (string) $subscription['id'] : ''; + +// "active until X" date: prefer next_payment for actively-renewing subs; fall +// back to expires_on for the expiring variant. Either way the source is the +// fake-data fixture, formatted with WP's locale-aware date helper. +$end_iso = ''; +if ( ! empty( $subscription['next_payment'] ) ) { + $end_iso = (string) $subscription['next_payment']; +} elseif ( ! empty( $subscription['expires_on'] ) ) { + $end_iso = (string) $subscription['expires_on']; +} +$end_date_label = ''; +if ( '' !== $end_iso ) { + $end_ts = strtotime( $end_iso ); + $end_date_label = $end_ts ? date_i18n( 'F j, Y', $end_ts ) : $end_iso; +} +?> + diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php new file mode 100644 index 0000000000..69102037e0 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php @@ -0,0 +1,260 @@ + the donation row (active recurring). + * - `currency_symbol` => currency symbol from fake data. + */ + +defined( 'ABSPATH' ) || exit; + +$donation = isset( $args['donation'] ) ? $args['donation'] : []; +$currency_symbol = isset( $args['currency_symbol'] ) ? (string) $args['currency_symbol'] : '$'; +$donation_id = isset( $donation['id'] ) ? (string) $donation['id'] : ''; + +$initial_amount = isset( $donation['amount'] ) ? (float) $donation['amount'] : 0.0; +$initial_frequency = isset( $donation['frequency'] ) ? (string) $donation['frequency'] : 'month'; +$fees_covered = ! empty( $donation['fees_covered'] ); +$next_payment_iso = isset( $donation['next_payment'] ) ? (string) $donation['next_payment'] : ''; +$next_payment_date = ''; +if ( '' !== $next_payment_iso ) { + $ts = strtotime( $next_payment_iso ); + $next_payment_date = $ts ? date_i18n( 'F j, Y', $ts ) : $next_payment_iso; +} + +// Frequency catalogue (label = tab text, unit = "/ unit" suffix). +$frequencies = [ + [ + 'id' => 'month', + 'label' => __( 'Monthly', 'newspack-plugin' ), + 'unit' => __( 'month', 'newspack-plugin' ), + ], + [ + 'id' => 'year', + 'label' => __( 'Annually', 'newspack-plugin' ), + 'unit' => __( 'year', 'newspack-plugin' ), + ], +]; + +$unit_labels = []; +foreach ( $frequencies as $freq ) { + $unit_labels[ $freq['id'] ] = $freq['unit']; +} +$initial_unit = isset( $unit_labels[ $initial_frequency ] ) ? $unit_labels[ $initial_frequency ] : $initial_frequency; + +// Initial breakdown values, mirroring the live JS recompute. Vat rate matches +// the fake-data fixture (subtotal/total ratio); kept here as a single source +// for the data attributes the JS reads. +$vat_rate = 0.20; +$fee_rate = 0.02; +$initial_subtotal = $initial_amount / ( 1 + $vat_rate ); +$initial_vat = $initial_amount - $initial_subtotal; +$initial_fee = $fees_covered ? $initial_amount * $fee_rate : null; +$initial_total = $fees_covered ? $initial_amount + ( null === $initial_fee ? 0 : $initial_fee ) : $initial_amount; + +$format_amount = static function ( $value ) use ( $currency_symbol ) { + return $currency_symbol . number_format_i18n( (float) $value, 2 ); +}; + +// Per-frequency next-donation dates. The detail page only ships a single +// next_payment for the donation's current frequency; for the other tab we +// suppress the date so we don't lie to the reader. JSON-encoded for JS. +$next_dates = []; +if ( '' !== $next_payment_date ) { + $next_dates[ $initial_frequency ] = $next_payment_date; +} + +// Confirm button label is always "Confirm donation: $X / unit". Initial render +// matches the donation's current values; JS keeps it in sync afterwards. +/* translators: %1$s: amount with currency, %2$s: frequency unit. */ +$initial_confirm_label = sprintf( __( '%1$s / %2$s', 'newspack-plugin' ), $format_amount( $initial_amount ), $initial_unit ); +?> + diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php new file mode 100644 index 0000000000..2a68f70293 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php @@ -0,0 +1,179 @@ + the subscription row (id, product, + * amount, frequency). + * - `billing` => `tiers.billing` fixture (name, + * lines, email). + * - `currency_symbol` => currency symbol from the fake-data + * payload. + * - `reader_email` => email surfaced in the success copy. + */ + +defined( 'ABSPATH' ) || exit; + +$subscription = isset( $args['subscription'] ) ? $args['subscription'] : []; +$billing = isset( $args['billing'] ) ? $args['billing'] : []; +$currency_symbol = isset( $args['currency_symbol'] ) ? (string) $args['currency_symbol'] : '$'; +$reader_email = isset( $args['reader_email'] ) ? (string) $args['reader_email'] : ''; +$subscription_id = isset( $subscription['id'] ) ? (string) $subscription['id'] : ''; + +$product_name = isset( $subscription['product'] ) ? (string) $subscription['product'] : ''; +$amount = isset( $subscription['amount'] ) ? (float) $subscription['amount'] : 0.0; +$frequency_unit = isset( $subscription['frequency'] ) ? (string) $subscription['frequency'] : ''; +$amount_label = $currency_symbol . number_format_i18n( $amount, 2 ); +if ( $frequency_unit ) { + /* translators: %1$s: amount with currency, %2$s: frequency unit. */ + $amount_per = sprintf( __( '%1$s / %2$s', 'newspack-plugin' ), $amount_label, $frequency_unit ); +} else { + $amount_per = $amount_label; +} +$summary_label = $product_name + ? sprintf( + /* translators: %1$s: subscription tier name, %2$s: amount + frequency. */ + __( '%1$s: %2$s', 'newspack-plugin' ), + $product_name, + $amount_per + ) + : $amount_per; +?> + diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/restart-donation-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/restart-donation-modal.php new file mode 100644 index 0000000000..d291521f87 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/restart-donation-modal.php @@ -0,0 +1,134 @@ + the donation row (id, amount, + * frequency_label). + * - `billing` => billing fixture (name, lines, email). + * - `currency_symbol` => currency symbol. + */ + +defined( 'ABSPATH' ) || exit; + +$donation = isset( $args['donation'] ) ? $args['donation'] : []; +$billing = isset( $args['billing'] ) ? $args['billing'] : []; +$currency_symbol = isset( $args['currency_symbol'] ) ? (string) $args['currency_symbol'] : '$'; +$donation_id = isset( $donation['id'] ) ? (string) $donation['id'] : ''; + +$amount = isset( $donation['amount'] ) ? (float) $donation['amount'] : 0.0; +$frequency_label = isset( $donation['frequency_label'] ) ? (string) $donation['frequency_label'] : ''; +$amount_label = $currency_symbol . number_format_i18n( $amount, 2 ); +if ( $frequency_label ) { + /* translators: %1$s: frequency label (e.g. "Annually"), %2$s: amount with currency. */ + $summary_label = sprintf( __( 'Donate %1$s: %2$s', 'newspack-plugin' ), $frequency_label, $amount_label ); +} else { + $summary_label = $amount_label; +} +?> + diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/subscription-details.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/subscription-details.php index cd49625dc2..9ab3e8be26 100644 --- a/includes/plugins/woocommerce/my-account/templates/v2-demo/subscription-details.php +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/subscription-details.php @@ -377,6 +377,10 @@ class="newspack-ui__button newspack-ui__button--ghost cancel newspack-ui__button $subscription, + 'reader_email' => $reader_email, + ] + ); +} + +// Renew subscription modal — rendered for cancelled / expiring subs (the +// header Renew button) and reused by the expiring variant's inline +// "renew now" anchor inside the error notice. +if ( $is_cancelled || $is_expiring ) { + load_template( + __DIR__ . '/partials/renew-subscription-modal.php', + false, + [ + 'subscription' => $subscription, + 'billing' => $tiers_billing, + 'currency_symbol' => $currency_symbol, + 'reader_email' => $reader_email, + ] + ); +} ?> diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/subscriptions.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/subscriptions.php index adab8fb9aa..9b6acfee0a 100644 --- a/includes/plugins/woocommerce/my-account/templates/v2-demo/subscriptions.php +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/subscriptions.php @@ -282,3 +282,33 @@ class="newspack-ui__box newspack-ui__box--border newspack-ui__stack newspack-ui_ + + $list_sub, + 'billing' => $tiers_billing, + 'currency_symbol' => $currency_symbol, + 'reader_email' => $reader_email, + ] + ); +} +?> diff --git a/src/my-account/v2-demo/donations.js b/src/my-account/v2-demo/donations.js index d1c0e7e483..1ddcb99298 100644 --- a/src/my-account/v2-demo/donations.js +++ b/src/my-account/v2-demo/donations.js @@ -6,16 +6,13 @@ * donations table is clicked. Provides the row-as-link affordance that * pure HTML doesn't give us inside markup. (Hover/cursor styling * is intentionally deferred — Phase 6 polish, not a Phase 3 blocker.) - * - Detail page: stub snackbars for the modal-trigger buttons (Modify / - * Cancel / Restart / Update payment method). Phase 5 swaps these for - * real modals; for now we surface the would-be confirmation copy so the - * end-state is clickable in the prototype. + * - Detail page: open the Modify / Cancel / Restart donation modals when + * their trigger buttons fire. Each modal lives at the bottom of the + * detail template (one per donation, keyed by id); init / success steps + * transition inline. Update payment method stays a stub snackbar — the + * brief lumps it with the v1 checkout flow, not a Phase 5 modal. * - Dropdown for the "More" menu auto-wires via newspack-ui's own * `js/dropdowns.js` — no work needed here. - * - * Snackbar helper is shared with newsletters.js / subscriptions.js via - * `./util/snackbar` (factored out at the rule-of-three threshold ahead of - * the Phase 5 modals). */ import { __ } from '@wordpress/i18n'; @@ -88,9 +85,242 @@ function wireListRoot( root ) { } /** - * Wire the detail-page modal-trigger buttons. Each surfaces a snackbar with - * the eventual success copy from Figma so the click feels real even before - * Phase 5 hooks up real modals. + * Wire the Cancel donation modal — confirmation pattern (init step → success + * step inline). Mirrors the subscriptions.js wireConfirmModal helper; kept + * local rather than shared because the donation/subscription packages stay + * independent at the wiring layer. + * + * @param {HTMLElement} modal The modal container element. + */ +function wireCancelDonationModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const initStep = modal.querySelector( '[data-step="init"]' ); + const successStep = modal.querySelector( '[data-step="success"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + + const goToStep = step => { + if ( ! initStep || ! successStep ) { + return; + } + initStep.hidden = step !== 'init'; + successStep.hidden = step !== 'success'; + }; + + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => goToStep( 'success' ) ); + } + + modal.addEventListener( 'closeModal', () => goToStep( 'init' ) ); +} + +/** + * Wire the Restart donation modal — single-screen transaction (billing + + * payment form), terminating on a snackbar (no Figma success state). The + * Confirm button just closes the modal and surfaces "Donation restarted." + * + * @param {HTMLElement} modal The modal container element. + */ +function wireRestartDonationModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => { + modal.setAttribute( 'data-state', 'closed' ); + snackbar( __( 'Donation restarted.', 'newspack-plugin' ) ); + } ); + } +} + +/** + * Wire the Modify donation modal — frequency segmented control + amount + * editor + recurring totals readout that recomputes as the amount changes. + * Confirm → snackbar (no Figma success state for modify). + * + * Math model: amount in the input is the gross "Recurring total" the reader + * pays. Subtotal is amount / (1 + vatRate); vat is amount - subtotal; + * transaction fee defaults to null but flips on when "Cover transaction + * fees?" is checked (2% of amount, rounded to 2dp). All numbers update + * declaratively whenever the amount, frequency, or fee toggle changes. + * + * @param {HTMLElement} modal The modal container element. + */ +function wireModifyDonationModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const tabs = [ ...modal.querySelectorAll( '[data-frequency][role="tab"]' ) ]; + const amountInput = modal.querySelector( '[data-modify-amount]' ); + const feeToggle = modal.querySelector( '[data-modify-cover-fees]' ); + const amountUnitLabel = modal.querySelector( '[data-modify-amount-unit]' ); + const totalsHeading = modal.querySelector( '[data-modify-totals-heading]' ); + const subtotalEl = modal.querySelector( '[data-modify-subtotal]' ); + const vatEl = modal.querySelector( '[data-modify-vat]' ); + const feeEl = modal.querySelector( '[data-modify-fee]' ); + const totalEl = modal.querySelector( '[data-modify-total]' ); + const nextDateEl = modal.querySelector( '[data-modify-next]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + const confirmLabel = modal.querySelector( '[data-modify-confirm-label]' ); + + const symbol = modal.dataset.currencySymbol || '$'; + const vatRate = Number.parseFloat( modal.dataset.vatRate || '0.2' ); + const feeRate = Number.parseFloat( modal.dataset.feeRate || '0.02' ); + const initialFrequency = modal.dataset.initialFrequency || 'month'; + const nextDates = ( () => { + try { + return JSON.parse( modal.dataset.nextDates || '{}' ); + } catch ( _e ) { + return {}; + } + } )(); + const unitLabels = ( () => { + try { + return JSON.parse( modal.dataset.unitLabels || '{}' ); + } catch ( _e ) { + return {}; + } + } )(); + const recurringTotalLabels = ( () => { + try { + return JSON.parse( modal.dataset.recurringTotalLabels || '{}' ); + } catch ( _e ) { + return {}; + } + } )(); + + let activeFrequency = initialFrequency; + + const formatAmount = n => `${ symbol }${ Number.isFinite( n ) ? n.toFixed( 2 ) : '0.00' }`; + + const recompute = () => { + const amount = Math.max( 0, Number.parseFloat( amountInput?.value || '0' ) || 0 ); + const subtotal = amount / ( 1 + vatRate ); + const vat = amount - subtotal; + const fee = feeToggle?.checked ? amount * feeRate : null; + const unit = unitLabels[ activeFrequency ] || activeFrequency; + + if ( amountUnitLabel ) { + amountUnitLabel.textContent = unit; + } + if ( totalsHeading ) { + totalsHeading.textContent = recurringTotalLabels.heading || totalsHeading.textContent; + } + if ( subtotalEl ) { + subtotalEl.textContent = `${ formatAmount( subtotal ) } / ${ unit }`; + } + if ( vatEl ) { + vatEl.textContent = formatAmount( vat ); + } + if ( feeEl ) { + feeEl.textContent = null === fee ? '—' : formatAmount( fee ); + } + if ( totalEl ) { + const grandTotal = null === fee ? amount : amount + fee; + totalEl.textContent = `${ formatAmount( grandTotal ) } / ${ unit }`; + } + if ( nextDateEl ) { + nextDateEl.textContent = nextDates[ activeFrequency ] || ''; + } + if ( confirmLabel ) { + confirmLabel.textContent = `${ formatAmount( amount ) } / ${ unit }`; + } + if ( confirmBtn ) { + confirmBtn.disabled = amount <= 0; + } + }; + + const setActiveFrequency = freq => { + if ( ! freq || freq === activeFrequency ) { + return; + } + activeFrequency = freq; + tabs.forEach( tab => { + const isActive = tab.dataset.frequency === freq; + tab.classList.toggle( 'selected', isActive ); + tab.setAttribute( 'aria-selected', isActive ? 'true' : 'false' ); + } ); + recompute(); + }; + + tabs.forEach( tab => { + tab.addEventListener( 'click', () => setActiveFrequency( tab.dataset.frequency ) ); + } ); + if ( amountInput ) { + amountInput.addEventListener( 'input', recompute ); + } + if ( feeToggle ) { + feeToggle.addEventListener( 'change', recompute ); + } + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => { + if ( confirmBtn.disabled ) { + return; + } + modal.setAttribute( 'data-state', 'closed' ); + snackbar( __( 'Donation modified.', 'newspack-plugin' ) ); + } ); + } + + // Reset to the initial state on close so re-opening shows the donation's + // current values rather than the previous edit. + modal.addEventListener( 'closeModal', () => { + if ( amountInput ) { + amountInput.value = amountInput.dataset.initialAmount || amountInput.value; + } + if ( feeToggle ) { + feeToggle.checked = feeToggle.dataset.initialChecked === 'true'; + } + setActiveFrequency( initialFrequency ); + recompute(); + } ); + + recompute(); +} + +/** + * Look up the modal for a given `data-action` + donation id, opening it if + * it exists. Closes any open dropdown first. Returns true if a modal was + * opened. + * + * @param {string} action Trigger's data-action. + * @param {string} donationId Trigger's data-donation-id. + * @param {HTMLElement} root Container element (for dropdown close). + * @return {boolean} Whether a modal was opened. + */ +function tryOpenDonationModal( action, donationId, root ) { + const slug = { + 'modify-donation': 'modify-donation', + 'cancel-donation': 'cancel-donation', + 'restart-donation': 'restart-donation', + }[ action ]; + if ( ! slug || ! donationId ) { + return false; + } + const modal = document.getElementById( `newspack-my-account__${ slug }-${ donationId }` ); + if ( ! modal ) { + return false; + } + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown ) { + openDropdown.classList.remove( 'active' ); + } + modal.setAttribute( 'data-state', 'open' ); + return true; +} + +/** + * Wire the detail-page modal-trigger buttons. Routes each `data-action` + * through the modal lookup; falls back to a snackbar for actions without a + * Phase 5 modal (today: only `update-payment-method`). * * @param {HTMLElement} root Detail container element. */ @@ -101,40 +331,28 @@ function wireDetailRoot( root ) { root.dataset.newspackMyAccountV2DemoWired = 'true'; root.addEventListener( 'click', event => { - const button = event.target.closest( 'button[data-action]' ); - if ( ! button || ! root.contains( button ) ) { + const trigger = event.target.closest( '[data-action]' ); + if ( ! trigger || ! root.contains( trigger ) ) { return; } - // Let the dropdown toggle keep its own behaviour — newspack-ui's - // dropdowns.js owns it. - if ( button.classList.contains( 'newspack-ui__dropdown__toggle' ) ) { + if ( trigger.classList.contains( 'newspack-ui__dropdown__toggle' ) ) { + return; + } + + const action = trigger.dataset.action; + const donationId = trigger.dataset.donationId || ''; + + if ( tryOpenDonationModal( action, donationId, root ) ) { return; } - // If a dropdown menu is open and the click was inside it, close it - // before showing the snackbar so the menu doesn't linger. const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); - if ( openDropdown && openDropdown.contains( button ) ) { + if ( openDropdown && openDropdown.contains( trigger ) ) { openDropdown.classList.remove( 'active' ); } - const action = button.dataset.action; - switch ( action ) { - case 'modify-donation': - snackbar( __( 'Donation modified.', 'newspack-plugin' ) ); - break; - case 'cancel-donation': - snackbar( __( 'Donation cancelled.', 'newspack-plugin' ) ); - break; - case 'restart-donation': - snackbar( __( 'Donation restarted.', 'newspack-plugin' ) ); - break; - case 'update-payment-method': - snackbar( __( 'Payment method updated.', 'newspack-plugin' ) ); - break; - default: - // Unknown action — let it fall through. - break; + if ( action === 'update-payment-method' ) { + snackbar( __( 'Payment method updated.', 'newspack-plugin' ) ); } } ); } @@ -142,6 +360,9 @@ function wireDetailRoot( root ) { document.addEventListener( 'DOMContentLoaded', () => { document.querySelectorAll( '[data-newspack-my-account-v2-demo="donations"]' ).forEach( wireListRoot ); document.querySelectorAll( '[data-newspack-my-account-v2-demo="donation-details"]' ).forEach( wireDetailRoot ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="modify-donation-modal"]' ).forEach( wireModifyDonationModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="cancel-donation-modal"]' ).forEach( wireCancelDonationModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="restart-donation-modal"]' ).forEach( wireRestartDonationModal ); // If the user just landed via a Phase 5–style update flow that includes // a `payment-updated` query param (Figma 2636:46500 "new payment method" diff --git a/src/my-account/v2-demo/subscriptions.js b/src/my-account/v2-demo/subscriptions.js index 99d7938120..e7c3808e20 100644 --- a/src/my-account/v2-demo/subscriptions.js +++ b/src/my-account/v2-demo/subscriptions.js @@ -2,46 +2,23 @@ * Subscriptions screens — client-side wiring. * * Responsibilities: - * - Detail page: stub snackbars for the modal-trigger buttons (Change / - * Cancel / Renew / Update payment method). Phase 5 swaps these for real - * modals. - * - List page: no row-click table here (the previous-subscriptions cards - * are themselves elements), but the inline "renew now" anchor inside - * the expiring active card needs the same stub snackbar treatment so it - * doesn't appear inert. + * - Detail page: open the Change / Cancel / Renew subscription modals when + * their trigger buttons fire. Each modal lives at the bottom of the + * detail template (one per subscription, keyed by id); init / success + * steps transition inline. Update payment method stays a stub snackbar + * — the brief lumps it with the v1 checkout flow, not a Phase 5 modal. + * - List page: the inline "renew now" anchor inside the expiring active + * card opens the Renew subscription modal too (when the modal is + * rendered there, e.g. once Phase 6 fixtures put an expiring sub into + * the active bucket). When no modal is found we fall back to a stub + * snackbar so the click never feels inert. * - Dropdown for "More" auto-wires via newspack-ui's own js/dropdowns.js. - * - * Snackbar helper is shared with newsletters.js / donations.js via - * `./util/snackbar` (factored out at the rule-of-three threshold ahead of - * the Phase 5 modals). */ import { __ } from '@wordpress/i18n'; import { snackbar } from './util/snackbar'; -/** - * Map a `data-action` value to its stub snackbar message. `change-subscription` - * is intentionally absent — that one opens the real Figma modal flow (see - * wireChangeSubscriptionModal) and only the modal's terminal "Pay now" surfaces - * a snackbar. Phase 5 swaps the rest for real modals. - * - * @param {string} action Data-action attribute value. - * @return {string|null} Snackbar message, or null if the action is unknown. - */ -function snackbarMessageForAction( action ) { - switch ( action ) { - case 'cancel-subscription': - return __( 'Subscription cancelled.', 'newspack-plugin' ); - case 'renew-subscription': - return __( 'Subscription renewed.', 'newspack-plugin' ); - case 'update-payment-method': - return __( 'Payment method updated.', 'newspack-plugin' ); - default: - return null; - } -} - /** * Wire a Change subscription modal. The modal lives at the bottom of the * subscription-details template (one per active/renewed sub). State machine: @@ -210,102 +187,212 @@ function wireChangeSubscriptionModal( modal ) { } /** - * Wire the list-page inline "renew now" anchor inside the expiring active - * card. The anchor points at `#renew` (and would navigate), so we - * preventDefault and surface the same stub snackbar Phase 5 modals will - * eventually trigger. Idempotent. + * Wire a confirmation modal that flips between an `init` step (the "Are you + * sure?" body) and a `success` step (green check + email-sent line). The + * Cancel subscription modal uses this; donations.js mirrors it for Cancel + * donation. Tied to the action-router below which opens the modal — this + * function only owns the inside-the-modal step transitions. * - * @param {HTMLElement} root List container element. + * Confirm button → success step. Modal close (any close-button or overlay) + * → state resets to init the next time it opens, via the closeModal event + * dispatched by newspack-ui's modals.js mutation observer. + * + * @param {HTMLElement} modal The modal container element. */ -function wireListRoot( root ) { - if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { +function wireConfirmModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { return; } - root.dataset.newspackMyAccountV2DemoWired = 'true'; + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const initStep = modal.querySelector( '[data-step="init"]' ); + const successStep = modal.querySelector( '[data-step="success"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); - root.addEventListener( 'click', event => { - const trigger = event.target.closest( '[data-action="renew-subscription"]' ); - if ( ! trigger || ! root.contains( trigger ) ) { + const goToStep = step => { + if ( ! initStep || ! successStep ) { return; } - // `` triggers also navigate; anchor-form triggers - // (the inline notice) get the snackbar but skip navigation. Button - // triggers don't navigate to begin with. - if ( trigger.tagName === 'A' ) { - event.preventDefault(); - } - snackbar( __( 'Subscription renewed.', 'newspack-plugin' ) ); - } ); + initStep.hidden = step !== 'init'; + successStep.hidden = step !== 'success'; + }; + + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => goToStep( 'success' ) ); + } + + // Reset to the init step every time the modal closes so re-opening + // presents a fresh confirmation rather than the lingering success state. + modal.addEventListener( 'closeModal', () => goToStep( 'init' ) ); } /** - * Wire the detail-page modal-trigger buttons. Each surfaces a stub snackbar - * with the eventual success copy from Figma so the click feels real before - * Phase 5 hooks up real modals. + * Wire a transaction modal — single-screen flow with a billing readout + + * payment form, terminating on a success step. Used by Renew subscription + * (and the donations equivalent for Restart donation). The structure + * mirrors the Change subscription modal's transaction step but skips the + * preceding tier-picker. * - * @param {HTMLElement} root Detail container element. + * @param {HTMLElement} modal The modal container element. */ -function wireDetailRoot( root ) { - if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { +function wireTransactionModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { return; } - root.dataset.newspackMyAccountV2DemoWired = 'true'; + modal.dataset.newspackMyAccountV2DemoWired = 'true'; - root.addEventListener( 'click', event => { - const trigger = event.target.closest( '[data-action]' ); - if ( ! trigger || ! root.contains( trigger ) ) { - return; - } - // Let the dropdown toggle keep its own behaviour — newspack-ui's - // dropdowns.js owns it. - if ( trigger.classList.contains( 'newspack-ui__dropdown__toggle' ) ) { - return; - } + const initStep = modal.querySelector( '[data-step="init"]' ); + const successStep = modal.querySelector( '[data-step="success"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); - // Change subscription opens the multi-step modal flow rendered into - // the page by partials/change-subscription-modal.php. Other actions - // keep the stub-snackbar treatment until Phase 5 wires their modals. - if ( 'change-subscription' === trigger.dataset.action ) { - const subscriptionId = trigger.dataset.subscriptionId || ''; - const modal = document.getElementById( `newspack-my-account__change-subscription-${ subscriptionId }` ); - if ( modal ) { - event.preventDefault(); - const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); - if ( openDropdown ) { - openDropdown.classList.remove( 'active' ); - } - modal.setAttribute( 'data-state', 'open' ); - } + const goToStep = step => { + if ( ! initStep || ! successStep ) { return; } + initStep.hidden = step !== 'init'; + successStep.hidden = step !== 'success'; + }; - const message = snackbarMessageForAction( trigger.dataset.action ); - if ( ! message ) { - return; - } + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => goToStep( 'success' ) ); + } - // Header action links (``) and - // the inline "renew now" anchor in the expiring notice are anchors - // that would otherwise navigate to a hash. Suppress the navigation - // so the page stays put while we surface the snackbar / open the - // modal. - if ( trigger.tagName === 'A' ) { - event.preventDefault(); - } + modal.addEventListener( 'closeModal', () => goToStep( 'init' ) ); +} - // Close any open dropdown the click came from so the menu doesn't - // linger above the snackbar. - const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); - if ( openDropdown && openDropdown.contains( trigger ) ) { - openDropdown.classList.remove( 'active' ); - } +/** + * Look up the modal for a given `data-action` + resource id, opening it if + * it exists. Closes any open dropdown the click came from so the menu + * doesn't hover above the modal. Returns true if a modal was opened. + * + * Update-payment-method has no Phase 5 modal (the brief lumps it with the + * v1 checkout flow), so its action returns null here and the caller falls + * back to a stub snackbar. + * + * @param {string} action Data-action value of the trigger. + * @param {string} subscriptionId Resource id from the trigger. + * @param {HTMLElement} root Container the click came from (for + * dropdown close-on-open). + * @return {boolean} Whether a modal was opened. + */ +function tryOpenModal( action, subscriptionId, root ) { + const slug = { + 'change-subscription': 'change-subscription', + 'cancel-subscription': 'cancel-subscription', + 'renew-subscription': 'renew-subscription', + }[ action ]; + if ( ! slug || ! subscriptionId ) { + return false; + } + const modal = document.getElementById( `newspack-my-account__${ slug }-${ subscriptionId }` ); + if ( ! modal ) { + return false; + } + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown ) { + openDropdown.classList.remove( 'active' ); + } + modal.setAttribute( 'data-state', 'open' ); + return true; +} - snackbar( message ); - } ); +/** + * Map a `data-action` to the stub-snackbar copy used when no modal exists + * for the action (today: only `update-payment-method`). The other actions + * either resolve through `tryOpenModal` or fall through silently. + * + * @param {string} action Data-action value. + * @return {string|null} Snackbar copy, or null if there's nothing to say. + */ +function fallbackSnackbar( action ) { + switch ( action ) { + case 'update-payment-method': + return __( 'Payment method updated.', 'newspack-plugin' ); + default: + return null; + } +} + +/** + * Click handler shared by the list and detail roots. Routes any + * `data-action` trigger through `tryOpenModal` first; falls back to a + * snackbar for actions that don't (yet) have a modal. + * + * @param {HTMLElement} root Container element. + * @param {Event} event Click event. + */ +function handleActionClick( root, event ) { + const trigger = event.target.closest( '[data-action]' ); + if ( ! trigger || ! root.contains( trigger ) ) { + return; + } + // Let the dropdown toggle keep its own behaviour — newspack-ui's + // dropdowns.js owns it. + if ( trigger.classList.contains( 'newspack-ui__dropdown__toggle' ) ) { + return; + } + const action = trigger.dataset.action; + const subscriptionId = trigger.dataset.subscriptionId || ''; + + // Suppress hash navigation on `` etc. so + // the modal opens in place rather than scrolling the page. + if ( trigger.tagName === 'A' ) { + event.preventDefault(); + } + + if ( tryOpenModal( action, subscriptionId, root ) ) { + return; + } + + const message = fallbackSnackbar( action ); + if ( ! message ) { + return; + } + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown && openDropdown.contains( trigger ) ) { + openDropdown.classList.remove( 'active' ); + } + snackbar( message ); +} + +/** + * Wire the list root. The only triggerable action on the list page is the + * inline "renew now" anchor inside the expiring active card's notice, but + * `handleActionClick` covers it: when the renew modal isn't on the page + * (today: never, since sub-expiring lives in `extras` rather than + * `active`), `tryOpenModal` returns false and there's no snackbar copy + * for renew-subscription either, so the click falls through silently. + * Phase 6 fixtures will swap an expiring sub into `active` and render the + * modal alongside, at which point this same handler picks it up. + * + * @param {HTMLElement} root List container element. + */ +function wireListRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + root.addEventListener( 'click', event => handleActionClick( root, event ) ); +} + +/** + * Wire the detail-page action triggers (header buttons, dropdown menu items, + * and the inline `renew now` anchor on the expiring variant's notice). + * + * @param {HTMLElement} root Detail container element. + */ +function wireDetailRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + root.addEventListener( 'click', event => handleActionClick( root, event ) ); } document.addEventListener( 'DOMContentLoaded', () => { document.querySelectorAll( '[data-newspack-my-account-v2-demo="subscriptions"]' ).forEach( wireListRoot ); document.querySelectorAll( '[data-newspack-my-account-v2-demo="subscription-details"]' ).forEach( wireDetailRoot ); document.querySelectorAll( '[data-newspack-my-account-v2-demo="change-subscription-modal"]' ).forEach( wireChangeSubscriptionModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="cancel-subscription-modal"]' ).forEach( wireConfirmModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="renew-subscription-modal"]' ).forEach( wireTransactionModal ); } ); From 891ef3ab0b2c12ef20b8ee08712cfca0dec7fee7 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 17:16:44 +0100 Subject: [PATCH 3/4] docs(my-account): insert Phase 6 (payment methods), renumber polish to Phase 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-Phase-5 scope addition: reproduce v1's `/my-account/payment-methods/` page byte-for-byte under the v2-demo flag, fed by fake data — no new design, no new components. Closes a credibility gap (the v1 surface exists today and readers see it; the prototype had no story there). Brief §3 grows from three to four primary surfaces (newsletters / donations / subscriptions / payment methods). Brief §10 gains a new Phase 6 entry; the previous Phase 6 (polish + scenario fixtures) becomes Phase 7. §12 definition-of-done updated to "five primary screens." Devlog adds the Phase 6 / Phase 7 stubs and a cross-phase decision row. Historical entries' Phase 6 references (scenario fixtures, polish, etc.) are left as-is; the decision row dates the renumbering so the timeline stays auditable. --- docs/my-account-v2-prototype-brief.md | 16 ++++++++++++---- docs/my-account-v2-prototype-devlog.md | 13 +++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/my-account-v2-prototype-brief.md b/docs/my-account-v2-prototype-brief.md index a1f78835d5..2096fa11d9 100644 --- a/docs/my-account-v2-prototype-brief.md +++ b/docs/my-account-v2-prototype-brief.md @@ -150,7 +150,7 @@ Before writing any code: clone the repo via `n`, install deps (`npm install` and ## 3. Scope of screens -Three priority surfaces, each with several variant states. Everything else (account settings, delete account, signed-out, email-unverified) is **reused from v1 as-is** for the prototype. +Four priority surfaces, each with several variant states. Everything else (account settings, delete account, signed-out, email-unverified) is **reused from v1 as-is** for the prototype. **Newsletters** (Figma section `2636:46703`) @@ -182,6 +182,10 @@ v1 already extends the WooCommerce Subscriptions endpoint, but the design is bei - Detail variants: `active`, `active (no fees)`, `cancelled`, `expiring`, `renewed`. - Modals: `Cancel subscription – Init/Success`, `Renew subscription` and its `Success`, `Change subscription – Init / Monthly selected / Plan selected / Transaction modal`. +**Payment methods** (no Figma — reproduce v1 as-is) + +v1 already renders `/my-account/payment-methods/` as a `
` of saved cards with action buttons (Make default / Delete) plus an "Add payment method" CTA. The v2 prototype reproduces the exact v1 DOM under the `?v2-demo` flag, fed by fake data instead of real `wc_get_customer_saved_methods_list()` — no new design, no new components. This means v2-demo readers can see and click through the payment-methods experience without needing real WC payment tokens on the demo site. Phase 6 ships this; details in §10 → Phase 6. + Reused from v1 unchanged: account-page page template, sidebar/menu, account settings, delete-account flow, signed-out state. ## 4. The `?v2-demo` mechanism @@ -615,11 +619,15 @@ All 5 detail variants. Figma section `2636:46116`. v1 already has a subscription All six modal flows (cancel donation, modify donation, restart donation, cancel subscription, renew subscription, change subscription) wired to client-side handlers + toast confirmations. All use `Newspack / Modal` → `.newspack-ui__modal*` (see §6). -### Phase 6 — Polish + scenario fixtures (~0.5 day) +### Phase 6 — Payment methods (~1–1.5 days) + +Reproduce v1's `/my-account/payment-methods/` surface byte-for-byte under the v2-demo flag, fed entirely by fake data (no real WooCommerce payment-token storage). The v1 page is a saved-cards table backed by `wc_get_account_payment_methods_columns()` + `wc_get_account_payment_methods_types()` plus an "Add payment method" CTA; the v2 prototype renders the same DOM (`` headers, per-row method/expiry/actions cells, default-card badge) so v1's existing styling carries through verbatim. Fake data adds a `payment_methods` slice on `My_Account_UI_V2_Demo::get_fake_data()` (an array of saved cards with brand / last4 / expiry / is_default / actions). Action buttons (Make default / Delete / Add new) wire to client-side stubs that surface snackbars, mirroring the Phase 5 modal-trigger pattern. Sidebar item is added at the same priority as the Phase 1–4 endpoints. No new modals. + +### Phase 7 — Polish + scenario fixtures (~0.5 day) `?v2-demo=` overrides for cancelled / expired / empty / no-fees states (see §7). Final screenshot pass against Figma. -**Total estimate:** ~7 dev-days for one engineer, end-to-end clickable on any Newspack site. +**Total estimate:** ~8–8.5 dev-days for one engineer, end-to-end clickable on any Newspack site. ## 11. Working in the open — dev log practice @@ -655,7 +663,7 @@ Add links to PRs, commits, and Figma frames. Keep it scannable. - An admin can append `?v2-demo` (or `?v2-demo=`) to any `/my-account/...` URL on any Newspack site and see the prototype rendered. - A non-admin appending the same URL sees v1 unchanged. - All visible markup uses `.newspack-ui*` classes. The v2-demo `style.scss` contains the `.newspack-my-account--v2-demo` scoping wrapper and effectively nothing else — open it in PR review and check. -- All four primary screens (dashboard, newsletters, donations, subscriptions) plus all six modals are reachable. +- All five primary screens (dashboard, newsletters, donations, subscriptions, payment methods) plus all six modals are reachable. - Scenario flag toggles produce visibly different fixtures. - `npm run lint` and `npm run lint:php` pass. - The devlog has at least one entry per shipped phase. diff --git a/docs/my-account-v2-prototype-devlog.md b/docs/my-account-v2-prototype-devlog.md index 0da02dd765..5385635263 100644 --- a/docs/my-account-v2-prototype-devlog.md +++ b/docs/my-account-v2-prototype-devlog.md @@ -282,9 +282,17 @@ The `closeModal` event newspack-ui's `modals.js` dispatches on every state→clo --- -## Phase 6 — Polish + scenario fixtures +## Phase 6 — Payment methods -> See [brief §10 → Phase 6](my-account-v2-prototype-brief.md#phase-6--polish--scenario-fixtures-05-day). +> See [brief §10 → Phase 6](my-account-v2-prototype-brief.md#phase-6--payment-methods-115-days). Reproduce v1's `/my-account/payment-methods/` byte-for-byte under the v2-demo flag, fed by fake data. + +_(empty)_ + +--- + +## Phase 7 — Polish + scenario fixtures + +> See [brief §10 → Phase 7](my-account-v2-prototype-brief.md#phase-7--polish--scenario-fixtures-05-day). _(empty)_ @@ -328,3 +336,4 @@ A flat list of decisions that span phases or that future-you will want to find w | 2026-04-28 | `update-payment-method` stays a snackbar in Phase 5. Brief §10 lumps it with the v1 checkout flow (real Stripe form, real billing readout from WC); a fake modal would either duplicate renew/restart's payment form or pretend to mutate state. Phase 6 / productisation decides how to wire it. | Phase 5 | | 2026-04-28 | Renew + Restart modals reuse the `subscriptions.tiers.billing` fixture from the Phase 4 fake-data slice for the billing readout. Donations don't carry their own billing block; the demo address is shared across all transactional modals so reusing keeps the prototype's fictional reader consistent without proliferating fixtures. | Phase 5 | | 2026-04-28 | Modal data-attribute payloads (`data-unit-labels`, `data-next-dates`, `data-recurring-total-labels`, `data-vat-rate`, `data-fee-rate`, `data-currency-symbol`) replace `wp_localize_script` for per-modal config. Each modal partial JSON-encodes its own params on the container element; the JS reads them once on wire-up. Cheaper than a separate localization pass when each payload is bound to a specific resource id and lives next to the markup that consumes it. | Phase 5 | +| 2026-04-28 | Roadmap renumbering: insert a new **Phase 6 — Payment methods** (reproduce v1's `/my-account/payment-methods/` page byte-for-byte under the demo flag, fake data only — no new design). The previous Phase 6 (polish + scenario fixtures) becomes **Phase 7**. Brief §3 grows from three to four primary surfaces (newsletters / donations / subscriptions / payment methods); §10 gets a new Phase 6 entry; §12 definition-of-done updated to "five primary screens" (dashboard counts as a screen). Reason: the prototype currently has no story for the payment-methods page, but v1 has a real surface there that readers see today. Reproducing the existing v1 surface keeps the prototype credible without expanding the design footprint. | Phase 5 (mid-phase scope addition) | From c987a776a04f443cfeb460d9a16975cd564316a5 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 17:28:00 +0100 Subject: [PATCH 4/4] fix(my-account): address Copilot review on Phase 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues flagged on PR #4681: - subscriptions.js: `handleActionClick` was unconditionally calling `preventDefault()` on any anchor with a `data-action`, which would break the list-page "Manage subscription" link (``) by suppressing real navigation when no modal handler matches. Move `preventDefault()` inside the handled paths so unhandled actions let the trigger behave naturally. - modify-donation-modal.php: the amount input used `number_format_i18n()` for both `value` and `data-initial-amount`. In comma-decimal locales this produces "9,99", which JS `parseFloat` truncates to 9 — breaking the live totals math and confirm-button enable/disable. Switch to dot-decimal `number_format($amount, 2, '.', '')` for the machine-readable value (display-only strings keep i18n). - renew-subscription-modal.php / restart-donation-modal.php: the `
` would duplicate IDs when multiple modals render on the same page (the brief's forward-compat for Phase 6 fixtures puts more than one renew modal on the list view) and could collide with WC's own checkout markup elsewhere. Suffix with the resource id. --- .../v2-demo/partials/modify-donation-modal.php | 12 ++++++++++-- .../partials/renew-subscription-modal.php | 2 +- .../partials/restart-donation-modal.php | 2 +- src/my-account/v2-demo/subscriptions.js | 18 ++++++++++++------ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php index 69102037e0..169bc6b2d0 100644 --- a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/modify-donation-modal.php @@ -147,15 +147,23 @@ class="" ?> + ` requires a dot-decimal value regardless + // of locale; the JS `parseFloat()` in donations.js can't handle + // `9,99` from a comma-decimal locale. Use plain `number_format` + // with explicit `.` decimal sep + no thousands separator for both + // the input value and the data-initial-amount attribute. + $amount_machine = number_format( $initial_amount, 2, '.', '' ); + ?>
diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php index 2a68f70293..3893736738 100644 --- a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/renew-subscription-modal.php @@ -103,7 +103,7 @@ class="newspack-ui__button newspack-ui__button--primary newspack-ui__button--wid -
+