From 5e760d2fc8ebb22614cacdb257b6909f7349ac0c Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 11:20:05 +0100 Subject: [PATCH 01/32] feat(my-account): scaffold v2 prototype demo gate behind ?v2-demo flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 plumbing for the My Account v2 prototype. Adds a new `Newspack\My_Account_UI_V2_Demo` class that gates a stub on `is_account_page()` + admin + `?v2-demo`, sets a scoped body class, enqueues a dedicated webpack bundle (`my-account-v2-demo`), and short-circuits to a no-op for everyone else. Loaded after the v1 class so v1's filters register first. Bundles in `src/my-account/v2-demo/{index.js,style.scss}`. SCSS is intentionally empty under the `.newspack-my-account--v2-demo` scope — newspack-ui primitives drive the visuals in later phases. Brief and dev log live under `docs/my-account-v2-prototype-*.md`. --- docs/my-account-v2-prototype-brief.md | 683 ++++++++++++++++++ docs/my-account-v2-prototype-devlog.md | 120 +++ .../class-my-account-ui-v2-demo.php | 121 ++++ .../class-woocommerce-my-account.php | 5 + src/my-account/v2-demo/index.js | 13 + src/my-account/v2-demo/style.scss | 7 + webpack.config.js | 1 + 7 files changed, 950 insertions(+) create mode 100644 docs/my-account-v2-prototype-brief.md create mode 100644 docs/my-account-v2-prototype-devlog.md create mode 100644 includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php create mode 100644 src/my-account/v2-demo/index.js create mode 100644 src/my-account/v2-demo/style.scss diff --git a/docs/my-account-v2-prototype-brief.md b/docs/my-account-v2-prototype-brief.md new file mode 100644 index 0000000000..a93e93c160 --- /dev/null +++ b/docs/my-account-v2-prototype-brief.md @@ -0,0 +1,683 @@ +# My Account v2 Prototype — Design / Dev Brief + +**Status:** Draft for review +**Author:** thomas@a8c.com +**Last updated:** 2026-04-28 +**Figma:** [My Account — i5 (Final)](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-44336) + +--- + +## 1. Goal + +Stand up a clickable prototype of the "My Account v2" experience inside `newspack-plugin`, gated to admins via a `?v2-demo` query parameter on the WooCommerce `/my-account/` URL. The prototype must be: + +- **In-repo and testable** on any Newspack site (no separate sandbox). +- **Built exclusively from existing Newspack UI primitives** (`/src/newspack-ui/`) and its utility classes — no bespoke CSS or one-off React components. New components only as a last resort, and only as additions to `newspack-ui` itself. +- **Fed by fake/static data** — no real WooCommerce, WC Subscriptions, or ESP queries. The demo can render for admins on any site, even one with no donations or subscriptions configured. +- **Strictly scoped** — never visible to non-admin users, never altering production behaviour, never leaking styles outside the demo body class. + +Out of scope: production wiring, real ESP integration, real Stripe/payment flows, mobile-first polish (mobile is in Figma but secondary; desktop is the priority for the prototype). + +## 2. Before you start (junior dev primer) + +This section exists for devs new to WordPress, WooCommerce, or the Newspack codebase. Skip it if you've already shipped a Newspack feature. + +### 2.1 — The non-negotiable rule: newspack-ui only + +**Build everything with existing Newspack UI components and utility classes. No custom CSS.** This is the whole point of the prototype — proving the design can be assembled from primitives we already ship. + +If you find yourself about to write a fresh CSS rule, stop and check three places first: the [Newspack UI utility class reference](../src/newspack-ui/UTILITY_CLASSES.md), the existing class names in `src/newspack-ui/scss/elements/`, and the live demo at any URL with `?ui-demo` appended (an admin-only gallery of every component on display). If a genuine gap exists, raise it as an addition to `src/newspack-ui/` itself — not a one-off rule inside the prototype's SCSS. Document the gap in the devlog (see §11) before you write the workaround. + +The demo's SCSS file (`src/my-account/v2-demo/style.scss`) should stay nearly empty. Most of it is just the scoping wrapper: + +```scss +.newspack-my-account--v2-demo { + // If you're writing rules in here, pause and re-read §2.1. +} +``` + +If at any point your `style.scss` has more than a handful of lines, treat that as a smell and ask in PR review. + +### 2.2 — WordPress hooks in 30 seconds + +WordPress runs hundreds of named events ("hooks") during every request. Code subscribes to a hook and runs at that moment. Two flavours: *actions* (do something — e.g., `wp_enqueue_scripts`) and *filters* (transform a value — e.g., `body_class` filter takes the array of CSS classes for the `` element and returns a modified array). You attach with `add_action( 'hook_name', $callback )` or `add_filter( 'hook_name', $callback )`. That's the whole model. + +### 2.3 — The hooks we'll actually use + +- `wc_get_template` (filter) — WooCommerce's template-override hook. When WC asks for `myaccount/dashboard.php`, our filter can return a different file path. This is how v1 swaps in its own templates and how v2 will too. +- `woocommerce_account_content` (action) — fires inside the `[woocommerce_my_account]` shortcode body. Use this (not `the_content`) for any "inject content into the My Account page" trick. v1's custom page template renders the account area via the shortcode and never calls `the_content()`, so the `the_content` filter doesn't fire here. +- `body_class` (filter) — modify the `` array. We add `newspack-my-account--v2-demo` so SCSS scoping works. +- `woocommerce_account_menu_items` (filter) — modify the sidebar nav items. v1 already filters this at priority 1001; we run later (1100) so we can rename items only when the demo flag is set. +- `query_vars` (filter) — tell WordPress that `v2-demo` is a recognized URL parameter so it isn't stripped during URL parsing. +- `wp_enqueue_scripts` (action) — register a CSS/JS bundle for a page. +- `wp_localize_script` (function) — pass a PHP array to the browser as a JS global like `window.newspackMyAccountV2Demo = {...}`. This is how the fake data reaches the JS layer. +- `add_rewrite_endpoint` + `flush_rewrite_rules` — register `/my-account/donations/` as a real URL. Endpoints have to be flushed once after registration; otherwise WordPress returns 404 for the new URL. +- `is_account_page()` — WooCommerce helper, returns `true` if the current request is anywhere under `/my-account/`. We gate every hook on this. + +### 2.4 — Newspack class init pattern + +Most Newspack PHP classes live in `includes/`, declare `namespace Newspack;`, and end with a one-liner that calls `init()`. The class does nothing until `init()` runs and registers its hooks. + +```php +namespace Newspack; + +final class My_Feature { + public static function init() { + add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue' ] ); + } + public static function enqueue() { + // ... + } +} +My_Feature::init(); +``` + +### 2.5 — Two foot-guns to know about + +**`composer dump-autoload`.** When you add a new PHP class file, the autoloader doesn't know about it until you run this command. If your class works "everywhere except after a deploy", you forgot it. + +**`src/shared/js/public-path.js`.** Every standalone webpack entry must `import` this file as its very first line. It tells webpack where to load lazy-loaded chunks from at runtime. Skip it and the bundle compiles fine but breaks in the browser with cryptic 404s on chunked assets. + +### 2.6 — Newspack UI requires the `.newspack-ui` body wrapper + +All Newspack UI styles are scoped under an ancestor with class `newspack-ui`. v1 already adds it to the body class on `/my-account/`, so we inherit it for free. If you ever build a Newspack UI page outside My Account, add the wrapper class yourself — otherwise none of the styles apply. + +### 2.7 — Tooling: Figma MCP + +The design source of truth is here: + +- **File:** [My Account — i5 (Final)](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-44336&t=WMJl9b81CENbehe7-1) +- **MCP file key:** `mkvHE3qozmmGrytPt9RGrV` + +If your Claude/Cursor/etc. workspace has the Figma MCP server enabled, you can pull live design context for any frame instead of guessing from a static screenshot. The available tools are `get_design_context` (returns reference code, screenshot, and visible text), `get_screenshot`, `get_metadata`, and `get_variable_defs`. + +Important: the MCP integration is bound to the **Figma desktop app** — you must have Figma open and the frame *selected* in the canvas before the tools can return useful per-frame data. Bulk metadata works without selection but only includes structural information (frame names, sizes, hierarchy), not visible text or copy. + +Recommended workflow per screen: + +1. Open the Figma file in the desktop app. +2. Select the specific frame you're implementing (its node ID is in Appendix A). +3. From your editor/agent, call `get_design_context` for that frame. +4. Implement the screen using newspack-ui (per §2.1) and the Figma reference for copy and structure only. + +Don't paste Figma reference code verbatim — it's a generic React/HTML approximation, not Newspack UI markup. Use it to confirm copy and layout, then re-build with our components. + +### 2.8 — Files to read first (~60 minutes well spent) + +- [`includes/class-newspack-ui.php`](../includes/class-newspack-ui.php) — see `init()` and `load_demo()`. The simplest example of an admin-gated demo. +- [`includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php`](../includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php) — the closest analog to what you'll build. Pay attention to `init()`, `page_template()`, `add_body_class()`, `enqueue_assets()`, and the `wc_get_template` filter. +- [`includes/plugins/woocommerce/my-account/templates/v1/`](../includes/plugins/woocommerce/my-account/templates/v1/) — open `navigation.php` and `my-account.php` to see the layout shell we're reusing. +- [`src/newspack-ui/UTILITY_CLASSES.md`](../src/newspack-ui/UTILITY_CLASSES.md) — utility class reference. **Read this twice.** Stack is the layout primitive; everything else builds on it. +- The `?ui-demo` gallery — log in as admin and visit any page with `?ui-demo` appended. Bookmark it. Refer to it whenever you're not sure which class to use. +- [`webpack.config.js`](../webpack.config.js) — search for `my-account` to see the entries you'll be adding alongside. +- Root [`newspack-workspace/AGENTS.md`](../../AGENTS.md) — Docker setup, the `n` script. +- This repo's [`AGENTS.md`](../AGENTS.md) — namespace map, lint commands, common gotchas. + +### 2.9 — Local setup checklist + +Before writing any code: clone the repo via `n`, install deps (`npm install` and `composer install`), boot a local site via Docker, log into wp-admin as an administrator, and confirm you can visit `/my-account/` and see the v1 layout. Then visit `/?ui-demo` on any page and confirm the component gallery renders. If either fails, fix that first — there's no shortcut. + +--- + +## 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. + +**Newsletters** (Figma section `2636:46703`) + +The v1 surface today is a thin form repurposed from the WooCommerce edit-account page. v2 promotes it to a first-class endpoint with grouped lists. + +- `Newsletters list w/ sections` — categories (e.g. Featured / Premium / Tech), each a section with its own list of newsletter rows and per-row subscribe state. +- `Newsletters list w/o categories` — flat list variant for sites without categories. +- `Newsletters list w/ sections — Unsubscribed` — confirmation toast state after unsubscribing from one newsletter. + +**Row anatomy.** Each newsletter row has a square *thumbnail* (a full image, not an icon — fake data should use `https://picsum.photos/seed/{slug}/128/128` so every row gets a stable but distinct image), a name, a *frequency badge* (display labels: `Daily`, `Weekly`, `Monthly`, `Twice weekly`, `As needed`, plus free-text fallbacks like "3 times a week"), an optional `SUBSCRIBER-ONLY` badge, a one-line description, and a `Sign up` or `Unsubscribe` button. Categories in the w/sections variant are *always-expanded visual labels, not collapsible accordions*. The bottom of the list has a separate "Unsubscribe from all" row with its own button. + +**Donations** (Figma section `2636:46466`) + +There is no dedicated v1 donations surface today. Donations piggy-back on the WooCommerce Subscriptions endpoint or order history. v2 introduces a first-class endpoint. + +- `Donations (init)` — list view, splits into "recurring" and "previous" sections. +- `Donations details — recurring (active)`. +- `Donations details — recurring (cancelled)`. +- `Donations details — one-time`. +- `Donations details — new payment method` (variant after a successful payment-method update). +- `Donations w/ billing history` — list with an embedded billing-history table. +- Modals: `Modify donation`, `Cancel donation – Init`, `Cancel donation – Success`, `Restart monthly donation`. + +**Subscriptions** (Figma section `2636:46116`) + +v1 already extends the WooCommerce Subscriptions endpoint, but the design is being substantially upgraded. + +- `Subscriptions (init)` and `Subscriptions (init 2)` — list with active and previous sections. +- 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`. + +Reused from v1 unchanged: account-page page template, sidebar/menu, account settings, delete-account flow, signed-out state. + +## 4. The `?v2-demo` mechanism + +The model is **`?ui-demo`** as implemented in `includes/class-newspack-ui.php`. That class hooks `the_content` and, if `isset( $_REQUEST['ui-demo'] )` and `current_user_can( 'manage_options' )`, appends a long inline demo to the rendered content. v2 follows the same shape but operates earlier in the pipeline because My Account uses a custom page template, not `the_content`. + +A new class `Newspack\My_Account_UI_V2_Demo` lives at `includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php`. It: + +1. **Gates everything on `is_account_page() && is_user_logged_in() && current_user_can( 'manage_options' ) && isset( $_GET['v2-demo'] )`.** A single private static helper `is_demo_active()` returns the boolean; every other hook short-circuits when it returns false. No-op for everyone else. +2. **Adds a body class `newspack-my-account--v2-demo`** via `body_class`. All v2 SCSS is nested under this selector so demo styles cannot leak. +3. **Swaps templates** via `wc_get_template` (the same hook v1 uses) for the dashboard, newsletters, donations, and subscriptions endpoints — pointing them at v2 templates under `includes/plugins/woocommerce/my-account/templates/v2-demo/`. +4. **Forces v1's account-page page template** to remain in effect (no header/footer chrome) — v1's `My_Account_UI_V1::page_template()` already does this for any logged-in account page, so we inherit it for free. +5. **Enqueues a single new bundle** `newspack-my-account-v2-demo` (CSS + JS, see §6). Existing `newspack-ui` script + style is already enqueued globally by `Newspack_UI`, so we get all of newspack-ui for free without touching the enqueue list. +6. **Registers a fake-data provider** keyed by the WP user id (so the demo state is per-admin and per-session, not global). Fake data is exposed to JS via `wp_localize_script( 'newspack-my-account-v2-demo', 'newspackMyAccountV2Demo', [...] )` and to PHP templates via a shared `My_Account_UI_V2_Demo::get_fake_data()` static method. +7. **Registers a query var** `v2-demo` via the `query_vars` filter so WordPress doesn't strip it. + +The class follows the static `init()` pattern (per `AGENTS.md`), is `include_once`d from `includes/class-newspack.php` after the v1 class, and `composer dump-autoload` is run after creation. + +```php +namespace Newspack; + +final class My_Account_UI_V2_Demo { + const DEMO_FLAG = 'v2-demo'; + + public static function init() { + add_filter( 'query_vars', [ __CLASS__, 'query_vars' ] ); + add_filter( 'body_class', [ __CLASS__, 'body_class' ] ); + add_filter( 'wc_get_template', [ __CLASS__, 'wc_get_template' ], 5, 5 ); + add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'menu_items' ], 1100 ); + add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ], 12 ); + add_action( 'init', [ __CLASS__, 'register_endpoints' ] ); + } + + private static function is_demo_active() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET[ self::DEMO_FLAG ] ) ) return false; + if ( ! function_exists( 'is_account_page' ) || ! is_account_page() ) return false; + if ( ! is_user_logged_in() ) return false; + return current_user_can( 'manage_options' ); + } + // ... +} +My_Account_UI_V2_Demo::init(); +``` + +The demo flag is preserved across navigation by appending `?v2-demo=1` to every internal nav link generated for the v2 menu items (a small filter on `woocommerce_get_account_menu_item_classes` / a `wc_get_account_endpoint_url` wrapper). + +## 5. File structure + +``` +includes/plugins/woocommerce/my-account/ +├── class-my-account-ui-v2-demo.php # NEW — demo gate + fake data +└── templates/v2-demo/ # NEW — v2 templates + ├── dashboard.php # entry, optional welcome + ├── newsletters.php # priority screen + ├── newsletters-row.php # partial + ├── donations.php # list (init / w/ billing) + ├── donation-details.php # detail page + ├── subscriptions.php # list (init) + ├── subscription-details.php # detail page + ├── partials/ + │ ├── plan-card.php # composed of .newspack-ui__box + │ ├── billing-history-table.php + │ ├── section-title.php + │ └── menu-sidebar.php # passes through to v1 nav + └── modals/ + ├── cancel-donation.php + ├── modify-donation.php + ├── restart-donation.php + ├── cancel-subscription.php + ├── renew-subscription.php + └── change-subscription.php + +src/my-account/v2-demo/ +├── index.js # webpack entry, imports public-path then style + JS +├── style.scss # all styles scoped under .newspack-my-account--v2-demo +├── newsletters.js # wires up unsubscribe toast, optimistic UI +├── donations.js # cancel/modify modal triggers +├── subscriptions.js # cancel/renew/change modal triggers +└── fake-data.js # mirrors the PHP fake-data shape +``` + +A new webpack entry is added to `webpack.config.js` (alongside the existing `my-account`, `my-account-v0`, `my-account-v1` entries): + +```js +'my-account-v2-demo': path.join( __dirname, 'src', 'my-account', 'v2-demo', 'index.js' ), +``` + +Per `AGENTS.md`, `index.js` must `import 'src/shared/js/public-path.js'` first. + +## 6. Component mapping (Figma → newspack-ui) + +> **Reminder of §2.1: no custom CSS.** Every row in the table below maps to an existing class or composition. If you reach for a class you don't see here, search `src/newspack-ui/scss/` first, then visit `?ui-demo` on a logged-in admin session, then ask in PR review — *before* writing any new SCSS. + +Every Figma element maps to an existing newspack-ui primitive. There are no genuinely new components; everything is composition + utility classes. The "Newspack / …" naming in Figma corresponds directly to newspack-ui CSS classes once the deprecated entries are swapped. + +| Figma instance / pattern | newspack-ui mapping | Source | +|---|---|---| +| `Menu - Sidebar` (left nav) | Re-render v1's `templates/v1/navigation.php` unchanged | `class-my-account-ui-v1.php` | +| `Newspack / Modal` | `.newspack-ui__modal-container` + `__modal` + `__header` / `__content` / `__footer` | `src/newspack-ui/scss/_modals.scss`, `js/modals.js` | +| `Newspack / Badge` | `.newspack-ui__badge` and variants (`--success`, `--warning`, `--error`, `--outline`) | `scss/elements/misc/_badge.scss` | +| `Newspack / Radio` and `Newspack / Radio Card` | Native `input[type="radio"]` styled by `_checkbox-radio.scss`; "Radio Card" = a `.newspack-ui__input-card` wrapper | `scss/elements/forms/` | +| `Newspack / Note` | `.newspack-ui__notice` (and `--success` / `--warning` / `--error`) | `scss/elements/_notices.scss` | +| `Toast Notification` | `Newspack_UI::add_notice()` PHP helper (renders `.newspack-ui__snackbar__item`) | `class-newspack-ui.php` | +| `Plan Card` (donation/subscription tile) | `.newspack-ui__box` + a vertical `newspack-ui__stack` of: badge, amount (`__font--xl --bold`), frequency (`__color--neutral-60`), action button row | composition | +| `Button Card` (clickable CTA tile) | `` with stack contents | composition | +| `section-title` | `

