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' ) }

+ + + + +
+
+ ); +} +``` + +`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' + ) } + + + + + + + ) } + + ); +} 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/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' ) } + + ) } + + + + + + ) } + + ); +} 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 ) + ) } +

+ + + + +
+ ) } +
+ ); +} 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 ) } +

+ + + + +
+ ) } +
+ ); +} 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' + ) } +

+ ) } + + + + +
+ ); +} + +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' ) }

+ + + + + +
+ ); + } 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 ( + + + + + + + + + + ); +} 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 }
+ + + + +
+
+ ) ) } +
+ ) } + + + + { 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 ? ( + <> + + + + ) : ( + + ) } + +
+
+ ); + } ) + ) } +
+
+ + + + + { 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' ) }
+ + + +
+
+ ) : ( + <> + { 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' && } + { 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; + } + } +}