diff --git a/docs/superpowers/specs/2026-05-01-subscribers-tags-newsletters-design.md b/docs/superpowers/specs/2026-05-01-subscribers-tags-newsletters-design.md
new file mode 100644
index 0000000000..67d81923b1
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-01-subscribers-tags-newsletters-design.md
@@ -0,0 +1,334 @@
+# Subscribers prototype — tags + newsletters
+
+Design spec for two additions to the Subscribers management prototype shipped in [PR #4649](https://github.com/Automattic/newspack-plugin/pull/4649) on the `prototype/subscribers-demo` branch.
+
+## Goals
+
+1. Let admins attach short, free-form **tags** (e.g. `vip`, `valued-reader`, `met-in-person`) to a subscriber from the L1 profile and filter the L0 list by tag.
+2. Let admins see and modify a subscriber's **newsletter subscriptions** from the L1 profile and filter the L0 list by newsletter membership.
+
+Both features stay within the prototype's existing constraints: mock data in `src/wizards/subscribersDemo/data/mock-subscribers.js`, localStorage persistence, no REST endpoint or backend wiring. Production wiring will be designed separately.
+
+## Non-goals
+
+- Backend persistence, REST endpoints, or user/post meta storage.
+- Tag management UI (rename, merge, delete across all subscribers).
+- Newsletter creation or sync with the actual newsletters plugin / ESP.
+- Bulk tag/newsletter operations from the L0 DataViews.
+
+## Context
+
+The prototype is a hidden wizard at `admin.php?page=newspack-subscribers-demo` with three levels:
+
+- **L0** (`src/wizards/subscribersDemo/screens/SubscriberList.jsx`) — DataViews list with `Status` and `Plan` filters using the `isAny` operator, sorted by last payment.
+- **L1** (`src/wizards/subscribersDemo/screens/PersonProfile.jsx`) — Two-column `Row`-based layout with header (status badge + email + status summary lines), notes Cards, Subscriptions Row, Payment methods Row, and an Order history table.
+- **L2** — Modal flows under `src/wizards/subscribersDemo/flows/` (e.g. `NoteFlow.jsx`).
+
+Private notes already establish the prototype's pattern for admin-only annotations: kebab → modal → localStorage → transient `Snackbar`. Tags follow the same pattern, with FormTokenField in place of TextareaControl.
+
+## Architecture
+
+### Data shape additions
+
+`mock-subscribers.js` gains two new exports:
+
+```js
+export const KNOWN_TAGS = [ 'vip', 'valued-reader', 'met-in-person' ];
+
+export const NEWSLETTERS = [
+ { id: 'daily', name: 'Daily Brief', description: 'Top stories every weekday morning.' },
+ { id: 'weekly', name: 'Weekend Read', description: 'Long reads delivered Saturday.' },
+ { id: 'arts', name: 'Arts & Culture', description: 'Reviews and what's on, monthly.' },
+ { id: 'breaking', name: 'Breaking News', description: 'Real-time alerts on major stories.' },
+];
+
+export const ALL_TAGS = [ ...new Set( SUBSCRIBERS.flatMap( s => s.tags || [] ) ) ].sort();
+```
+
+Each subscriber object gains two optional fields:
+
+- `tags: string[]` — lowercase, trimmed, deduplicated.
+- `newsletters: string[]` — array of newsletter ids that the subscriber is subscribed to.
+
+### Seeded fixture data
+
+The five hand-crafted fixtures get deterministic tags and newsletter subscriptions:
+
+| Fixture | tags | newsletters |
+| ------------- | ----------------------------- | --------------------------------- |
+| Matt Moore | `['valued-reader']` | `['daily', 'weekly']` |
+| Jane Chen | `[]` | `['daily']` |
+| Priya Patel | `['vip', 'valued-reader']` | `['daily', 'weekly', 'arts']` |
+| Aisha Khan | `['met-in-person']` | `['daily', 'breaking']` |
+| Oscar Rivera | `[]` | `[]` |
+
+The 42 pseudo-random extras get tags and newsletters generated via the existing seeded `mulberry32(42)` PRNG so the list stays stable across reloads:
+
+- ~40% of extras get 1–2 random tags from `KNOWN_TAGS`.
+- ~70% of extras get 1–3 random newsletter ids from `NEWSLETTERS`.
+
+This guarantees the L0 filter element lists are populated from the first load and reviewers can exercise all filter combinations without seeding state by hand.
+
+### Persistence (prototype only)
+
+Mirrors the existing notes persistence in `mock-subscribers.js`, with one important difference: tags and newsletters have **seeded fixture values**, so the storage API must distinguish "no entry for this subscriber yet" (use the seeded fixture value) from "entry exists but is empty" (the user intentionally cleared all tags/newsletters).
+
+```js
+const TAGS_STORAGE_KEY = 'newspack-subscribers-demo:tags';
+const NEWSLETTERS_STORAGE_KEY = 'newspack-subscribers-demo:newsletters';
+
+// Returns the stored array if an entry exists for the id, or `null` if not.
+// Callers fall back to the seeded fixture value when this returns `null`.
+export function getStoredTags( id ) { /* ... */ }
+export function setStoredTags( id, tags ) { /* ... */ } // setting [] keeps the entry (so user-cleared survives reload)
+export function getStoredNewsletters( id ) { /* ... */ }
+export function setStoredNewsletters( id, ids ) { /* ... */ }
+```
+
+Same fail-silent behavior on storage errors. Same code comment flagging that production needs server-side storage.
+
+In `PersonProfile.jsx`, the existing `useMemo` that already merges stored notes onto the seeded fixture is extended to merge tags and newsletters too:
+
+```js
+const initial = useMemo( () => {
+ const found = getSubscriberById( id );
+ if ( ! found ) return found;
+ const storedTags = getStoredTags( id );
+ const storedNewsletters = getStoredNewsletters( id );
+ return {
+ ...found,
+ notes: getStoredNotes( id ),
+ tags: storedTags !== null ? storedTags : ( found.tags || [] ),
+ newsletters: storedNewsletters !== null ? storedNewsletters : ( found.newsletters || [] ),
+ };
+}, [ id ] );
+```
+
+A pair of sibling effects mirrors the existing notes effect, persisting the two arrays to localStorage whenever they change.
+
+**Known prototype caveats:**
+
+- The L0 `SubscriberList` reads from the in-memory `SUBSCRIBERS` array and does not merge per-subscriber localStorage overrides. So tag/newsletter changes made on L1 are not reflected in the L0 row's "Tags" or "Newsletters" cells, nor in the L0 `ALL_TAGS` filter element list, until a hard reload (and even then, only if the change was made on a fixture's pre-seeded id — random extras are regenerated at module load). This matches the existing notes prototype behavior (notes also don't surface on L0). Documented as a code comment in `mock-subscribers.js`; acceptable for a prototype.
+
+## Components
+
+### 1. Header — tag display (L1)
+
+In `PersonProfile.jsx`, the existing `setHeaderData({ sectionDescription })` `VStack` already renders the email plus 1–2 status summary lines. A new tag chip line is appended after those when `subscriber.tags?.length > 0`:
+
+```jsx
+{ ( subscriber.tags || [] ).length > 0 && (
+
+ { subscriber.tags.map( t => ) }
+
+) }
+```
+
+The kebab `actions` array gains a new entry between "Edit WordPress user" and "Add private note":
+
+```js
+{ type: 'more', label: __( 'Manage tags', 'newspack-plugin' ), action: () => setModal( { kind: 'tags' } ) },
+```
+
+Tag chips in the header are read-only — removal happens inside the modal. This keeps `Badge` semantics consistent (status pill ≠ removable).
+
+### 2. `TagsFlow.jsx` (new file)
+
+Path: `src/wizards/subscribersDemo/flows/TagsFlow.jsx`. Modeled directly on `NoteFlow.jsx`.
+
+```jsx
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { FormTokenField, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components';
+import { Button, Modal } from '../../../../packages/components/src';
+import { KNOWN_TAGS } from '../data/mock-subscribers';
+
+const normalize = tokens =>
+ [ ...new Set( tokens.map( t => String( t ).trim().toLowerCase() ).filter( Boolean ) ) ];
+
+export default function TagsFlow( { tags = [], onClose, onComplete } ) {
+ const [ next, setNext ] = useState( tags );
+ const dirty = JSON.stringify( normalize( next ) ) !== JSON.stringify( normalize( tags ) );
+
+ const submit = () => {
+ const finalTags = normalize( next );
+ onComplete( {
+ type: 'success',
+ transient: true,
+ message: __( 'Tags updated.', 'newspack-plugin' ),
+ mutate: subscriber => ( { ...subscriber, tags: finalTags } ),
+ } );
+ };
+
+ return (
+
+
+
+ { __( 'Tags are visible only to admins. Press Enter or comma to add.', 'newspack-plugin' ) }
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { __( 'Save', 'newspack-plugin' ) }
+
+
+
+
+ );
+}
+```
+
+`PersonProfile.jsx` renders it via the existing modal switch:
+
+```jsx
+{ modal?.kind === 'tags' && }
+```
+
+The `useEffect` that already persists notes is extended (or paired with a sibling effect) to persist `subscriber.tags` to `setStoredTags( id, tags )`.
+
+### 3. Newsletters Row (L1)
+
+A new `Row` is inserted between the Subscriptions Row and the Payment methods Row in `PersonProfile.jsx`:
+
+```jsx
+
+
+
+ { NEWSLETTERS.map( newsletter => {
+ const isSubscribed = ( subscriber.newsletters || [] ).includes( newsletter.id );
+ return (
+
+
+ { newsletter.name }
+ { newsletter.description }
+
+ {
+ const nextList = isSubscribed
+ ? ( subscriber.newsletters || [] ).filter( id => id !== newsletter.id )
+ : [ ...( subscriber.newsletters || [] ), newsletter.id ];
+ setSubscriber( prev => ( { ...prev, newsletters: nextList } ) );
+ setSnackbar( {
+ message: isSubscribed
+ ? sprintf( __( 'Unsubscribed from %s.', 'newspack-plugin' ), newsletter.name )
+ : sprintf( __( 'Subscribed to %s.', 'newspack-plugin' ), newsletter.name ),
+ } );
+ } }
+ __nextHasNoMarginBottom
+ />
+
+ );
+ } ) }
+
+
+
+```
+
+A sibling persistence effect mirrors the notes/tags pattern: `useEffect( () => setStoredNewsletters( id, subscriber.newsletters ), [ subscriber.newsletters ] )`.
+
+### 4. L0 DataViews — Tags + Newsletters fields
+
+Two new field definitions added to the `fields` memo in `SubscriberList.jsx`:
+
+```js
+{
+ id: 'tags',
+ label: __( 'Tags', 'newspack-plugin' ),
+ elements: ALL_TAGS.map( t => ( { value: t, label: t } ) ),
+ filterBy: { operators: [ 'isAny' ] },
+ getValue: ( { item } ) => ( item.tags || [] ).join( ', ' ),
+ render: ( { item } ) => (
+
+ { ( item.tags || [] ).map( t => ) }
+
+ ),
+ enableSorting: false,
+},
+{
+ id: 'newsletters',
+ label: __( 'Newsletters', 'newspack-plugin' ),
+ elements: NEWSLETTERS.map( n => ( { value: n.id, label: n.name } ) ),
+ filterBy: { operators: [ 'isAny' ] },
+ getValue: ( { item } ) =>
+ ( item.newsletters || [] ).map( id => NEWSLETTERS.find( n => n.id === id )?.name ).filter( Boolean ).join( ', ' ),
+ render: ( { item } ) => (
+
+ { ( item.newsletters || [] )
+ .map( id => NEWSLETTERS.find( n => n.id === id )?.name )
+ .filter( Boolean )
+ .join( ', ' ) }
+
+ ),
+ enableSorting: false,
+},
+```
+
+`DEFAULT_VIEW.fields` stays as `[ 'status', 'plans', 'lastPayment', 'memberSince' ]` — the new columns are hidden by default but available via the DataViews column control. Filters work regardless of column visibility.
+
+## Data flow
+
+```
+L1 mount
+ ↳ getSubscriberById(id) → seeded subscriber
+ ↳ getStoredTags(id) → if non-null, overrides seeded .tags; if null, fixture value is used
+ ↳ getStoredNewsletters(id) → if non-null, overrides seeded .newsletters; if null, fixture value is used
+
+L1 user toggles a newsletter
+ ↳ setSubscriber(prev => { ...prev, newsletters: nextList })
+ ↳ useEffect → setStoredNewsletters(id, nextList)
+ ↳ setSnackbar({ message })
+
+L1 user opens kebab → Manage tags
+ ↳ setModal({ kind: 'tags' })
+ ↳ TagsFlow renders with current tags
+ ↳ user edits + saves → onComplete({ mutate, transient, message })
+ ↳ PersonProfile setSubscriber + setSnackbar + setModal(null)
+ ↳ useEffect → setStoredTags(id, tags)
+
+L0 mount
+ ↳ SUBSCRIBERS (in-memory mock) drives the table
+ ↳ ALL_TAGS computed once from SUBSCRIBERS
+ ↳ NEWSLETTERS imported from mock-subscribers.js
+ ↳ Filters: isAny against item.tags / item.newsletters
+```
+
+## Error handling
+
+- localStorage failures (quota, disabled) fail silently in `setStoredTags` / `setStoredNewsletters`, matching `setStoredNotes`.
+- `tags` and `newsletters` arrays default to `[]` on read everywhere — every consumer guards with `( ... || [] )`.
+- An unrecognized newsletter id (e.g. removed from `NEWSLETTERS` after being saved) is silently filtered out of the rendered list. Acceptable for the prototype.
+
+## Testing
+
+This is a design prototype, not production code, so the testing surface is manual review per the existing PR template. New manual checks added to the PR description:
+
+1. **Tags — header**: Open Priya Patel — confirm `vip` and `valued-reader` Badges render under the email/status summary lines in the header.
+2. **Tags — modal**: Open the kebab → "Manage tags" — confirm the FormTokenField is pre-populated with the current tags, that the three suggestions appear, and that typing `VIP` then Enter normalizes to `vip` and dedupes if already present.
+3. **Tags — persistence**: Add a tag, reload the page, confirm the tag survives.
+4. **Newsletters — Card**: Open Matt Moore — confirm the Newsletters Row renders between Subscriptions and Payment methods with four toggles, two on (Daily Brief, Weekend Read) and two off.
+5. **Newsletters — toggle**: Toggle Arts & Culture on — confirm a "Subscribed to Arts & Culture." Snackbar fires at the bottom-left and the toggle stays on after reload.
+6. **L0 — Tags filter**: Apply the Tags filter with `vip` selected — confirm only Priya appears (plus any random extras seeded with `vip`).
+7. **L0 — Newsletters filter**: Apply the Newsletters filter with `breaking` selected — confirm Aisha appears (plus any random extras seeded with `breaking`).
+8. **L0 — Column visibility**: Confirm Tags and Newsletters columns are hidden by default but available via the DataViews column control.
+
+## Out of scope (deferred)
+
+- Adding Tags / Newsletters to the bulk-action set on L0.
+- Inline tag removal from the header chip (would require extending the Badge component).
+- A "Manage all tags" admin screen for renaming/merging tags across subscribers.
+- Real backend storage and migration from localStorage to user/post meta or an option.
+
+These all belong to the productionization phase, not the prototype.
diff --git a/includes/class-newspack.php b/includes/class-newspack.php
index e430dc7e3d..5a25d05052 100644
--- a/includes/class-newspack.php
+++ b/includes/class-newspack.php
@@ -176,6 +176,7 @@ private function includes() {
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-setup-wizard.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-components-demo.php';
+ include_once NEWSPACK_ABSPATH . 'includes/wizards/class-subscribers-demo.php';
// Listings Wizard.
include_once NEWSPACK_ABSPATH . 'includes/wizards/class-listings-wizard.php';
diff --git a/includes/class-wizards.php b/includes/class-wizards.php
index 873b72558f..23ea36c0e6 100644
--- a/includes/class-wizards.php
+++ b/includes/class-wizards.php
@@ -42,6 +42,7 @@ public static function init() {
public static function init_wizards() {
self::$wizards = [
'components-demo' => new Components_Demo(),
+ 'subscribers-demo' => new Subscribers_Demo(),
// v2 Information Architecture.
'newspack-dashboard' => new Newspack_Dashboard(),
'setup' => new Setup_Wizard(),
diff --git a/includes/wizards/class-subscribers-demo.php b/includes/wizards/class-subscribers-demo.php
new file mode 100644
index 0000000000..8bdfe4b455
--- /dev/null
+++ b/includes/wizards/class-subscribers-demo.php
@@ -0,0 +1,74 @@
+slug ) {
+ return;
+ }
+
+ wp_enqueue_script(
+ 'newspack-subscribers-demo',
+ Newspack::plugin_url() . '/dist/subscribersDemo.js',
+ $this->get_script_dependencies( [ 'wp-html-entities' ] ),
+ NEWSPACK_PLUGIN_VERSION,
+ true
+ );
+
+ wp_enqueue_style(
+ 'newspack-subscribers-demo',
+ Newspack::plugin_url() . '/dist/subscribersDemo.css',
+ [ 'wp-components' ],
+ NEWSPACK_PLUGIN_VERSION
+ );
+ }
+}
diff --git a/includes/wizards/class-wizard.php b/includes/wizards/class-wizard.php
index e9921450a6..315b3bebbc 100644
--- a/includes/wizards/class-wizard.php
+++ b/includes/wizards/class-wizard.php
@@ -190,8 +190,9 @@ public function enqueue_scripts_and_styles() {
}
if ( Newspack::is_debug_mode() && current_user_can( 'manage_options' ) ) {
- $urls['components_demo'] = esc_url( admin_url( 'admin.php?page=newspack-components-demo' ) );
- $urls['setup_wizard'] = esc_url( admin_url( 'admin.php?page=newspack-setup-wizard' ) );
+ $urls['components_demo'] = esc_url( admin_url( 'admin.php?page=newspack-components-demo' ) );
+ $urls['subscribers_demo'] = esc_url( admin_url( 'admin.php?page=newspack-subscribers-demo' ) );
+ $urls['setup_wizard'] = esc_url( admin_url( 'admin.php?page=newspack-setup-wizard' ) );
$urls['reset_url'] = esc_url(
add_query_arg(
array(
diff --git a/packages/components/src/footer/index.js b/packages/components/src/footer/index.js
index 11df8e0367..1c960aa05c 100644
--- a/packages/components/src/footer/index.js
+++ b/packages/components/src/footer/index.js
@@ -16,6 +16,7 @@ import './style.scss';
const Footer = ( { simple = undefined } ) => {
const {
components_demo: componentsDemo = false,
+ subscribers_demo: subscribersDemo = false,
support = false,
setup_wizard: setupWizard = false,
reset_url: resetUrl = false,
@@ -47,6 +48,12 @@ const Footer = ( { simple = undefined } ) => {
url: componentsDemo,
} );
}
+ if ( subscribersDemo ) {
+ footerElements.push( {
+ label: __( 'Subscribers Demo', 'newspack-plugin' ),
+ url: subscribersDemo,
+ } );
+ }
if ( setupWizard ) {
footerElements.push( {
label: __( 'Setup Wizard', 'newspack-plugin' ),
diff --git a/src/wizards/subscribersDemo/assets/cards/amex.svg b/src/wizards/subscribersDemo/assets/cards/amex.svg
new file mode 100644
index 0000000000..7b5bf66f9b
--- /dev/null
+++ b/src/wizards/subscribersDemo/assets/cards/amex.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/wizards/subscribersDemo/assets/cards/discover.svg b/src/wizards/subscribersDemo/assets/cards/discover.svg
new file mode 100644
index 0000000000..a41879771d
--- /dev/null
+++ b/src/wizards/subscribersDemo/assets/cards/discover.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/wizards/subscribersDemo/assets/cards/jcb.svg b/src/wizards/subscribersDemo/assets/cards/jcb.svg
new file mode 100644
index 0000000000..f29d4b4726
--- /dev/null
+++ b/src/wizards/subscribersDemo/assets/cards/jcb.svg
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/wizards/subscribersDemo/assets/cards/mastercard.svg b/src/wizards/subscribersDemo/assets/cards/mastercard.svg
new file mode 100644
index 0000000000..f4a3d10b60
--- /dev/null
+++ b/src/wizards/subscribersDemo/assets/cards/mastercard.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/wizards/subscribersDemo/assets/cards/visa.svg b/src/wizards/subscribersDemo/assets/cards/visa.svg
new file mode 100644
index 0000000000..264ad64dcd
--- /dev/null
+++ b/src/wizards/subscribersDemo/assets/cards/visa.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/wizards/subscribersDemo/data/mock-subscribers.js b/src/wizards/subscribersDemo/data/mock-subscribers.js
new file mode 100644
index 0000000000..ef8c52fbc1
--- /dev/null
+++ b/src/wizards/subscribersDemo/data/mock-subscribers.js
@@ -0,0 +1,380 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise, no-nested-ternary */
+/**
+ * Mock subscriber data for the Subscribers Demo wizard.
+ *
+ * Designed to cover every state the UI needs to render:
+ * - Active, single digital subscription (happy path)
+ * - Lapsed with failed payment + alert
+ * - Active with digital + print add-on (multi-plan)
+ * - Cancelled, no payment method
+ *
+ * Plus ~40 seeded pseudo-random extras so DataViews has enough to
+ * filter, sort and paginate through.
+ */
+
+export const DIGITAL_PLANS = [
+ { name: 'Monthly Digital', cadence: 'Monthly', amount: 12, access: 'Full digital access' },
+ { name: 'Yearly Digital', cadence: 'Yearly', amount: 120, access: 'Full digital access' },
+ { name: 'Student Monthly', cadence: 'Monthly', amount: 6, access: 'Student digital access' },
+ { name: 'Supporter Annual', cadence: 'Yearly', amount: 250, access: 'Full digital access + supporter perks' },
+];
+
+export const PRINT_PLANS = [
+ { name: 'Monthly Print', cadence: 'Monthly', amount: 15, access: 'Weekly print delivery' },
+ { name: 'Yearly Print', cadence: 'Yearly', amount: 150, access: 'Weekly print delivery' },
+];
+
+export const ALL_PLANS = [ ...DIGITAL_PLANS, ...PRINT_PLANS ];
+
+export const KNOWN_TAGS = [ 'vip', 'valued-reader', 'met-in-person' ];
+
+export const NEWSLETTERS = [
+ { id: 'daily', name: 'Daily Brief', description: 'Top stories every weekday morning.' },
+ { id: 'weekly', name: 'Weekend Read', description: 'Long reads delivered Saturday.' },
+ { id: 'arts', name: 'Arts & Culture', description: 'Reviews and what’s on, monthly.' },
+ { id: 'breaking', name: 'Breaking News', description: 'Real-time alerts on major stories.' },
+];
+
+// Tiny deterministic PRNG so the list is stable between reloads.
+function mulberry32( seed ) {
+ return function () {
+ let t = ( seed += 0x6d2b79f5 );
+ t = Math.imul( t ^ ( t >>> 15 ), t | 1 );
+ t ^= t + Math.imul( t ^ ( t >>> 7 ), t | 61 );
+ return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296;
+ };
+}
+const rand = mulberry32( 42 );
+const pick = arr => arr[ Math.floor( rand() * arr.length ) ];
+
+const FIRST = [
+ 'Matt',
+ 'Jane',
+ 'Alex',
+ 'Priya',
+ 'Oscar',
+ 'Mei',
+ 'Tom',
+ 'Sofia',
+ 'Liam',
+ 'Nadia',
+ 'Ben',
+ 'Aisha',
+ 'Carlos',
+ 'Yuki',
+ 'Leo',
+ 'Hannah',
+ 'Ravi',
+ 'Eva',
+ 'Theo',
+ 'Zara',
+ 'Luca',
+ 'Ines',
+ 'Kai',
+ 'Maya',
+ 'Owen',
+ 'Ada',
+ 'Finn',
+ 'Noor',
+];
+const LAST = [
+ 'Moore',
+ 'Chen',
+ 'Ali',
+ 'Garcia',
+ 'Nguyen',
+ 'Okafor',
+ 'Ross',
+ 'Bauer',
+ 'Silva',
+ 'Khan',
+ 'Walsh',
+ 'Park',
+ 'Rivera',
+ 'Ito',
+ 'Baker',
+ 'Haas',
+ 'Patel',
+ 'Lind',
+ 'Marsh',
+ 'Rossi',
+];
+
+function iso( daysAgo ) {
+ const d = new Date();
+ d.setDate( d.getDate() - daysAgo );
+ return d.toISOString().slice( 0, 10 );
+}
+
+function futureIso( daysAhead ) {
+ const d = new Date();
+ d.setDate( d.getDate() + daysAhead );
+ return d.toISOString().slice( 0, 10 );
+}
+
+function makeSub( plan, status = 'active' ) {
+ return {
+ id: 'sub_' + Math.floor( rand() * 1e6 ),
+ plan: plan.name,
+ status,
+ access: plan.access,
+ cadence: plan.cadence,
+ nextBillingDate: status === 'active' ? futureIso( Math.floor( rand() * 30 ) + 1 ) : null,
+ amount: plan.amount,
+ };
+}
+
+// Four hand-crafted scenarios the design brief calls out.
+const FIXTURES = [
+ {
+ id: '1',
+ name: 'Matt Moore',
+ email: 'matthew.moore@gmail.com',
+ status: 'active',
+ memberSince: '2022-09-30',
+ lastPayment: iso( 10 ),
+ subscriptions: [ makeSub( DIGITAL_PLANS[ 0 ] ) ],
+ paymentMethods: [ { id: 'pm_1', type: 'Visa', last4: '4242', expiry: '08/27', isDefault: true } ],
+ alerts: [],
+ tags: [ 'valued-reader' ],
+ newsletters: [ 'daily', 'weekly' ],
+ orders: [
+ { id: 'ord_1', date: iso( 10 ), amount: 12.0, type: 'Subscription payment' },
+ { id: 'ord_2', date: iso( 40 ), amount: 12.0, type: 'Subscription payment' },
+ { id: 'ord_3', date: iso( 70 ), amount: 12.0, type: 'Subscription payment' },
+ ],
+ },
+ {
+ id: '2',
+ name: 'Jane Chen',
+ email: 'jane.chen@example.com',
+ status: 'lapsed',
+ memberSince: '2021-04-12',
+ lastPayment: iso( 45 ),
+ subscriptions: [ { ...makeSub( DIGITAL_PLANS[ 1 ] ), status: 'lapsed', nextBillingDate: null } ],
+ paymentMethods: [],
+ alerts: [
+ {
+ id: 'alert_pay',
+ level: 'error',
+ title: 'Payment failed',
+ message: 'The last renewal payment was declined and no payment method is on file.',
+ },
+ ],
+ tags: [],
+ newsletters: [ 'daily' ],
+ orders: [
+ { id: 'ord_21', date: iso( 45 ), amount: 0, type: 'Failed renewal' },
+ { id: 'ord_22', date: iso( 410 ), amount: 120.0, type: 'Subscription payment' },
+ ],
+ },
+ {
+ id: '3',
+ name: 'Priya Patel',
+ email: 'priya.patel@example.com',
+ status: 'active',
+ memberSince: '2023-01-05',
+ lastPayment: iso( 3 ),
+ subscriptions: [ makeSub( DIGITAL_PLANS[ 1 ] ), makeSub( PRINT_PLANS[ 0 ] ) ],
+ paymentMethods: [
+ { id: 'pm_3a', type: 'Mastercard', last4: '1881', expiry: '02/28', isDefault: true },
+ { id: 'pm_3b', type: 'Visa', last4: '9933', expiry: '06/27', isDefault: false },
+ ],
+ alerts: [],
+ tags: [ 'vip', 'valued-reader' ],
+ newsletters: [ 'daily', 'weekly', 'arts' ],
+ orders: [
+ { id: 'ord_31', date: iso( 3 ), amount: 15.0, type: 'Subscription payment' },
+ { id: 'ord_32', date: iso( 20 ), amount: 120.0, type: 'Subscription payment' },
+ ],
+ },
+ {
+ id: '5',
+ name: 'Aisha Khan',
+ email: 'aisha.khan@example.com',
+ status: 'active',
+ memberSince: '2022-02-14',
+ lastPayment: iso( 7 ),
+ subscriptions: [ makeSub( DIGITAL_PLANS[ 0 ] ), { ...makeSub( PRINT_PLANS[ 1 ] ), status: 'cancelled', nextBillingDate: null } ],
+ paymentMethods: [ { id: 'pm_5', type: 'Visa', last4: '0007', expiry: '11/26', isDefault: true } ],
+ alerts: [],
+ tags: [ 'met-in-person' ],
+ newsletters: [ 'daily', 'breaking' ],
+ orders: [
+ { id: 'ord_51', date: iso( 7 ), amount: 12.0, type: 'Subscription payment' },
+ { id: 'ord_52', date: iso( 60 ), amount: 150.0, type: 'Subscription payment' },
+ { id: 'ord_53', date: iso( 75 ), amount: 0, type: 'Cancellation' },
+ ],
+ },
+ {
+ id: '4',
+ name: 'Oscar Rivera',
+ email: 'oscar@example.com',
+ status: 'cancelled',
+ memberSince: '2020-06-18',
+ lastPayment: iso( 220 ),
+ subscriptions: [ { ...makeSub( DIGITAL_PLANS[ 0 ] ), status: 'cancelled', nextBillingDate: null } ],
+ paymentMethods: [],
+ alerts: [],
+ tags: [],
+ newsletters: [],
+ orders: [ { id: 'ord_41', date: iso( 220 ), amount: 12.0, type: 'Subscription payment' } ],
+ },
+];
+
+function makeRandom( i ) {
+ const first = pick( FIRST );
+ const last = pick( LAST );
+ const name = `${ first } ${ last }`;
+ const email = `${ first.toLowerCase() }.${ last.toLowerCase() }${ i }@example.com`;
+ const roll = rand();
+ const status = roll < 0.45 ? 'active' : roll < 0.8 ? 'lapsed' : 'cancelled';
+ const digital = pick( DIGITAL_PLANS );
+ const withPrint = status === 'active' && rand() < 0.25;
+ const subs = [ makeSub( digital, status === 'active' ? 'active' : status ) ];
+ if ( withPrint ) {
+ subs.push( makeSub( pick( PRINT_PLANS ) ) );
+ }
+ const memberSinceDays = Math.floor( rand() * 1500 ) + 30;
+ const lastPaymentDays = Math.floor( rand() * 60 );
+ const alerts =
+ status === 'lapsed' && rand() < 0.6
+ ? [
+ {
+ id: 'alert_pay',
+ level: 'warning',
+ title: 'Payment needs attention',
+ message: 'Last renewal attempt failed.',
+ },
+ ]
+ : [];
+ const tags = [];
+ if ( rand() < 0.4 ) {
+ const firstTag = KNOWN_TAGS[ Math.floor( rand() * KNOWN_TAGS.length ) ];
+ tags.push( firstTag );
+ if ( rand() < 0.3 ) {
+ const secondTag = KNOWN_TAGS[ Math.floor( rand() * KNOWN_TAGS.length ) ];
+ if ( secondTag !== firstTag ) {
+ tags.push( secondTag );
+ }
+ }
+ }
+ const newsletters = [];
+ if ( rand() < 0.7 ) {
+ const count = Math.floor( rand() * 3 ) + 1;
+ while ( newsletters.length < count ) {
+ const candidate = NEWSLETTERS[ Math.floor( rand() * NEWSLETTERS.length ) ].id;
+ if ( ! newsletters.includes( candidate ) ) {
+ newsletters.push( candidate );
+ }
+ }
+ }
+ return {
+ id: String( 100 + i ),
+ name,
+ email,
+ status,
+ memberSince: iso( memberSinceDays ),
+ lastPayment: iso( lastPaymentDays ),
+ subscriptions: subs,
+ paymentMethods:
+ ( status === 'cancelled' && rand() < 0.5 ) || ( status === 'lapsed' && rand() < 0.5 )
+ ? []
+ : [
+ {
+ id: 'pm_r' + i,
+ type: rand() < 0.6 ? 'Visa' : 'Mastercard',
+ last4: String( Math.floor( rand() * 9000 ) + 1000 ),
+ expiry: '0' + ( Math.floor( rand() * 9 ) + 1 ) + '/2' + ( Math.floor( rand() * 6 ) + 6 ),
+ isDefault: true,
+ },
+ ],
+ alerts,
+ tags,
+ newsletters,
+ orders: [
+ {
+ id: 'ord_r' + i + '_1',
+ date: iso( lastPaymentDays ),
+ amount: digital.amount,
+ type: status === 'lapsed' ? 'Failed renewal' : 'Subscription payment',
+ },
+ ],
+ };
+}
+
+const EXTRAS = Array.from( { length: 42 }, ( _, i ) => makeRandom( i ) );
+
+export const SUBSCRIBERS = [ ...FIXTURES, ...EXTRAS ];
+
+// PROTOTYPE ONLY: tag/newsletter changes made in L1 are persisted to localStorage but the
+// in-memory SUBSCRIBERS array isn't mutated. As a result the L0 list and the ALL_TAGS
+// filter elements only reflect the seeded values. Acceptable for a prototype.
+export const ALL_TAGS = [ ...new Set( SUBSCRIBERS.flatMap( s => s.tags || [] ) ) ].sort();
+
+export function getSubscriberById( id ) {
+ return SUBSCRIBERS.find( s => s.id === id );
+}
+
+// PROTOTYPE ONLY: notes/tags/newsletters are persisted to the current admin's localStorage
+// so they survive a refresh during a demo. In production these need to live server-side
+// (REST endpoint + user/post meta or an option) so they're shared across every admin
+// viewing the same subscriber.
+const NOTES_STORAGE_KEY = 'newspack-subscribers-demo:notes';
+const TAGS_STORAGE_KEY = 'newspack-subscribers-demo:tags';
+const NEWSLETTERS_STORAGE_KEY = 'newspack-subscribers-demo:newsletters';
+
+function readStore( key ) {
+ try {
+ return JSON.parse( window.localStorage.getItem( key ) ) || {};
+ } catch ( e ) {
+ return {};
+ }
+}
+
+function writeStore( key, store ) {
+ try {
+ window.localStorage.setItem( key, JSON.stringify( store ) );
+ } catch ( e ) {
+ // Storage quota or disabled — fail silently in the prototype.
+ }
+}
+
+export function getStoredNotes( id ) {
+ return readStore( NOTES_STORAGE_KEY )[ id ] || [];
+}
+
+export function setStoredNotes( id, notes ) {
+ const store = readStore( NOTES_STORAGE_KEY );
+ if ( notes && notes.length ) {
+ store[ id ] = notes;
+ } else {
+ delete store[ id ];
+ }
+ writeStore( NOTES_STORAGE_KEY, store );
+}
+
+// Returns the stored array if an entry exists, or null when there's no entry yet
+// (so callers can fall back to the seeded fixture value). An empty array still counts
+// as a real entry — the user may have intentionally cleared all tags/newsletters.
+export function getStoredTags( id ) {
+ const store = readStore( TAGS_STORAGE_KEY );
+ return Object.prototype.hasOwnProperty.call( store, id ) ? store[ id ] : null;
+}
+
+export function setStoredTags( id, tags ) {
+ const store = readStore( TAGS_STORAGE_KEY );
+ store[ id ] = tags || [];
+ writeStore( TAGS_STORAGE_KEY, store );
+}
+
+export function getStoredNewsletters( id ) {
+ const store = readStore( NEWSLETTERS_STORAGE_KEY );
+ return Object.prototype.hasOwnProperty.call( store, id ) ? store[ id ] : null;
+}
+
+export function setStoredNewsletters( id, ids ) {
+ const store = readStore( NEWSLETTERS_STORAGE_KEY );
+ store[ id ] = ids || [];
+ writeStore( NEWSLETTERS_STORAGE_KEY, store );
+}
diff --git a/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx b/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx
new file mode 100644
index 0000000000..e8dbef647b
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx
@@ -0,0 +1,70 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * Flow E — Guided fix for an alert.
+ */
+
+import { useState, createRoot } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Notice, Snackbar, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Modal, Waiting } from '../../../../packages/components/src';
+
+function showSnackbar( message ) {
+ const target = document.getElementById( 'wpbody' ) || document.body;
+ const wrap = document.createElement( 'div' );
+ wrap.className = 'components-snackbar-list newspack-subscribers-demo__snackbar';
+ target.appendChild( wrap );
+ const root = createRoot( wrap );
+ const dismiss = () => {
+ root.unmount();
+ wrap.remove();
+ };
+ root.render( { message } );
+ setTimeout( dismiss, 4000 );
+}
+
+export default function GuidedFixFlow( { alert, onClose, onOpenPaymentUpdate } ) {
+ const [ state, setState ] = useState( 'choose' );
+
+ const sendLink = () => {
+ setState( 'loading' );
+ setTimeout( () => {
+ showSnackbar( __( 'Payment link sent to the subscriber.', 'newspack-plugin' ) );
+ onClose();
+ }, 700 );
+ };
+
+ return (
+
+ { state === 'loading' ? (
+
+ ) : (
+
+
+ { alert.message }
+
+
+ { __(
+ 'Choose how to resolve this. Sending a payment link lets the subscriber update their own card. You can also update the card on their behalf.',
+ 'newspack-plugin'
+ ) }
+
+
+ {
+ onClose();
+ onOpenPaymentUpdate();
+ } }
+ >
+ { __( 'Update payment method', 'newspack-plugin' ) }
+
+
+ { __( 'Send payment link', 'newspack-plugin' ) }
+
+
+
+ ) }
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/flows/NoteFlow.jsx b/src/wizards/subscribersDemo/flows/NoteFlow.jsx
new file mode 100644
index 0000000000..3311b38fdd
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/NoteFlow.jsx
@@ -0,0 +1,64 @@
+/**
+ * Flow — Add or edit a private note.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { TextareaControl, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Modal } from '../../../../packages/components/src';
+
+export default function NoteFlow( { note, onClose, onComplete } ) {
+ const isEdit = !! note;
+ const [ text, setText ] = useState( note?.text || '' );
+ const trimmed = text.trim();
+
+ const submit = () => {
+ if ( ! trimmed ) {
+ return;
+ }
+ onComplete( {
+ type: 'success',
+ transient: true,
+ message: isEdit ? __( 'Private note updated.', 'newspack-plugin' ) : __( 'Private note added.', 'newspack-plugin' ),
+ mutate: subscriber => {
+ const notes = subscriber.notes || [];
+ if ( isEdit ) {
+ return {
+ ...subscriber,
+ notes: notes.map( n => ( n.id === note.id ? { ...n, text: trimmed } : n ) ),
+ };
+ }
+ return {
+ ...subscriber,
+ notes: [ ...notes, { id: `note_${ Date.now() }`, text: trimmed } ],
+ };
+ },
+ } );
+ };
+
+ return (
+
+
+
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { isEdit ? __( 'Save changes', 'newspack-plugin' ) : __( 'Save note', 'newspack-plugin' ) }
+
+
+
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx
new file mode 100644
index 0000000000..b485eb3549
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx
@@ -0,0 +1,89 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * Flow D — Payment method update.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Notice, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Grid, Modal, TextControl, Waiting } from '../../../../packages/components/src';
+
+export default function PaymentUpdateFlow( { onClose, onComplete, paymentMethod } ) {
+ const isEdit = !! paymentMethod;
+ const expiryPlaceholder = `01/${ String( new Date().getFullYear() + 2 ).slice( -2 ) }`;
+ const [ number, setNumber ] = useState( isEdit ? '•••• •••• •••• ' + paymentMethod.last4 : '' );
+ const [ expiry, setExpiry ] = useState( isEdit ? paymentMethod.expiry : '' );
+ const [ cvc, setCvc ] = useState( '' );
+ const [ state, setState ] = useState( 'form' );
+
+ const digits = number.replace( /\D/g, '' );
+ const valid = digits.length >= 12 && /^\d{2}\/\d{2}$/.test( expiry ) && cvc.length >= 3;
+
+ const submit = () => {
+ if ( ! valid ) {
+ return;
+ }
+ setState( 'loading' );
+ setTimeout( () => {
+ const last4 = digits.slice( -4 );
+ const type = digits.startsWith( '4' ) ? 'Visa' : 'Mastercard';
+ onComplete( {
+ type: 'success',
+ message: isEdit ? __( 'Payment method updated.', 'newspack-plugin' ) : __( 'Payment method added.', 'newspack-plugin' ),
+ mutate: s => {
+ if ( isEdit ) {
+ return {
+ ...s,
+ paymentMethods: s.paymentMethods.map( m => ( m.id === paymentMethod.id ? { ...m, type, last4, expiry } : m ) ),
+ };
+ }
+ const next = { id: 'pm_' + Date.now(), type, last4, expiry, isDefault: s.paymentMethods.length === 0 };
+ return { ...s, paymentMethods: [ ...s.paymentMethods, next ] };
+ },
+ } );
+ }, 700 );
+ };
+
+ return (
+
+ { state === 'loading' ? (
+
+ ) : (
+
+
+
+
+
+
+ { ! valid && number.length > 0 && (
+
+ { __( 'Check the card details.', 'newspack-plugin' ) }
+
+ ) }
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { __( 'Save', 'newspack-plugin' ) }
+
+
+
+ ) }
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx b/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx
new file mode 100644
index 0000000000..77552dc3f1
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx
@@ -0,0 +1,85 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * Flow C — Plan change.
+ */
+
+import { useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Notice, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Modal, SelectControl, Waiting } from '../../../../packages/components/src';
+import { DIGITAL_PLANS, PRINT_PLANS } from '../data/mock-subscribers';
+
+export default function PlanChangeFlow( { subscription, onClose, onComplete } ) {
+ const pool = DIGITAL_PLANS.some( p => p.name === subscription.plan ) ? DIGITAL_PLANS : PRINT_PLANS;
+ const options = pool.filter( p => p.name !== subscription.plan );
+ const [ planName, setPlanName ] = useState( options[ 0 ]?.name || '' );
+ const [ state, setState ] = useState( 'choose' );
+ const plan = options.find( p => p.name === planName );
+
+ const submit = () => {
+ setState( 'loading' );
+ setTimeout( () => {
+ onComplete( {
+ type: 'success',
+ message: sprintf( __( 'Plan changed to %s.', 'newspack-plugin' ), plan.name ),
+ mutate: s => ( {
+ ...s,
+ subscriptions: s.subscriptions.map( sub =>
+ sub.id === subscription.id
+ ? { ...sub, plan: plan.name, access: plan.access, cadence: plan.cadence, amount: plan.amount }
+ : sub
+ ),
+ } ),
+ } );
+ }, 700 );
+ };
+
+ if ( ! plan ) {
+ return (
+
+
+ { __( 'No other plans available in this category.', 'newspack-plugin' ) }
+
+
+ );
+ }
+
+ return (
+
+ { state === 'loading' ? (
+
+ ) : (
+
+ { sprintf( __( 'Currently on %s.', 'newspack-plugin' ), subscription.plan ) }
+ ( {
+ label: `${ p.name } — $${ p.amount }/${ p.cadence === 'Monthly' ? 'mo' : 'yr' }`,
+ value: p.name,
+ } ) ) }
+ onChange={ setPlanName }
+ />
+
+ { sprintf(
+ __(
+ 'Change takes effect at the next billing cycle on %1$s. New charge: $%2$s. Proration will be applied to the first invoice.',
+ 'newspack-plugin'
+ ),
+ subscription.nextBillingDate || __( 'next renewal', 'newspack-plugin' ),
+ plan.amount.toFixed( 2 )
+ ) }
+
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { __( 'Confirm', 'newspack-plugin' ) }
+
+
+
+ ) }
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/flows/RefundFlow.jsx b/src/wizards/subscribersDemo/flows/RefundFlow.jsx
new file mode 100644
index 0000000000..34606fb08d
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/RefundFlow.jsx
@@ -0,0 +1,92 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * Flow A — Refund / Cancel.
+ */
+
+import { useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Notice, RadioControl, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Modal, Waiting } from '../../../../packages/components/src';
+
+export default function RefundFlow( { subscription, onClose, onComplete } ) {
+ const [ choice, setChoice ] = useState( 'refund-only' );
+ const [ state, setState ] = useState( 'choose' ); // choose | loading | error
+ const amount = subscription.amount.toFixed( 2 );
+
+ const submit = () => {
+ setState( 'loading' );
+ setTimeout( () => {
+ // Fake failure on a deterministic case so the error state is visible.
+ const fail = false;
+ if ( fail ) {
+ setState( 'error' );
+ } else {
+ onComplete( {
+ type: 'success',
+ message:
+ choice === 'refund-only'
+ ? sprintf( __( 'Refund of $%s processed.', 'newspack-plugin' ), amount )
+ : sprintf( __( 'Refund of $%s processed and subscription cancelled.', 'newspack-plugin' ), amount ),
+ mutate: subscriber => {
+ if ( choice !== 'refund-only' ) {
+ const subscriptions = subscriber.subscriptions.map( s =>
+ s.id === subscription.id ? { ...s, status: 'cancelled', nextBillingDate: null } : s
+ );
+ const hasActive = subscriptions.some( s => s.status === 'active' );
+ return {
+ ...subscriber,
+ status: hasActive ? subscriber.status : 'cancelled',
+ subscriptions,
+ };
+ }
+ return subscriber;
+ },
+ } );
+ }
+ }, 700 );
+ };
+
+ return (
+
+ { state === 'loading' && }
+ { state === 'error' && (
+
+ { __( 'Refund failed. Please try again.', 'newspack-plugin' ) }
+
+ ) }
+ { state === 'choose' && (
+
+ { sprintf( __( '%1$s — $%2$s %3$s', 'newspack-plugin' ), subscription.plan, amount, subscription.cadence.toLowerCase() ) }
+
+
+ { choice === 'refund-only'
+ ? sprintf(
+ __(
+ "The subscriber will be refunded $%s. Their access will continue and they'll renew normally.",
+ 'newspack-plugin'
+ ),
+ amount
+ )
+ : sprintf( __( 'The subscriber will be refunded $%s. Their access will end immediately.', 'newspack-plugin' ), amount ) }
+
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { __( 'Confirm', 'newspack-plugin' ) }
+
+
+
+ ) }
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx b/src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx
new file mode 100644
index 0000000000..22ae914c63
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx
@@ -0,0 +1,134 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * Flow B — Resubscribe.
+ *
+ * If a payment method is already on file, go straight to the plan picker.
+ * Otherwise, branch: send a self-serve link, enter card on behalf of the
+ * subscriber, or comp a free subscription.
+ */
+
+import { useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Notice, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Modal, SelectControl, Waiting } from '../../../../packages/components/src';
+import { DIGITAL_PLANS } from '../data/mock-subscribers';
+
+function PlanPicker( { subscriber, onComplete, onCancel, comped = false } ) {
+ const [ planName, setPlanName ] = useState( DIGITAL_PLANS[ 0 ].name );
+ const [ loading, setLoading ] = useState( false );
+ const plan = DIGITAL_PLANS.find( p => p.name === planName );
+
+ const submit = () => {
+ setLoading( true );
+ setTimeout( () => {
+ onComplete( {
+ type: 'success',
+ message: comped
+ ? sprintf( __( 'Granted %1$s free access to %2$s.', 'newspack-plugin' ), subscriber.name, planName )
+ : sprintf( __( 'Resubscribed %1$s to %2$s.', 'newspack-plugin' ), subscriber.name, planName ),
+ mutate: s => ( {
+ ...s,
+ status: 'active',
+ subscriptions: [
+ {
+ id: 'sub_new_' + Date.now(),
+ plan: plan.name,
+ status: 'active',
+ access: plan.access,
+ cadence: plan.cadence,
+ nextBillingDate: new Date( Date.now() + 30 * 86400000 ).toISOString().slice( 0, 10 ),
+ amount: comped ? 0 : plan.amount,
+ },
+ ],
+ } ),
+ } );
+ }, 700 );
+ };
+
+ if ( loading ) {
+ return ;
+ }
+
+ return (
+
+ ( {
+ label: `${ p.name } — $${ p.amount }/${ p.cadence === 'Monthly' ? 'mo' : 'yr' }`,
+ value: p.name,
+ } ) ) }
+ onChange={ setPlanName }
+ />
+ { comped ? (
+
+ { sprintf( __( 'This will grant %s free access with no billing. Use sparingly.', 'newspack-plugin' ), plan.name ) }
+
+ ) : (
+
+ { sprintf(
+ __( 'Billing will start today. First charge: $%1$s. Next renewal in %2$s.', 'newspack-plugin' ),
+ plan.amount.toFixed( 2 ),
+ plan.cadence === 'Monthly' ? '30 days' : '1 year'
+ ) }
+
+ ) }
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { comped ? __( 'Grant free access', 'newspack-plugin' ) : __( 'Confirm', 'newspack-plugin' ) }
+
+
+
+ );
+}
+
+export default function ResubscribeFlow( { subscriber, onClose, onComplete } ) {
+ const hasPaymentMethod = subscriber.paymentMethods && subscriber.paymentMethods.length > 0;
+ const [ step, setStep ] = useState( hasPaymentMethod ? 'plan' : 'choose' );
+ const [ loading, setLoading ] = useState( false );
+
+ const sendLink = () => {
+ setLoading( true );
+ setTimeout( () => {
+ onComplete( {
+ type: 'success',
+ message: sprintf( __( 'Resubscribe link sent to %s.', 'newspack-plugin' ), subscriber.email ),
+ } );
+ }, 700 );
+ };
+
+ let body;
+ if ( loading ) {
+ body = ;
+ } else if ( step === 'choose' ) {
+ body = (
+
+ { __( 'No payment method on file. Choose how to collect payment before resubscribing.', 'newspack-plugin' ) }
+
+ setStep( 'comp' ) }>
+ { __( 'Grant free access', 'newspack-plugin' ) }
+
+ setStep( 'plan' ) }>
+ { __( 'Enter card details', 'newspack-plugin' ) }
+
+
+ { __( 'Send resubscribe link', 'newspack-plugin' ) }
+
+
+
+ );
+ } else if ( step === 'plan' ) {
+ body = setStep( 'choose' ) } />;
+ } else if ( step === 'comp' ) {
+ body = setStep( 'choose' ) } comped />;
+ }
+
+ return (
+
+ { body }
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/flows/TagsFlow.jsx b/src/wizards/subscribersDemo/flows/TagsFlow.jsx
new file mode 100644
index 0000000000..f18a22b7c8
--- /dev/null
+++ b/src/wizards/subscribersDemo/flows/TagsFlow.jsx
@@ -0,0 +1,50 @@
+/**
+ * Flow — Manage tags.
+ */
+
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { FormTokenField, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+import { Button, Modal } from '../../../../packages/components/src';
+import { KNOWN_TAGS } from '../data/mock-subscribers';
+
+const normalize = tokens => [ ...new Set( ( tokens || [] ).map( t => String( t ).trim().toLowerCase() ).filter( Boolean ) ) ];
+
+export default function TagsFlow( { tags = [], onClose, onComplete } ) {
+ const [ next, setNext ] = useState( tags );
+ const finalTags = normalize( next );
+ const dirty = JSON.stringify( finalTags ) !== JSON.stringify( normalize( tags ) );
+
+ const submit = () => {
+ onComplete( {
+ type: 'success',
+ transient: true,
+ message: __( 'Tags updated.', 'newspack-plugin' ),
+ mutate: subscriber => ( { ...subscriber, tags: finalTags } ),
+ } );
+ };
+
+ return (
+
+
+
+
+
+ { __( 'Cancel', 'newspack-plugin' ) }
+
+
+ { __( 'Save', 'newspack-plugin' ) }
+
+
+
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/index.js b/src/wizards/subscribersDemo/index.js
new file mode 100644
index 0000000000..14c07962dd
--- /dev/null
+++ b/src/wizards/subscribersDemo/index.js
@@ -0,0 +1,44 @@
+import '../../shared/js/public-path';
+
+/**
+ * Subscribers Demo — people-first subscriber management prototype.
+ *
+ * Entry point: mounts a Wizard with two routed sections — the DataViews
+ * list (full-width) and the person profile.
+ */
+
+/**
+ * WordPress dependencies.
+ */
+import { render } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies.
+ */
+import { Wizard } from '../../../packages/components/src';
+import SubscriberList from './screens/SubscriberList';
+import PersonProfile from './screens/PersonProfile';
+
+function SubscribersDemoApp() {
+ return (
+
+ );
+}
+
+render( , document.getElementById( 'newspack-subscribers-demo' ) );
diff --git a/src/wizards/subscribersDemo/screens/PersonProfile.jsx b/src/wizards/subscribersDemo/screens/PersonProfile.jsx
new file mode 100644
index 0000000000..12507f6a4e
--- /dev/null
+++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx
@@ -0,0 +1,523 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * L1 — Person profile.
+ *
+ * Two-column grid layout (SectionHeader in the left column, content in
+ * the right) modelled on Access Control > Add new content gate.
+ * Alerts and Current Status are pinned above Identity when issues
+ * exist, so the hierarchy concern from Katie (multiple subs + broken
+ * membership) is handled.
+ */
+
+/**
+ * WordPress dependencies.
+ */
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { useDispatch } from '@wordpress/data';
+import { dateI18n, getSettings } from '@wordpress/date';
+import { __experimentalVStack as VStack, __experimentalHStack as HStack, Notice, Snackbar, ToggleControl } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+
+/**
+ * Internal dependencies.
+ */
+import { Badge, Button, Card, Divider, Grid, Router, SectionHeader } from '../../../../packages/components/src';
+import './style.scss';
+import { WIZARD_STORE_NAMESPACE } from '../../../../packages/components/src/wizard/store';
+import {
+ getSubscriberById,
+ getStoredNotes,
+ setStoredNotes,
+ getStoredTags,
+ setStoredTags,
+ getStoredNewsletters,
+ setStoredNewsletters,
+ NEWSLETTERS,
+} from '../data/mock-subscribers';
+
+import visaIcon from '../assets/cards/visa.svg';
+import mastercardIcon from '../assets/cards/mastercard.svg';
+import amexIcon from '../assets/cards/amex.svg';
+import discoverIcon from '../assets/cards/discover.svg';
+import jcbIcon from '../assets/cards/jcb.svg';
+
+const CARD_ICONS = {
+ Visa: visaIcon,
+ Mastercard: mastercardIcon,
+ Amex: amexIcon,
+ Discover: discoverIcon,
+ JCB: jcbIcon,
+};
+
+import RefundFlow from '../flows/RefundFlow';
+import ResubscribeFlow from '../flows/ResubscribeFlow';
+import PlanChangeFlow from '../flows/PlanChangeFlow';
+import PaymentUpdateFlow from '../flows/PaymentUpdateFlow';
+import GuidedFixFlow from '../flows/GuidedFixFlow';
+import NoteFlow from '../flows/NoteFlow';
+import TagsFlow from '../flows/TagsFlow';
+
+const { useParams } = Router;
+
+const fmtDate = date => ( date ? dateI18n( getSettings().formats.date, date ) : '' );
+
+const STATUS_LABELS = {
+ active: __( 'Active', 'newspack-plugin' ),
+ lapsed: __( 'Lapsed', 'newspack-plugin' ),
+ cancelled: __( 'Cancelled', 'newspack-plugin' ),
+};
+
+const STATUS_BADGE_LEVEL = {
+ active: 'success',
+ lapsed: 'warning',
+ cancelled: 'error',
+};
+
+function getStatusSummary( subscriber ) {
+ if ( subscriber.status === 'active' ) {
+ const activeSubs = subscriber.subscriptions.filter( s => s.status === 'active' );
+ if ( activeSubs.length === 0 ) {
+ return [ __( 'Active subscriber with no current plan on file', 'newspack-plugin' ) ];
+ }
+ if ( activeSubs.length > 1 ) {
+ const names = activeSubs.map( s => s.plan ).join( ' and ' );
+ const next = activeSubs
+ .map( s => s.nextBillingDate )
+ .filter( Boolean )
+ .sort()[ 0 ];
+ return [
+ sprintf( __( 'Active subscriber on %s', 'newspack-plugin' ), names ),
+ sprintf( __( 'Next billing %s', 'newspack-plugin' ), fmtDate( next ) ),
+ ];
+ }
+ const sub = activeSubs[ 0 ];
+ return [
+ sprintf( __( 'Active subscriber on %s', 'newspack-plugin' ), sub.plan ),
+ sprintf( __( 'Next billing %s', 'newspack-plugin' ), fmtDate( sub.nextBillingDate ) ),
+ ];
+ }
+ if ( subscriber.status === 'lapsed' ) {
+ return [ sprintf( __( 'Subscription lapsed — last payment on %s', 'newspack-plugin' ), fmtDate( subscriber.lastPayment ) ) ];
+ }
+ return [ __( 'Subscription cancelled — access has ended', 'newspack-plugin' ) ];
+}
+
+/**
+ * Row of a two-column section: header on the left, children on the right.
+ */
+function Row( { title, description, children, showDivider = true } ) {
+ return (
+ <>
+
+
+ { children }
+
+ { showDivider && }
+ >
+ );
+}
+
+export default function PersonProfile() {
+ const { id } = useParams();
+ const initial = useMemo( () => {
+ const found = getSubscriberById( id );
+ if ( ! found ) {
+ return found;
+ }
+ const storedTags = getStoredTags( id );
+ const storedNewsletters = getStoredNewsletters( id );
+ return {
+ ...found,
+ notes: getStoredNotes( id ),
+ tags: storedTags !== null ? storedTags : found.tags || [],
+ newsletters: storedNewsletters !== null ? storedNewsletters : found.newsletters || [],
+ };
+ }, [ id ] );
+ const [ subscriber, setSubscriber ] = useState( initial );
+
+ useEffect( () => {
+ if ( subscriber ) {
+ setStoredNotes( subscriber.id, subscriber.notes || [] );
+ }
+ }, [ subscriber ] );
+
+ useEffect( () => {
+ if ( subscriber ) {
+ setStoredTags( subscriber.id, subscriber.tags || [] );
+ }
+ }, [ subscriber ] );
+
+ useEffect( () => {
+ if ( subscriber ) {
+ setStoredNewsletters( subscriber.id, subscriber.newsletters || [] );
+ }
+ }, [ subscriber ] );
+ const [ flash, setFlash ] = useState( null );
+ const [ snackbar, setSnackbar ] = useState( null );
+ const [ modal, setModal ] = useState( null );
+
+ useEffect( () => {
+ setSubscriber( initial );
+ setFlash( null );
+ setSnackbar( null );
+ setModal( null );
+ }, [ id, initial ] );
+
+ const { setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE );
+
+ useEffect( () => {
+ if ( ! subscriber ) {
+ return;
+ }
+ setHeaderData( {
+ backNav: '#/',
+ sectionName: subscriber.name,
+ sectionTitle: subscriber.name,
+ badges: [ { label: STATUS_LABELS[ subscriber.status ], level: STATUS_BADGE_LEVEL[ subscriber.status ] } ],
+ sectionDescription: (
+
+ { subscriber.email }
+ { getStatusSummary( subscriber ).map( ( line, i ) => (
+ { line }
+ ) ) }
+ { ( subscriber.tags || [] ).length > 0 && (
+
+ { subscriber.tags.map( t => (
+
+ ) ) }
+
+ ) }
+
+ ),
+ actions: [
+ { type: 'more', label: __( 'View in WooCommerce', 'newspack-plugin' ), action: () => {} },
+ { type: 'more', label: __( 'Edit WordPress user', 'newspack-plugin' ), action: () => {} },
+ { type: 'more', label: __( 'Manage tags', 'newspack-plugin' ), action: () => setModal( { kind: 'tags' } ) },
+ { type: 'more', label: __( 'Add private note', 'newspack-plugin' ), action: () => setModal( { kind: 'note' } ) },
+ { type: 'more', label: __( 'View raw subscription data', 'newspack-plugin' ), action: () => {} },
+ ],
+ } );
+ }, [ subscriber, setHeaderData ] );
+
+ if ( ! subscriber ) {
+ return (
+
+ { __( 'Subscriber not found.', 'newspack-plugin' ) }
+
+ );
+ }
+
+ const closeModal = () => setModal( null );
+ const completeFlow = ( { type, message, mutate, transient } ) => {
+ if ( mutate ) {
+ setSubscriber( prev => mutate( prev ) );
+ }
+ if ( transient ) {
+ setSnackbar( { message } );
+ } else {
+ setFlash( { type, message } );
+ }
+ setModal( null );
+ };
+
+ const hasAlerts = subscriber.alerts && subscriber.alerts.length > 0;
+
+ return (
+
+ { flash && (
+
+ { flash.message }
+
+ ) }
+
+ { hasAlerts &&
+ subscriber.alerts.map( alert => (
+
setModal( { kind: 'guided', alert } ),
+ variant: 'link',
+ },
+ ] }
+ >
+ { `${ alert.title }. ${ alert.message }` }
+
+ ) ) }
+
+ { ( subscriber.notes || [] ).length > 0 && (
+
+ { subscriber.notes.map( note => (
+
+
+ { note.text }
+
+ setModal( { kind: 'note', note } ) }>
+ { __( 'Edit', 'newspack-plugin' ) }
+
+ {
+ setSubscriber( prev => ( {
+ ...prev,
+ notes: ( prev.notes || [] ).filter( n => n.id !== note.id ),
+ } ) );
+ setSnackbar( { message: __( 'Private note deleted.', 'newspack-plugin' ) } );
+ } }
+ >
+ { __( 'Delete', 'newspack-plugin' ) }
+
+
+
+
+ ) ) }
+
+ ) }
+
+
+
+ { subscriber.subscriptions.length === 0 ? (
+
+ { __( 'No subscriptions on file.', 'newspack-plugin' ) }
+ setModal( { kind: 'resubscribe' } ) }>
+ { __( 'Resubscribe', 'newspack-plugin' ) }
+
+
+ ) : (
+ subscriber.subscriptions.map( sub => {
+ const isActive = sub.status === 'active';
+ return (
+
+ { sub.plan }
+
+
+ ),
+ } }
+ >
+
+
+ { sub.access } · { sub.cadence } · ${ sub.amount.toFixed( 2 ) }
+ { isActive && sub.nextBillingDate && (
+ <>
+
+ { sprintf( __( 'Next billing %s', 'newspack-plugin' ), fmtDate( sub.nextBillingDate ) ) }
+ >
+ ) }
+
+
+ { isActive ? (
+ <>
+ setModal( { kind: 'refund', subscription: sub } ) }
+ >
+ { __( 'Refund / Cancel', 'newspack-plugin' ) }
+
+ setModal( { kind: 'plan', subscription: sub } ) }
+ >
+ { __( 'Change plan', 'newspack-plugin' ) }
+
+ >
+ ) : (
+ setModal( { kind: 'resubscribe' } ) }>
+ { __( 'Resubscribe', 'newspack-plugin' ) }
+
+ ) }
+
+
+
+ );
+ } )
+ ) }
+
+
+
+
+
+
+ { NEWSLETTERS.map( newsletter => {
+ const isSubscribed = ( subscriber.newsletters || [] ).includes( newsletter.id );
+ return (
+
+
+ { newsletter.name }
+ { newsletter.description }
+
+ {
+ const nextList = isSubscribed
+ ? ( subscriber.newsletters || [] ).filter( i => i !== newsletter.id )
+ : [ ...( subscriber.newsletters || [] ), newsletter.id ];
+ setSubscriber( prev => ( { ...prev, newsletters: nextList } ) );
+ setSnackbar( {
+ message: isSubscribed
+ ? sprintf( __( 'Unsubscribed from %s.', 'newspack-plugin' ), newsletter.name )
+ : sprintf( __( 'Subscribed to %s.', 'newspack-plugin' ), newsletter.name ),
+ } );
+ } }
+ __nextHasNoMarginBottom
+ />
+
+ );
+ } ) }
+
+
+
+
+
+
+ { subscriber.paymentMethods.length === 0 ? (
+
+
+ { __( 'No payment method on file.', 'newspack-plugin' ) }
+
+ setModal( { kind: 'payment' } ) }>
+ { __( 'Add payment method', 'newspack-plugin' ) }
+
+
+
+
+ ) : (
+ <>
+ { subscriber.paymentMethods.map( pm => (
+
+
+ { CARD_ICONS[ pm.type ] && (
+
+ ) }
+
+ { pm.type } ···· { pm.last4 }
+
+
+ { pm.isDefault && }
+
+ ),
+ } }
+ >
+
+ { sprintf( __( 'Expires %s', 'newspack-plugin' ), pm.expiry ) }
+
+ setModal( { kind: 'payment', paymentMethod: pm } ) }
+ >
+ { __( 'Update', 'newspack-plugin' ) }
+
+ { ! pm.isDefault && (
+ <>
+
+ setSubscriber( prev => ( {
+ ...prev,
+ paymentMethods: prev.paymentMethods.map( m => ( {
+ ...m,
+ isDefault: m.id === pm.id,
+ } ) ),
+ } ) )
+ }
+ >
+ { __( 'Make default', 'newspack-plugin' ) }
+
+
+ setSubscriber( prev => ( {
+ ...prev,
+ paymentMethods: prev.paymentMethods.filter( m => m.id !== pm.id ),
+ } ) )
+ }
+ >
+ { __( 'Remove', 'newspack-plugin' ) }
+
+ >
+ ) }
+
+
+
+ ) ) }
+
+ setModal( { kind: 'payment' } ) }>
+ { __( 'Add payment method', 'newspack-plugin' ) }
+
+
+ >
+ ) }
+
+
+
+
+
+
+
+
+ { __( 'Date', 'newspack-plugin' ) }
+ { __( 'Type', 'newspack-plugin' ) }
+ { __( 'Amount', 'newspack-plugin' ) }
+
+
+
+ { subscriber.orders.map( o => (
+
+ { fmtDate( o.date ) }
+ { o.type }
+ ${ o.amount.toFixed( 2 ) }
+
+ ) ) }
+
+
+
+
+ { modal?.kind === 'refund' &&
}
+ { modal?.kind === 'plan' &&
}
+ { modal?.kind === 'resubscribe' &&
}
+ { modal?.kind === 'payment' &&
}
+ { snackbar && (
+
+ setSnackbar( null ) }>{ snackbar.message }
+
+ ) }
+
+ { modal?.kind === 'note' &&
}
+ { modal?.kind === 'tags' &&
}
+ { modal?.kind === 'guided' && (
+
setModal( { kind: 'payment' } ) }
+ />
+ ) }
+
+ );
+}
diff --git a/src/wizards/subscribersDemo/screens/SubscriberList.jsx b/src/wizards/subscribersDemo/screens/SubscriberList.jsx
new file mode 100644
index 0000000000..b944ada202
--- /dev/null
+++ b/src/wizards/subscribersDemo/screens/SubscriberList.jsx
@@ -0,0 +1,170 @@
+/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */
+/**
+ * L0 — Subscriber list (DataViews, full-width).
+ */
+
+/**
+ * WordPress dependencies.
+ */
+import { useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { filterSortAndPaginate } from '@wordpress/dataviews';
+import { dateI18n, getSettings } from '@wordpress/date';
+import { __experimentalHStack as HStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+
+const fmtDate = date => ( date ? dateI18n( getSettings().formats.date, date ) : '' );
+
+/**
+ * Internal dependencies.
+ */
+import { Badge, DataViews, Router } from '../../../../packages/components/src';
+import './style.scss';
+import { SUBSCRIBERS, DIGITAL_PLANS, PRINT_PLANS, ALL_TAGS, NEWSLETTERS } from '../data/mock-subscribers';
+
+const { useHistory } = Router;
+
+const STATUS_LABELS = {
+ active: __( 'Active', 'newspack-plugin' ),
+ lapsed: __( 'Lapsed', 'newspack-plugin' ),
+ cancelled: __( 'Cancelled', 'newspack-plugin' ),
+};
+
+const STATUS_BADGE_LEVEL = {
+ active: 'success',
+ lapsed: 'warning',
+ cancelled: 'error',
+};
+
+const ALL_PLAN_NAMES = [ ...DIGITAL_PLANS, ...PRINT_PLANS ].map( p => p.name );
+
+const DEFAULT_VIEW = {
+ type: 'table',
+ page: 1,
+ perPage: 20,
+ sort: { field: 'lastPayment', direction: 'desc' },
+ search: '',
+ fields: [ 'status', 'plans', 'lastPayment', 'memberSince' ],
+ filters: [],
+ layout: {},
+ titleField: 'name',
+};
+
+export default function SubscriberList() {
+ const history = useHistory();
+ const [ view, setView ] = useState( DEFAULT_VIEW );
+
+ const openProfile = id => history.push( `/profile/${ id }` );
+
+ const fields = useMemo(
+ () => [
+ {
+ id: 'name',
+ label: __( 'Subscriber', 'newspack-plugin' ),
+ enableGlobalSearch: true,
+ getValue: ( { item } ) => `${ item.name } ${ item.email }`,
+ render: ( { item } ) => (
+
+
{ item.name }
+
{ item.email }
+
+ ),
+ },
+ {
+ id: 'status',
+ label: __( 'Status', 'newspack-plugin' ),
+ elements: Object.entries( STATUS_LABELS ).map( ( [ value, label ] ) => ( { value, label } ) ),
+ filterBy: { operators: [ 'isAny' ] },
+ getValue: ( { item } ) => item.status,
+ render: ( { item } ) => ,
+ },
+ {
+ id: 'plans',
+ label: __( 'Plan', 'newspack-plugin' ),
+ elements: ALL_PLAN_NAMES.map( n => ( { value: n, label: n } ) ),
+ filterBy: { operators: [ 'isAny' ] },
+ getValue: ( { item } ) => item.subscriptions.map( s => s.plan ).join( ', ' ),
+ render: ( { item } ) => (
+
+ { item.subscriptions.map( s => (
+
{ s.plan }
+ ) ) }
+
+ ),
+ enableSorting: false,
+ },
+ {
+ id: 'lastPayment',
+ label: __( 'Last payment', 'newspack-plugin' ),
+ getValue: ( { item } ) => item.lastPayment,
+ render: ( { item } ) => { fmtDate( item.lastPayment ) } ,
+ },
+ {
+ id: 'memberSince',
+ label: __( 'Member since', 'newspack-plugin' ),
+ getValue: ( { item } ) => item.memberSince,
+ render: ( { item } ) => { fmtDate( item.memberSince ) } ,
+ },
+ {
+ id: 'tags',
+ label: __( 'Tags', 'newspack-plugin' ),
+ elements: ALL_TAGS.map( t => ( { value: t, label: t } ) ),
+ filterBy: { operators: [ 'isAny' ] },
+ getValue: ( { item } ) => item.tags || [],
+ render: ( { item } ) => (
+
+ { ( item.tags || [] ).map( t => (
+
+ ) ) }
+
+ ),
+ enableSorting: false,
+ },
+ {
+ id: 'newsletters',
+ label: __( 'Newsletters', 'newspack-plugin' ),
+ elements: NEWSLETTERS.map( n => ( { value: n.id, label: n.name } ) ),
+ filterBy: { operators: [ 'isAny' ] },
+ getValue: ( { item } ) => item.newsletters || [],
+ render: ( { item } ) => (
+
+ { ( item.newsletters || [] )
+ .map( id => NEWSLETTERS.find( n => n.id === id )?.name )
+ .filter( Boolean )
+ .join( ', ' ) }
+
+ ),
+ enableSorting: false,
+ },
+ ],
+ []
+ );
+
+ const actions = useMemo(
+ () => [
+ {
+ id: 'view-profile',
+ label: __( 'View profile', 'newspack-plugin' ),
+ isPrimary: true,
+ callback: items => openProfile( items[ 0 ].id ),
+ },
+ ],
+ []
+ );
+
+ const { data: processedData, paginationInfo } = useMemo( () => filterSortAndPaginate( SUBSCRIBERS, view, fields ), [ view, fields ] );
+
+ return (
+ item.id }
+ onClickItem={ item => openProfile( item.id ) }
+ search
+ />
+ );
+}
diff --git a/src/wizards/subscribersDemo/screens/style.scss b/src/wizards/subscribersDemo/screens/style.scss
new file mode 100644
index 0000000000..78b9b0e80e
--- /dev/null
+++ b/src/wizards/subscribersDemo/screens/style.scss
@@ -0,0 +1,91 @@
+@use "~@wordpress/base-styles/variables" as wp-vars;
+@use "~@wordpress/base-styles/colors" as wp-colors;
+
+.newspack-wizard__header .newspack-section-header p {
+ white-space: pre-line !important;
+}
+
+.newspack-subscribers-demo__snackbar {
+ bottom: 24px;
+ left: 24px;
+ position: fixed;
+ z-index: 100000;
+}
+
+.newspack-subscribers-demo__card-icon {
+ width: 32px;
+ height: auto;
+ display: block;
+}
+
+.newspack-subscribers-demo__email {
+ color: wp-colors.$gray-700;
+ font-size: wp-vars.$helptext-font-size;
+ line-height: wp-vars.$default-line-height;
+ font-weight: normal;
+}
+
+.newspack-subscribers-demo__note-card.newspack-card--core {
+ background-color: wp-colors.$gray-100;
+ overflow: hidden;
+
+ .components-card__body,
+ .newspack-card--core__body {
+ background-color: transparent;
+ }
+}
+
+.newspack-subscribers-demo__profile {
+ .components-notice__actions {
+ margin-top: 12px;
+
+ .components-button {
+ height: 32px;
+ line-height: 1;
+ margin-left: 0;
+ margin-right: 0;
+ padding: 0 12px;
+ }
+ }
+
+ .components-card__header,
+ .newspack-card--core__header {
+ padding: 16px;
+ }
+ .components-card__body,
+ .newspack-card--core__body {
+ padding: 16px;
+
+ > * {
+ margin: 0 !important;
+ padding: 0 !important;
+ }
+ }
+}
+
+// DataViews ships its search input and footer page-select as `.components-base-control`,
+// which adds `margin-bottom: 16px`. That throws their parent HStacks out of vertical
+// alignment with the buttons next to them. Drop the bottom margin so the toolbar and
+// footer collapse to a single line.
+.dataviews-wrapper {
+ .dataviews__search .components-base-control,
+ .dataviews-pagination .components-base-control {
+ margin-bottom: 0;
+ }
+}
+
+.newspack-subscribers-demo__orders-wrapper {
+ .dataviews-view-table {
+ tr td:first-child,
+ tr th:first-child {
+ padding-left: 12px !important;
+ }
+ tr td:last-child,
+ tr th:last-child {
+ padding-right: 12px !important;
+ }
+ tr:hover {
+ background: #f8f8f8;
+ }
+ }
+}