` | utilities | +| `table-header` / `table-row` (billing history) | newspack-ui table styles (`scss/elements/_tables.scss`) wrapped in `.newspack-ui__box` for the rounded card | composition | +| `Dropdown` (menus on subscriptions/donations rows) | `.newspack-ui__dropdown__toggle` + `__dropdown__content` (already wired up in `js/dropdowns.js`) | `scss/elements/misc/_dropdown.scss` | +| `Input [Text]` | Default `input[type="text"]` styling under `.newspack-ui` | `scss/elements/forms/_text-inputs.scss` | +| `Checkbox` (newsletter rows) | Native `input[type="checkbox"]` | `_checkbox-radio.scss` | +| `[DEPRECATED] Newspack / Button` | **Replace** with `.newspack-ui__button` family (`--primary`, `--secondary`, `--ghost`, `--destructive`) | `_buttons.scss` | +| Newsletter "categories" sections | Always-expanded visual labels — **not** collapsible accordions. A vertical stack of `section-title` + a `.newspack-ui__box` containing the rows. | utilities | +| Newsletter row thumbnail | A square `` (request 128×128, render at design size) inside a sized container. For fake data, `https://picsum.photos/seed/{slug}/128/128` gives a stable but distinct image per newsletter. | composition | +| Frequency badge / `SUBSCRIBER-ONLY` badge | `.newspack-ui__badge` (frequency) and `.newspack-ui__badge--outline` (subscriber-only). Both render side-by-side in a horizontal stack. | `_badge.scss` | +| "Unsubscribe from all" row | A `.newspack-ui__box` at the bottom of the newsletters list with title + supporting copy on the left and a `.newspack-ui__button--secondary` on the right. | composition | +| "active" vs "previous" / "cancelled" splits | Vertical stack with two section-titles and two stacks of `Plan Card`s | utilities | + +**Layout primitives.** Page chrome on every screen is the same: `container` (max-width content area, ~768px) → vertical `newspack-ui__stack --gap-6` for top-level sections. Inside each section, a vertical stack of `.newspack-ui__box` cards. Margins are controlled by stack `gap`, never per-element margins (per `UTILITY_CLASSES.md`). The whole page sits inside `` — `newspack-ui` is the wrapper newspack-ui CSS scopes itself to. + +**Things to verify in newspack-ui before final implementation.** Whether a "Plan Card" pattern already has a documented compositional recipe; whether `.newspack-ui__accordion` supports the section grouping we want for newsletters; whether tables are styled within `.newspack-ui__box` or stand alone. Spot-checks of the `?ui-demo` page on a local site will confirm. + +## 7. Fake data + +Fake data lives in **PHP** (single source of truth) inside `class-my-account-ui-v2-demo.php` as a static method `get_fake_data()` returning an associative array, optionally filtered through a small filter for the demo to be reshaped per scenario. It is shipped to the browser via `wp_localize_script`. JS consumers read from `window.newspackMyAccountV2Demo`. + +**Data shape (informed by Figma, kept minimal):** + +```php +return [ + 'reader' => [ + 'display_name' => 'Casey Reader', + 'email' => 'casey@example.com', + 'avatar_url' => '...', + ], + 'newsletters' => [ + 'sections' => [ + [ + 'id' => 'featured', + 'label' => 'Featured', + 'lists' => [ + [ + 'id' => 'morning-brief', + 'name' => 'The Morning', + 'description' => 'A daily roundup of the most relevant stories from the previous day', + 'frequency' => 'Daily', // Display label. Conventional: Daily, Weekly, Monthly, Twice weekly, As needed. Free-text fallback allowed (e.g. '3 times a week'). + 'subscriber_only' => false, + 'thumbnail' => 'https://picsum.photos/seed/morning-brief/128/128', + 'subscribed' => true, + ], + // ... + ], + ], + // 'premium', 'tech' ... + ], + 'unsubscribe_from_all' => [ + 'enabled' => true, // Renders the bottom row. Click handler is client-side only — fires a toast. + ], + ], + 'donations' => [ + 'recurring' => [ + [ + 'id' => 'don-001', + 'status' => 'active', // active | cancelled | expired + 'amount' => 15.00, + 'currency' => 'USD', + 'frequency' => 'month', // month | year + 'next_payment' => '2026-05-12', + 'started' => '2024-03-01', + 'payment_method' => [ 'brand' => 'Visa', 'last4' => '4242', 'exp' => '12/27' ], + ], + ], + 'one_time' => [ + [ 'id' => 'don-002', 'amount' => 50.00, 'currency' => 'USD', 'date' => '2025-12-04' ], + ], + 'billing_history' => [ + [ 'date' => '2026-04-12', 'amount' => 15.00, 'status' => 'paid', 'invoice_url' => '#' ], + // ... + ], + ], + 'subscriptions' => [ + 'active' => [ + [ + 'id' => 'sub-001', + 'status' => 'active', // active | cancelled | expiring | renewed + 'product' => 'Newspack Premium', + 'amount' => 9.99, + 'currency' => 'USD', + 'frequency' => 'month', + 'next_payment' => '2026-05-04', + 'fees_covered' => true, + 'payment_method' => [ 'brand' => 'Mastercard', 'last4' => '5454', 'exp' => '08/28' ], + ], + ], + 'previous' => [ + [ 'id' => 'sub-002', 'product' => 'Daily Digest', 'status' => 'cancelled', 'ended' => '2025-09-01' ], + ], + ], +]; +``` + +**Variant switching for screenshot/test scenarios.** A second query parameter, `?v2-demo=`, picks a fixture variant: `?v2-demo=cancelled-sub`, `?v2-demo=expired-payment`, `?v2-demo=no-donations`, etc. `My_Account_UI_V2_Demo::get_fake_data()` merges the scenario overrides into the base fixture. The default `?v2-demo=1` is the "happy path" (one active recurring donation, one active sub, one one-time donation, billing history populated). + +**Mutations.** All "Cancel", "Modify", "Renew", "Change", "Unsubscribe" actions are **client-side only** in JS — they trigger the appropriate modal, then on submit show a toast via `newspackUI` (already exposed by `js/modals.js`) and optimistically update the DOM. No POST, no AJAX. This keeps the prototype stateless and risk-free. + +## 8. Reuse vs. new (cheat sheet) + +Reuse from v1 verbatim: +- Page template: `templates/v1/my-account.php` (no header/footer wrapper). +- Sidebar/menu: `templates/v1/navigation.php` and `My_Account_UI_V1::my_account_menu_items()`. +- Body classes machinery, `is_account_page` gating, `wc_get_template` hook plumbing. +- `Newspack_UI::add_notice()` for snackbars/toasts. +- Email-unverified, signed-out, account settings, delete-account screens (no v2 designs are missing from these for prototype purposes). + +Replace for v2: +- Newsletters main column — promote from inline form to dedicated endpoint. +- Subscriptions list and detail templates — new layout, new modals. +- Donations list and detail templates — entirely new endpoint (didn't exist in v1). + +Add menu items via `woocommerce_account_menu_items` filter on the demo class only (so they appear ONLY when `?v2-demo` is set): +- "Newsletters" (slug `newsletters`) +- "Donations" (slug `donations`) +- "Subscriptions" (slug `subscriptions`) — already exists from WC Subscriptions, just relabel/reorder. + +Custom endpoints (`add_rewrite_endpoint`) are registered conditionally in `My_Account_UI_V2_Demo::register_endpoints()`. After registration, a flush-rewrite-rules step is needed once per site (CLI: `wp rewrite flush`). Note this in onboarding. + +## 9. Risks & open questions + +1. **Sidebar nav collisions.** v1's `My_Account_UI_V1::my_account_menu_items()` already filters and renames items. Our v2 filter must run at a higher priority (1100 vs 1001) and only mutate when the demo flag is set. Verify there's no `wp_cache`-style memoization that bakes the v1 list before our filter runs. +2. **Endpoint flush.** Registering `donations` and `newsletters` endpoints requires a one-time `flush_rewrite_rules`. We should not auto-flush on every demo load. Recommendation: flush on plugin activation if a constant is defined, or document a manual `wp rewrite flush` step. +3. **Plan Card / Button Card components.** These are Figma instance names that map to *compositions* in newspack-ui, not single classes. If the same composition is repeated in 5+ places we should extract a partial (`partials/plan-card.php`) — but **not** a new Sass class. If a true gap appears, raise it as an addition to `newspack-ui` rather than a one-off in the prototype. +4. **`?v2-demo` link preservation.** Every internal link must carry the flag forward; otherwise clicking a sidebar item drops back to v1. Wrap `wc_get_account_endpoint_url()` results in a small helper that re-appends the demo query var when the demo is active. +5. **Mobile.** Figma has both desktop and a `MOBILE` section (`4105:131109`). The prototype targets desktop first. Mobile responsive tweaks should land in a follow-up; v1's existing breakpoints will hold up reasonably as a baseline. +6. **Translatable strings.** All visible copy must use `__( '…', 'newspack-plugin' )`. Even fake data labels (e.g. "The Morning") should be wrapped, since this prototype lives in the plugin and gets scanned by translation tooling. +7. **Figma desktop-app dependency.** The Figma MCP available in this workspace requires the desktop app and a layer selection to return component code; bulk metadata is available but text content is not. Detailed text/copy in screens must be pulled either by selecting each frame in the Figma desktop app at implementation time, or by using exported screenshots as the source of truth. +8. **Donations "no fees" vs "with fees" variants.** Both subscriptions and donations have `(no fees)` variants. Confirm the data flag (e.g. `fees_covered` boolean) and how the Figma renders the difference (likely an extra line in the Plan Card). + +## 10. Suggested phasing + +### Phase 1 — Plumbing (~half a day) + +**Goal:** an admin who appends `?v2-demo=1` to `/my-account/` sees a "Hello v2 demo" stub *and* the body class `newspack-my-account--v2-demo`. A non-admin appending the same URL sees v1 unchanged. + +**Step 1 — Read first.** Spend 30–60 minutes on the files listed in §2.8. Don't skip this. + +**Step 2 — Create the PHP class.** New file `includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php`: + +```php +

