From 9396247223a177a1233e9f94bc1d912cbe225893 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Thu, 9 Apr 2026 15:02:39 +0100 Subject: [PATCH 01/14] feat(subscribers): add subscribers management UX prototype --- includes/class-newspack.php | 1 + includes/class-wizards.php | 1 + includes/wizards/class-subscribers-demo.php | 74 ++++ includes/wizards/class-wizard.php | 5 +- packages/components/src/footer/index.js | 7 + packages/components/src/notice/index.js | 4 +- packages/components/src/notice/style.scss | 4 + .../subscribersDemo/assets/cards/amex.svg | 12 + .../subscribersDemo/assets/cards/discover.svg | 39 ++ .../subscribersDemo/assets/cards/jcb.svg | 68 ++++ .../assets/cards/mastercard.svg | 7 + .../subscribersDemo/assets/cards/visa.svg | 6 + .../subscribersDemo/data/mock-subscribers.js | 270 +++++++++++++ .../subscribersDemo/flows/GuidedFixFlow.jsx | 68 ++++ .../flows/PaymentUpdateFlow.jsx | 86 ++++ .../subscribersDemo/flows/PlanChangeFlow.jsx | 84 ++++ .../subscribersDemo/flows/RefundFlow.jsx | 93 +++++ .../subscribersDemo/flows/ResubscribeFlow.jsx | 137 +++++++ src/wizards/subscribersDemo/index.js | 44 ++ .../subscribersDemo/screens/PersonProfile.jsx | 376 ++++++++++++++++++ .../screens/SubscriberList.jsx | 138 +++++++ .../subscribersDemo/screens/style.scss | 62 +++ 22 files changed, 1583 insertions(+), 3 deletions(-) create mode 100644 includes/wizards/class-subscribers-demo.php create mode 100644 src/wizards/subscribersDemo/assets/cards/amex.svg create mode 100644 src/wizards/subscribersDemo/assets/cards/discover.svg create mode 100644 src/wizards/subscribersDemo/assets/cards/jcb.svg create mode 100644 src/wizards/subscribersDemo/assets/cards/mastercard.svg create mode 100644 src/wizards/subscribersDemo/assets/cards/visa.svg create mode 100644 src/wizards/subscribersDemo/data/mock-subscribers.js create mode 100644 src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx create mode 100644 src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx create mode 100644 src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx create mode 100644 src/wizards/subscribersDemo/flows/RefundFlow.jsx create mode 100644 src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx create mode 100644 src/wizards/subscribersDemo/index.js create mode 100644 src/wizards/subscribersDemo/screens/PersonProfile.jsx create mode 100644 src/wizards/subscribersDemo/screens/SubscriberList.jsx create mode 100644 src/wizards/subscribersDemo/screens/style.scss 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/packages/components/src/notice/index.js b/packages/components/src/notice/index.js index 337b8c3d8b..3babc84e9f 100644 --- a/packages/components/src/notice/index.js +++ b/packages/components/src/notice/index.js @@ -31,6 +31,7 @@ class Notice extends Component { isHelp, isSuccess, isWarning, + noMargin, noticeText, rawHTML, style = {}, @@ -44,7 +45,8 @@ class Notice extends Component { isHandoff && 'newspack-notice__is-handoff', isHelp && 'newspack-notice__is-help', isSuccess && 'newspack-notice__is-success', - isWarning && 'newspack-notice__is-warning' + isWarning && 'newspack-notice__is-warning', + noMargin && 'newspack-notice__no-margin' ); let noticeIcon; if ( isHelp ) { diff --git a/packages/components/src/notice/style.scss b/packages/components/src/notice/style.scss index 408de62265..88bd64878c 100644 --- a/packages/components/src/notice/style.scss +++ b/packages/components/src/notice/style.scss @@ -110,4 +110,8 @@ & + & { margin-top: -16px; } + + &__no-margin { + margin: 0; + } } 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..33ad197aba --- /dev/null +++ b/src/wizards/subscribersDemo/data/mock-subscribers.js @@ -0,0 +1,270 @@ +/* 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 ]; + +// 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: [], + 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.', + }, + ], + 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: [], + 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: [], + 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: [], + 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.', + }, + ] + : []; + 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, + 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 ]; + +export function getSubscriberById( id ) { + return SUBSCRIBERS.find( s => s.id === id ); +} diff --git a/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx b/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx new file mode 100644 index 0000000000..630a1c504a --- /dev/null +++ b/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx @@ -0,0 +1,68 @@ +/* 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 { __experimentalHStack as HStack, __experimentalVStack as VStack, Snackbar } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Button, Modal, Notice, 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' ? ( + + ) : ( + + + + { __( + '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' + ) } + + + + + + + ) } + + ); +} diff --git a/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx new file mode 100644 index 0000000000..a4bd6a3f5b --- /dev/null +++ b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx @@ -0,0 +1,86 @@ +/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */ +/** + * Flow D — Payment method update. + */ + +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Button, Grid, Modal, Notice, 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 valid = number.replace( /\s/g, '' ).length >= 12 && /^\d{2}\/\d{2}$/.test( expiry ) && cvc.length >= 3; + + const submit = () => { + if ( ! valid ) { + return; + } + setState( 'loading' ); + setTimeout( () => { + const last4 = number.replace( /\s/g, '' ).slice( -4 ); + const type = number.replace( /\D/g, '' ).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 && ( + + ) } + + + + + + ) } + + ); +} diff --git a/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx b/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx new file mode 100644 index 0000000000..fea9978a2e --- /dev/null +++ b/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx @@ -0,0 +1,84 @@ +/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */ +/** + * Flow C — Plan change. + */ + +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Button, Modal, Notice, 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 ( + + + + ); + } + + 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 } + /> + + + + + +
+ ) } +
+ ); +} diff --git a/src/wizards/subscribersDemo/flows/RefundFlow.jsx b/src/wizards/subscribersDemo/flows/RefundFlow.jsx new file mode 100644 index 0000000000..d56ca93378 --- /dev/null +++ b/src/wizards/subscribersDemo/flows/RefundFlow.jsx @@ -0,0 +1,93 @@ +/* eslint-disable @wordpress/i18n-translator-comments, no-bitwise */ +/** + * Flow A — Refund / Cancel. + */ + +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { RadioControl, __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Button, Modal, Notice, 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 updated = { + ...subscriber, + status: 'cancelled', + subscriptions: subscriber.subscriptions.map( s => + s.id === subscription.id ? { ...s, status: 'cancelled', nextBillingDate: null } : s + ), + }; + return updated; + } + return subscriber; + }, + } ); + } + }, 700 ); + }; + + return ( + + { state === 'loading' && } + { state === 'error' && } + { state === 'choose' && ( + +

{ sprintf( __( '%1$s — $%2$s %3$s', 'newspack-plugin' ), subscription.plan, amount, subscription.cadence.toLowerCase() ) }

+ + + + + + +
+ ) } +
+ ); +} diff --git a/src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx b/src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx new file mode 100644 index 0000000000..27ab6cc7d7 --- /dev/null +++ b/src/wizards/subscribersDemo/flows/ResubscribeFlow.jsx @@ -0,0 +1,137 @@ +/* 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 { __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Button, Modal, Notice, 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 } + /> + + + + + + + ); +} + +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 = ( + + + + + + + + + ); + } else if ( step === 'plan' ) { + body = setStep( 'choose' ) } />; + } else if ( step === 'comp' ) { + body = setStep( 'choose' ) } comped />; + } + + return ( + + { body } + + ); +} 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..abf9a65ab6 --- /dev/null +++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx @@ -0,0 +1,376 @@ +/* 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 } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis + +/** + * Internal dependencies. + */ +import { Badge, Button, Card, Divider, Grid, Notice, Router, SectionHeader } from '../../../../packages/components/src'; +import './style.scss'; +import { WIZARD_STORE_NAMESPACE } from '../../../../packages/components/src/wizard/store'; +import { getSubscriberById } 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'; + +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( () => getSubscriberById( id ), [ id ] ); + const [ subscriber, setSubscriber ] = useState( initial ); + const [ flash, setFlash ] = useState( null ); + const [ modal, setModal ] = useState( null ); + + 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 } + ) ) } + + ), + actions: [ + { type: 'more', label: __( 'View in WooCommerce', 'newspack-plugin' ), action: () => {} }, + { type: 'more', label: __( 'Edit WordPress user', 'newspack-plugin' ), action: () => {} }, + { type: 'more', label: __( 'View raw subscription data', 'newspack-plugin' ), action: () => {} }, + ], + } ); + }, [ subscriber, setHeaderData ] ); + + if ( ! subscriber ) { + return ; + } + + const closeModal = () => setModal( null ); + const completeFlow = ( { type, message, mutate } ) => { + if ( mutate ) { + setSubscriber( prev => mutate( prev ) ); + } + setFlash( { type, message } ); + setModal( null ); + }; + + const hasAlerts = subscriber.alerts && subscriber.alerts.length > 0; + + return ( +
+ { flash && } + + { hasAlerts && + subscriber.alerts.map( alert => ( + + + + ) ) } + + + + { subscriber.subscriptions.length === 0 ? ( + +

{ __( 'No subscriptions on file.', '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 ? ( + <> + + + + ) : ( + + ) } + +
+
+ ); + } ) + ) } +
+
+ + + + { subscriber.paymentMethods.length === 0 ? ( + + +
{ __( 'No payment method on file.', 'newspack-plugin' ) }
+ + + +
+
+ ) : ( + <> + { subscriber.paymentMethods.map( pm => ( + + + { CARD_ICONS[ pm.type ] && ( + { + ) } +

+ { pm.type } ···· { pm.last4 } +

+
+ { pm.isDefault && } + + ), + } } + > + +
{ sprintf( __( 'Expires %s', 'newspack-plugin' ), pm.expiry ) }
+ + + { ! pm.isDefault && ( + <> + + + + ) } + +
+
+ ) ) } + + + + + ) } +
+
+ + +
+ + + + + + + + + + { subscriber.orders.map( o => ( + + + + + + ) ) } + +
{ __( 'Date', 'newspack-plugin' ) }{ __( 'Type', 'newspack-plugin' ) }{ __( 'Amount', 'newspack-plugin' ) }
{ fmtDate( o.date ) }{ o.type }${ o.amount.toFixed( 2 ) }
+
+ + { modal?.kind === 'refund' && } + { modal?.kind === 'plan' && } + { modal?.kind === 'resubscribe' && } + { modal?.kind === 'payment' && } + { 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..95bb5d8dd7 --- /dev/null +++ b/src/wizards/subscribersDemo/screens/SubscriberList.jsx @@ -0,0 +1,138 @@ +/* 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'; + +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 } 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 ) }, + }, + ], + [] + ); + + 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..9ff37733a7 --- /dev/null +++ b/src/wizards/subscribersDemo/screens/style.scss @@ -0,0 +1,62 @@ +@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__profile { + .newspack-notice__content .components-button { + margin-left: 8px; + } + + .components-card__header, + .newspack-card--core__header { + padding: 16px; + } + .components-card__body, + .newspack-card--core__body { + padding: 16px; + + > * { + margin: 0 !important; + padding: 0 !important; + } + } +} + +.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; + } + } +} From 60eee7fe6411a5c9b89565dd87202133a8ec61f3 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Thu, 9 Apr 2026 15:12:16 +0100 Subject: [PATCH 02/14] fix(subscribers): address copilot review feedback --- .../subscribersDemo/flows/PaymentUpdateFlow.jsx | 7 ++++--- src/wizards/subscribersDemo/flows/RefundFlow.jsx | 13 +++++++------ .../subscribersDemo/screens/PersonProfile.jsx | 6 ++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx index a4bd6a3f5b..569da4830d 100644 --- a/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx +++ b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx @@ -16,7 +16,8 @@ export default function PaymentUpdateFlow( { onClose, onComplete, paymentMethod const [ cvc, setCvc ] = useState( '' ); const [ state, setState ] = useState( 'form' ); - const valid = number.replace( /\s/g, '' ).length >= 12 && /^\d{2}\/\d{2}$/.test( expiry ) && cvc.length >= 3; + const digits = number.replace( /\D/g, '' ); + const valid = digits.length >= 12 && /^\d{2}\/\d{2}$/.test( expiry ) && cvc.length >= 3; const submit = () => { if ( ! valid ) { @@ -24,8 +25,8 @@ export default function PaymentUpdateFlow( { onClose, onComplete, paymentMethod } setState( 'loading' ); setTimeout( () => { - const last4 = number.replace( /\s/g, '' ).slice( -4 ); - const type = number.replace( /\D/g, '' ).startsWith( '4' ) ? 'Visa' : 'Mastercard'; + 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' ), diff --git a/src/wizards/subscribersDemo/flows/RefundFlow.jsx b/src/wizards/subscribersDemo/flows/RefundFlow.jsx index d56ca93378..6516aec1bf 100644 --- a/src/wizards/subscribersDemo/flows/RefundFlow.jsx +++ b/src/wizards/subscribersDemo/flows/RefundFlow.jsx @@ -29,14 +29,15 @@ export default function RefundFlow( { subscription, onClose, onComplete } ) { : sprintf( __( 'Refund of $%s processed and subscription cancelled.', 'newspack-plugin' ), amount ), mutate: subscriber => { if ( choice !== 'refund-only' ) { - const updated = { + 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: 'cancelled', - subscriptions: subscriber.subscriptions.map( s => - s.id === subscription.id ? { ...s, status: 'cancelled', nextBillingDate: null } : s - ), + status: hasActive ? subscriber.status : 'cancelled', + subscriptions, }; - return updated; } return subscriber; }, diff --git a/src/wizards/subscribersDemo/screens/PersonProfile.jsx b/src/wizards/subscribersDemo/screens/PersonProfile.jsx index abf9a65ab6..02e22a5a7c 100644 --- a/src/wizards/subscribersDemo/screens/PersonProfile.jsx +++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx @@ -113,6 +113,12 @@ export default function PersonProfile() { const [ flash, setFlash ] = useState( null ); const [ modal, setModal ] = useState( null ); + useEffect( () => { + setSubscriber( initial ); + setFlash( null ); + setModal( null ); + }, [ id, initial ] ); + const { setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE ); useEffect( () => { From 51cabad85bdfbe1f157fde92b4cf96fd20f6762c Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Thu, 9 Apr 2026 16:03:22 +0100 Subject: [PATCH 03/14] refactor(subscribers): use WordPress Notice instead of Newspack Notice --- packages/components/src/notice/index.js | 4 +- packages/components/src/notice/style.scss | 4 -- .../subscribersDemo/flows/GuidedFixFlow.jsx | 8 ++-- .../flows/PaymentUpdateFlow.jsx | 8 ++-- .../subscribersDemo/flows/PlanChangeFlow.jsx | 15 ++++---- .../subscribersDemo/flows/RefundFlow.jsx | 38 +++++++++---------- .../subscribersDemo/flows/ResubscribeFlow.jsx | 33 +++++++--------- .../subscribersDemo/screens/PersonProfile.jsx | 32 +++++++++++----- .../subscribersDemo/screens/style.scss | 4 -- 9 files changed, 73 insertions(+), 73 deletions(-) diff --git a/packages/components/src/notice/index.js b/packages/components/src/notice/index.js index 3babc84e9f..337b8c3d8b 100644 --- a/packages/components/src/notice/index.js +++ b/packages/components/src/notice/index.js @@ -31,7 +31,6 @@ class Notice extends Component { isHelp, isSuccess, isWarning, - noMargin, noticeText, rawHTML, style = {}, @@ -45,8 +44,7 @@ class Notice extends Component { isHandoff && 'newspack-notice__is-handoff', isHelp && 'newspack-notice__is-help', isSuccess && 'newspack-notice__is-success', - isWarning && 'newspack-notice__is-warning', - noMargin && 'newspack-notice__no-margin' + isWarning && 'newspack-notice__is-warning' ); let noticeIcon; if ( isHelp ) { diff --git a/packages/components/src/notice/style.scss b/packages/components/src/notice/style.scss index 88bd64878c..408de62265 100644 --- a/packages/components/src/notice/style.scss +++ b/packages/components/src/notice/style.scss @@ -110,8 +110,4 @@ & + & { margin-top: -16px; } - - &__no-margin { - margin: 0; - } } diff --git a/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx b/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx index 630a1c504a..e8dbef647b 100644 --- a/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx +++ b/src/wizards/subscribersDemo/flows/GuidedFixFlow.jsx @@ -5,8 +5,8 @@ import { useState, createRoot } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { __experimentalHStack as HStack, __experimentalVStack as VStack, Snackbar } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis -import { Button, Modal, Notice, Waiting } from '../../../../packages/components/src'; +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; @@ -39,7 +39,9 @@ export default function GuidedFixFlow( { alert, onClose, onOpenPaymentUpdate } ) ) : ( - + + { 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.', diff --git a/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx index 569da4830d..b485eb3549 100644 --- a/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx +++ b/src/wizards/subscribersDemo/flows/PaymentUpdateFlow.jsx @@ -5,8 +5,8 @@ import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis -import { Button, Grid, Modal, Notice, TextControl, Waiting } from '../../../../packages/components/src'; +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; @@ -70,7 +70,9 @@ export default function PaymentUpdateFlow( { onClose, onComplete, paymentMethod { ! valid && number.length > 0 && ( - + + { __( 'Check the card details.', 'newspack-plugin' ) } + ) } + { `${ alert.title }. ${ alert.message }` } ) ) } diff --git a/src/wizards/subscribersDemo/screens/style.scss b/src/wizards/subscribersDemo/screens/style.scss index 9ff37733a7..ba1bbd937d 100644 --- a/src/wizards/subscribersDemo/screens/style.scss +++ b/src/wizards/subscribersDemo/screens/style.scss @@ -26,10 +26,6 @@ } .newspack-subscribers-demo__profile { - .newspack-notice__content .components-button { - margin-left: 8px; - } - .components-card__header, .newspack-card--core__header { padding: 16px; From 073aea91ecc282c7a69c9e018ba18d3914d4cb22 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Thu, 9 Apr 2026 16:47:31 +0100 Subject: [PATCH 04/14] style(subscribers): tighten WordPress Notice spacing in demo wizard --- src/wizards/subscribersDemo/screens/style.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/wizards/subscribersDemo/screens/style.scss b/src/wizards/subscribersDemo/screens/style.scss index ba1bbd937d..473c184cab 100644 --- a/src/wizards/subscribersDemo/screens/style.scss +++ b/src/wizards/subscribersDemo/screens/style.scss @@ -26,6 +26,15 @@ } .newspack-subscribers-demo__profile { + .components-notice__actions { + margin-top: 12px; + + .components-button { + margin-left: 0; + margin-right: 0; + } + } + .components-card__header, .newspack-card--core__header { padding: 16px; From 845286edee9026f5ea7dc32eda57821648614997 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Thu, 9 Apr 2026 17:15:43 +0100 Subject: [PATCH 05/14] style(subscribers): compact size for Notice action button --- src/wizards/subscribersDemo/screens/style.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wizards/subscribersDemo/screens/style.scss b/src/wizards/subscribersDemo/screens/style.scss index 473c184cab..2f14a9195f 100644 --- a/src/wizards/subscribersDemo/screens/style.scss +++ b/src/wizards/subscribersDemo/screens/style.scss @@ -30,8 +30,11 @@ margin-top: 12px; .components-button { + height: 32px; + line-height: 1; margin-left: 0; margin-right: 0; + padding: 0 12px; } } From 59c662f4bdcb51dd1ec1a9e14b78daec3a316432 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 29 Apr 2026 13:07:32 +0100 Subject: [PATCH 06/14] feat(subscribers): add private notes to subscriber profile --- .../subscribersDemo/data/mock-subscribers.js | 32 +++++++++ .../subscribersDemo/flows/NoteFlow.jsx | 64 +++++++++++++++++ .../subscribersDemo/screens/PersonProfile.jsx | 68 +++++++++++++++++-- .../subscribersDemo/screens/style.scss | 10 +++ 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 src/wizards/subscribersDemo/flows/NoteFlow.jsx diff --git a/src/wizards/subscribersDemo/data/mock-subscribers.js b/src/wizards/subscribersDemo/data/mock-subscribers.js index 33ad197aba..f2ed959fb3 100644 --- a/src/wizards/subscribersDemo/data/mock-subscribers.js +++ b/src/wizards/subscribersDemo/data/mock-subscribers.js @@ -268,3 +268,35 @@ export const SUBSCRIBERS = [ ...FIXTURES, ...EXTRAS ]; export function getSubscriberById( id ) { return SUBSCRIBERS.find( s => s.id === id ); } + +// PROTOTYPE ONLY: notes 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'; + +function readNotesStore() { + try { + return JSON.parse( window.localStorage.getItem( NOTES_STORAGE_KEY ) ) || {}; + } catch ( e ) { + return {}; + } +} + +export function getStoredNotes( id ) { + return readNotesStore()[ id ] || []; +} + +export function setStoredNotes( id, notes ) { + try { + const store = readNotesStore(); + if ( notes && notes.length ) { + store[ id ] = notes; + } else { + delete store[ id ]; + } + window.localStorage.setItem( NOTES_STORAGE_KEY, JSON.stringify( store ) ); + } catch ( e ) { + // Storage quota or disabled — fail silently in the prototype. + } +} 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 ( + + + + + + + + + + ); +} diff --git a/src/wizards/subscribersDemo/screens/PersonProfile.jsx b/src/wizards/subscribersDemo/screens/PersonProfile.jsx index 233de00b09..733978039b 100644 --- a/src/wizards/subscribersDemo/screens/PersonProfile.jsx +++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx @@ -16,7 +16,7 @@ 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 } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { __experimentalVStack as VStack, __experimentalHStack as HStack, Notice, Snackbar } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis /** * Internal dependencies. @@ -24,7 +24,7 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, Notice 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 } from '../data/mock-subscribers'; +import { getSubscriberById, getStoredNotes, setStoredNotes } from '../data/mock-subscribers'; import visaIcon from '../assets/cards/visa.svg'; import mastercardIcon from '../assets/cards/mastercard.svg'; @@ -45,6 +45,7 @@ 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'; const { useParams } = Router; @@ -108,14 +109,28 @@ function Row( { title, description, children, showDivider = true } ) { export default function PersonProfile() { const { id } = useParams(); - const initial = useMemo( () => getSubscriberById( id ), [ id ] ); + const initial = useMemo( () => { + const found = getSubscriberById( id ); + if ( ! found ) { + return found; + } + return { ...found, notes: getStoredNotes( id ) }; + }, [ id ] ); const [ subscriber, setSubscriber ] = useState( initial ); + + useEffect( () => { + if ( subscriber ) { + setStoredNotes( subscriber.id, subscriber.notes || [] ); + } + }, [ 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 ] ); @@ -141,6 +156,7 @@ export default function PersonProfile() { actions: [ { type: 'more', label: __( 'View in WooCommerce', 'newspack-plugin' ), action: () => {} }, { type: 'more', label: __( 'Edit WordPress user', 'newspack-plugin' ), action: () => {} }, + { type: 'more', label: __( 'Add private note', 'newspack-plugin' ), action: () => setModal( { kind: 'note' } ) }, { type: 'more', label: __( 'View raw subscription data', 'newspack-plugin' ), action: () => {} }, ], } ); @@ -155,11 +171,15 @@ export default function PersonProfile() { } const closeModal = () => setModal( null ); - const completeFlow = ( { type, message, mutate } ) => { + const completeFlow = ( { type, message, mutate, transient } ) => { if ( mutate ) { setSubscriber( prev => mutate( prev ) ); } - setFlash( { type, message } ); + if ( transient ) { + setSnackbar( { message } ); + } else { + setFlash( { type, message } ); + } setModal( null ); }; @@ -191,6 +211,37 @@ export default function PersonProfile() { ) ) } + { ( subscriber.notes || [] ).length > 0 && ( + + { subscriber.notes.map( note => ( + + +
{ note.text }
+ + + + +
+
+ ) ) } +
+ ) } + { subscriber.subscriptions.length === 0 ? ( @@ -380,6 +431,13 @@ export default function PersonProfile() { { modal?.kind === 'plan' && } { modal?.kind === 'resubscribe' && } { modal?.kind === 'payment' && } + { snackbar && ( +
+ setSnackbar( null ) }>{ snackbar.message } +
+ ) } + + { modal?.kind === 'note' && } { modal?.kind === 'guided' && ( Date: Wed, 29 Apr 2026 13:07:40 +0100 Subject: [PATCH 07/14] refactor(subscribers): replace info notices with paragraphs in flows --- .../subscribersDemo/flows/PlanChangeFlow.jsx | 4 +-- .../subscribersDemo/flows/RefundFlow.jsx | 4 +-- .../subscribersDemo/flows/ResubscribeFlow.jsx | 26 ++++++++++--------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx b/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx index b9deea8f85..77552dc3f1 100644 --- a/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx +++ b/src/wizards/subscribersDemo/flows/PlanChangeFlow.jsx @@ -60,7 +60,7 @@ export default function PlanChangeFlow( { subscription, onClose, onComplete } ) } ) ) } 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.', @@ -69,7 +69,7 @@ export default function PlanChangeFlow( { subscription, onClose, onComplete } ) subscription.nextBillingDate || __( 'next renewal', 'newspack-plugin' ), plan.amount.toFixed( 2 ) ) } - +

+ + +
+ + ); +} +``` + +`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. From 2efb9f7352c151df95aec3c607c7bed765d53022 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Fri, 1 May 2026 12:52:29 +0100 Subject: [PATCH 09/14] feat(subscribers): add tags and newsletter subscriptions to prototype --- .../subscribersDemo/data/mock-subscribers.js | 112 +++++++++++++++--- .../subscribersDemo/flows/TagsFlow.jsx | 51 ++++++++ .../subscribersDemo/screens/PersonProfile.jsx | 77 +++++++++++- .../screens/SubscriberList.jsx | 38 +++++- 4 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 src/wizards/subscribersDemo/flows/TagsFlow.jsx diff --git a/src/wizards/subscribersDemo/data/mock-subscribers.js b/src/wizards/subscribersDemo/data/mock-subscribers.js index f2ed959fb3..ef8c52fbc1 100644 --- a/src/wizards/subscribersDemo/data/mock-subscribers.js +++ b/src/wizards/subscribersDemo/data/mock-subscribers.js @@ -26,6 +26,15 @@ export const PRINT_PLANS = [ 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 () { @@ -127,6 +136,8 @@ const FIXTURES = [ 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' }, @@ -150,6 +161,8 @@ const FIXTURES = [ 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' }, @@ -168,6 +181,8 @@ const FIXTURES = [ { 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' }, @@ -183,6 +198,8 @@ const FIXTURES = [ 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' }, @@ -199,6 +216,8 @@ const FIXTURES = [ 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' } ], }, ]; @@ -229,6 +248,27 @@ function makeRandom( i ) { }, ] : []; + 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, @@ -250,6 +290,8 @@ function makeRandom( i ) { }, ], alerts, + tags, + newsletters, orders: [ { id: 'ord_r' + i + '_1', @@ -265,38 +307,74 @@ 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 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. +// 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 readNotesStore() { +function readStore( key ) { try { - return JSON.parse( window.localStorage.getItem( NOTES_STORAGE_KEY ) ) || {}; + 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 readNotesStore()[ id ] || []; + return readStore( NOTES_STORAGE_KEY )[ id ] || []; } export function setStoredNotes( id, notes ) { - try { - const store = readNotesStore(); - if ( notes && notes.length ) { - store[ id ] = notes; - } else { - delete store[ id ]; - } - window.localStorage.setItem( NOTES_STORAGE_KEY, JSON.stringify( store ) ); - } catch ( e ) { - // Storage quota or disabled — fail silently in the prototype. + 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/TagsFlow.jsx b/src/wizards/subscribersDemo/flows/TagsFlow.jsx new file mode 100644 index 0000000000..dccaa51bd9 --- /dev/null +++ b/src/wizards/subscribersDemo/flows/TagsFlow.jsx @@ -0,0 +1,51 @@ +/** + * 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 ( + + + +

{ __( 'Tags are visible only to admins. Press Enter or comma to add.', 'newspack-plugin' ) }

+ + + + +
+
+ ); +} diff --git a/src/wizards/subscribersDemo/screens/PersonProfile.jsx b/src/wizards/subscribersDemo/screens/PersonProfile.jsx index 733978039b..17c445eb8c 100644 --- a/src/wizards/subscribersDemo/screens/PersonProfile.jsx +++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx @@ -16,7 +16,7 @@ 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 } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { __experimentalVStack as VStack, __experimentalHStack as HStack, Notice, Snackbar, ToggleControl } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis /** * Internal dependencies. @@ -24,7 +24,16 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, Notice, 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 } from '../data/mock-subscribers'; +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'; @@ -46,6 +55,7 @@ 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; @@ -114,7 +124,14 @@ export default function PersonProfile() { if ( ! found ) { return found; } - return { ...found, notes: getStoredNotes( id ) }; + 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 ); @@ -123,6 +140,18 @@ export default function PersonProfile() { 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 ); @@ -151,11 +180,19 @@ export default function PersonProfile() { { 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: () => {} }, ], @@ -310,6 +347,39 @@ export default function PersonProfile() { + + + + { 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 ? ( @@ -438,6 +508,7 @@ export default function PersonProfile() { ) } { modal?.kind === 'note' && } + { modal?.kind === 'tags' && } { modal?.kind === 'guided' && ( ( date ? dateI18n( getSettings().formats.date, date ) : '' ); @@ -18,7 +19,7 @@ const fmtDate = date => ( date ? dateI18n( getSettings().formats.date, date ) : */ import { Badge, DataViews, Router } from '../../../../packages/components/src'; import './style.scss'; -import { SUBSCRIBERS, DIGITAL_PLANS, PRINT_PLANS } from '../data/mock-subscribers'; +import { SUBSCRIBERS, DIGITAL_PLANS, PRINT_PLANS, ALL_TAGS, NEWSLETTERS } from '../data/mock-subscribers'; const { useHistory } = Router; @@ -103,6 +104,41 @@ export default function SubscriberList() { 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 || [] ).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, + }, ], [] ); From d30db4833279961b51172ccf344cc589e8398771 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Fri, 1 May 2026 14:20:28 +0100 Subject: [PATCH 10/14] fix(subscribers): use default badge level for tags --- src/wizards/subscribersDemo/screens/PersonProfile.jsx | 2 +- src/wizards/subscribersDemo/screens/SubscriberList.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wizards/subscribersDemo/screens/PersonProfile.jsx b/src/wizards/subscribersDemo/screens/PersonProfile.jsx index 17c445eb8c..1e2c016081 100644 --- a/src/wizards/subscribersDemo/screens/PersonProfile.jsx +++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx @@ -183,7 +183,7 @@ export default function PersonProfile() { { ( subscriber.tags || [] ).length > 0 && ( { subscriber.tags.map( t => ( - + ) ) } ) } diff --git a/src/wizards/subscribersDemo/screens/SubscriberList.jsx b/src/wizards/subscribersDemo/screens/SubscriberList.jsx index 98fd5c9c51..0b208decc1 100644 --- a/src/wizards/subscribersDemo/screens/SubscriberList.jsx +++ b/src/wizards/subscribersDemo/screens/SubscriberList.jsx @@ -113,7 +113,7 @@ export default function SubscriberList() { render: ( { item } ) => ( { ( item.tags || [] ).map( t => ( - + ) ) } ), From f53592e0899a50f28656076464ba512c600794f4 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Fri, 1 May 2026 14:21:07 +0100 Subject: [PATCH 11/14] fix(subscribers): drop redundant Newsletters row description --- src/wizards/subscribersDemo/screens/PersonProfile.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wizards/subscribersDemo/screens/PersonProfile.jsx b/src/wizards/subscribersDemo/screens/PersonProfile.jsx index 1e2c016081..12507f6a4e 100644 --- a/src/wizards/subscribersDemo/screens/PersonProfile.jsx +++ b/src/wizards/subscribersDemo/screens/PersonProfile.jsx @@ -347,7 +347,7 @@ export default function PersonProfile() {
- + { NEWSLETTERS.map( newsletter => { From c1822a33121cee72c83a2600c896a319a9db4942 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Fri, 1 May 2026 14:21:44 +0100 Subject: [PATCH 12/14] fix(subscribers): drop tags modal help text --- src/wizards/subscribersDemo/flows/TagsFlow.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wizards/subscribersDemo/flows/TagsFlow.jsx b/src/wizards/subscribersDemo/flows/TagsFlow.jsx index dccaa51bd9..f18a22b7c8 100644 --- a/src/wizards/subscribersDemo/flows/TagsFlow.jsx +++ b/src/wizards/subscribersDemo/flows/TagsFlow.jsx @@ -36,7 +36,6 @@ export default function TagsFlow( { tags = [], onClose, onComplete } ) { __next40pxDefaultSize __nextHasNoMarginBottom /> -

{ __( 'Tags are visible only to admins. Press Enter or comma to add.', 'newspack-plugin' ) }