diff --git a/docs/my-account-v2-prototype-brief.md b/docs/my-account-v2-prototype-brief.md new file mode 100644 index 0000000000..9eecda186b --- /dev/null +++ b/docs/my-account-v2-prototype-brief.md @@ -0,0 +1,758 @@ +# 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 `?my-account-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--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.1.1 — The other non-negotiable rule: reuse v1's class names + +**If v1 already renders the surface you're rebuilding in v2, copy v1's DOM and class names verbatim and feed them with fake data. Do not invent fresh markup or compose from utility classes when v1 has done this work already.** + +The my-account-v2-demo body class chain (`.woocommerce-account.newspack-my-account.newspack-ui` plus `.newspack-my-account--my-account-v2-demo`) means **every v1 SCSS rule already paints my-account-v2-demo pages**. Reusing v1's classes gets free design fidelity, free responsive behaviour (mobile vs desktop breakpoints, action-link / actions-dropdown swaps), and consistency with the rest of the site. Reinventing the markup forces you to hand-rebuild all of that — and you'll get the gaps wrong, the chevron wrong, the dropdown wrong, and weird grey backgrounds you don't expect. + +The reflex order when building any v2 surface: + +1. **Look at v1 first.** Does `templates/v1/.php`, `class-my-account-ui-v1.php`, `class-subscriptions-tiers.php`, or `templates/v1/_subscriptions.scss` (or any Newspack-Blocks `modal-checkout/templates/*.php`) already render this surface? Copy the DOM verbatim. If you need an icon, use `Newspack_UI_Icons::print_svg( 'name' )` rather than inlining SVG. +2. **Look at WooCommerce / WC Subscriptions next.** Do ``, `
`, `
` with simple `
` | +| Subscription totals / Amount breakdown | WCS `templates/myaccount/subscription-totals.php` — `` with `tr.order-total` for the Total row | +| Billing history / related orders table | WCS `templates/myaccount/related-orders.php` — `
` with `` | +| Status dot in a table | `.newspack-my-account__subscription--order-status-label.` (already styled in `_subscriptions.scss` for paid/cancelled/failed/processing/refunded/completed/pending/on-hold) | +| Subscription tier picker (segmented control + tier cards + Current badge) | `class-subscriptions-tiers.php::render_form` and `render_product_card` — `
` with `__tabs` + `__content` + `__panel.selected`, `
` of saved cards under the demo flag. Mid-prototype the call reversed: Figma shows a card-based **Payment information** surface (methods + addresses) that already lives in v1 as `payment-information.php`, so Phase 6's table got rebuilt onto v1's card DOM with five new modals (Add / Edit / Delete payment method, Edit/Add / Delete address). Sidebar label is "Payment information" (not "Payment methods"); URL slug stays `payment-methods` because WC core owns it. + +**Account settings** (Figma `2636:46773`, Phase 9) + +v1 today is the standard WooCommerce edit-account form. v2 keeps the same DOM (so v1's SCSS does the styling work for free) but layers on stateless form submits (Save → snackbar) and a redesigned **Delete account** flow: a two-step modal with a "Manage my…" alternative-actions row (Donations / Subscriptions / Newsletters) on step 1, and a "check inbox" success state on step 2. Triggers `wc_get_template` swap of `myaccount/form-edit-account.php` at priority 2 (so the demo lands after v1's priority-1 swap). + +**WP-admin: Reader Account Customization** (Figma `4510:261706`, Phase 10) + +A pure-demo admin tab inside the **Audience** wizard, between *Configuration* and *Checkout & Payment*. First v2 surface that lives in WP-admin rather than `/my-account/`. Three two-column sections (title + description left, controls right): Branding (logo upload), Newsletters (page title + description), Account & Billing (Terminology toggle with a *Custom* option that reveals stacked singular/plural inputs, plus cancel-recurring-donation message and billing/invoice footer). Local React `useState` only — no REST roundtrip, Save button is a no-op. Lets stakeholders see how the admin would surface the v2 customisation controls without anything actually persisting. + +Reused from v1 unchanged: account-page page template, sidebar/menu, signed-out state. + +## 4. The `?my-account-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_V2_Demo` lives at `includes/plugins/woocommerce/my-account/class-my-account-v2-demo.php`. It: + +1. **Gates everything on `is_account_page() && is_user_logged_in() && current_user_can( 'manage_options' ) && isset( $_GET['my-account-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--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/my-account-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_V2_Demo::get_fake_data()` static method. +7. **Registers a query var** `my-account-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/plugins/woocommerce/my-account/class-woocommerce-my-account.php` after the v1 class, and `composer dump-autoload` is run after creation. + +```php +namespace Newspack; + +final class My_Account_V2_Demo { + const DEMO_FLAG = 'my-account-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_V2_Demo::init(); +``` + +The demo flag is preserved across navigation by appending `?my-account-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-v2-demo.php # NEW — demo gate + fake data +└── templates/my-account-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--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', '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--outline` (frequency: white background, 1px border) and `.newspack-ui__badge--secondary` (subscriber-only: filled neutral grey). Both render side-by-side in a horizontal stack; `_badge.scss` applies `text-transform: uppercase` so the displayed casing comes from CSS, not source strings. | `_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-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, `?my-account-v2-demo=`, picks a fixture variant: `?my-account-v2-demo=cancelled-sub`, `?my-account-v2-demo=expired-payment`, `?my-account-v2-demo=no-donations`, etc. `My_Account_V2_Demo::get_fake_data()` merges the scenario overrides into the base fixture. The default `?my-account-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 `?my-account-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_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. **`?my-account-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 `?my-account-v2-demo=1` to `/my-account/` sees a "Hello v2 demo" stub *and* the body class `newspack-my-account--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-v2-demo.php`: + +```php +

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

'; + } +} +My_Account_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 my-account-v2-demo `include_once` immediately after the v1 includes there, so v1's filters register first and my-account-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-v2-demo.php'; // v2 prototype demo, admin-only behind ?my-account-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--my-account-v2-demo { + // All my-account-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', '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/?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--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--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 — Payment methods (~1–1.5 days) + +Reproduce v1's `/my-account/payment-methods/` surface byte-for-byte under the my-account-v2-demo flag, fed entirely by fake data (no real WooCommerce payment-token storage). The v1 page is a saved-cards table backed by `wc_get_account_payment_methods_columns()` + `wc_get_account_payment_methods_types()` plus an "Add payment method" CTA; the v2 prototype renders the same DOM (`

` headers, per-row method/expiry/actions cells, default-card badge) so v1's existing styling carries through verbatim. Fake data adds a `payment_methods` slice on `My_Account_V2_Demo::get_fake_data()` (an array of saved cards with brand / last4 / expiry / is_default / actions). Action buttons (Make default / Delete / Add new) wire to client-side stubs that surface snackbars, mirroring the Phase 5 modal-trigger pattern. Sidebar item is added at the same priority as the Phase 1–4 endpoints. No new modals. + +### Phase 7 — Polish + scenario fixtures (~0.5 day) + +`?my-account-v2-demo=` overrides for cancelled / expired / empty / no-fees states (see §7). Final screenshot pass against Figma. + +### Phase 8 — Documentation pass (~0.5 day) + +The prototype lands as ~10 PHP templates + ~7 JS modules + a single class with a fake-data layer; the next person to touch it (human or agent) needs a single document that explains "what flow lives where, how does the scenario flag work, what's clickable vs. stubbed, and how does this productionise into v1?". Phase 8 produces that document — three sections, each a few hundred words: + +1. **Reader's guide** — entry URLs, scenario index (canonicalised from the `SCENARIOS` constant), per-flow walkthroughs (what's clickable, what surfaces a snackbar, what's a real form), and links to the matching Figma frames. +2. **Architectural map for agents** — fake-data shape, the takeover/redirect/menu plumbing, the modal-router pattern, the v1-class-names-first reflex (already in §2.1.1 but worth a one-screen index), reserved-globals trap, the auto-flush plumbing, the scenario merge flow. +3. **Productionisation playbook** — the to-do list for when the prototype rolls into v1: real WC/WCS data sources to swap in, real Stripe wiring on the renew/restart/modify forms, scrubbing the takeover sledgehammers (subscriptions, payment-methods), dropping the `?my-account-v2-demo` gate, removing the auto-flush option, and the menu-item filter cleanup. + +The doc lives at `docs/my-account-v2-prototype-guide.md` (sibling to this brief and the devlog). No new code. + +### Phase 9 — Account settings (~0.5 day) + +> Out-of-roadmap addition after Phase 8. Targets Figma frames `2636:46773` (Account settings), `2636:46785` (Delete account modal), `4865:92398` (Check inbox success), and `4863:20067` (variant section). + +Hook `wc_get_template` at priority 2 (so the demo lands after v1's priority-1 swap) and point `myaccount/form-edit-account.php` at a new v2-demo template that reuses v1's DOM (``, the section ids, the input + button classes) — so v1's existing SCSS does all the styling work. Forms are stateless: client-side `submit` listeners surface snackbars (`Profile updated.` / `Password updated.`) and return. The **Delete account** button opens a two-step modal partial (`partials/delete-account-modal.php`): step 1 is the "Are you sure?" prose plus a three-row "Manage my…" alternative-actions list (Donations / Subscriptions / Newsletters, each with a Manage button linking to the corresponding endpoint with the demo flag preserved); step 2 flips to a "We have just sent instructions on how to delete your account…" success state with the reader's email echoed back. Modal close (X / Cancel / ESC / backdrop) resets via the `closeModal` event. The three "soft alternatives" rows are the **first scoped SCSS rule on the my-account/ surface** (~25 lines under `.newspack-my-account-v2-demo-account-settings__alternatives*`) — newspack-ui has no compositional primitive that pairs a label+description vertical block with a right-aligned button. + +### Phase 10 — Reader Account Customization admin (~0.5 day) + +> Out-of-roadmap addition after Phase 9. **First v2 surface that lives in WP-admin rather than `/my-account/`.** Targets Figma frame `4510:261706` (marked outdated by Thomas — used as a layout reference, not a 1:1 spec). + +Add a new tab to the Audience wizard at `/wp-admin/admin.php?page=newspack-audience#/reader-account-customization`, between Configuration and Checkout & Payment. Pure-demo: local React `useState`, no REST roundtrip, no persistence — Save button is a no-op. Three sections in a two-column layout copied from the Access control edit page (`` + `` left, `` of controls right, `` between sections): + +1. **Branding** — `` for the sidebar logo + helper paragraph. +2. **Newsletters** — `` page title + `` page description. +3. **Account & Billing** — `` for Terminology with three options (`Subscription` / `Membership` / `Custom`); selecting Custom reveals stacked singular + plural ``s. Below the toggle, two ``s for the cancel-recurring-donation message and the billing/invoice footer. + +Two new files: `src/wizards/audience/views/setup/reader-account-customization.{js,scss}`, plus a 3-line edit to the same folder's `index.js` to register the tab + route. No PHP class — pure-frontend tabs have precedent in the same wizard (the `/complete` route is purely state-driven). The brief's §2.1 ("no custom CSS") and §2.1.1 ("reuse v1 class names") **don't apply on this surface** — Audience wizard pages compose from `@wordpress/components` + `packages/components/src` (`Grid`, `SectionHeader`, `Divider`, `ImageUpload`, `Button`); the reflex order is `@wordpress/components` first → `packages/components/src` second → custom SCSS last. The single scoped SCSS rule that ships zeroes the legacy `.components-base-control` margins so `` is the only source of vertical rhythm. + +**Total estimate:** ~9.5–10 dev-days for one engineer, end-to-end clickable on any Newspack site, including the two out-of-roadmap admin and account-settings additions. + +## 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--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 `?my-account-v2-demo` (or `?my-account-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 on the `/my-account/` surface uses `.newspack-ui*` classes or v1 class names. The my-account-v2-demo `style.scss` contains the `.newspack-my-account--my-account-v2-demo` scoping wrapper plus the small handful of scoped rules each documented in the devlog — open it in PR review and check. +- All five primary `/my-account/` screens (dashboard, newsletters, donations, subscriptions, payment information), the Account settings rebuild (Phase 9), the homepage drawer, and the WP-admin Reader Account Customization tab (Phase 10) are reachable. +- All twelve modals (six original + Change subscription pulled forward in Phase 4, Add / Edit / Delete payment method, Edit/Add / Delete address from the Phase 6 rebuild, Delete account from Phase 9) 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. +- Phase 8 ships `docs/my-account-v2-prototype-guide.md` covering reader's guide / architectural map / productionisation playbook. + +--- + +## 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..5e46437165 --- /dev/null +++ b/docs/my-account-v2-prototype-devlog.md @@ -0,0 +1,603 @@ +# 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-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 my-account-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 my-account-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 my-account-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 `?my-account-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/?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/?my-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. + +**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/my-account-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 `?my-account-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/?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--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.** my-account-v2-demo is loaded at default priority alongside v1 in the same closure. If a future change needs my-account-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 `?my-account-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. + +--- + +## 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. + +**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:46467`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46467), [`2636:46488`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46488), [`2636:46661`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46661), [`2636:46512`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46512), [`2636:46500`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46500), [`3619:292407`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=3619-292407), [`4339:17740`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=4339-17740) + +**What I built** + +The Donations list and detail surfaces, end-to-end. (a) Registered a `donations` rewrite endpoint with `EP_PAGES`, bumped `ENDPOINTS_VERSION` from 2 → 3 so the auto-flush guard re-runs once. (b) Hooked `woocommerce_account_donations_endpoint` to a single render function that distinguishes list vs. detail by reading `get_query_var('donations')` — bare endpoint = list, value = donation id (`don-001`, etc.). (c) Renamed the Phase 2 `redirect_non_demo_newsletters_endpoint` to `redirect_non_demo_v2_endpoints` and extended it to bounce non-demo guessers off `/my-account/donations//` too. (d) Inserted a "Donations" sidebar item right after "Newsletters" in the v2 menu-items filter. (e) Extended the `query_vars` filter so WP doesn't strip the `donations` value during URL parsing. + +The list template (Figma `2636:46467`) renders three sections: a "Recurring donation" Plan Card stack (one `__box --border` per active recurring), a "Previous donations" `
` (cancelled recurring + one-times sorted newest-first, with `data-href` on each ``), and a "Billing history" Button Card at the bottom (an `` with a receipt SVG). The detail template handles all four functional variants from a single file, branching on `kind` + `status` + `fees_covered`: recurring/active gets the Edit donation button + "More" newspack-ui dropdown (Update payment method / Cancel donation); recurring/cancelled gets a CANCELLED badge + Restart donation button only + em-dash for next payment; one-time has no top-right buttons and one Donation date row; the no-fees variant (Figma `4339:17740`) is just `fees_covered = true` suppressing the entire Amount breakdown. donations.js wires row-click navigation in the previous-donations table (anchors aren't valid `` children) and stub snackbars for the modal-trigger buttons (Phase 5 swaps these for real modals). + +Verified server-side: list renders 1 active recurring + 3 previous rows + the billing button; all four detail URLs resolve and render their variant correctly; anonymous + admin-without-flag both 302 to `/my-account/edit-account/`; the `?my-account-v2-demo=1` flag is preserved on every internal link (sidebar, Manage donation button, row-click hrefs, back-to-list chevron) via the existing `woocommerce_get_endpoint_url` filter. Console clean, no JS errors. + +**What I learned** + +`add_rewrite_endpoint` + the auto-flush guard isn't actually atomic on a fresh code load. After bumping `ENDPOINTS_VERSION` to 3, the first request to `/my-account/donations/` 404'd because PHP opcache was still serving the old class file — so `add_rewrite_endpoint('donations', EP_PAGES)` never ran during the flush, and the regenerated rules table contained `newsletters` but not `donations`. Solution on the dev box was `wp eval 'opcache_reset();'` + `wp option delete newspack_my_account_v2_demo_endpoints_version` + `wp rewrite flush`. In production this won't bite (deploys clear opcache), but it's worth noting that any phase whose ENDPOINTS_VERSION bump _intersects_ with an opcache cycle will need a one-time reset — flagging because Phase 4 will bump again for `subscriptions`. + +The brief's recurring "no fees" variant (`4339:17740`) reads at first glance like a separate template variant. It's not — it's the same detail page with the Amount breakdown section omitted. A `fees_covered` boolean on the donation row is enough; the template just guards the whole `
` block. One flag, one branch. Worth documenting in the data shape so Phase 4 (which has its own `(no fees)` subscription variant) can reuse the pattern. + +The reflex Phase 2 burned a cycle on fired again here. The Amount breakdown section in Figma uses two equal-width flex columns (label column / value column) — newspack-ui's `__stack` doesn't expose a `flex: 1` modifier, so my first pass had `style="flex:1"` inline on each child. I caught it before committing because of Phase 2's lesson, swapped to `__stack--horizontal --justify-between` per row (label flush-left, value flush-right at the page edges), and the visual reads cleanly at 768px even though it's not the exact 50/50 split Figma shows. Pure composition again. The escape hatch reflex I'm building: when a layout primitive seems missing, the answer is usually "split the section into multiple stacks where each stack is a single row" rather than "add flex-grow." + +The other small lesson is the same reserved-globals trap Phase 2 hit. PHPCS rejects `$id`, `$status`, and `$title` as template variables. Renamed to `$donation_id`, `$donation_status`, and `$header_title`. The fix is mechanical now; the takeaway for Phase 4 is to skip those names from the start. + +**Decisions and why** + +- **Detail-page URLs use the rewrite endpoint's value parameter, not a query arg.** `add_rewrite_endpoint('donations', EP_PAGES)` natively accepts a value: `/my-account/donations/` sets `get_query_var('donations')` to `''`, `/my-account/donations/don-001/` sets it to `'don-001'`. That gives pretty URLs, requires no new query var, plays well with the auto-flush guard we already had, and the existing `false === get_query_var('donations', false)` redirect-bounce check works for both shapes (bare list URL and detail URL alike). The alternative — `/my-account/donations/?donation=` — would have needed a second query var registration and uglier URLs. +- **`redirect_non_demo_newsletters_endpoint` was renamed/expanded, not duplicated.** Phase 2's redirect was newsletters-specific. Rather than ship a parallel `redirect_non_demo_donations_endpoint`, I generalised it to `redirect_non_demo_v2_endpoints` checking both query vars in one pass. Same redirect target, same caps gate; future endpoints just add another `false !== get_query_var(...)` line. Cheaper than N parallel functions. +- **One detail template, four functional variants, branching in markup** — I considered splitting into `donation-details-recurring.php` / `donation-details-one-time.php` to keep each file shorter. But the variants share ~80% of their structure (same header layout, same date-row rhythm, same payment-method row, same billing-history table); split files would have duplicated all of that. One file with four explicit `if ( $is_recurring && $is_active )` / `elseif ( $is_cancelled )` / `else` blocks is denser but reads top-to-bottom as the spec. +- **JS snackbar duplication is intentional for now.** `snackbar()` and `ensureSnackbarContainer()` in `donations.js` are copy-pasted from `newsletters.js`. Phase 5 modals will be the third caller — that's the right time to extract `src/my-account-v2-demo/util/snackbar.js`. Two callers is "rule of three" territory, not premature abstraction yet, and refactoring Phase 2 in a Phase 3 commit muddies the diff. +- **Currency rendered as `$` / USD, not `£`.** Brief §7 schema is USD; Figma frames render in £. The data is currency-neutral and a `currency_symbol` field controls the rendering. Stuck with USD per brief; Phase 6 scenario fixtures could flip this if needed. +- **Translatable fake data strings — same call as Phase 2.** Every visible string (`Donation`, `Subtotal`, `VAT`, `Transaction fee`, `Visa`, `Amex`, etc.) is wrapped in `__()`. The card brand wraps feel a bit silly translated, but consistency beats a one-off carve-out. +- **Row-click affordance via JS, not CSS.** `
` with a delegated click handler in `donations.js` navigates on click. Cursor:pointer styling would have required scoped SCSS — not worth the escape-hatch on a prototype that's still composing primarily from utility classes. The user can still click "Manage donation" on the active recurring card; the previous-donations rows are functionally clickable but visually static. Phase 6 polish can revisit if it bothers anyone. + +**Open questions** + +- **CANCELLED badge looks subtler than Figma.** newspack-ui's `__badge--error` uses `var(--newspack-ui-color-error-0)` (the lightest red tint) for background; Figma renders it noticeably more saturated. Either Figma is using a different badge variant we should add (`--badge--error-strong`?), or the Figma frame is out of sync with the current badge tokens. Flagging as a Phase 6 design-review topic before raising it as a `newspack-ui` change. +- **Detail-page section gap reads tall.** Each kv section is its own `
` in a `__stack--gap-6` ancestor; the visible whitespace between First/Latest/Next donation rows is bigger than Figma. The fix would be a smaller gap (e.g. `--gap-4`) for sibling kv sections or grouping them under a single section — happy to tweak in PR review. +- **No "Donations w/ billing history" inline scenario yet.** The `billing_history_inline` flag in the data wires the inline-table form (Figma `3619:292407`) but defaults to false. Phase 6's `?my-account-v2-demo=` machinery should expose it via something like `?my-account-v2-demo=billing-history`. Not blocking Phase 3. +- **"New payment method" Figma variant (`2636:46500`) is data-equivalent to the active variant.** I render it as the same active-recurring template, with donations.js surfacing a "Payment method updated." snackbar on first paint when a `?payment-updated=1` flag is present. Phase 5 will add that flag to the update-payment-method modal flow. Calling it out so the variant doesn't get re-implemented in Phase 5. + +--- + +--- + +## 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`. + +**Date:** 2026-04-28 +**By:** thomas@a8c.com +**PR:** _pending — phase branch `prototype/my-account-demo-phase-4`, draft PR targets `prototype/my-account-demo` (umbrella tracker is #4679)_ +**Commits:** _pending — code is staged on `prototype/my-account-demo-phase-4`_ +**Figma:** [`2636:46117`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46117), [`2636:46133`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46133), [`2636:46149`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46149), [`4351:66807`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=4351-66807), [`2636:46177`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46177), [`2636:46232`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46232), [`2636:46204`](https://www.figma.com/design/mkvHE3qozmmGrytPt9RGrV/My-Account?node-id=2636-46204) + +**What I built** + +The Subscriptions list and detail surfaces, end-to-end, including the workflow change that ships this phase as its own PR against `prototype/my-account-demo` rather than stacking on the cumulative draft. (a) Registered a `subscriptions` rewrite endpoint with `EP_PAGES`, bumped `ENDPOINTS_VERSION` from 3 → 4 so the auto-flush guard re-runs once. (b) Hooked `woocommerce_account_subscriptions_endpoint` to a single render function that distinguishes list vs detail by reading `get_query_var('subscriptions')`. (c) Added a `template_redirect` priority-8 callback `takeover_subscriptions_endpoint` that, when the demo flag is active and we're on `/subscriptions/`, calls `remove_all_actions('woocommerce_account_subscriptions_endpoint')` to drop WCS' renderer and Newspack's memberships-table append, then re-adds ours. (d) Extended `redirect_non_demo_v2_endpoints` to bounce non-demo guessers off `/my-account/subscriptions//` — but **only** when WC Subscriptions isn't installed, so non-demo readers on WCS sites still see WCS' real subscriptions screen. (e) Inserted a "Subscriptions" sidebar item right after "Donations" in the v2 menu-items filter, plucking out any pre-existing `subscriptions` slug (added by WCS, hoisted to the top by `wc_subscriptions_at_top`) so order is consistent on sites with and without WCS. (f) Added `'subscriptions'` to the `query_vars` filter alongside `newsletters` and `donations`. + +The list template (Figma `2636:46117`) renders two sections: an "Active subscription" Plan Card with an optional inline error notice (for the `expiring` status), the product/price line, and a "Manage subscription" button; and a "Previous subscription" clickable Plan Card rendered as `` with a status-driven meta string on the right ("Cancelled on …" or "Expired on …"). The detail template (`subscription-details.php`) handles all five Figma variants from a single file, branching on `status` + `fees_covered`: `active`/`renewed` get the Change subscription button + More dropdown; `cancelled` gets the CANCELLED badge + Renew subscription button + em-dash next-payment row; `expiring` gets the inline error notice (above the Amount section) + Renew button; the no-fees variant is `fees_covered = true` collapsing the Amount breakdown to a single Total row, exactly mirroring the Phase 3 donation no-fees treatment. `subscriptions.js` wires stub snackbars for the modal-trigger buttons (Phase 5 swaps for real modals) and intercepts the inline `renew now` anchor click so it doesn't navigate to `#renew`. + +The fake-data shape splits into three buckets: `active` (the one currently-paying card on the list — sub-001 happy-path active), `previous` (the one terminated card on the list — sub-cancelled), and `extras` (sub-expiring, sub-renewed, sub-active-no-fees — detail-page-only fixtures reachable by direct URL but not rendered on the list). The first cut tried to surface every detail variant as its own list card so the demo would be one-click-reachable, but Thomas pushed back: a "Renewed on November 2, 2023" card in the Previous section reads as if it's still active (because it is — `renewed` means recently renewed and currently paying), and a "No fees variant" list-card label is a meta-tag for the engineer, not a thing the design intends to surface. Reorganising into the three buckets keeps the list view byte-faithful to Figma init 1 and parks the variant-only fixtures behind direct URLs until Phase 6 scenario fixtures (`?my-account-v2-demo=expiring`, etc.) swap which row appears in the active/previous slot. + +Verified server-side (admin curl with auth cookie + `?my-account-v2-demo=1`): list renders 1 active card (sub-001) + 1 previous card (sub-cancelled), section titles "Active subscription" / "Previous subscription" — singular — via `_n()`; all 5 detail URLs resolve via `find_subscription_by_id` checking all three buckets (`/subscriptions/sub-001/`, `/subscriptions/sub-cancelled/`, `/subscriptions/sub-expiring/`, `/subscriptions/sub-renewed/`, `/subscriptions/sub-active-no-fees/`) and render their variant correctly (active = breakdown + Change/More; expiring = notice + breakdown + Renew, no More; cancelled = CANCELLED badge + breakdown + Renew; renewed = breakdown + Change/More like active; no-fees = collapsed Amount + Change/More); admin without flag and anonymous-with-flag both leave the v2 markup absent; sidebar order is `edit-account → newsletters → donations → subscriptions`; every internal `/my-account/...` link in the rendered page preserves `?my-account-v2-demo=1`. Lint clean across `:php`, `:js`, `:scss`. + +**What I learned** + +The WC Subscriptions collision is messier than a query-var swap. Both `wcs_get_subscription`-installed and not-installed sites need the prototype to render the v2 design, so gating registration on `! function_exists('wcs_get_subscription')` would mean no demo on production-shaped sites — the wrong default. Instead I always register the endpoint (`add_rewrite_endpoint` is idempotent — re-registering the same slug a second time is a no-op, even if WCS got there first), and use a `template_redirect` priority-8 hook to call `remove_all_actions('woocommerce_account_subscriptions_endpoint')` whenever the demo is active and we're on the subscriptions endpoint. That drops WCS' `WCS_Query::endpoint_content` (priority 10) and Newspack's `WooCommerce_My_Account::append_membership_table` (priority 11) in one move, then re-adds our own renderer. Confirmed on this dev box (WCS 8.5.0 + WC 10.7.0) that the takeover works for demo requests and that non-demo requests still get WCS' default — exactly what we want. The trade-off worth noting: any future site-specific hook on this action (custom membership extension, site-installed plugin) will also be silently dropped during demo requests. Acceptable for a prototype, but worth flagging as a Phase 6 polish item: the takeover could be more surgical (remove only the two known handlers by name) if it ever stops being acceptable. + +A second wrinkle: `redirect_non_demo_v2_endpoints` for `subscriptions` can't unconditionally bounce, the way it does for newsletters/donations. On a WCS-installed site, a logged-in non-admin reader visiting `/my-account/subscriptions/` is supposed to see WCS' real subscriptions page — bouncing them to `/edit-account/` would be a regression of v1 behaviour just because we hooked the endpoint. So the redirect for subscriptions is gated on `! function_exists('wcs_get_subscription')` — only fires on sites where WCS isn't installed and our endpoint is the only thing registered. Same `function_exists` sentinel the rest of the plugin uses for WCS detection (`class-woocommerce-my-account.php` line 419, etc.). + +The Phase 3 / Phase 2 reflexes carried over cleanly. (i) Reserved-globals trap: I started the detail template with `$status`, `$id`, `$title`, then renamed to `$subscription_status`, `$subscription_id`, `$header_title` from the start — saved a PHPCS round-trip. (ii) The "more stacks, not new SCSS" reflex held: every layout in the list and detail is nested `__stack--horizontal` / `--vertical` with `--justify-between` for left/right pinning. The `style.scss` file is still wrapper-only. (iii) The opcache + ENDPOINTS_VERSION-bump caveat (Phase 3 cross-phase log) bit again — first request after the bump 404'd until I ran `wp eval 'opcache_reset();' && wp option delete newspack_my_account_v2_demo_endpoints_version && wp rewrite flush`. Production deploys clear opcache so it won't bite there, but I'll mirror the same caveat in the PR test plan. (iv) The expiring-variant inline error notice has a "renew now" anchor with `data-action="renew-subscription"`. The card's outer container is also a clickable Plan Card — but for the `active` bucket the outer is a `
`, not an ``, so there's no nested-link bug. The "renew now" anchor renders inside a div, not inside a parent anchor. + +The five detail variants felt like they wanted five files until the second-pass refactor: `cancelled` and `expiring` share the Renew button + em-dash next payment + identical structure; the difference is one shows a notice and the other shows a CANCELLED badge. `active` and `renewed` are visually identical — the only thing distinguishing them in Figma is the data fixture (a recently-renewed sub vs a long-running active one). And `active no-fees` is just `fees_covered = true` collapsing one section. Five variants, two structural axes (`status`, `fees_covered`), one template — same density payoff as the Phase 3 donation-details file. + +**Decisions and why** + +- **Workflow split: Phase 4 ships as its own draft PR targeting `prototype/my-account-demo`.** Per the new rule from this phase: cumulative diffs were making Copilot reviews crawl. The umbrella PR (#4679) becomes a tracker — title and description updated as each phase merges down into the prototype branch. Phase 4 lives on `prototype/my-account-demo-phase-4` cut from `prototype/my-account-demo`. +- **WC Subscriptions collision strategy: takeover via `remove_all_actions` on `template_redirect` rather than gate on `function_exists`.** Trade-offs evaluated in "What I learned" above. Picked takeover because the prototype must be demoable on any Newspack site, including the realistic shape (WCS installed) where the design review actually happens. The two alternatives the brief listed each had a real failure mode: gating on `! function_exists('wcs_get_subscription')` means no demo on the most production-shaped sites; "let WCS win" means the Phase 4 design never renders for the people who most need to see it. Cross-phase decision log row added below. +- **Always register the `subscriptions` endpoint.** `add_rewrite_endpoint` is idempotent (the same slug registered twice is a no-op), so this is harmless on WCS-installed sites and necessary on plain ones. Means we don't need conditional registration logic, and the auto-flush guard (`ENDPOINTS_VERSION`) covers both shapes. +- **`redirect_non_demo_v2_endpoints` is gated on `! function_exists('wcs_get_subscription')` for the subscriptions branch only.** Newsletters and donations always bounce — those are v2 endpoints by definition. Subscriptions is special-cased because WCS owns the URL on its own sites. Leaving the bounce conditional means non-demo readers on WCS sites still see WCS' real screen. +- **Menu_items pluck-then-reinsert pattern.** The wrapper class' `wc_subscriptions_at_top` filter hoists `subscriptions` to the top of the menu when WCS is installed; if I just re-inserted at my position without removing it first, it would render twice (or stay at the top). `unset( $items['subscriptions'] )` before the foreach gives a stable order regardless of WCS' presence. +- **One detail template, five functional variants, branching in markup.** Same call as Phase 3's donation-details. Variant explosion would have meant a 5x duplicated header/Amount/dates/payment/billing-history scaffold. Status flags + a `fees_covered` boolean cleanly project all five Figma frames onto one file. +- **Snackbar/wireDetailRoot is a near-clone of `donations.js`.** Brief and Phase 3 devlog both call out that the rule-of-three for extracting `src/my-account-v2-demo/util/snackbar.js` is reached when Phase 5 modals add a third caller — explicitly NOT extracting it preemptively in Phase 4. Two callers is still the right side of premature abstraction, and refactoring Phase 2/3 in a Phase 4 commit muddies the diff. +- **Pulled the Change subscription modal forward from Phase 5.** Brief §10 lumps all six modal flows into Phase 5 for consistency, but Thomas pushed back: the Change subscription button was a stub snackbar, and "fake data can drive the modal too." Right call. The four Figma frames (`2636:46318` Init, `46331` Monthly selected, `46344` Plan selected, `46297` Transaction) collapse cleanly into one modal partial with two visual steps (`select` and `transaction`) and internal interaction state (active frequency tab + selected tier). Each active subscription gets its own modal instance keyed by `data-subscription-id`; the v1 `Subscriptions_Tiers::render_modal` from `class-subscriptions-tiers.php` was instructive but not reusable directly — it depends on real `WC_Product` and `WC_Subscription` objects and renders a single-screen tier picker, not the four-step Figma flow. Net Phase 4 scope grew by one modal partial + the JS state machine + the tier/billing fake-data slice; Phase 5 work shrinks by one of six modals. +- **Match v1's class names where v1 already styles them.** First pass on the detail-page header invented its own composition (`__stack--horizontal --justify-between` + hand-rolled chevron SVG + `

` with utility classes for the title). Felt off — gaps wrong, chevron wrong, dropdown spacing different. Reason: the existing v1 SCSS file `_subscriptions.scss` already paints `.newspack-my-account__subscription--header / --title / --back-link / --actions-container / --actions-dropdown` for the v1 detail page, with the right responsive breakpoint behaviour (back-link offset on desktop, actions-dropdown hidden on mobile). The my-account-v2-demo body class chain is `.woocommerce-account.newspack-my-account.newspack-ui` plus `.newspack-my-account--my-account-v2-demo`, which means **every v1 SCSS rule already targets us**. Refactored the header markup to use the v1 conventions verbatim — `header.newspack-my-account__subscription--header` containing `.--title` (back-link `` with `Newspack_UI_Icons::print_svg('chevronLeft')` + `

` + status badge) and `.--actions` (actions-container with the primary action + actions-dropdown with the secondary actions, mirroring v1's `subscription-header.php`). The change-subscription tier cards in the modal now use the v1 `` tag inside the notice, intercepted by JS.** It could have been a `

`; donation-details now follows the same convention. +- **Action buttons live directly inside `.newspack-ui__modal__content`, never `__footer`.** Phase 4 lesson held: the footer's `--newspack-ui-color-neutral-5` background visibly clashes with the design. Every Phase 5 modal terminates with a wide primary button + (where Figma shows one) a wide ghost cancel button, both directly inside the content section. +- **Confirmation modals use `data-step="init"` + `data-step="success"` toggles.** Two `__modal__content` divs, one with `hidden`, JS flips them on Confirm. Same pattern across cancel-subscription, cancel-donation, renew-subscription. Discoverable from the markup, no separate "is this in success state?" boolean to track. +- **Update-payment-method stays a snackbar.** Brief §10 lumps it with the v1 checkout flow (real Stripe form, real billing readout sourced from WC). Building a fake "payment-method updated" modal in Phase 5 would either duplicate the renew/restart payment forms (cheap) or pretend to mutate state (lying). The phase ends with a confirmed gap. +- **Restart donation has no Figma success state, so it terminates on snackbar.** Cancel/Renew show explicit success frames — Restart only shows the init transaction. Adding a fictional success step would invent design that isn't in Figma. Snackbar termination is consistent with what Phase 4's change-subscription does after its transaction step. +- **Modify donation recomputes totals in JS.** The Figma frame shows totals updating as the amount is edited. Two options: freeze the totals to match the donation's current numbers (declarative + simple) or recompute (matches Figma intent). Picked recompute because it's ~30 lines and the design is clearly interactive. Math is approximate — the comment in the JS says so. +- **Renew + Restart reuse the shared `tiers.billing` fixture from `get_fake_data()['subscriptions']`.** Donations don't carry their own billing block in the fake data, and the demo address is the same one Change subscription already uses. Reusing keeps the modals visually consistent with each other (one fictional reader, one fictional address) without proliferating fixture data. +- **Action-router lookup is keyed by data-action → slug.** A literal slug map (`'cancel-subscription' → 'cancel-subscription'`) reads as identity right now, but it's the seam where a future flow could route two actions to one modal (e.g. "renew-subscription" and "renew-now-from-list-anchor" both pointing at "renew-subscription"). Cheaper to leave the map than to flatten it and refactor on first divergence. +- **List-page renew modal renders only for expiring subs in active.** Today there are none — the modal's a no-op. Phase 6 scenario fixtures (`?my-account-v2-demo=expiring`) are expected to swap an expiring sub into `active`; rendering the modal alongside there means the anchor opens the modal in place, no further code changes. Cheap forward-compat for Phase 6. +- **`The News Paper` stays hardcoded in modal copy.** The change-subscription modal already used it (cover-fees helper text); cancel-subscription / renew-subscription / etc. follow. Phase 6 polish can sweep these to `get_bloginfo('name')` or a fake-data field — until then, consistency over real-site reflection. +- **Reserved-globals trap: every modal partial uses `$donation_id`, `$subscription_id`, `$frequency_unit`, `$reader_email` etc. — never `$id`, `$status`, `$frequency`.** Same call as Phase 4's subscription-details template; saved a PHPCS round-trip across six new files. + +**Open questions** + +- **Designed but stub-labelled buttons.** Cancel/Renew/Cancel-donation success frames in Figma all carry placeholder buttons ("Button", "Continue", "Secondary", "Cancel") below the success box. I picked the simplest Newspack-canonical interpretation (success box + a single Done CTA), but if those stub buttons are meant to be real (e.g. "Subscribe to a newsletter", "View receipt"), the success states need a second pass with copy from the designer. +- **Modify-donation totals precision.** The math is approximate. If the design review surfaces specific totals (e.g. exact VAT rate per region, exact Stripe fee tiers), Phase 6 should swap in either a static fixture matching Figma byte-for-byte or a proper `wc_get_price_excluding_tax`-style helper. +- **Update-payment-method.** Brief §10 says "v1 checkout flow." Confirm with Thomas before Phase 6: do we render a stub modal mirroring the renew-subscription payment form, or stay pointed at real WC infrastructure? +- **Inline `` etc.** The detail-page header links carry hash fragments so they look like real links. Once the modals are universally wired, the hashes are dead — they don't navigate, they don't get bookmarked. Worth scrubbing in Phase 6. + +--- + +## Phase 6 — Payment methods + +> See [brief §10 → Phase 6](my-account-v2-prototype-brief.md#phase-6--payment-methods-115-days). Reproduce v1's `/my-account/payment-methods/` byte-for-byte under the my-account-v2-demo flag, fed by fake data. + +**Date:** 2026-04-28 +**By:** thomas@a8c.com +**PR:** _pending — phase branch `prototype/my-account-demo-phase-6`, draft PR targets `prototype/my-account-demo` (umbrella tracker is #4679)_ +**Commits:** _pending — code is staged on `prototype/my-account-demo-phase-6`_ + +**What I built** + +The `/my-account/payment-methods/` surface, end-to-end, byte-for-byte against WC core's `myaccount/payment-methods.php`. (a) Registered a new `woocommerce_account_payment-methods_endpoint` action handler `render_payment_methods_endpoint` that loads `templates/my-account-v2-demo/payment-methods.php`. (b) Added a `template_redirect` priority-8 callback `takeover_payment_methods_endpoint` that calls `remove_all_actions('woocommerce_account_payment-methods_endpoint')` to drop both WC core's default `woocommerce_account_payment_methods` (which renders WC core's table) and v1's effective swap of the underlying `wc_get_template('myaccount/payment-methods.php')` to `payment-information.php`, then re-adds our renderer. (c) Bumped `ENDPOINTS_VERSION` from 4 → 5 so the auto-flush guard re-runs once on the next admin /my-account/ visit — even though we don't `add_rewrite_endpoint` for `payment-methods` (WC core already owns that slug), the version bump kept the flush plumbing consistent across phases. (d) Plucked `payment-methods` out of the inbound menu items (v1 had relabelled it to "Payment information") and re-inserted it in the v2 sidebar order: `edit-account → newsletters → donations → subscriptions → payment-methods`. (e) Extended `get_fake_data()` with a `payment_methods` slice (two saved cards: default Visa with only a Delete action, and a non-default Mastercard with Make default + Delete actions — shape mirrors `wc_get_customer_saved_methods_list()`). (f) Created `src/my-account-v2-demo/payment-methods.js` — a snackbar-only dispatcher (no modals this phase), wiring `data-action="set-default-payment-method"`, `delete-payment-method`, and `add-payment-method` clicks to localized snackbars via the shared `util/snackbar.js`. (g) Imported the new module from `src/my-account-v2-demo/index.js`. + +The template mirrors WC core verbatim: `
`, same `` row in the previous- + * donations table is clicked. Provides the row-as-link affordance that + * pure HTML doesn't give us inside markup. (Hover/cursor styling + * is intentionally deferred — Phase 6 polish, not a Phase 3 blocker.) + * - Detail page: open the Modify / Cancel / Restart donation modals when + * their trigger buttons fire. Each modal lives at the bottom of the + * detail template (one per donation, keyed by id); init / success steps + * transition inline. Update payment method stays a stub snackbar — the + * brief lumps it with the v1 checkout flow, not a Phase 5 modal. + * - Dropdown for the "More" menu auto-wires via newspack-ui's own + * `js/dropdowns.js` — no work needed here. + */ + +import { __ } from '@wordpress/i18n'; + +import { snackbar } from './util/snackbar'; + +/** + * Wire row-click + keyboard navigation for the previous-donations table on + * the list page. The template marks each `` with `tabindex="0"` and + * `role="link"`; this handler activates them on click, Enter, or Space so + * keyboard and screen-reader users can navigate too. Idempotent. + * + * @param {HTMLElement} root List container element. + */ +function wireListRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + + const navigateToRow = row => { + const url = row.dataset.href; + if ( url ) { + window.location.href = url; + } + }; + + root.addEventListener( 'click', event => { + const row = event.target.closest( 'tr[data-href]' ); + if ( ! row || ! root.contains( row ) ) { + return; + } + // Don't hijack clicks on actual links/buttons inside cells (none + // today, but cheap insurance for future cell affordances). + if ( event.target.closest( 'a, button' ) ) { + return; + } + navigateToRow( row ); + } ); + + root.addEventListener( 'keydown', event => { + if ( event.key !== 'Enter' && event.key !== ' ' ) { + return; + } + const row = event.target.closest( 'tr[data-href]' ); + if ( ! row || ! root.contains( row ) ) { + return; + } + // Same insurance as the click handler — let nested controls own + // their own keyboard semantics. + if ( event.target.closest( 'a, button, input, select, textarea' ) ) { + return; + } + event.preventDefault(); + navigateToRow( row ); + } ); + + // "Billing history" Button Card has no Phase 3 destination — wire a + // stub snackbar so the card actually does something when clicked. + // `?my-account-v2-demo=billing-history` (Phase 7) flips `billing_history_inline` + // to true and replaces the card with the embedded table, so this + // listener only fires for the default Button Card variant. + root.addEventListener( 'click', event => { + const trigger = event.target.closest( '[data-action="open-billing-history"]' ); + if ( ! trigger || ! root.contains( trigger ) ) { + return; + } + event.preventDefault(); + snackbar( __( 'Billing history will be available in a future update.', 'newspack-plugin' ) ); + } ); +} + +/** + * Wire the Cancel donation modal — confirmation pattern (init step → success + * step inline). Mirrors the subscriptions.js wireConfirmModal helper; kept + * local rather than shared because the donation/subscription packages stay + * independent at the wiring layer. + * + * @param {HTMLElement} modal The modal container element. + */ +function wireCancelDonationModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const initStep = modal.querySelector( '[data-step="init"]' ); + const successStep = modal.querySelector( '[data-step="success"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + + const goToStep = step => { + if ( ! initStep || ! successStep ) { + return; + } + initStep.hidden = step !== 'init'; + successStep.hidden = step !== 'success'; + }; + + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => goToStep( 'success' ) ); + } + + modal.addEventListener( 'closeModal', () => goToStep( 'init' ) ); +} + +/** + * Wire the Restart donation modal — single-screen transaction (billing + + * payment form), terminating on a snackbar (no Figma success state). The + * Confirm button just closes the modal and surfaces "Donation restarted." + * + * @param {HTMLElement} modal The modal container element. + */ +function wireRestartDonationModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => { + modal.setAttribute( 'data-state', 'closed' ); + snackbar( __( 'Donation restarted.', 'newspack-plugin' ) ); + } ); + } +} + +/** + * Wire the Modify donation modal — frequency segmented control + amount + * editor + recurring totals readout that recomputes as the amount changes. + * Confirm → snackbar (no Figma success state for modify). + * + * Math model: amount in the input is the gross "Recurring total" the reader + * pays. Subtotal is amount / (1 + vatRate); vat is amount - subtotal; + * transaction fee defaults to null but flips on when "Cover transaction + * fees?" is checked (2% of amount, rounded to 2dp). All numbers update + * declaratively whenever the amount, frequency, or fee toggle changes. + * + * @param {HTMLElement} modal The modal container element. + */ +function wireModifyDonationModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const tabs = [ ...modal.querySelectorAll( '[data-frequency][role="tab"]' ) ]; + const amountInput = modal.querySelector( '[data-modify-amount]' ); + const feeToggle = modal.querySelector( '[data-modify-cover-fees]' ); + const amountUnitLabel = modal.querySelector( '[data-modify-amount-unit]' ); + const totalsHeading = modal.querySelector( '[data-modify-totals-heading]' ); + const subtotalEl = modal.querySelector( '[data-modify-subtotal]' ); + const vatEl = modal.querySelector( '[data-modify-vat]' ); + const feeEl = modal.querySelector( '[data-modify-fee]' ); + const totalEl = modal.querySelector( '[data-modify-total]' ); + const nextDateEl = modal.querySelector( '[data-modify-next]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + const confirmLabel = modal.querySelector( '[data-modify-confirm-label]' ); + + const symbol = modal.dataset.currencySymbol || '$'; + const vatRate = Number.parseFloat( modal.dataset.vatRate || '0.2' ); + const feeRate = Number.parseFloat( modal.dataset.feeRate || '0.02' ); + const initialFrequency = modal.dataset.initialFrequency || 'month'; + const nextDates = ( () => { + try { + return JSON.parse( modal.dataset.nextDates || '{}' ); + } catch ( _e ) { + return {}; + } + } )(); + const unitLabels = ( () => { + try { + return JSON.parse( modal.dataset.unitLabels || '{}' ); + } catch ( _e ) { + return {}; + } + } )(); + const recurringTotalLabels = ( () => { + try { + return JSON.parse( modal.dataset.recurringTotalLabels || '{}' ); + } catch ( _e ) { + return {}; + } + } )(); + + let activeFrequency = initialFrequency; + + const formatAmount = n => `${ symbol }${ Number.isFinite( n ) ? n.toFixed( 2 ) : '0.00' }`; + + const recompute = () => { + const amount = Math.max( 0, Number.parseFloat( amountInput?.value || '0' ) || 0 ); + const subtotal = amount / ( 1 + vatRate ); + const vat = amount - subtotal; + const fee = feeToggle?.checked ? amount * feeRate : null; + const unit = unitLabels[ activeFrequency ] || activeFrequency; + + if ( amountUnitLabel ) { + amountUnitLabel.textContent = unit; + } + if ( totalsHeading ) { + totalsHeading.textContent = recurringTotalLabels.heading || totalsHeading.textContent; + } + if ( subtotalEl ) { + subtotalEl.textContent = `${ formatAmount( subtotal ) } / ${ unit }`; + } + if ( vatEl ) { + vatEl.textContent = formatAmount( vat ); + } + if ( feeEl ) { + feeEl.textContent = null === fee ? '—' : formatAmount( fee ); + } + if ( totalEl ) { + const grandTotal = null === fee ? amount : amount + fee; + totalEl.textContent = `${ formatAmount( grandTotal ) } / ${ unit }`; + } + if ( nextDateEl ) { + nextDateEl.textContent = nextDates[ activeFrequency ] || ''; + } + if ( confirmLabel ) { + confirmLabel.textContent = `${ formatAmount( amount ) } / ${ unit }`; + } + if ( confirmBtn ) { + confirmBtn.disabled = amount <= 0; + } + }; + + const setActiveFrequency = freq => { + if ( ! freq || freq === activeFrequency ) { + return; + } + activeFrequency = freq; + tabs.forEach( tab => { + const isActive = tab.dataset.frequency === freq; + tab.classList.toggle( 'selected', isActive ); + tab.setAttribute( 'aria-selected', isActive ? 'true' : 'false' ); + } ); + recompute(); + }; + + tabs.forEach( tab => { + tab.addEventListener( 'click', () => setActiveFrequency( tab.dataset.frequency ) ); + } ); + if ( amountInput ) { + amountInput.addEventListener( 'input', recompute ); + } + if ( feeToggle ) { + feeToggle.addEventListener( 'change', recompute ); + } + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => { + if ( confirmBtn.disabled ) { + return; + } + modal.setAttribute( 'data-state', 'closed' ); + snackbar( __( 'Donation modified.', 'newspack-plugin' ) ); + } ); + } + + // Reset to the initial state on close so re-opening shows the donation's + // current values rather than the previous edit. + modal.addEventListener( 'closeModal', () => { + if ( amountInput ) { + amountInput.value = amountInput.dataset.initialAmount || amountInput.value; + } + if ( feeToggle ) { + feeToggle.checked = feeToggle.dataset.initialChecked === 'true'; + } + setActiveFrequency( initialFrequency ); + recompute(); + } ); + + recompute(); +} + +/** + * Look up the modal for a given `data-action` + donation id, opening it if + * it exists. Closes any open dropdown first. Returns true if a modal was + * opened. + * + * @param {string} action Trigger's data-action. + * @param {string} donationId Trigger's data-donation-id. + * @param {HTMLElement} root Container element (for dropdown close). + * @return {boolean} Whether a modal was opened. + */ +function tryOpenDonationModal( action, donationId, root ) { + const slug = { + 'modify-donation': 'modify-donation', + 'cancel-donation': 'cancel-donation', + 'restart-donation': 'restart-donation', + }[ action ]; + if ( ! slug || ! donationId ) { + return false; + } + const modal = document.getElementById( `newspack-my-account__${ slug }-${ donationId }` ); + if ( ! modal ) { + return false; + } + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown ) { + openDropdown.classList.remove( 'active' ); + } + modal.setAttribute( 'data-state', 'open' ); + return true; +} + +/** + * Wire the detail-page modal-trigger buttons. Routes each `data-action` + * through the modal lookup; falls back to a snackbar for actions without a + * Phase 5 modal (today: only `update-payment-method`). + * + * @param {HTMLElement} root Detail container element. + */ +function wireDetailRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + + root.addEventListener( 'click', event => { + const trigger = event.target.closest( '[data-action]' ); + if ( ! trigger || ! root.contains( trigger ) ) { + return; + } + if ( trigger.classList.contains( 'newspack-ui__dropdown__toggle' ) ) { + return; + } + + const action = trigger.dataset.action; + const donationId = trigger.dataset.donationId || ''; + + if ( tryOpenDonationModal( action, donationId, root ) ) { + return; + } + + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown && openDropdown.contains( trigger ) ) { + openDropdown.classList.remove( 'active' ); + } + + if ( action === 'update-payment-method' ) { + snackbar( __( 'Payment method updated.', 'newspack-plugin' ) ); + } + } ); +} + +document.addEventListener( 'DOMContentLoaded', () => { + document.querySelectorAll( '[data-newspack-my-account-v2-demo="donations"]' ).forEach( wireListRoot ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="donation-details"]' ).forEach( wireDetailRoot ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="modify-donation-modal"]' ).forEach( wireModifyDonationModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="cancel-donation-modal"]' ).forEach( wireCancelDonationModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="restart-donation-modal"]' ).forEach( wireRestartDonationModal ); + + // If the user just landed via a Phase 5–style update flow that includes + // a `payment-updated` query param (Figma 2636:46500 "new payment method" + // state), surface a snackbar on first paint. Hooked here so the detail + // template stays declarative. + try { + const params = new URL( window.location.href ).searchParams; + if ( params.get( 'payment-updated' ) ) { + snackbar( __( 'Payment method updated.', 'newspack-plugin' ) ); + } + } catch ( _e ) { + // URL parsing failures are harmless; the snackbar is best-effort. + } +} ); diff --git a/src/my-account-v2-demo/index.js b/src/my-account-v2-demo/index.js new file mode 100644 index 0000000000..7ac7ff38c9 --- /dev/null +++ b/src/my-account-v2-demo/index.js @@ -0,0 +1,15 @@ +/** + * Newspack My Account v2 prototype demo. + * + * 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'; +import './account-settings'; +import './newsletters'; +import './donations'; +import './subscriptions'; +import './payment-methods'; diff --git a/src/my-account-v2-demo/newsletters.js b/src/my-account-v2-demo/newsletters.js new file mode 100644 index 0000000000..3b4a02fa14 --- /dev/null +++ b/src/my-account-v2-demo/newsletters.js @@ -0,0 +1,209 @@ +/** + * Newsletters screen — client-side optimistic UI. + * + * Every Sign up / Unsubscribe action mutates the DOM in place and fires a + * snackbar. No fetch, no form post — but the per-row subscribed state is + * mirrored to localStorage so it survives a refresh (a deviation from the + * brief §7 "state resets on reload" rule, kept because designers reviewing + * the prototype expect to see their toggles stick). + */ + +import { __, sprintf } from '@wordpress/i18n'; + +import { snackbar } from './util/snackbar'; + +const SUBSCRIBE = 'subscribe'; +const UNSUBSCRIBE = 'unsubscribe'; +const UNSUBSCRIBE_FROM_ALL = 'unsubscribe-from-all'; +// Fake-network delay before flipping a row's state. Long enough to let the +// loading spinner read as intentional; short enough not to feel broken. +const LOADING_DELAY_MS = 1500; +// localStorage key for the per-row subscribed state. Stored shape: +// `{ [list_id: string]: boolean }`. Scoped to the prototype so it can't +// collide with anything else on the site. +const STORAGE_KEY = 'newspack-my-account-v2-demo:newsletters'; + +/** + * Read the persisted state map. Tolerates missing / corrupt storage by + * returning an empty map — refresh-without-persistence falls back to the + * server-rendered fake data. + * + * @return {Object} Map of list id -> boolean. + */ +function readState() { + try { + const raw = window.localStorage.getItem( STORAGE_KEY ); + const parsed = raw ? JSON.parse( raw ) : {}; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch ( _err ) { + return {}; + } +} + +/** + * Persist a single row's subscribed state. No-op when storage is unavailable + * (private mode quota errors, etc.) — same UX as before, just without the + * cross-refresh memory. + * + * @param {string} listId Newsletter list id. + * @param {boolean} subscribed Subscribed state. + */ +function persistRow( listId, subscribed ) { + if ( ! listId ) { + return; + } + try { + const state = readState(); + state[ listId ] = !! subscribed; + window.localStorage.setItem( STORAGE_KEY, JSON.stringify( state ) ); + } catch ( _err ) { + // Swallow. + } +} + +/** + * 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; + } + const label = button.querySelector( 'span' ) || button; + if ( subscribed ) { + button.classList.remove( 'newspack-ui__button--primary' ); + button.classList.add( 'newspack-ui__button--secondary' ); + button.dataset.action = UNSUBSCRIBE; + label.textContent = button.dataset.labelUnsubscribe; + } else { + button.classList.remove( 'newspack-ui__button--secondary' ); + button.classList.add( 'newspack-ui__button--primary' ); + button.dataset.action = SUBSCRIBE; + label.textContent = button.dataset.labelSubscribe; + } + persistRow( row.dataset.listId, subscribed ); +} + +/** + * Replay any persisted row state on top of the server-rendered fake data. + * Called once when wireRoot mounts. Rows whose ids aren't in storage stay + * at whatever the fake data put them at. + * + * @param {HTMLElement} root Container. + */ +function applyPersistedState( root ) { + const state = readState(); + root.querySelectorAll( '[data-list-id]' ).forEach( row => { + const listId = row.dataset.listId; + if ( Object.prototype.hasOwnProperty.call( state, listId ) ) { + setRowSubscribed( row, !! state[ listId ] ); + } + } ); +} + +/** + * 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 ) { + if ( button.disabled ) { + return; + } + const row = button.closest( '[data-list-id]' ); + if ( ! row ) { + return; + } + const willSubscribe = action === SUBSCRIBE; + const name = button.dataset.listName || ''; + // Show a loading state on the clicked button while the prototype + // pretends to talk to the server. State flip + snackbar fire + // after the delay; further clicks are ignored while loading. + button.classList.add( 'newspack-ui__button--loading' ); + button.disabled = true; + setTimeout( () => { + button.classList.remove( 'newspack-ui__button--loading' ); + button.disabled = false; + 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 ) + ); + }, LOADING_DELAY_MS ); + return; + } + + if ( action === UNSUBSCRIBE_FROM_ALL ) { + if ( button.disabled ) { + return; + } + // Loading state cascades: the bulk button itself plus every + // currently-subscribed row's Unsubscribe button. Wrapping the + // row buttons in span happened in the partial, so the loading + // modifier hides their labels via newspack-ui's _buttons.scss. + const subscribedRows = Array.from( root.querySelectorAll( '[data-list-id][data-subscribed="true"]' ) ); + const rowButtons = subscribedRows.map( row => row.querySelector( 'button[data-action="unsubscribe"]' ) ).filter( Boolean ); + [ button, ...rowButtons ].forEach( el => { + el.classList.add( 'newspack-ui__button--loading' ); + el.disabled = true; + } ); + setTimeout( () => { + rowButtons.forEach( el => { + el.classList.remove( 'newspack-ui__button--loading' ); + el.disabled = false; + } ); + button.classList.remove( 'newspack-ui__button--loading' ); + subscribedRows.forEach( row => setRowSubscribed( row, false ) ); + syncUnsubscribeAllState( root ); + snackbar( __( 'Unsubscribed from all newsletters.', 'newspack-plugin' ) ); + }, LOADING_DELAY_MS ); + } + } ); + + applyPersistedState( root ); + syncUnsubscribeAllState( root ); +} + +document.addEventListener( 'DOMContentLoaded', () => { + document.querySelectorAll( '[data-newspack-my-account-v2-demo="newsletters"]' ).forEach( wireRoot ); +} ); diff --git a/src/my-account-v2-demo/payment-methods.js b/src/my-account-v2-demo/payment-methods.js new file mode 100644 index 0000000000..64cd4242e5 --- /dev/null +++ b/src/my-account-v2-demo/payment-methods.js @@ -0,0 +1,164 @@ +/** + * Payment information screen — client-side wiring. + * + * Mirrors the post-Phase-6 rebuild that swapped WC core's table DOM for + * v1's `payment-information.php` card layout (see brief §2.1.1, devlog + * "Payment information rebuild"). Five modals total: + * + * - Add payment method (singleton — `#newspack-my-account__add-payment-method`) + * - Edit payment method (per-card — `#newspack-my-account__edit-payment-method-`) + * - Delete payment method (per-card — `#newspack-my-account__delete-payment-method-`) + * - Edit / Add address (per-type — `#newspack-my-account__edit-address-`) + * - Delete address (per-type — `#newspack-my-account__delete-address-`) + * + * Triggers carry v1's hook classes (`newspack-my-account__delete-payment-method`, + * `…__edit-address`, etc.) plus a `data-payment-method` / `data-address-type` + * attribute. Same dispatcher shape as v1's `payment-information.js`, but + * rebuilt as event-delegated listeners on the page so dynamically-rendered + * triggers still work without a re-wire. + * + * All confirm buttons fire a snackbar + close the modal. No real + * mutations — the brief lumps that with the v1 checkout flow during + * productionisation. + */ + +import { __ } from '@wordpress/i18n'; + +import { snackbar } from './util/snackbar'; + +const TRIGGER_TO_MODAL = [ + { + selector: 'a.newspack-my-account__add-payment-method', + modalId: () => 'newspack-my-account__add-payment-method', + }, + { + selector: 'a.newspack-my-account__edit-payment-method[data-payment-method]', + modalId: trigger => `newspack-my-account__edit-payment-method-${ trigger.dataset.paymentMethod }`, + }, + { + selector: 'a.newspack-my-account__delete-payment-method[data-payment-method]', + modalId: trigger => `newspack-my-account__delete-payment-method-${ trigger.dataset.paymentMethod }`, + }, + { + selector: 'a.newspack-my-account__edit-address[data-address-type]', + modalId: trigger => `newspack-my-account__edit-address-${ trigger.dataset.addressType }`, + }, + { + selector: 'a.newspack-my-account__delete-address[data-address-type]', + modalId: trigger => `newspack-my-account__delete-address-${ trigger.dataset.addressType }`, + }, +]; + +/** + * The success copy fired by each modal's confirm button. Keyed by the + * `data-newspack-my-account-v2-demo` attribute on the modal container so + * the dispatcher can look up the right message without sprinkling + * if/else chains. + * + * @return {Record} Modal slug → translated snackbar copy. + */ +function getConfirmMessages() { + return { + 'add-payment-method-modal': __( 'Payment method added.', 'newspack-plugin' ), + 'edit-payment-method-modal': __( 'Payment method updated.', 'newspack-plugin' ), + 'delete-payment-method-modal': __( 'Payment method deleted.', 'newspack-plugin' ), + 'edit-address-modal': __( 'Address saved.', 'newspack-plugin' ), + 'delete-address-modal': __( 'Address deleted.', 'newspack-plugin' ), + }; +} + +/** + * Open a modal by id and collapse the originating dropdown so the menu + * doesn't float above the modal overlay (matches v1 payment-information.js). + * + * @param {string} modalId Modal id (no leading `#`). + * @param {HTMLElement} trigger Element that fired the click. + * @return {boolean} True if the modal was found and opened. + */ +function openModal( modalId, trigger ) { + const modal = document.getElementById( modalId ); + if ( ! modal ) { + return false; + } + modal.setAttribute( 'data-state', 'open' ); + const dropdown = trigger.closest( '.newspack-ui__dropdown' ); + if ( dropdown ) { + dropdown.classList.remove( 'active' ); + } + return true; +} + +/** + * Close a modal by setting `data-state="closed"`. newspack-ui's modals.js + * dispatches `closeModal` on the container when the state flips, which any + * step-resetting consumer can subscribe to. + * + * @param {HTMLElement} modal Modal container. + */ +function closeModal( modal ) { + modal.setAttribute( 'data-state', 'closed' ); +} + +/** + * Wire each confirm button (`[data-action="confirm"]`) to fire the + * snackbar matching its modal slug, then close the modal. Idempotent — + * guards against double-wiring with a dataset flag. + */ +function wireConfirmButtons() { + const messages = getConfirmMessages(); + document.querySelectorAll( '[data-newspack-my-account-v2-demo]' ).forEach( modal => { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + const slug = modal.dataset.newspackMyAccountV2Demo; + const message = messages[ slug ]; + if ( ! message ) { + return; + } + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + if ( ! confirmBtn ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + confirmBtn.addEventListener( 'click', event => { + event.preventDefault(); + snackbar( message ); + closeModal( modal ); + } ); + } ); +} + +/** + * Page-level click delegate. Walks the trigger map; first match opens + * its modal and stops the click from following the placeholder `#` + * href. Triggers without a matching modal in the DOM (e.g. the + * "Add address" CTA when address modals were never rendered) fall + * through to a no-op snackbar so clicks never feel broken. + * + * @param {MouseEvent} event Click event. + */ +function handlePageClick( event ) { + for ( const { selector, modalId } of TRIGGER_TO_MODAL ) { + const trigger = event.target.closest( selector ); + if ( ! trigger ) { + continue; + } + event.preventDefault(); + const id = modalId( trigger ); + if ( ! openModal( id, trigger ) ) { + snackbar( __( 'This action will be available soon.', 'newspack-plugin' ) ); + } + return; + } +} + +document.addEventListener( 'DOMContentLoaded', () => { + const sections = document.querySelectorAll( '#payment-methods, #addresses' ); + if ( ! sections.length ) { + return; + } + sections.forEach( section => { + section.addEventListener( 'click', handlePageClick ); + } ); + wireConfirmButtons(); +} ); diff --git a/src/my-account-v2-demo/style.scss b/src/my-account-v2-demo/style.scss new file mode 100644 index 0000000000..eae1664635 --- /dev/null +++ b/src/my-account-v2-demo/style.scss @@ -0,0 +1,421 @@ +// All my-account-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. + // Phase 2: still nothing. Newsletters list composes from stack, + // badge, button, and hr primitives alone. + + // Newsletters polish: inter-row hairlines must span the row container + // edge-to-edge. Two overrides win against existing rules. + // 1. The newspack-ui hr rule ships margin spacer-6 zero; the parent + // stack gap should control spacing, not per-element margins. + // 2. Newspack themes cap hr at roughly 8em via max-width; force the + // cap back to none so the hairline reads edge to edge. + // See devlog cross-phase log entry "First scoped SCSS rule". + .newspack-my-account-v2-demo-newsletters hr { + margin: 0; + max-width: none; + width: 100%; + } + + // Section titles render at medium font size. The newspack-ui font-m + // utility compiles to a two-class selector and gets beaten by some + // theme-level rules on entry-content headings. Pin it explicitly so + // the section heading reads consistently regardless of theme. + .newspack-my-account-v2-demo-newsletters h2 { + font-size: var(--newspack-ui-font-size-m); + line-height: var(--newspack-ui-line-height-m); + } + + // Sign up / Unsubscribe row buttons share the same width so the right + // edge stays steady as rows toggle between subscribed states (and so + // the loading-state spinner doesn't reveal a wider label after the + // flip). Targets only per-row buttons, not the bottom "Unsubscribe + // from all" CTA which sits in a different context. + .newspack-my-account-v2-demo-newsletters [data-list-id] > .newspack-ui__button { + min-width: 8rem; + } + + // Vertical hairline separator used between the plan name and price + // on subscription / donation cards (Figma 2636:46117 renders a + // `1px × 16px` #ddd line, not a literal "|" glyph). newspack-ui has no + // vertical-divider primitive — `
` is horizontal — so the rule + // lives here. Sized in `em` so detail-page header contexts at larger + // font sizes scale the line proportionally without per-page overrides. + &__separator { + background: var(--newspack-ui-color-border, #ddd); + display: inline-block; + height: 1em; + margin: 0 var(--newspack-ui-spacer-1); + vertical-align: middle; + width: 1px; + } + + // Donations list anchors the billing-history block to the bottom of + // the visible viewport (Figma 2636:46467). Sidebar is position:fixed + // 100vh, so our content has no parent to inherit height from — we + // pin a viewport-based min-height for justify-between to distribute. + // Subtract the WP admin bar offset and the .woocommerce wrapper's + // spacer-11 vertical padding (top + bottom = 128px) so the page + // doesn't overflow. Desktop only; below 768px the sidebar is an + // overlay and there's nothing to match. + .newspack-my-account-v2-demo-donations { + @media ( min-width: 768px ) { + min-height: calc( + 100vh + - var(--wp-admin--admin-bar--height, 0px) + - var(--newspack-ui-spacer-11) * 2 + ); + } + } + + // Section title font-size pin. The newspack-ui font-m utility + // compiles to a two-class selector and gets out-specificed by some + // theme rules on entry-content headings. Same fix as newsletters. + // Also zero margins so the parent stack's gap is the only spacing + // between sections (themes often ship h2 margin-top/bottom that + // stacks on top of __stack--gap-11). + .newspack-my-account-v2-demo-donations h2, + .newspack-my-account-v2-demo-subscriptions h2 { + font-size: var(--newspack-ui-font-size-m); + line-height: var(--newspack-ui-line-height-m); + margin: 0; + } + + // __box ships with margin-bottom: spacer-5; inside a __stack the + // margin gets added on top of the gap, inflating the visible space + // between sections. Zero it inside donations + subscriptions so the + // stack gap is the single source of inter-section spacing. + .newspack-my-account-v2-demo-donations .newspack-ui__box, + .newspack-my-account-v2-demo-subscriptions .newspack-ui__box { + margin-bottom: 0; + } + + // Billing history Button Card label block fills the remaining width + // so the title/description stretch across the button instead of + // sitting at content width while justify-between leaves a wide gap + // before the icon. Mirrors Figma's `flex-[1_0_0]` on the label. + // newspack-ui has no flex-grow utility, so this stays as a scoped + // rule for the single instance. + .newspack-my-account-v2-demo-donations [data-action="open-billing-history"] > .newspack-ui__stack--vertical { + flex: 1; + min-width: 0; + text-align: initial; + } + + // v1 ships a `.newspack-my-account .woocommerce-MyAccount-content + // :not(.__stack--gap-11) section + section { margin-top: spacer-11 }` + // rule that fires on our consecutive
elements because the + // outer wrapper isn't __stack--gap-11 (only the inner one is) — so + // it stacks an extra spacer-11 on top of __stack--gap-11. The v1 + // selector has 3 classes; this override compounds the section's own + // __stack class to land at 4 classes and win the specificity battle. + .newspack-my-account-v2-demo-donations section.newspack-ui__stack + section.newspack-ui__stack { + margin-top: 0; + } + + // Payment information page (Figma 2636:45349). Pin the gap between + // the Payment methods and Addresses sections to spacer-11 explicitly — + // v1's `:not(.__stack--gap-11) section + section` rule wins on the + // rest of the page, but the per-section

top margin compounds + // here and reads as a tighter gap than spec. + #payment-methods + #addresses { + margin-top: var(--newspack-ui-spacer-11); + } + + // Donation + subscription detail pages share the same three-table + // layout (Amount breakdown · dates+payment · billing history). v1's + // _subscriptions.scss already paints them via `shop_table.order_details`, + // `.subscription_details`, and `woocommerce-orders-table--orders`, + // but the inherited density (4px order_details rows; uniform #ddd + // dividers on every billing-history row) reads tighter and busier + // than Figma `2636:46488` / `2636:46512`. Re-paint per-page with + // the same thead-heavy / tbody-light pattern as the donations list + // previous-table. + .newspack-my-account-v2-demo-donation-details, + .newspack-my-account-v2-demo-subscription-details { + // Page-title h2 is `

` which + // compiles to `.newspack-ui .newspack-ui__font--m` (0,2,0) — + // out-specificed by some themes' `.entry-content h2` rules. + // Same fix shape as the newsletters / donations list h2 pins. + .newspack-my-account__subscription--title h2 { + font-size: var(--newspack-ui-font-size-m); + line-height: var(--newspack-ui-line-height-m); + margin: 0; + } + + // Amount + dates+payment tables: drop the inherited tr divider, + // even row spacing across the breakdown, body-small type so the + // tabular content reads denser than the page-level paragraph + // size (Figma). + table.shop_table.order_details, + table.shop_table.subscription_details { + tr { + border-bottom: none; + } + + th, + td { + font-size: var(--newspack-ui-font-size-s); + line-height: var(--newspack-ui-line-height-s); + padding: var(--newspack-ui-spacer-3) 0; + // `_my-account.scss` ships `vertical-align: middle` on + // shop_table cells. The Payment method row in + // subscription_details is the only multi-line cell on + // the page (brand + Exp. on a `
`); middle alignment + // pulls the "Payment method" label off the brand line. + // Top-align across both tables — single-line rows are + // visually unaffected. + vertical-align: top; + } + } + + // "Billing history" section heading reads at the small type-scale + // step (Figma) — themes typically ship h2 at the doc-level + // `--newspack-ui-font-size-l` and the inherited size dwarfs the + // table beneath. Pin to font-size-s and zero the theme's stacked + // h2 margin so the spacer-2 bottom margin is the only gap to the + // table. + [data-section-id="billing-history"] h2 { + font-size: var(--newspack-ui-font-size-s); + line-height: var(--newspack-ui-line-height-s); + margin: 0 0 var(--newspack-ui-spacer-2); + } + + // Billing history table: heavier divider under thead, lighter + // hairline between tbody rows. Cell padding bumps row height + // closer to Figma. Mirrors `__previous-table` on the list page. + table.woocommerce-orders-table--orders { + thead tr { + border-bottom: 1px solid var(--newspack-ui-color-border); + } + + tbody tr { + border-bottom: 1px solid var(--newspack-ui-color-neutral-5); + } + + thead th { + padding: var(--newspack-ui-spacer-2) 0; + } + + tbody td { + padding: var(--newspack-ui-spacer-3) 0; + } + } + } + + // Modify donation modal (Figma 2636:46578 / 2983:16128). + // newspack-ui's `__segmented-control` defaults to `align-items: center` + // — the `__tabs` block stays content-width, sitting centered with + // whitespace either side. Stretch it to fill the modal column and + // make each button flex to equal width. Theme h3 margin also inflates + // the spacer-2 gap newspack-ui ships between label + control to + // spacer-2 + theme-margin; zero the h3 margin so 8px is the visible + // gap on both Frequency + Amount blocks. The Amount wrapper is a + // plain `
`, so re-stack it as a flex column to inherit the same + // spacer-2 rhythm. + .newspack-my-account-v2-demo-modify-donation { + // Modal content: 24px gap between top-level sections (Frequency, + // Amount, Cover transaction, Recurring totals, Action buttons). + // newspack-ui's default leaves children to stack via theme h3 / p / + // box margins plus a first-button `margin-top: spacer-5` rule — + // re-paint as a flex column with explicit gap so the rhythm is + // uniform. Zero direct-child margins so the gap is the only space. + .newspack-ui__modal__content { + display: flex; + flex-direction: column; + gap: var(--newspack-ui-spacer-5); + + > * { + margin: 0; + } + + h3 { + margin: 0; + } + } + + // Frequency: full-width tabs, 8px label-to-control gap. + .newspack-ui__segmented-control { + align-items: stretch; + gap: var(--newspack-ui-spacer-1); + + &__tabs { + flex-wrap: nowrap; + + .newspack-ui__button { + flex: 1 1 100%; + } + } + } + + // Amount block (h3 + currency input) — 8px label-to-input gap. + .newspack-ui__modal__content > div:has(> .newspack-ui__currency-input) { + display: flex; + flex-direction: column; + gap: var(--newspack-ui-spacer-1); + } + + // Recurring totals box: `_my-account.scss .shop_table` ships + // `padding-top: spacer-2` (12px) on every cell. v1's + // `tr:not(:only-child)` 4px override only matches tbody rows + // (thead has one row), so the heading th keeps the 12px and + // stacks against the box's spacer-5 (24px) padding — heading + // sits ~36px from the box top instead of Figma's 24px. Pull + // the table up by spacer-2 so the th's padding-top consumes + // the box's padding zone cleanly. + .newspack-ui__box > table { + margin-top: calc(var(--newspack-ui-spacer-2) * -1); + } + + // Cover transaction fees: collapse the stacked label + // `margin-bottom: spacer-1` + helper-text `margin-top: spacer-1` + // so the helper text reads tight against the checkbox row. + // Figma sets gap-0 between the Input + Helper text containers and + // lets line-height handle the visual rhythm. + .form-row-wide { + label.checkbox { + margin-bottom: 0; + } + + .newspack-ui__helper-text { + margin-top: 0; + } + } + + // Action buttons group — Confirm + Cancel sit 12px apart per + // Figma's `_Base / Modal Buttons` group, while sitting 24px + // below the Recurring totals box (the parent gap on `__modal__content`). + &__actions { + display: flex; + flex-direction: column; + gap: var(--newspack-ui-spacer-2); + } + } + + // Previous donations table polish (Figma 2636:46479). Header gets a + // stronger bottom border (--color-border, #ddd); rows get a lighter + // divider (neutral-5, #f0f0f0); rows are clickable so cursor flips + // to pointer; cell padding bumps row height closer to Figma's 48px. + .newspack-my-account-v2-demo-donations__previous-table { + thead th { + border-bottom: 1px solid var(--newspack-ui-color-border); + padding: var(--newspack-ui-spacer-2) 0; + } + + tbody { + td { + border-bottom: 1px solid var(--newspack-ui-color-neutral-5); + padding: var(--newspack-ui-spacer-3) 0; + } + + tr { + cursor: pointer; + } + } + } + + // Phase 9 — Delete account modal action list. Three rows of + // label and description on the left, Manage button on the right, + // separated by hairlines. No newspack-ui primitive matches this + // layout so it lives here as a small scoped block. + // Two-column subgrid so all three Manage * buttons share the same + // auto-sized column track — grid sizes the column to fit the widest + // button across rows, so every button matches the longest one without + // hardcoded widths or JS measuring. + .newspack-my-account-v2-demo-account-settings__alternatives { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + list-style: none; + margin: 0 0 var(--newspack-ui-spacer-3); + padding: 0; + } + + .newspack-my-account-v2-demo-account-settings__alternatives-item { + align-items: center; + border-bottom: 1px solid var(--newspack-ui-color-border, #ddd); + display: grid; + gap: var(--newspack-ui-spacer-5); + grid-column: 1 / -1; + grid-template-columns: subgrid; + padding-bottom: var(--newspack-ui-spacer-5); + padding-top: var(--newspack-ui-spacer-5); + + &:first-child { + padding-top: 0; + } + + // Stretch each button to fill the auto-sized column so widths align + // across rows. `.newspack-ui__button` ships with + // `margin-bottom: spacer-2` (12px) which extends the grid row and + // creates phantom whitespace above each next row — zero it out so + // the row's padding fully controls vertical rhythm. + .newspack-ui__button { + justify-content: center; + margin: 0; + width: 100%; + } + } + + // Layout comes from `newspack-ui__stack newspack-ui__stack--vertical` on + // the markup; the only thing this override carries is a 4px gap (half of + // spacer-1, no utility for it) and the description's muted typography. + .newspack-my-account-v2-demo-account-settings__alternatives-details { + gap: calc(var(--newspack-ui-spacer-1) / 2); + min-width: 0; + + span { + color: var(--newspack-ui-color-neutral-60, #6c6c6c); + font-size: var(--newspack-ui-font-size-xs); + line-height: var(--newspack-ui-line-height-xs); + } + } + + // Sidebar nav footer (secondary links + Sign out) renders as a stack + // with spacer-1 between items. v1's `_navigation.scss` controls the + // outer footer block but doesn't gap the children, so the v2-demo + // secondary links sit too tight against the Sign out row. + .newspack-my-account__navigation-footer ul { + display: flex; + flex-direction: column; + gap: var(--newspack-ui-spacer-1); + } + + // Email-send icon at the top of the success step. Black filled + // circle, white icon — matches Figma frame 4865:92398. + .newspack-my-account-v2-demo-account-settings__success-icon { + align-items: center; + background: var(--newspack-ui-color-neutral-90, #1e1e1e); + border-radius: 999px; + color: var(--newspack-ui-color-bg-base, #fff); + display: inline-flex; + height: 40px; + justify-content: center; + margin: 0 auto var(--newspack-ui-spacer-3); + width: 40px; + + svg { + fill: currentcolor; + } + } +} + +// `processing` status dot — v1 `_subscriptions.scss` writes +// `background: var(--newspack-ui-color-warning-50)` to the +// `.woocommerce-account.newspack-my-account.newspack-ui ... +// --order-status-label.processing` chain (specificity 0,5,0), but that +// CSS var doesn't exist (`_colors.scss` ships warning 0/5/30/40 only), so +// production v1 silently falls through to the default neutral-60. Latent +// v1 bug. To override under the demo flag we need a 5-class chain too: +// `.newspack-my-account` + `.newspack-my-account-v2-demo` (both on body) +// plus the detail wrapper, status-label class and `.processing`. Same +// specificity as v1 means cascade order decides — v2-demo CSS enqueues +// at priority 12 vs v1's 11, so this rule wins. +.newspack-my-account.newspack-my-account-v2-demo { + .newspack-my-account-v2-demo-donation-details .newspack-my-account__subscription--order-status-label.processing, + .newspack-my-account-v2-demo-subscription-details .newspack-my-account__subscription--order-status-label.processing { + background: var(--newspack-ui-color-warning-30); + } +} diff --git a/src/my-account-v2-demo/subscriptions.js b/src/my-account-v2-demo/subscriptions.js new file mode 100644 index 0000000000..7c4319120b --- /dev/null +++ b/src/my-account-v2-demo/subscriptions.js @@ -0,0 +1,403 @@ +/** + * Subscriptions screens — client-side wiring. + * + * Responsibilities: + * - Detail page: open the Change / Cancel / Renew subscription modals when + * their trigger buttons fire. Each modal lives at the bottom of the + * detail template (one per subscription, keyed by id); init / success + * steps transition inline. Update payment method stays a stub snackbar + * — the brief lumps it with the v1 checkout flow, not a Phase 5 modal. + * - List page: the inline "renew now" anchor inside the expiring active + * card opens the Renew subscription modal too (when the modal is + * rendered there, e.g. once Phase 6 fixtures put an expiring sub into + * the active bucket). When no modal is found we fall back to a stub + * snackbar so the click never feels inert. + * - Dropdown for "More" auto-wires via newspack-ui's own js/dropdowns.js. + */ + +import { __ } from '@wordpress/i18n'; + +import { snackbar } from './util/snackbar'; + +/** + * Wire a Change subscription modal. The modal lives at the bottom of the + * subscription-details template (one per active/renewed sub). State machine: + * + * - select step (default): Frequency tabs swap which tier panel is visible. + * Selecting a non-current tier enables "Change subscription"; the current + * tier shows a CURRENT badge and keeps Change disabled. + * - transaction step: Reached by clicking "Change subscription". Shows the + * summary line (e.g. "Patron: $9.99 / month"), billing details, and a + * payment form. "Edit" goes back; "Pay now" closes the modal and surfaces + * a "Subscription changed." snackbar (Phase 4 stops at the success state). + * + * @param {HTMLElement} modal The modal container element. + */ +function wireChangeSubscriptionModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const currentTierId = modal.dataset.currentTier || ''; + const currentFrequency = modal.dataset.currentFrequency || ''; + const tabs = [ ...modal.querySelectorAll( '[data-frequency][role="tab"]' ) ]; + const panels = [ ...modal.querySelectorAll( '[data-frequency-panel]' ) ]; + const tierCards = [ ...modal.querySelectorAll( '[data-tier-card]' ) ]; + const advanceBtn = modal.querySelector( '[data-action="advance-to-transaction"]' ); + const backBtn = modal.querySelector( '[data-action="back-to-select"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm-change"]' ); + const summary = modal.querySelector( '[data-transaction-summary]' ); + const selectStep = modal.querySelector( '[data-step="select"]' ); + const transactionStep = modal.querySelector( '[data-step="transaction"]' ); + + let activeFrequency = currentFrequency; + let selectedTierId = currentTierId; + + const showPanel = frequency => { + // newspack-ui's _segmented-control.scss hides any `__panel` without + // `.selected` (`&:not(.selected) { display: none; }`) — match that + // instead of toggling `hidden` so the segmented control's own CSS + // stays in charge of panel visibility. + panels.forEach( panel => { + panel.classList.toggle( 'selected', panel.dataset.frequencyPanel === frequency ); + } ); + }; + + const setActiveTab = frequency => { + tabs.forEach( tab => { + const isActive = tab.dataset.frequency === frequency; + tab.classList.toggle( 'selected', isActive ); + tab.setAttribute( 'aria-selected', isActive ? 'true' : 'false' ); + } ); + }; + + const setSelectedTier = tierId => { + selectedTierId = tierId; + tierCards.forEach( card => { + const isSelected = card.dataset.tierId === tierId; + // `.current` is the v1 convention for highlighted radio cards + // (see Subscriptions_Tiers::render_product_card). Toggle on the + // selected card so the highlight follows the click. + card.classList.toggle( 'current', isSelected ); + const radio = card.querySelector( '[data-tier-radio]' ); + if ( radio ) { + radio.checked = isSelected; + } + } ); + // Change button enables only when a tier is selected AND it isn't the + // one already in effect — picking the current tier is a no-op. + const enable = !! tierId && tierId !== currentTierId; + if ( advanceBtn ) { + advanceBtn.disabled = ! enable; + } + }; + + const goToStep = step => { + if ( ! selectStep || ! transactionStep ) { + return; + } + selectStep.hidden = step !== 'select'; + transactionStep.hidden = step !== 'transaction'; + }; + + const updateSummary = () => { + if ( ! summary ) { + return; + } + const card = tierCards.find( c => c.dataset.tierId === selectedTierId ); + if ( ! card ) { + return; + } + // Match v1's render_product_card markup: for the tier name, + // for the price/freq line. + const name = card.querySelector( 'strong' )?.textContent?.trim() || ''; + const price = card.querySelector( '.newspack-ui__helper-text' )?.textContent?.trim() || ''; + // Compose ": / " — matches Figma 2636:46297. + summary.textContent = name && price ? `${ name }: ${ price }` : summary.textContent; + }; + + tabs.forEach( tab => { + tab.addEventListener( 'click', () => { + const freq = tab.dataset.frequency; + if ( ! freq || freq === activeFrequency ) { + return; + } + activeFrequency = freq; + setActiveTab( freq ); + showPanel( freq ); + // Switching tab clears selection unless the current tier lives in + // this tab — Figma `Monthly selected` is the unselected state. + const stillCurrent = freq === currentFrequency ? currentTierId : ''; + setSelectedTier( stillCurrent ); + } ); + } ); + + tierCards.forEach( card => { + card.addEventListener( 'click', event => { + // Avoid double-firing when the click started on the radio input + // (the label wraps it, so the browser already toggles the radio). + const tierId = card.dataset.tierId; + if ( ! tierId ) { + return; + } + // Ignore clicks on the CURRENT badge — it's purely informational. + if ( event.target.closest( '[data-tier-current-badge]' ) ) { + return; + } + setSelectedTier( tierId ); + } ); + } ); + + if ( advanceBtn ) { + advanceBtn.addEventListener( 'click', () => { + if ( advanceBtn.disabled ) { + return; + } + updateSummary(); + goToStep( 'transaction' ); + } ); + } + + if ( backBtn ) { + backBtn.addEventListener( 'click', () => goToStep( 'select' ) ); + } + + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => { + modal.setAttribute( 'data-state', 'closed' ); + snackbar( __( 'Subscription changed.', 'newspack-plugin' ) ); + } ); + } + + // Reset to the select step + current selection every time the modal closes, + // so re-opening shows a fresh init state instead of the previous attempt. + modal.addEventListener( 'closeModal', () => { + goToStep( 'select' ); + activeFrequency = currentFrequency; + setActiveTab( currentFrequency ); + showPanel( currentFrequency ); + setSelectedTier( currentTierId ); + } ); + + // Initial state. + setActiveTab( currentFrequency ); + showPanel( currentFrequency ); + setSelectedTier( currentTierId ); +} + +/** + * Wire a confirmation modal that flips between an `init` step (the "Are you + * sure?" body) and a `success` step (green check + email-sent line). The + * Cancel subscription modal uses this; donations.js mirrors it for Cancel + * donation. Tied to the action-router below which opens the modal — this + * function only owns the inside-the-modal step transitions. + * + * Confirm button → success step. Modal close (any close-button or overlay) + * → state resets to init the next time it opens, via the closeModal event + * dispatched by newspack-ui's modals.js mutation observer. + * + * @param {HTMLElement} modal The modal container element. + */ +function wireConfirmModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const initStep = modal.querySelector( '[data-step="init"]' ); + const successStep = modal.querySelector( '[data-step="success"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + + const goToStep = step => { + if ( ! initStep || ! successStep ) { + return; + } + initStep.hidden = step !== 'init'; + successStep.hidden = step !== 'success'; + }; + + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => goToStep( 'success' ) ); + } + + // Reset to the init step every time the modal closes so re-opening + // presents a fresh confirmation rather than the lingering success state. + modal.addEventListener( 'closeModal', () => goToStep( 'init' ) ); +} + +/** + * Wire a transaction modal — single-screen flow with a billing readout + + * payment form, terminating on a success step. Used by Renew subscription + * (and the donations equivalent for Restart donation). The structure + * mirrors the Change subscription modal's transaction step but skips the + * preceding tier-picker. + * + * @param {HTMLElement} modal The modal container element. + */ +function wireTransactionModal( modal ) { + if ( modal.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + modal.dataset.newspackMyAccountV2DemoWired = 'true'; + + const initStep = modal.querySelector( '[data-step="init"]' ); + const successStep = modal.querySelector( '[data-step="success"]' ); + const confirmBtn = modal.querySelector( '[data-action="confirm"]' ); + + const goToStep = step => { + if ( ! initStep || ! successStep ) { + return; + } + initStep.hidden = step !== 'init'; + successStep.hidden = step !== 'success'; + }; + + if ( confirmBtn ) { + confirmBtn.addEventListener( 'click', () => goToStep( 'success' ) ); + } + + modal.addEventListener( 'closeModal', () => goToStep( 'init' ) ); +} + +/** + * Look up the modal for a given `data-action` + resource id, opening it if + * it exists. Closes any open dropdown the click came from so the menu + * doesn't hover above the modal. Returns true if a modal was opened. + * + * Update-payment-method has no Phase 5 modal (the brief lumps it with the + * v1 checkout flow), so its action returns null here and the caller falls + * back to a stub snackbar. + * + * @param {string} action Data-action value of the trigger. + * @param {string} subscriptionId Resource id from the trigger. + * @param {HTMLElement} root Container the click came from (for + * dropdown close-on-open). + * @return {boolean} Whether a modal was opened. + */ +function tryOpenModal( action, subscriptionId, root ) { + const slug = { + 'change-subscription': 'change-subscription', + 'cancel-subscription': 'cancel-subscription', + 'renew-subscription': 'renew-subscription', + }[ action ]; + if ( ! slug || ! subscriptionId ) { + return false; + } + const modal = document.getElementById( `newspack-my-account__${ slug }-${ subscriptionId }` ); + if ( ! modal ) { + return false; + } + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown ) { + openDropdown.classList.remove( 'active' ); + } + modal.setAttribute( 'data-state', 'open' ); + return true; +} + +/** + * Map a `data-action` to the stub-snackbar copy used when no modal exists + * for the action (today: only `update-payment-method`). The other actions + * either resolve through `tryOpenModal` or fall through silently. + * + * @param {string} action Data-action value. + * @return {string|null} Snackbar copy, or null if there's nothing to say. + */ +function fallbackSnackbar( action ) { + switch ( action ) { + case 'update-payment-method': + return __( 'Payment method updated.', 'newspack-plugin' ); + default: + return null; + } +} + +/** + * Click handler shared by the list and detail roots. Routes any + * `data-action` trigger through `tryOpenModal` first; falls back to a + * snackbar for actions that don't (yet) have a modal. + * + * @param {HTMLElement} root Container element. + * @param {Event} event Click event. + */ +function handleActionClick( root, event ) { + const trigger = event.target.closest( '[data-action]' ); + if ( ! trigger || ! root.contains( trigger ) ) { + return; + } + // Let the dropdown toggle keep its own behaviour — newspack-ui's + // dropdowns.js owns it. + if ( trigger.classList.contains( 'newspack-ui__dropdown__toggle' ) ) { + return; + } + const action = trigger.dataset.action; + const subscriptionId = trigger.dataset.subscriptionId || ''; + + if ( tryOpenModal( action, subscriptionId, root ) ) { + // Only suppress navigation when we actually opened a modal — leaves + // real anchors like the list page's "Manage subscription" link + // (``) + // free to navigate normally when no modal handler exists for them. + if ( trigger.tagName === 'A' ) { + event.preventDefault(); + } + return; + } + + const message = fallbackSnackbar( action ); + if ( ! message ) { + // Unhandled action — let the trigger behave naturally (e.g. real + // link navigation, button no-op). + return; + } + if ( trigger.tagName === 'A' ) { + event.preventDefault(); + } + const openDropdown = root.querySelector( '.newspack-ui__dropdown.active' ); + if ( openDropdown && openDropdown.contains( trigger ) ) { + openDropdown.classList.remove( 'active' ); + } + snackbar( message ); +} + +/** + * Wire the list root. The only triggerable action on the list page is the + * inline "renew now" anchor inside the expiring active card's notice. With + * Phase 7 scenario fixtures, `?my-account-v2-demo=expiring` swaps an expiring sub + * into `active` and the renew modal renders alongside, so the same + * `handleActionClick` handler used on the detail page picks it up. Without + * the scenario, the active bucket holds sub-001 (status=active) and the + * inline anchor isn't rendered in the first place. + * + * @param {HTMLElement} root List container element. + */ +function wireListRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + root.addEventListener( 'click', event => handleActionClick( root, event ) ); +} + +/** + * Wire the detail-page action triggers (header buttons, dropdown menu items, + * and the inline `renew now` anchor on the expiring variant's notice). + * + * @param {HTMLElement} root Detail container element. + */ +function wireDetailRoot( root ) { + if ( root.dataset.newspackMyAccountV2DemoWired === 'true' ) { + return; + } + root.dataset.newspackMyAccountV2DemoWired = 'true'; + root.addEventListener( 'click', event => handleActionClick( root, event ) ); +} + +document.addEventListener( 'DOMContentLoaded', () => { + document.querySelectorAll( '[data-newspack-my-account-v2-demo="subscriptions"]' ).forEach( wireListRoot ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="subscription-details"]' ).forEach( wireDetailRoot ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="change-subscription-modal"]' ).forEach( wireChangeSubscriptionModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="cancel-subscription-modal"]' ).forEach( wireConfirmModal ); + document.querySelectorAll( '[data-newspack-my-account-v2-demo="renew-subscription-modal"]' ).forEach( wireTransactionModal ); +} ); diff --git a/src/my-account-v2-demo/util/snackbar.js b/src/my-account-v2-demo/util/snackbar.js new file mode 100644 index 0000000000..bf5cc86656 --- /dev/null +++ b/src/my-account-v2-demo/util/snackbar.js @@ -0,0 +1,66 @@ +/** + * Shared snackbar helper for the my-account-v2-demo prototype screens. + * + * Extracted on the third caller (Phase 5 modals) per the cross-phase devlog + * rule-of-three. Newsletters / donations / subscriptions used to each carry + * a private copy; they now import from here. + * + * Renders newspack-ui's snackbar markup directly rather than going through + * `newspackUI.notices.openNotice`: that helper posts an AJAX dismissal with + * a server-issued nonce we don't have in the demo, so routing through it + * would fire a 403 admin-ajax request on every toast. + */ + +const SNACKBAR_LIFETIME_MS = 5000; +const SNACKBAR_FADEOUT_MS = 300; + +/** + * Lazily create a top-right snackbar container if the page has none yet. + * On a fresh request with no PHP-rendered notices, the snackbar markup is + * absent and the toast needs somewhere to live. + * + * @return {HTMLElement} Snackbar container element. + */ +function ensureSnackbarContainer() { + let container = document.querySelector( '.newspack-ui__snackbar--top-right' ); + if ( container ) { + return container; + } + const wrap = document.createElement( 'div' ); + wrap.className = 'newspack-ui'; + container = document.createElement( 'div' ); + container.className = 'newspack-ui__snackbar newspack-ui__snackbar--top-right'; + wrap.appendChild( container ); + document.body.appendChild( wrap ); + return container; +} + +/** + * Show a transient snackbar. + * + * @param {string} message Pre-translated message copy. + * @param {string} type 'success' | 'error' (default 'success'). + */ +export function snackbar( message, type = 'success' ) { + const container = ensureSnackbarContainer(); + const item = document.createElement( 'div' ); + item.className = `newspack-ui__snackbar__item newspack-ui__snackbar__item--${ type } active`; + item.dataset.autohide = 'true'; + item.setAttribute( 'role', 'status' ); + item.setAttribute( 'aria-live', 'polite' ); + const content = document.createElement( 'div' ); + content.className = 'newspack-ui__snackbar__content'; + content.textContent = message; + item.appendChild( content ); + container.appendChild( item ); + + window.setTimeout( () => { + item.classList.remove( 'active' ); + // Give the CSS transition time to finish before detaching. + window.setTimeout( () => { + if ( item.parentNode ) { + item.parentNode.removeChild( item ); + } + }, SNACKBAR_FADEOUT_MS ); + }, SNACKBAR_LIFETIME_MS ); +} diff --git a/src/my-account/v1/_content.scss b/src/my-account/v1/_content.scss index 99f132d69b..963e71b8ac 100644 --- a/src/my-account/v1/_content.scss +++ b/src/my-account/v1/_content.scss @@ -20,8 +20,10 @@ opacity: 0.5; pointer-events: none; } - section + section { - margin-top: var(--newspack-ui-spacer-11); + :not(.newspack-ui__stack--gap-11) { + section + section { + margin-top: var(--newspack-ui-spacer-11); + } } .wp-block-details + .wp-block-details { margin-top: 0; diff --git a/src/wizards/audience/views/setup/index.js b/src/wizards/audience/views/setup/index.js index ffd870e78e..7bdad8de7d 100644 --- a/src/wizards/audience/views/setup/index.js +++ b/src/wizards/audience/views/setup/index.js @@ -20,6 +20,7 @@ import { withWizard } from '../../../../../packages/components/src'; import Router from '../../../../../packages/components/src/proxied-imports/router'; import ContentGating from './content-gating'; import Payment from './payment'; +import ReaderAccountCustomization from './reader-account-customization'; const { HashRouter, Redirect, Route, Switch } = Router; @@ -106,6 +107,10 @@ function AudienceWizard( { confirmAction, pluginRequirements, wizardApiFetch }, label: __( 'Content Gating', 'newspack-plugin' ), path: '/content-gating', }, + { + label: __( 'Reader Account Customization', 'newspack-plugin' ), + path: '/reader-account-customization', + }, { label: __( 'Checkout & Payment', 'newspack-plugin' ), path: '/payment', @@ -157,6 +162,7 @@ function AudienceWizard( { confirmAction, pluginRequirements, wizardApiFetch }, { pluginRequirements } } /> } /> + } /> } /> } /> } /> diff --git a/src/wizards/audience/views/setup/reader-account-customization.js b/src/wizards/audience/views/setup/reader-account-customization.js new file mode 100644 index 0000000000..3cc1614f9f --- /dev/null +++ b/src/wizards/audience/views/setup/reader-account-customization.js @@ -0,0 +1,179 @@ +/** + * Reader Account Customization (My Account v2 prototype demo). + * + * Pure-demo screen — no REST roundtrip, no persistence. Local React state only. + * Demonstrates the admin shape we have in mind for the v2 My Account experience + * so publishers can click through the controls during stakeholder review. + */ + +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { + TextareaControl, + TextControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, +} from '@wordpress/components'; + +/** + * Internal dependencies. + */ +import { Button, Divider, Grid, ImageUpload, SectionHeader, withWizardScreen } from '../../../../../packages/components/src'; +import WizardsTab from '../../../wizards-tab'; +import './reader-account-customization.scss'; + +const DEFAULTS = { + logo: '', + newslettersTitle: '', + newslettersDescription: '', + terminology: 'subscription', + terminologyCustomSingular: '', + terminologyCustomPlural: '', + cancelDonationMessage: __( 'We appreciate your generous support, even if you can no longer donate at this time.', 'newspack-plugin' ), + billingFooter: '', +}; + +// Zero out intrinsic top/bottom margins on non-control elements so the per-section VStack `spacing={ 8 }` is the single source of vertical rhythm inside each section. +const NO_MARGIN = { marginTop: 0, marginBottom: 0 }; + +function ReaderAccountCustomization() { + const [ settings, setSettings ] = useState( DEFAULTS ); + const update = ( key, value ) => setSettings( prev => ( { ...prev, [ key ]: value } ) ); + + return ( + + + + + update( 'logo', value ) } + /> +

+ { __( 'Display your logo in the sidebar (on a white background). It will replace the default icon.', 'newspack-plugin' ) } +

+
+
+ + + + + + + update( 'newslettersTitle', value ) } + __next40pxDefaultSize + /> + update( 'newslettersDescription', value ) } + rows={ 4 } + /> + + + + + + + + + update( 'terminology', value ) } + isBlock + __next40pxDefaultSize + > + + + + + { settings.terminology === 'custom' && ( + + update( 'terminologyCustomSingular', value ) } + __next40pxDefaultSize + /> + update( 'terminologyCustomPlural', value ) } + __next40pxDefaultSize + /> + + ) } + update( 'cancelDonationMessage', value ) } + rows={ 4 } + /> + update( 'billingFooter', value ) } + rows={ 4 } + /> + + + +
+ +
+
+ ); +} + +export default withWizardScreen( ReaderAccountCustomization ); diff --git a/src/wizards/audience/views/setup/reader-account-customization.scss b/src/wizards/audience/views/setup/reader-account-customization.scss new file mode 100644 index 0000000000..5d093c97eb --- /dev/null +++ b/src/wizards/audience/views/setup/reader-account-customization.scss @@ -0,0 +1,17 @@ +/** + * Reader Account Customization (My Account v2 prototype demo). + * + * Single rhythm rule: zero out intrinsic top/bottom margins on every + * `.components-base-control` inside the view so each section's `` (32px) becomes the only source of vertical rhythm. + * `__nextHasNoMarginBottom` is supposed to do this in `@wordpress/components` + * but lands inconsistently across control types and Emotion-generated classes; + * this scoped override is the safety net. + */ + +.newspack-audience__reader-account-customization { + .components-base-control { + margin-top: 0; + margin-bottom: 0; + } +} diff --git a/webpack.config.js b/webpack.config.js index 4161fe7baa..61ff6027d0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,8 @@ 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' ), + 'my-account-v2-demo-homepage': path.join( __dirname, 'src', 'my-account-v2-demo-homepage', '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' ),