' . esc_html__( 'Hello v2 demo. Phase 1 stub is working.', 'newspack-plugin' ) . '

'; + } +} +My_Account_UI_V2_Demo::init(); +``` + +This is intentionally smaller than the final shape sketched in §5 — Phase 1 just proves the gate works. Later phases swap in `wc_get_template` for real templates, register endpoints, and add menu items. + +**Step 3 — Wire it into the bootstrap.** The v1 class isn't included from `includes/class-newspack.php`; it's loaded from `includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php` inside an `init`-time `else` branch (the v0/v1 version switch, gated on `Reader_Activation::is_enabled()`). Add the v2-demo `include_once` immediately after the v1 includes there, so v1's filters register first and v2-demo inherits the same Reader Activation + version gating for free: + +```php +// inside class-woocommerce-my-account.php, the v1 branch of the version switch +include_once __DIR__ . '/class-my-account-ui-v1.php'; +include_once __DIR__ . '/class-my-account-ui-v1-passwords.php'; +include_once __DIR__ . '/class-my-account-ui-v2-demo.php'; // v2 prototype demo, admin-only behind ?v2-demo. +``` + +**Step 4 — Refresh the autoloader.** + +```bash +composer dump-autoload +``` + +Don't skip this even if your class loads "by accident" via `include_once` (see §2.5). + +**Step 5 — Scaffold the SCSS + JS bundle.** Even though Phase 1 doesn't render any v2 markup yet, set up the bundle so later phases just add to it. + +`src/my-account/v2-demo/index.js`: + +```js +import '../../shared/js/public-path'; +import './style.scss'; + +console.log( 'Newspack My Account v2 demo bundle loaded.' ); +``` + +`src/my-account/v2-demo/style.scss`: + +```scss +.newspack-my-account--v2-demo { + // All v2-demo styles live under this scope. + // Phase 1: nothing yet — newspack-ui handles the rest. +} +``` + +> **Note.** Verify the `public-path` import path against an existing entry like `src/my-account/v1/index.js` — match the existing convention exactly. If you skip this import, the bundle compiles but breaks at runtime (see §2.5). + +**Step 6 — Register the webpack entry.** Open `webpack.config.js` and add this entry beside the existing my-account entries (around line 56): + +```js +'my-account-v2-demo': path.join( __dirname, 'src', 'my-account', 'v2-demo', 'index.js' ), +``` + +**Step 7 — Build assets.** + +```bash +npm run build +# or, while developing: +npm start +``` + +`npm start` watches and rebuilds; use it during active work. + +**Step 8 — Test as an admin.** Log in as an administrator and visit: + +``` +/my-account/?v2-demo=1 +``` + +Expected: +- The page renders with the v1 layout shell. +- Inside the My Account content area (near the bottom of the shortcode body) you see "Hello v2 demo. Phase 1 stub is working." +- DevTools: `` has the `newspack-my-account--v2-demo` class. +- Console: "Newspack My Account v2 demo bundle loaded." +- Network: `my-account-v2-demo.css` and `.js` are fetched. + +**Step 9 — Test gating.** Log out (or switch to a non-admin user). Visit the same URL. Expected: +- v1 renders unchanged. +- No "Hello v2 demo" anywhere. +- No `newspack-my-account--v2-demo` body class. +- The bundle is *not* enqueued (check the Network tab). + +If any of these fail, debug *now* before adding more code. + +**Step 10 — Lint.** + +```bash +npm run lint:php +npm run lint +``` + +Fix everything before committing. + +**Step 11 — Document the phase.** Open [`my-account-v2-prototype-devlog.md`](my-account-v2-prototype-devlog.md) and add a Phase 1 entry using the format described in §11. Then commit and open a PR. + +### Phase 2 — Newsletters (~1 day) + +Build the list with sections + the "unsubscribed" toast variant. This validates the component-mapping approach end-to-end. Use the Figma MCP per §2.7 to pull design context for `2636:46704`, `4645:19732`, and `2636:46736`. Switch the demo from `the_content` injection to a real `wc_get_template` swap, register the `newsletters` endpoint, and add the menu item (gated on the demo flag). + +### Phase 3 — Donations list + detail (~2 days) + +Including the billing-history table variant. Figma frames `2636:46467`, `2636:46488`, `2636:46512`, `2636:46661`, `2636:46500`, `3619:292407`. Pull each via Figma MCP per §2.7. + +### Phase 4 — Subscriptions list + detail (~2 days) + +All 5 detail variants. Figma section `2636:46116`. v1 already has a subscriptions endpoint; v2 just swaps templates. + +### Phase 5 — Modals (~1 day) + +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) + +`?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. + +## 11. Working in the open — dev log practice + +A commit history tells you *what* changed, not *why*, and almost never captures the dead-ends you walked down on the way to a working solution. We want both. The discipline is small: keep a running dev log alongside this brief, written in the moment, in your own voice. + +**Where it lives.** [`my-account-v2-prototype-devlog.md`](my-account-v2-prototype-devlog.md), sibling to this brief. A starter file is committed alongside; just open it and start adding entries. + +**Format.** One section per phase, dated entries within. Each entry covers four things: + +1. **What I built.** Plain English. Two or three sentences. +2. **What I learned.** Surprises, gotchas, things the brief got wrong, things the codebase taught you. The most valuable section over time. +3. **Decisions and why.** Especially anything that deviates from this brief or from a "default" approach. If you picked option A over option B, write down why — even if it feels obvious right now. +4. **Open questions.** Things you couldn't answer alone, things to bring up in PR review, things to revisit later. + +Add links to PRs, commits, and Figma frames. Keep it scannable. + +**Cadence.** Minimum: one entry per phase, written before opening the PR. Better: an entry whenever you stop for the day or hit a meaningful checkpoint. The cost is 5–10 minutes; the payoff is the difference between "the next dev can pick this up cold" and "the next dev needs an hour with you". + +**When the brief is wrong, fix the brief.** If something in this document turns out to be inaccurate or a decision changes (the deprecated-button question gets resolved, the accordion question gets settled, the demo flag's link-preservation strategy changes), update the brief in the same PR as the implementation change. Note the brief edit in the devlog entry. + +**Inline code comments should explain *why*, not *what*.** The "what" is in the code. Use comments for: + +- Non-obvious tradeoffs ("filtering at priority 1100 because v1 runs at 1001 and we need to win the conflict"). +- Guards against future regressions ("this short-circuit must run before the body_class filter; otherwise admin sees the demo class on every account page even without the flag"). +- Pointers back to the brief or devlog when a decision is documented elsewhere. + +**The newspack-ui escape-hatch rule.** If you ever catch yourself writing a fresh CSS rule outside `.newspack-my-account--v2-demo { }` (or, worse, a custom React component to fill a perceived gap), write a devlog entry *before* committing the workaround. Describe what you tried with newspack-ui and why it didn't fit. That entry is the trigger for raising the gap as a `newspack-ui` addition rather than letting it ossify in the prototype. + +--- + +## 12. Definition of done + +- 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. +- 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. +- A short PR description documents the flag, the scenarios, and known limitations. + +--- + +## Appendix A — Figma screen index (priority screens only) + +``` +SUBSCRIPTIONS (2636:46116) + Subscriptions (init) 2636:46117 + Subscriptions (init 2) 2636:46133 + Subscription details - active 2636:46149 + Subscription details - active no fees 4351:66807 + Subscription details - cancelled 2636:46177 + Subscription details - expiring 2636:46232 + Subscription details - renewed 2636:46204 + Cancel subscription – Init 2636:46259 + Cancel subscription – Success 2636:46262 + Renew subscription 2636:46276 + Renew subscription – Success 2636:46269 + Change subscription - Init 2636:46318 + Change subscription - Monthly sel. 2636:46331 + Change subscription - Plan selected 2636:46344 + Change subscription - Transaction 2636:46297 + +DONATIONS (2636:46466) + Donations (init) 2636:46467 + Donations details — recurring active 2636:46488 + Donations details — recurring cancel 2636:46661 + Donations details — one-time 2636:46512 + Donations details — new pmt method 2636:46500 + Donations details — recurring no fees 4339:17740 + Donations w/ billing history 3619:292407 + Modify donation 2636:46578 + Cancel donation – Init 2636:46591 + Cancel donation – Success 2636:46550 + Restart monthly donation 2636:46530 + +NEWSLETTERS (2636:46703) + Newsletters list w/ sections 2636:46704 + Newsletters list w/o categories 4645:19732 + Newsletters list — Unsubscribed 2636:46736 +``` + +## Appendix B — Quick reference: newspack-ui surface used + +CSS files actually exercised by this prototype (all under `src/newspack-ui/scss/`): +`elements/forms/_buttons.scss`, `_text-inputs.scss`, `_checkbox-radio.scss`, `_labels.scss`, `_select.scss`, `_accordion.scss`, `_spinner.scss`; `elements/_notices.scss`, `_tables.scss`, `_typography.scss`, `_segmented-control.scss`, `_boxes.scss`, `_stack.scss`, `_layout.scss`; `elements/misc/_badge.scss`, `_dropdown.scss`, `_spacing.scss`, `_position.scss`, `_visibility.scss`; `_modals.scss` (top-level); `elements/woocommerce/_my-account.scss`. + +JS modules: `src/newspack-ui/js/modals.js`, `dropdowns.js`, `accordions.js`, `notices.js`, `segmented-control/index.js`, plus `Newspack_UI::add_notice()` PHP for snackbars. + +Utility classes: stack (`__stack`, `--vertical`, `--gap-{1-12}`, `--align-*`, `--justify-*`); spacing (`__spacing-{side}--{n}`); typography (`__font--{2xs..6xl}`, `--bold`, `--normal`); color (`__color--{neutral|primary|success|error|warning}-{step}`); position; visibility (`hidden`, `overflow-hidden`). diff --git a/docs/my-account-v2-prototype-devlog.md b/docs/my-account-v2-prototype-devlog.md new file mode 100644 index 0000000000..3410df40c2 --- /dev/null +++ b/docs/my-account-v2-prototype-devlog.md @@ -0,0 +1,120 @@ +# My Account v2 Prototype — Dev Log + +A running diary of building the My Account v2 prototype. + +See [the brief](my-account-v2-prototype-brief.md) §11 for the full format guidelines and cadence. The short version: + +- One section per phase, dated entries within. +- Each entry has four parts: **What I built**, **What I learned**, **Decisions and why**, **Open questions**. +- Add links to PRs, commits, and Figma frames. +- Write entries in the moment, not retroactively. +- If the brief is wrong, fix the brief — and note it here. +- If you wrote any custom CSS, the rule from §11 applies: write the entry *before* committing the workaround. + +--- + +## Phase 0 — Brief read, ready to start + +**Date:** _YYYY-MM-DD_ +**By:** _your-name@a8c.com_ + +**What I built** + +Nothing yet. Read the brief end-to-end, set up the local Newspack site, confirmed I can log in as an administrator and load `/my-account/`. Visited `/?ui-demo` and confirmed the component gallery renders. + +**What I learned** + +_(Drop in any aha moments from reading the brief, the v1 code, or the `?ui-demo` gallery. e.g. "didn't realize the v1 sidebar nav is a separate template I can pass through unchanged" — that kind of thing.)_ + +**Decisions and why** + +_(Note any pre-implementation decisions made with the team here — e.g. the call between accordion vs flat newsletter sections, or any of the other items still open in brief §9.)_ + +**Open questions** + +_(Anything in the brief that didn't make sense, that you want to clarify before Phase 1 — list it here so it's not lost.)_ + +--- + +## Phase 1 — Plumbing + +> See [brief §10 → Phase 1](my-account-v2-prototype-brief.md#phase-1--plumbing-half-a-day) for the step-by-step. + +**Date:** 2026-04-28 +**By:** thomas@a8c.com +**PR:** _pending_ +**Commits:** _pending — code is staged on `prototype/my-account-demo`_ + +**What I built** + +Created `includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php` with the four-hook gating shape from brief §10 (query var, body class, asset enqueue at priority 12, and a `woocommerce_account_content` stub at priority 100). Scaffolded the webpack entry at `src/my-account/v2-demo/{index.js,style.scss}`, registered it in `webpack.config.js` next to `my-account-v1`, and confirmed the bundle compiles (`dist/my-account-v2-demo.{js,css,asset.php}`). Verified the gate end-to-end against a real Newspack site (`localhost`) for all four paths: admin + flag, admin + no flag, logged-out + flag, and the unit-style branch coverage of `is_demo_active()` via `wp eval-file`. All Step 8 / Step 9 signals checked out. + +**What I learned** + +The brief said to wire the new class in via `includes/class-newspack.php` "in the same block as the v1 class". The v1 class isn't actually loaded from there — it's loaded from `class-woocommerce-my-account.php` inside an `init`-time `else` branch that gates on `Reader_Activation::is_enabled()` and the `NEWSPACK_MY_ACCOUNT_VERSION` version switch (v0 vs v1). Including v2-demo there means it inherits the same Reader Activation + version preconditions for free. Updated brief §10 → Step 3 in the same change. + +The other small gotcha was the `wp_enqueue_scripts` priority: v1 enqueues at 11; the v2-demo runs at 12 so `newspack-ui` is already registered as a dependency by the time our handle declares it. The brief called this out, but it's worth flagging that any later phase adding more bundles needs to follow the same ordering. + +**Decisions and why** + +- **Used `woocommerce_account_content` (action) for the stub, not `the_content` (filter).** v1's account-page page template renders the account area via `[woocommerce_my_account]` and never calls `the_content()`, so a `the_content` filter would never fire on `/my-account/`. The brief calls this out in §2.3 and §10 Step 2; I went with the documented action. +- **Loaded v2-demo from `class-woocommerce-my-account.php`, not `class-newspack.php`.** Deviation from brief §10 Step 3 as written, but consistent with its intent ("after the v1 include, so v1's filters register first") because that's the only place v1 actually gets included. Fixed the brief in the same change. +- **Kept `console.log` in `index.js` for Phase 1 only.** Brief Step 8 lists the log line as a verification signal; fine for Phase 1, but it should come out in Phase 2 once we have real interactions to wire up. Logged this as a Phase 2 cleanup item below. + +**Open questions** + +- **Login redirect drops `?v2-demo`.** When a logged-in user lands on `/my-account/`, `WooCommerce_My_Account::redirect_to_account_details()` (existing v1 behaviour) bounces them to `/my-account/edit-account/` and `wp_safe_redirect` strips the query string. Side effect: hitting `wp-login.php` with `redirect_to=/my-account/?v2-demo=1` lands you on `/edit-account/` *without* the flag. Workarounds for the prototype: bookmark a sub-endpoint URL like `/my-account/edit-account/?v2-demo=1`, or wait for Phase 2's link-preservation work (brief §9 risk #4) which will re-append the flag on every internal nav. Not a Phase 1 blocker; the gate itself behaves correctly on any URL where the flag *is* present. +- **Phase 2 cleanup.** Remove the `console.log` in `src/my-account/v2-demo/index.js` once there's real JS to register. Trivial, just don't forget. +- **Open-question sweep.** None of brief §9's open items came up in Phase 1 (sidebar nav collisions, endpoint flush, link preservation, etc. — all Phase 2+ concerns). + +--- + +## Phase 2 — Newsletters + +> See [brief §10 → Phase 2](my-account-v2-prototype-brief.md#phase-2--newsletters-1-day). Pull design context from Figma frames `2636:46704`, `4645:19732`, `2636:46736` per brief §2.7. + +_(empty — fill in when you start Phase 2)_ + +--- + +## Phase 3 — Donations list + detail + +> See [brief §10 → Phase 3](my-account-v2-prototype-brief.md#phase-3--donations-list--detail-2-days). Figma frames listed in the brief. + +_(empty)_ + +--- + +## Phase 4 — Subscriptions list + detail + +> See [brief §10 → Phase 4](my-account-v2-prototype-brief.md#phase-4--subscriptions-list--detail-2-days). Figma section `2636:46116`. + +_(empty)_ + +--- + +## Phase 5 — Modals + +> See [brief §10 → Phase 5](my-account-v2-prototype-brief.md#phase-5--modals-1-day). All six flows. + +_(empty)_ + +--- + +## Phase 6 — Polish + scenario fixtures + +> See [brief §10 → Phase 6](my-account-v2-prototype-brief.md#phase-6--polish--scenario-fixtures-05-day). + +_(empty)_ + +--- + +## Decision log (cross-phase) + +A flat list of decisions that span phases or that future-you will want to find without scrolling. Each row links to the phase entry where it was made. + +| Date | Decision | Where it lives | +|------|----------|---------------| +| 2026-04-28 | `[DEPRECATED] Newspack / Button` instances in Figma map to `.newspack-ui__button`. The component was renamed in Figma but the rename hasn't propagated to every frame. Use the modern class everywhere. | Pre-Phase 1 (resolved with Thomas; closed brief §9 Q9 before kickoff) | +| 2026-04-28 | Newsletter row anatomy resolved from a rendered design Thomas shared: square thumbnail (full image, not icon — fake data uses `picsum.photos/seed/{slug}/128/128`), frequency badge (display labels: Daily / Weekly / Monthly / Twice weekly / As needed, plus free-text fallbacks), optional `SUBSCRIBER-ONLY` outline badge, separate "Unsubscribe from all" row at the bottom. Categories in the w/sections variant are always-expanded section labels, **not** accordions. | Pre-Phase 2 (resolved with Thomas; closed brief §9 Q4 — accordion question) | +| 2026-04-28 | v2-demo `include_once` lives in `class-woocommerce-my-account.php` next to the v1 includes, not in `class-newspack.php`. Reason: v1 isn't loaded from `class-newspack.php` either; that's where the version + Reader Activation gating lives. Brief §10 Step 3 updated to match. | Phase 1 | diff --git a/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php b/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php new file mode 100644 index 0000000000..97391b98c9 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php @@ -0,0 +1,121 @@ +

' . esc_html__( 'Hello v2 demo. Phase 1 stub is working.', 'newspack-plugin' ) . '

'; + } +} +My_Account_UI_V2_Demo::init(); diff --git a/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php b/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php index aa50e015e8..4007baa847 100644 --- a/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php +++ b/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php @@ -87,6 +87,11 @@ function() { } else { include_once __DIR__ . '/class-my-account-ui-v1.php'; include_once __DIR__ . '/class-my-account-ui-v1-passwords.php'; + // v2 prototype demo. Admin-only, gated by ?v2-demo on + // /my-account/. Loaded after v1 so v1's filters register + // first; we re-filter at higher priorities to win. + // See docs/my-account-v2-prototype-brief.md. + include_once __DIR__ . '/class-my-account-ui-v2-demo.php'; } } ); diff --git a/src/my-account/v2-demo/index.js b/src/my-account/v2-demo/index.js new file mode 100644 index 0000000000..92bd5f460c --- /dev/null +++ b/src/my-account/v2-demo/index.js @@ -0,0 +1,13 @@ +/** + * Newspack My Account v2 prototype demo. + * + * Phase 1 scaffold: imports the public-path bootstrap (required for any + * standalone webpack entry) and the demo's scoped stylesheet. Later phases + * wire up newsletters/donations/subscriptions interactions here. + */ + +import '../../shared/js/public-path'; +import './style.scss'; + +// eslint-disable-next-line no-console +console.log( 'Newspack My Account v2 demo bundle loaded.' ); diff --git a/src/my-account/v2-demo/style.scss b/src/my-account/v2-demo/style.scss new file mode 100644 index 0000000000..f891df2774 --- /dev/null +++ b/src/my-account/v2-demo/style.scss @@ -0,0 +1,7 @@ +// All v2-demo styles live under this scope so they cannot leak outside +// the prototype. Per brief §2.1: rules in here are a smell — newspack-ui +// utility classes and components should cover everything. If you find +// yourself adding a rule, log it in the devlog before committing. +.newspack-my-account--v2-demo { + // Phase 1: nothing yet. +} diff --git a/webpack.config.js b/webpack.config.js index 4161fe7baa..682c388f61 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,7 @@ const entry = { 'my-account': path.join( __dirname, 'src', 'my-account', 'index.js' ), 'my-account-v0': path.join( __dirname, 'src', 'my-account', 'v0', 'index.js' ), 'my-account-v1': path.join( __dirname, 'src', 'my-account', 'v1', 'index.js' ), + 'my-account-v2-demo': path.join( __dirname, 'src', 'my-account', 'v2-demo', 'index.js' ), 'account-frontend': path.join( __dirname, 'src', 'my-account', 'v1', 'frontend.js' ), admin: path.join( __dirname, 'src', 'admin', 'index.js' ), 'content-gate': path.join( __dirname, 'src', 'content-gate', 'gate.js' ), From 8862c17c51cb080967964c5f24060bb5dd2689b4 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 11:33:02 +0100 Subject: [PATCH 02/32] =?UTF-8?q?docs(my-account):=20clarify=20Phase=201?= =?UTF-8?q?=20scope=20in=20comment=20and=20brief=20=C2=A74?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #4679: - Inline comment in class-woocommerce-my-account.php now reads as forward-tense ("later phases re-filter…") rather than implying Phase 1 already does the filter override work. - Brief §4 now points at class-woocommerce-my-account.php for the include location, matching §10 Step 3. --- docs/my-account-v2-prototype-brief.md | 2 +- .../woocommerce/my-account/class-woocommerce-my-account.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/my-account-v2-prototype-brief.md b/docs/my-account-v2-prototype-brief.md index a93e93c160..8adc25b53e 100644 --- a/docs/my-account-v2-prototype-brief.md +++ b/docs/my-account-v2-prototype-brief.md @@ -169,7 +169,7 @@ A new class `Newspack\My_Account_UI_V2_Demo` lives at `includes/plugins/woocomme 6. **Registers a fake-data provider** keyed by the WP user id (so the demo state is per-admin and per-session, not global). Fake data is exposed to JS via `wp_localize_script( 'newspack-my-account-v2-demo', 'newspackMyAccountV2Demo', [...] )` and to PHP templates via a shared `My_Account_UI_V2_Demo::get_fake_data()` static method. 7. **Registers a query var** `v2-demo` via the `query_vars` filter so WordPress doesn't strip it. -The class follows the static `init()` pattern (per `AGENTS.md`), is `include_once`d from `includes/class-newspack.php` after the v1 class, and `composer dump-autoload` is run after creation. +The class follows the static `init()` pattern (per `AGENTS.md`), is `include_once`d from `includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php` after the v1 class, and `composer dump-autoload` is run after creation. ```php namespace Newspack; diff --git a/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php b/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php index 4007baa847..cc94b0b349 100644 --- a/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php +++ b/includes/plugins/woocommerce/my-account/class-woocommerce-my-account.php @@ -89,8 +89,9 @@ function() { include_once __DIR__ . '/class-my-account-ui-v1-passwords.php'; // v2 prototype demo. Admin-only, gated by ?v2-demo on // /my-account/. Loaded after v1 so v1's filters register - // first; we re-filter at higher priorities to win. - // See docs/my-account-v2-prototype-brief.md. + // first; later phases re-filter selected v1 hooks at + // higher priorities to override only when the demo is + // active. See docs/my-account-v2-prototype-brief.md. include_once __DIR__ . '/class-my-account-ui-v2-demo.php'; } } From dd0429073e5a534631da1d0d486fae7f78b9ac48 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 11:35:45 +0100 Subject: [PATCH 03/32] docs(my-account): record Phase 1 sync decisions in devlog Captures the sync conclusions that aren't otherwise in the brief or the code: auto-flush guard strategy, single-PR delivery, prototype-removal cleanup obligations, and the Phase 2 deferral of the login-redirect query-string strip. --- docs/my-account-v2-prototype-devlog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/my-account-v2-prototype-devlog.md b/docs/my-account-v2-prototype-devlog.md index 3410df40c2..46d0dafd25 100644 --- a/docs/my-account-v2-prototype-devlog.md +++ b/docs/my-account-v2-prototype-devlog.md @@ -118,3 +118,7 @@ A flat list of decisions that span phases or that future-you will want to find w | 2026-04-28 | `[DEPRECATED] Newspack / Button` instances in Figma map to `.newspack-ui__button`. The component was renamed in Figma but the rename hasn't propagated to every frame. Use the modern class everywhere. | Pre-Phase 1 (resolved with Thomas; closed brief §9 Q9 before kickoff) | | 2026-04-28 | Newsletter row anatomy resolved from a rendered design Thomas shared: square thumbnail (full image, not icon — fake data uses `picsum.photos/seed/{slug}/128/128`), frequency badge (display labels: Daily / Weekly / Monthly / Twice weekly / As needed, plus free-text fallbacks), optional `SUBSCRIBER-ONLY` outline badge, separate "Unsubscribe from all" row at the bottom. Categories in the w/sections variant are always-expanded section labels, **not** accordions. | Pre-Phase 2 (resolved with Thomas; closed brief §9 Q4 — accordion question) | | 2026-04-28 | v2-demo `include_once` lives in `class-woocommerce-my-account.php` next to the v1 includes, not in `class-newspack.php`. Reason: v1 isn't loaded from `class-newspack.php` either; that's where the version + Reader Activation gating lives. Brief §10 Step 3 updated to match. | Phase 1 | +| 2026-04-28 | Endpoint flush strategy: **auto-flush via a one-time guard**, not a documented manual `wp rewrite flush` step. Implementation: `update_option( 'newspack_my_account_v2_demo_endpoints_version', X )` after the first flush, bumped each phase that adds an endpoint so the flush re-runs exactly once per change. Reason: the demo audience won't have CLI access. | Phase 1 sync (resolves brief §9 risk #2) | +| 2026-04-28 | All phases stack on the single draft PR #4679 (`prototype/my-account-demo`). Title and description updated on each push. Whole prototype lands as one merge once Phase 6 wraps. | Phase 1 sync | +| 2026-04-28 | When the prototype is ripped out (post-Phase 6 / when productionised into v1), cleanup must include: deleting the `newspack_my_account_v2_demo_endpoints_version` option, re-flushing rewrite rules so `/my-account/newsletters/` etc. stop resolving, and removing the body class scope. Tracked as a Phase 6 task. | Phase 1 sync | +| 2026-04-28 | Login-redirect query-string strip (logging in with `redirect_to=/my-account/?v2-demo=1` lands on `/edit-account/` without the flag) is **deferred to Phase 2**. Workaround in the meantime: bookmark a sub-endpoint URL like `/my-account/edit-account/?v2-demo=1`. Phase 2 will wrap `wc_get_account_endpoint_url()` to re-append the flag on every internal nav link, which absorbs the post-login case too. | Phase 1 sync (resolves brief §9 risk #4 timing) | From 71786f398084c337a96075180c25a91042780102 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 11:54:00 +0100 Subject: [PATCH 04/32] feat(my-account): build v2 prototype newsletters list (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase 1 stub with the real Newsletters surface — grouped sections, frequency badges, optional SUBSCRIBER-ONLY badges, and client-side optimistic Sign up / Unsubscribe flow with snackbar confirmations. Built entirely from newspack-ui primitives, gated on ?v2-demo and admin capability so non-admins see v1 unchanged. - Register `newsletters` rewrite endpoint with the option-keyed auto-flush guard from the cross-phase decision log; bump ENDPOINTS_VERSION to 2. - Hook `woocommerce_account_newsletters_endpoint` to render the v2 template via load_template; skip wc_get_template since myaccount/newsletters.php isn't a real WC template path. - v2-only "Newsletters" menu item via woocommerce_account_menu_items at priority 1100 (after v1's 1001). - Preserve ?v2-demo on every internal nav link via woocommerce_get_endpoint_url filter, closing the Phase 1 login-redirect query-string-strip open question. - PHP fake data on the class as get_fake_data(), shipped to JS via wp_localize_script and to templates by passing into load_template. - No scoped CSS — row layout composes from nested __stack (horizontal + vertical), --justify-between for left/right pinning, attrs for thumbnail size, and
between vertical-stack siblings for the hairline separator. style.scss stays wrapper-only. --- docs/my-account-v2-prototype-devlog.md | 42 ++- .../class-my-account-ui-v2-demo.php | 256 +++++++++++++++++- .../templates/v2-demo/newsletters.php | 84 ++++++ .../v2-demo/partials/newsletters-row.php | 83 ++++++ src/my-account/v2-demo/index.js | 10 +- src/my-account/v2-demo/newsletters.js | 159 +++++++++++ src/my-account/v2-demo/style.scss | 2 + 7 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/newsletters.php create mode 100644 includes/plugins/woocommerce/my-account/templates/v2-demo/partials/newsletters-row.php create mode 100644 src/my-account/v2-demo/newsletters.js diff --git a/docs/my-account-v2-prototype-devlog.md b/docs/my-account-v2-prototype-devlog.md index 46d0dafd25..ac0392d358 100644 --- a/docs/my-account-v2-prototype-devlog.md +++ b/docs/my-account-v2-prototype-devlog.md @@ -73,7 +73,44 @@ The other small gotcha was the `wp_enqueue_scripts` priority: v1 enqueues at 11; > See [brief §10 → Phase 2](my-account-v2-prototype-brief.md#phase-2--newsletters-1-day). Pull design context from Figma frames `2636:46704`, `4645:19732`, `2636:46736` per brief §2.7. -_(empty — fill in when you start Phase 2)_ +**Date:** 2026-04-28 +**By:** thomas@a8c.com +**PR:** [#4679](https://github.com/Automattic/newspack-plugin/pull/4679) (stacked) +**Commits:** _pending — code is staged on `prototype/my-account-demo`_ +**Figma:** [`2636:46704`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46704), [`4645:19732`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=4645-19732), [`2636:46736`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46736) + +**What I built** + +The Newsletters list, end-to-end. Replaced the Phase 1 stub action with: (a) a `newsletters` rewrite endpoint registered with the option-keyed auto-flush guard from the cross-phase decision log, (b) a `woocommerce_account_newsletters_endpoint` action that loads `templates/v2-demo/newsletters.php`, (c) a v2-only menu-item filter at priority 1100 that injects the "Newsletters" link only when the demo flag is set, and (d) a `woocommerce_get_endpoint_url` filter that re-appends `?v2-demo=1` to every internal account URL — sidebar nav, post-login redirect, and the WC redirect-to-account-details bounce. The Phase 1 login-redirect open question (entry above) is closed by that filter. PHP fake data lives on the class as `get_fake_data()`, shipped to the JS layer via `wp_localize_script` and to PHP templates by passing the array into `load_template`. + +The list itself is composed entirely from newspack-ui primitives — vertical `__stack`s for sections, horizontal `__stack`s for rows, `__badge--outline` for frequency, `__badge--secondary` for SUBSCRIBER-ONLY, `__button--primary` / `--secondary` for the row CTAs. Section grouping is a flat label + list (not an accordion — that decision was settled pre-Phase 2 and is in the cross-phase decision log). The "Unsubscribed" frame (`2636:46736`) is realised as the bulk-unsubscribe end state: clicking "Unsubscribe from all" flips every subscribed row to "Sign up" and disables the bottom button, exactly mirroring Figma. The optimistic UI lives in `src/my-account/v2-demo/newsletters.js` — a single delegated click listener per root, no fetch, snackbar via `newspackUI.notices.openNotice`. Removed the Phase 1 placeholder `console.log` from `index.js` per the Phase 1 cleanup note. + +Verified server-side: 11 rows (5 featured + 3 tech + 3 premium), 6 sign-up / 5 unsubscribe buttons, 4 SUBSCRIBER-ONLY badges, 1 bulk-unsubscribe button. Anon `/my-account/?v2-demo=1` does not enqueue the demo bundle and does not get the body class — gate stays closed. + +**What I learned** + +The big surprise: my `add_action('init', 'register_endpoints')` registration never fired. The class file is included _inside_ an `init` callback in `class-woocommerce-my-account.php` (line 84), so by the time my class' `init()` method runs, the `init` action is already mid-flight at priority 10+. Registering another `init` action at default priority is too late — WP only re-fires actions on the next request, and on the next request the class file is once again loaded inside an `init` callback. So the endpoint registration would never run, and `/my-account/newsletters/` would 404 forever. + +Fix: call `self::register_endpoints()` directly from `init()` instead of registering it as an action. The class is loaded during the `init` action, so a direct call _is_ effectively running on `init` at priority 10. WC core registers its own endpoints at the same moment, so timing lines up. Confirmed by hitting `/my-account/` once and watching the `newspack_my_account_v2_demo_endpoints_version` option flip from absent to `2`. + +The other small lesson: PHPCS treats `$id` as a WordPress reserved global and rejects template variable assignments to it. Renamed to `$list_id` in the row partial. Worth knowing for future templates. + +I almost reached for the newspack-ui escape-hatch. The horizontal newsletter row "needs" a middle column that grows to fill width and a hairline separator between rows, both of which felt like genuine `__stack` gaps. First pass added three small scoped rules under `.newspack-my-account--v2-demo` and I drafted a devlog entry to back it up — but Thomas pushed back ("you could use a mix of stacks") and on a second look every rule was unnecessary. The corrected layout: outer `__stack--horizontal --justify-between` pushes the button to the right edge while a left-side `__stack--horizontal` groups image + details. Image dimensions go on the `` width/height attrs (the picsum source is already 128×128 square, so no `object-fit` is needed). Hairlines between rows are an `
` between siblings — newspack-ui's `_dividers.scss` styles `
` as a 1px line, and `__stack--vertical` zeroes child margins so `--gap-5` alone controls spacing. style.scss went back to the wrapper-only state it should be in per brief §2.1. The right escape-hatch reflex isn't "log a gap, write a rule" — it's "ask whether nested stacks already cover it." + +**Decisions and why** + +- **Direct `register_endpoints()` call instead of `add_action('init', …)`** — see "What I learned". Comment in the code explains why so future readers don't "fix" it. +- **Endpoint version bumped to 2** — auto-flush guard from the cross-phase decision log. Phase 1 didn't write any version (option absent); Phase 2 sets it to 2 so the flush runs exactly once per environment when this lands. Will be bumped again in Phase 3 when `donations` is added. +- **Skipped the `wc_get_template` filter for newsletters** — the brief listed it as a swap target, but `myaccount/newsletters.php` isn't a real WC template (there's no core file, no `wc_get_template` call). Hooked `woocommerce_account_newsletters_endpoint` directly and `load_template`'d the v2 file instead. Same end result, cleaner control flow, no accidental override of a non-existent path. +- **Image as a real `` tag (not `background-image`)** — brief §3 specifies a "full image, not an icon" using `picsum.photos/seed/{slug}/128/128`. `` gets `loading="lazy"` and `alt=""` (decorative) for free; CSS background-image would lose that. The tradeoff is a third scoped rule for `object-fit: cover` — fine, it's logged as part of the candidate gap. +- **JS ships translations via `@wordpress/i18n` directly** — no fallback shim. The dep is already on the page (newspack-ui depends on `wp-util`/`wp-i18n`); a shim would just be untested code. +- **Translatable fake data strings** — every newsletter name and description is wrapped in `__()`. Brief §9 risk #6 calls this out: the prototype lives in the plugin and is scanned by translation tooling. Names like "The Morning" sound silly translated, but consistency beats a one-off carve-out. + +**Open questions** + +- **`include_once` ordering inside the wrapper's init callback.** v2-demo is loaded at default priority alongside v1 in the same closure. If a future change needs v2-demo registered _before_ v1's filters (vs. its current after-v1 stance), that closure will need restructuring. Not blocking Phase 2; flagging for Phase 3+. +- **No newspack-ui gap after all.** Initial impulse was to file three additions (stack `--grow`, sized-image primitive, stack hairline). Stack composition handles all three: outer `__stack--justify-between` for left/right pinning, `` HTML attrs for size, and a styled `
` between children of a `__stack--vertical`. The takeaway is process: when the brief's escape-hatch reflex fires, first try _more_ stacks before reaching for SCSS. Resolved without leaving the demo. +- **No-categories scenario.** Figma `4645:19732` is the flat variant. Phase 2 implements only the sectioned variant; the flat variant should land via `?v2-demo=no-categories` in Phase 6 (scenario fixtures). Not a blocker — every section already renders independently, so a flatten in `get_fake_data()` is trivial. --- @@ -122,3 +159,6 @@ A flat list of decisions that span phases or that future-you will want to find w | 2026-04-28 | All phases stack on the single draft PR #4679 (`prototype/my-account-demo`). Title and description updated on each push. Whole prototype lands as one merge once Phase 6 wraps. | Phase 1 sync | | 2026-04-28 | When the prototype is ripped out (post-Phase 6 / when productionised into v1), cleanup must include: deleting the `newspack_my_account_v2_demo_endpoints_version` option, re-flushing rewrite rules so `/my-account/newsletters/` etc. stop resolving, and removing the body class scope. Tracked as a Phase 6 task. | Phase 1 sync | | 2026-04-28 | Login-redirect query-string strip (logging in with `redirect_to=/my-account/?v2-demo=1` lands on `/edit-account/` without the flag) is **deferred to Phase 2**. Workaround in the meantime: bookmark a sub-endpoint URL like `/my-account/edit-account/?v2-demo=1`. Phase 2 will wrap `wc_get_account_endpoint_url()` to re-append the flag on every internal nav link, which absorbs the post-login case too. | Phase 1 sync (resolves brief §9 risk #4 timing) | +| 2026-04-28 | Endpoint registration must call `self::register_endpoints()` directly from the class' `init()` — _not_ `add_action('init', …)`. The class is itself loaded inside an `init` callback, so a deferred action registers too late and never fires. Direct call is effectively running on `init` priority 10, same moment WC core registers its endpoints. Code comment in `class-my-account-ui-v2-demo.php` explains the trap. | Phase 2 | +| 2026-04-28 | `ENDPOINTS_VERSION` constant bumped to `2` for Phase 2 (registers `newsletters`). Bump again in each subsequent phase that adds an endpoint (`donations` in Phase 3) so the auto-flush guard re-runs exactly once per change. | Phase 2 | +| 2026-04-28 | No newspack-ui additions needed for the newsletter row layout. Nested `__stack` (horizontal/vertical), `__stack--justify-between` for left/right pinning, `` for image size, and `
` (already styled by `_dividers.scss`) between vertical-stack children cover everything. Reinforces the brief §2.1 reflex: try more stacks before reaching for scoped SCSS. | Phase 2 | diff --git a/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php b/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php index 97391b98c9..c36f2b03e0 100644 --- a/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php +++ b/includes/plugins/woocommerce/my-account/class-my-account-ui-v2-demo.php @@ -3,9 +3,10 @@ * Newspack "My Account" v2 prototype demo. * * Admin-only, gated by the `?v2-demo` query parameter on /my-account/. See - * docs/my-account-v2-prototype-brief.md for the full spec. Phase 1 is the - * gating plumbing only; later phases swap in real templates, register - * endpoints, and add menu items. + * docs/my-account-v2-prototype-brief.md for the full spec. Phase 2 swaps in + * real templates for newsletters, registers the `newsletters` endpoint, and + * adds the v2 menu item. Donations/subscriptions templates land in later + * phases. * * @package Newspack */ @@ -18,8 +19,12 @@ * Newspack "My Account" v2 prototype demo gate. */ final class My_Account_UI_V2_Demo { - const DEMO_FLAG = 'v2-demo'; - const BODY_CLASS = 'newspack-my-account--v2-demo'; + const DEMO_FLAG = 'v2-demo'; + const BODY_CLASS = 'newspack-my-account--v2-demo'; + const ENDPOINTS_OPTION = 'newspack_my_account_v2_demo_endpoints_version'; + // Bump when the set of registered endpoints changes so the auto-flush + // guard re-runs. See devlog Decision log "Endpoint flush strategy". + const ENDPOINTS_VERSION = 2; /** * Initialize hooks. @@ -32,11 +37,20 @@ public static function init() { // v1 enqueues at priority 11; run at 12 so newspack-ui dependencies // are already registered when our bundle is enqueued. \add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ], 12 ); - // woocommerce_account_content fires inside the [woocommerce_my_account] - // shortcode body. v1's page template renders the account area via the - // shortcode and never calls the_content(), so the_content filter would - // never fire here. See brief §2.3. - \add_action( 'woocommerce_account_content', [ __CLASS__, 'render_stub' ], 100 ); + // v1 filters at 1001 to rename/remove items; we run at 1100 so we + // see v1's output and can layer on top. + \add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'menu_items' ], 1100 ); + // Render the newsletters endpoint body. + \add_action( 'woocommerce_account_newsletters_endpoint', [ __CLASS__, 'render_newsletters_endpoint' ] ); + // Preserve `?v2-demo` on every internal nav link (sidebar, post-login + // redirect, etc.) so a single click can't drop you back into v1. + \add_filter( 'woocommerce_get_endpoint_url', [ __CLASS__, 'preserve_demo_flag_on_endpoint_url' ], 10, 4 ); + // Register custom endpoints + auto-flush rewrite rules once per + // version bump. Called directly because this class is itself loaded + // inside an `init` callback (see class-woocommerce-my-account.php), + // so add_action('init', ...) would register too late to fire — init + // has already started by the time we get here. + self::register_endpoints(); } /** @@ -70,6 +84,7 @@ public static function is_demo_active() { */ public static function query_vars( $vars ) { $vars[] = self::DEMO_FLAG; + $vars[] = 'newsletters'; return $vars; } @@ -87,7 +102,7 @@ public static function body_class( $classes ) { } /** - * Enqueue the v2 demo CSS + JS bundle. + * Enqueue the v2 demo CSS + JS bundle, plus localized fake data. */ public static function enqueue_assets() { if ( ! self::is_demo_active() ) { @@ -106,16 +121,229 @@ public static function enqueue_assets() { NEWSPACK_PLUGIN_VERSION, true ); + \wp_localize_script( + 'newspack-my-account-v2-demo', + 'newspackMyAccountV2Demo', + self::get_fake_data() + ); } /** - * Phase 1 stub. Replaced by wc_get_template swaps in later phases. + * Register custom endpoints (`newsletters`, etc.) and auto-flush rewrite + * rules exactly once per bump of `ENDPOINTS_VERSION`. The demo audience + * doesn't have CLI access, so we trade a one-shot DB write for not + * shipping a manual `wp rewrite flush` step. See devlog cross-phase log. */ - public static function render_stub() { + public static function register_endpoints() { + \add_rewrite_endpoint( 'newsletters', EP_PAGES ); + + $current = (int) \get_option( self::ENDPOINTS_OPTION, 0 ); + if ( $current !== self::ENDPOINTS_VERSION ) { + \flush_rewrite_rules( false ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules + \update_option( self::ENDPOINTS_OPTION, self::ENDPOINTS_VERSION ); + } + } + + /** + * Inject v2-only menu items. Gated on `is_demo_active()` so v1 sees the + * unmodified menu. Mutates by reference of the items array, then re-emits. + * + * @param array $items Existing menu items. + * @return array + */ + public static function menu_items( $items ) { + if ( ! self::is_demo_active() ) { + return $items; + } + // v1 already removed `customer-logout` and `edit-address`. Insert + // `newsletters` right after `edit-account` so it lines up with the + // design, and `donations` is reserved for Phase 3. + $ordered = []; + foreach ( $items as $slug => $label ) { + $ordered[ $slug ] = $label; + if ( 'edit-account' === $slug ) { + $ordered['newsletters'] = __( 'Newsletters', 'newspack-plugin' ); + } + } + // Fallback: if `edit-account` was removed upstream, append. + if ( ! isset( $ordered['newsletters'] ) ) { + $ordered['newsletters'] = __( 'Newsletters', 'newspack-plugin' ); + } + return $ordered; + } + + /** + * Render the newsletters endpoint. Loaded by WooCommerce when the user + * visits `/my-account/newsletters/`. Gated so a non-demo admin (or any + * non-admin) doesn't see prototype output if they reach the URL directly. + */ + public static function render_newsletters_endpoint() { if ( ! self::is_demo_active() ) { return; } - echo '

' . esc_html__( 'Hello v2 demo. Phase 1 stub is working.', 'newspack-plugin' ) . '

'; + \load_template( + __DIR__ . '/templates/v2-demo/newsletters.php', + false, + [ 'data' => self::get_fake_data() ] + ); + } + + /** + * Append `?v2-demo` to every account endpoint URL so internal nav, the + * post-login redirect, and the WC redirect-to-account-details bounce all + * keep the demo active. Filter only runs on demo requests, so non-demo + * users see unchanged URLs. + * + * @param string $url The endpoint URL. + * @param string $endpoint The endpoint slug. + * @param string $value Endpoint query value. + * @param string $permalink The page permalink. + * @return string + */ + public static function preserve_demo_flag_on_endpoint_url( $url, $endpoint, $value, $permalink ) { + unset( $endpoint, $value, $permalink ); + if ( ! self::is_demo_active() ) { + return $url; + } + return \add_query_arg( self::DEMO_FLAG, '1', $url ); + } + + /** + * Fake data shared by PHP templates and JS. Single source of truth. + * + * Phase 2 ships only the `newsletters` slice; Phase 3+ will add donations + * / subscriptions slices alongside it. Scenario overrides via + * `?v2-demo=` will be wired in Phase 6. + * + * @return array + */ + public static function get_fake_data() { + $user = \wp_get_current_user(); + return [ + 'reader' => [ + 'display_name' => $user && $user->ID ? $user->display_name : __( 'Casey Reader', 'newspack-plugin' ), + 'email' => $user && $user->ID ? $user->user_email : 'casey@example.com', + ], + 'newsletters' => [ + 'sections' => [ + [ + 'id' => 'featured', + 'label' => __( 'Featured newsletters', 'newspack-plugin' ), + 'description' => __( 'Get curated information and inspiration sent straight to your inbox.', 'newspack-plugin' ), + 'lists' => [ + [ + 'id' => 'morning-brief', + 'name' => __( 'The Morning', 'newspack-plugin' ), + 'description' => __( 'A daily roundup of the most relevant stories from the previous day.', 'newspack-plugin' ), + 'frequency' => __( 'Daily', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => false, + ], + [ + 'id' => 'recap', + 'name' => __( 'The Recap', 'newspack-plugin' ), + 'description' => __( 'Once a month, a roundup of the most relevant stories.', 'newspack-plugin' ), + 'frequency' => __( 'Monthly', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => true, + ], + [ + 'id' => 'breaking-news', + 'name' => __( 'Breaking News', 'newspack-plugin' ), + 'description' => __( 'Get informed as important news breaks around the world.', 'newspack-plugin' ), + 'frequency' => __( 'As needed', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => false, + ], + [ + 'id' => 'evening', + 'name' => __( 'The Evening', 'newspack-plugin' ), + 'description' => __( 'Catch up on the biggest news, and wind down to end your day.', 'newspack-plugin' ), + 'frequency' => __( 'Daily', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => true, + ], + [ + 'id' => 'science-times', + 'name' => __( 'Science Times', 'newspack-plugin' ), + 'description' => __( 'Receive tales of nature, the cosmos, and the human body\'s wonders.', 'newspack-plugin' ), + 'frequency' => __( 'Twice weekly', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => true, + ], + ], + ], + [ + 'id' => 'tech', + 'label' => __( 'Technology', 'newspack-plugin' ), + 'description' => __( 'Get in-depth insights on the rapid pace of tech each week from our sharp analysts.', 'newspack-plugin' ), + 'lists' => [ + [ + 'id' => 'emerging-tech', + 'name' => __( 'Emerging Tech Today', 'newspack-plugin' ), + 'description' => __( 'The latest on cutting-edge innovations soon to impact business and society.', 'newspack-plugin' ), + 'frequency' => __( 'Monthly', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => true, + ], + [ + 'id' => 'tech-policy', + 'name' => __( 'Tech Policy Roundup', 'newspack-plugin' ), + 'description' => __( 'A weekly dive into the critical laws and regulations shaping the tech sector.', 'newspack-plugin' ), + 'frequency' => __( 'Weekly', 'newspack-plugin' ), + 'subscriber_only' => false, + 'subscribed' => true, + ], + [ + 'id' => 'cio-brief', + 'name' => __( 'CIO Brief', 'newspack-plugin' ), + 'description' => __( 'Data security, IT infrastructure, and digital transformation insights for enterprise leaders.', 'newspack-plugin' ), + 'frequency' => __( 'As needed', 'newspack-plugin' ), + 'subscriber_only' => true, + 'subscribed' => false, + ], + ], + ], + [ + 'id' => 'premium', + 'label' => __( 'Subscriber-only newsletters', 'newspack-plugin' ), + 'description' => __( 'Enjoy exclusive newsletters with your subscription.', 'newspack-plugin' ), + 'lists' => [ + [ + 'id' => 'la-fourchette', + 'name' => __( 'La Fourchette', 'newspack-plugin' ), + 'description' => __( 'Unleash a culinary world with our exclusive restaurant reviews — a day before everyone else.', 'newspack-plugin' ), + 'frequency' => __( 'Weekly', 'newspack-plugin' ), + 'subscriber_only' => true, + 'subscribed' => false, + ], + [ + 'id' => 'football-am', + 'name' => __( 'Football AM with Ade Lenworth', 'newspack-plugin' ), + 'description' => __( 'News and analysis, on and off the pitch.', 'newspack-plugin' ), + 'frequency' => __( '3 times a week', 'newspack-plugin' ), + 'subscriber_only' => true, + 'subscribed' => false, + ], + [ + 'id' => 'cio-brief-premium', + 'name' => __( 'CIO Brief', 'newspack-plugin' ), + 'description' => __( 'Data security, IT infrastructure, and digital transformation insights for enterprise leaders.', 'newspack-plugin' ), + 'frequency' => __( 'As needed', 'newspack-plugin' ), + 'subscriber_only' => true, + 'subscribed' => false, + ], + ], + ], + ], + 'unsubscribe_from_all' => [ + 'enabled' => true, + 'title' => __( 'Unsubscribe from all', 'newspack-plugin' ), + 'description' => __( 'Don’t want any newsletters from us?', 'newspack-plugin' ), + 'label' => __( 'Unsubscribe from all', 'newspack-plugin' ), + ], + ], + ]; } } My_Account_UI_V2_Demo::init(); diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/newsletters.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/newsletters.php new file mode 100644 index 0000000000..494b0c7dbc --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/newsletters.php @@ -0,0 +1,84 @@ + fake-data array. + */ + +defined( 'ABSPATH' ) || exit; + +$data = isset( $args['data'] ) ? $args['data'] : []; +$reader = isset( $data['reader'] ) ? $data['reader'] : []; +$payload = isset( $data['newsletters'] ) ? $data['newsletters'] : []; + +$sections = isset( $payload['sections'] ) ? $payload['sections'] : []; +$unsubscribe_from_all = isset( $payload['unsubscribe_from_all'] ) ? $payload['unsubscribe_from_all'] : []; +$reader_email = isset( $reader['email'] ) ? (string) $reader['email'] : ''; +?> + diff --git a/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/newsletters-row.php b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/newsletters-row.php new file mode 100644 index 0000000000..d2e7d95332 --- /dev/null +++ b/includes/plugins/woocommerce/my-account/templates/v2-demo/partials/newsletters-row.php @@ -0,0 +1,83 @@ + array. + */ + +defined( 'ABSPATH' ) || exit; + +$list = isset( $args['list'] ) ? $args['list'] : []; +if ( empty( $list ) || empty( $list['id'] ) ) { + return; +} + +$list_id = (string) $list['id']; +$name = isset( $list['name'] ) ? (string) $list['name'] : ''; +$description = isset( $list['description'] ) ? (string) $list['description'] : ''; +$frequency = isset( $list['frequency'] ) ? (string) $list['frequency'] : ''; +$subscriber_only = ! empty( $list['subscriber_only'] ); +$subscribed = ! empty( $list['subscribed'] ); +// Stable but distinct fake image per slug. Brief §3 specifies picsum.photos. +// Request 144×144 (2× the 72px display size) so the image is retina-sharp. +$thumbnail = isset( $list['thumbnail'] ) ? (string) $list['thumbnail'] : sprintf( 'https://picsum.photos/seed/%s/144/144', \rawurlencode( $list_id ) ); + +$button_classes = $subscribed + ? 'newspack-ui__button newspack-ui__button--secondary' + : 'newspack-ui__button newspack-ui__button--primary'; +$button_label = $subscribed + ? __( 'Unsubscribe', 'newspack-plugin' ) + : __( 'Sign up', 'newspack-plugin' ); +$button_action = $subscribed ? 'unsubscribe' : 'subscribe'; +?> +
+
+ +
+
+ + + + + + + + + + + +
+

+ +

+
+
+ +
diff --git a/src/my-account/v2-demo/index.js b/src/my-account/v2-demo/index.js index 92bd5f460c..5e92ddba44 100644 --- a/src/my-account/v2-demo/index.js +++ b/src/my-account/v2-demo/index.js @@ -1,13 +1,11 @@ /** * Newspack My Account v2 prototype demo. * - * Phase 1 scaffold: imports the public-path bootstrap (required for any - * standalone webpack entry) and the demo's scoped stylesheet. Later phases - * wire up newsletters/donations/subscriptions interactions here. + * Standalone webpack entry. Imports the public-path bootstrap (required for + * any standalone entry per AGENTS.md), the demo's scoped stylesheet, and + * the per-screen interaction modules. */ import '../../shared/js/public-path'; import './style.scss'; - -// eslint-disable-next-line no-console -console.log( 'Newspack My Account v2 demo bundle loaded.' ); +import './newsletters'; diff --git a/src/my-account/v2-demo/newsletters.js b/src/my-account/v2-demo/newsletters.js new file mode 100644 index 0000000000..1849b6fae8 --- /dev/null +++ b/src/my-account/v2-demo/newsletters.js @@ -0,0 +1,159 @@ +/** + * Newsletters screen — client-side optimistic UI. + * + * The prototype is stateless (brief §7): every Sign up / Unsubscribe action + * mutates the DOM in place and fires a snackbar. No fetch, no form post. + * State resets on reload. + */ + +import { __, sprintf } from '@wordpress/i18n'; + +const SUBSCRIBE = 'subscribe'; +const UNSUBSCRIBE = 'unsubscribe'; +const UNSUBSCRIBE_FROM_ALL = 'unsubscribe-from-all'; + +/** + * Show a transient snackbar via newspack-ui's notices module. Falls back to + * a no-op if the global helper isn't on the page yet — the optimistic UI + * mutation still runs, so the user sees a result either way. + * + * @param {string} message Pre-translated message. + * @param {string} type 'success' | 'error' (default 'success'). + */ +function snackbar( message, type = 'success' ) { + const api = window.newspackUI && window.newspackUI.notices; + if ( ! api || typeof api.openNotice !== 'function' ) { + return; + } + const container = ensureSnackbarContainer(); + const item = document.createElement( 'div' ); + item.className = `newspack-ui__snackbar__item newspack-ui__snackbar__item--${ type }`; + item.dataset.autohide = 'true'; + const content = document.createElement( 'div' ); + content.className = 'newspack-ui__snackbar__content'; + content.textContent = message; + item.appendChild( content ); + container.appendChild( item ); + api.openNotice( item, true ); +} + +/** + * 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 openNotice() 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 + * the data attribute, button modifier class, label, and data-action so the + * next click toggles the other way. + * + * @param {HTMLElement} row Row element. + * @param {boolean} subscribed Target state. + */ +function setRowSubscribed( row, subscribed ) { + row.dataset.subscribed = subscribed ? 'true' : 'false'; + const button = row.querySelector( 'button[data-action]' ); + if ( ! button ) { + return; + } + if ( subscribed ) { + button.classList.remove( 'newspack-ui__button--primary' ); + button.classList.add( 'newspack-ui__button--secondary' ); + button.dataset.action = UNSUBSCRIBE; + button.textContent = button.dataset.labelUnsubscribe; + } else { + button.classList.remove( 'newspack-ui__button--secondary' ); + button.classList.add( 'newspack-ui__button--primary' ); + button.dataset.action = SUBSCRIBE; + button.textContent = button.dataset.labelSubscribe; + } +} + +/** + * Toggle the bottom "Unsubscribe from all" button's disabled state to match + * whether anything is left to unsubscribe from. Mirrors the Figma frame + * 2636:46736 where the button greys out once everything is unsubscribed. + * + * @param {HTMLElement} root Container element. + */ +function syncUnsubscribeAllState( root ) { + const button = root.querySelector( 'button[data-action="unsubscribe-from-all"]' ); + if ( ! button ) { + return; + } + const anySubscribed = !! root.querySelector( '[data-list-id][data-subscribed="true"]' ); + button.disabled = ! anySubscribed; +} + +/** + * Wire up a single newsletters root via event delegation. Idempotent — + * subsequent calls on the same root are no-ops. + * + * @param {HTMLElement} root Container. + */ +function wireRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + + root.addEventListener( 'click', event => { + const button = event.target.closest( 'button[data-action]' ); + if ( ! button || ! root.contains( button ) ) { + return; + } + const action = button.dataset.action; + + if ( action === SUBSCRIBE || action === UNSUBSCRIBE ) { + const row = button.closest( '[data-list-id]' ); + if ( ! row ) { + return; + } + const willSubscribe = action === SUBSCRIBE; + const name = button.dataset.listName || ''; + setRowSubscribed( row, willSubscribe ); + syncUnsubscribeAllState( root ); + snackbar( + willSubscribe + ? // translators: %s is a newsletter name. + sprintf( __( 'Subscribed to %s.', 'newspack-plugin' ), name ) + : // translators: %s is a newsletter name. + sprintf( __( 'Unsubscribed from %s.', 'newspack-plugin' ), name ) + ); + return; + } + + if ( action === UNSUBSCRIBE_FROM_ALL ) { + if ( button.disabled ) { + return; + } + root.querySelectorAll( '[data-list-id][data-subscribed="true"]' ).forEach( row => { + setRowSubscribed( row, false ); + } ); + syncUnsubscribeAllState( root ); + snackbar( __( 'Unsubscribed from all newsletters.', 'newspack-plugin' ) ); + } + } ); + + syncUnsubscribeAllState( root ); +} + +document.addEventListener( 'DOMContentLoaded', () => { + document.querySelectorAll( '[data-newspack-my-account-v2-demo="newsletters"]' ).forEach( wireRoot ); +} ); diff --git a/src/my-account/v2-demo/style.scss b/src/my-account/v2-demo/style.scss index f891df2774..5b1664f398 100644 --- a/src/my-account/v2-demo/style.scss +++ b/src/my-account/v2-demo/style.scss @@ -4,4 +4,6 @@ // yourself adding a rule, log it in the devlog before committing. .newspack-my-account--v2-demo { // Phase 1: nothing yet. + // Phase 2: still nothing — newsletters list composes from __stack / + // __badge / __button /
alone. } From 1f7c8a398394360e489a048518fc9ae06627345b Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 28 Apr 2026 12:24:44 +0100 Subject: [PATCH 05/32] fix(my-account): address Copilot review on Phase 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render snackbar markup directly with setTimeout cleanup instead of routing through newspackUI.notices.openNotice — the latter calls closeNotice on dismiss, which always sends an admin-ajax dismissal using a server-issued nonce we don't have, producing a 403 on every toast. - Redirect non-demo visitors hitting /my-account/newsletters/ to /my-account/edit-account/ via template_redirect at priority 9 so they never land on an empty account body. Use get_query_var(), not is_wc_endpoint_url() (which only matches WC's hardcoded list). - Enqueue the JS bundle using deps from dist/my-account-v2-demo.asset.php so @wordpress/i18n is loaded automatically. Matches the canonical pattern used elsewhere in this repo (e.g. trait-content-gate-layout). - Drop role="list" / role="listitem" from the newsletters template —
separators between rows would break ARIA list semantics, and we're not actually conveying list semantics with
/