Skip to content

feat: ✨ subscription plan - #232

Open
aushamim wants to merge 156 commits into
mainfrom
feat/subs-plans
Open

feat: ✨ subscription plan#232
aushamim wants to merge 156 commits into
mainfrom
feat/subs-plans

Conversation

@aushamim

@aushamim aushamim commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

shamsbd71 and others added 30 commits September 9, 2026 16:59
The four cancellation options sat on Settings -> Customers, mixed in with
everything else. They now have a page of their own under the WPSubscription
menu: the reason list customers pick from, with the flow-wide options beside
it.

Every option key is unchanged, so nothing that reads them moved -
Cancellation::get_settings() and ::get_reasons() remain the only readers and
needed no edit, and existing sites keep their saved values.

The settings GROUP had to change, and this is the part that would have bitten.
wp-admin/options.php writes null to every option registered in the group it is
handed but absent from the POST. Leaving these four in `wp_subscription_settings`
would mean saving Settings wiped them, and saving this page wiped every other
setting. They are registered in `wp_subscription_cancellation_settings` instead;
Pro registers its two into the same group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
One title per section: the tab already names the panel, so the card headings
and the reason field's own label went. The reason list gets the row treatment
the manage modal uses, numbered, so the page and the modal read as one thing.

The unlabelled field also fixes a gap: the label element is emitted with
whitespace, so `:empty` never matched it, and its `flex: 0 0 220px` became a
220px tall void once the row stacked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Confirming a cancellation emailed the store owner nothing. An active
subscription goes to `pe_cancelled`, no email class listened to that, and the
cron only finalises it up to a day later - so free sent nothing at all and Pro
sent its notice long after the fact.

Free now has its own admin notice, sent the moment the customer confirms. It
reports the reason and deliberately nothing identifying: no customer, no link
to the subscription. Those are Pro's, and the closing block says so.

Backing out is reported too. Keep subscription, the X, the overlay and Escape
were a pure client-side dismiss that discarded the retention signal entirely;
they now post a save, throttled to once a day per subscription so idly opening
and closing the modal cannot spam an inbox.

Both notices are WC_Email classes, so they carry an enable toggle, subject,
heading and recipient like every other email here - and both stand aside when
Pro is active rather than doubling up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The reasons customers give were only visible to Pro, at the bottom of a long
scroll, and free's preview did not mention cancellation at all. Reports now
opens on Overview with a Cancellation tab beside it, and free's preview fills
that tab with sample reasons so the feature is legible before buying.

The sample rows are shaped exactly like Pro's query result, so both render from
the same structure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Cancelling went straight to "why are you leaving". It now opens on an offer -
a percentage off, configured on a new Offers tab - with Claim discount and No
thanks, continue. Declining falls through to the reasons exactly as before, and
with no offer configured the modal is unchanged.

Free ships the step, the settings UI and the request guards, and asks for an
offer through `subscrpt_cancellation_offer`. It has nothing to give: the coupon
is Pro's, so without a listener the claim reports no offer and the customer
continues rather than being promised a discount that never arrives.

Also teaches the modal's CSS that `hidden` beats a class setting `display`.
Without it the step being swapped out stayed on screen under the one being
swapped in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The cancellation modal gave both choices equal weight - two solid buttons side
by side on the right. Keeping the subscription is now the button, on the right,
and confirming the cancellation is a quiet link on the left.

The offer step meant the opposite thing by "keep" and "confirm" - declining an
offer is the leaving action there - so it gets its own pair of classes rather
than inheriting a restyle that would have inverted it.

The existing BEM class names stay put; themes target them directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
feat: ✨ move cancellation settings to a Cancellation Flow page
feat: ✨ cancellation notices, a Cancellation report tab, and a retention offer
The notice thanked the user for "using Subscription for WooCommerce" — a
name the plugin no longer goes by — and then explained the dependency in
a sentence that was neither passive nor translatable, with "Woocommerce"
miscapitalised and a stray space inside the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
reorder_submenu() pushed Reports, Delivery and Health to positions
200-220 whenever pro was inactive, which overrode the intended order
entirely and rearranged the menu the moment a licence was activated. A
locked page now keeps its place and just carries its pro badge.

Dashboard becomes Overview, and the order is Overview, Plans, Delivery,
Subscriptions, Reports, Health, Integrations, Cancellation Flow,
Settings, License, Help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The detail page already spoke of a plan holding durations; the list, the
product tab and one REST error still called the same thing a "plan
group" and its durations "plans". Nested references follow the detail
page's vocabulary, so "its first plan" is now "its first duration".

Only the wording moves. The plan_group table and the /plans/groups
routes keep their names — the data model really does have groups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Digital products / Physical products / Installments were brand-tinted on
a card that is already bordered, tinted and icon-coloured in brand — four
oranges in one row. --neutral washes out against the selected card's
peach ground, so --muted is a shade deeper and holds on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Add Duration and Manage Products sat on the tab rule, held there by a
dashed filler span, so the page had two competing header lines. They now
line up with the plan title in a two-column header and the tab strip is
just tabs.

The rename form's Save and Cancel lose --sm: they were 30px beside a 36px
input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
An unselected tab drew a transparent 2px rule, so on any tab strip whose
list border is suppressed it read as having no underline at all. Muted
when unselected, subtle on hover, brand when selected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The Draft badge is an inline-flex box sitting on a text baseline, so it
inflated the first line: a draft row was 5px taller than an active one
and its content sat 2.5px below centre. Name and badge now get their own
flex row with the badge's height as a floor, so every card matches and
the padding reads even top and bottom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Flipping a duration active or draft called preventDefault and then
reloaded the whole page, so the switch sat on its old state for the
entire round trip and the page blinked. It read as a dead control.

The switch now moves on the click, dims while the write is in flight, and
the row updates in place — tooltip, next action and the Draft badge —
with a success notice above the header. A failure puts the switch back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Settings had a rail of broad categories and, inside each, a row of tabs —
two levels for ten groups, so a setting was two clicks and two mental
models deep. "All Settings" sat above the rail and stacked every panel on
one page, which gave every setting a second address and made the rail
beside it look like decoration.

Now: one rail, one panel each, named for the job rather than the
internals — Renewals, Payments, Switching & Upgrades, Customers,
Advanced. Groups are merged into them rather than mapped one-to-one, so
nothing is a section of a single option: health_queue joins the API
settings under Advanced, and everything the customer meets — their role,
guest checkout, the subscription quick view — is under Customers.

A section holding one group drops that group's heading, because the rail
item already says the same word. Empty sections are dropped too: most are
filled by pro, and free alone was showing rail items that opened onto
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The heading band is pulled out of the card's padding with a -20px top
margin so it caps the card. Now that a panel can stack several groups,
every heading after the first was dragged over the previous field's
description. One that is not the first band keeps the full-bleed width
but sits below what it follows, squared off and ruled on both edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Settings opened straight onto its rail, while Plans and Reports each lead
with a title, a line of description and a rule. It now matches: title
left, Save changes right, description under it.

That also fixes the alignment. Save used to sit in a row inside the
content column, which pushed the first panel below the rail beside it.
With the header lifted out, the rail and the panel share a top edge — and
the heading band's padding now matches the card's, so the first line of a
panel starts at the same height whether it opens on a heading or straight
into a field. All five sections line up to the pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Attaching, detaching, removing a product and both Save buttons all ended
in window.location.reload(). Every one of them threw the whole screen
away and rebuilt it — the flash the merchant sees, and the reason a price
save felt like a submit rather than an edit.

They now refresh only the Products panel, from a new
GET /plans/group-products/{id} that renders the same fragment the detail
page includes. Rebuilding PlanPresenter's shape and WooCommerce's money
formatting in JavaScript would have drifted from the template the first
time either changed; the server already knows how. Which products were
expanded and the scroll position are restored, so the panel comes back
where it was, and each action reports itself with a notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The picker is the plan's product list, not an add queue: unticking a
product takes it off the plan. Nothing said so, and the button read
"Connect Product" — singular, and one-way — which is the opposite of what
it does.

It is now "Save products", under a line explaining that ticked products
are sold on this plan, with a running tally in the footer so the effect
of a tick is visible before saving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The upgrade overlay on Reports, Delivery and Health is a full-viewport
modal, so it dimmed and blocked the admin bar and the whole admin menu.
A paywall that greys out Plugins and Users reads like the site broke, not
like a feature is locked.

A --contained modifier insets it to match #wpcontent's own margins —
tracking the folded menu and the mobile breakpoints — so WordPress stays
usable behind it. Only the pro lock takes the modifier; real dialogs
still cover the viewport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
Three admin modules had each grown their own copy of the same trio: a
fetch wrapper, a button-busy lock, and some way of reporting the result.
The wrappers had already drifted — product-plans handled 204, the others
would have thrown on it — and the reporting had not settled at all.

plans.js injected a banner above the page header. It pushed the content
down on arrival and pulled it back up four seconds later, so saving a
price partway down a product list jumped the page twice and the message
was off screen for both. Everywhere else a failure was a blocking
window.alert.

Now: WPSubsSave.bind({ restUrl, nonce, i18n }) gives a screen its api(),
busy() and notify(), and WPSubsToast puts every message in the same place
— bottom right, 6px radius, green tick or red cross, gone in four
seconds, out of the page's flow so nothing moves.

window.confirm stays where it is. A toast reports; it does not ask, and a
destructive action still needs a real answer.

The toast animates its slide and never its opacity: animation is not
guaranteed to run in a throttled tab, and a confirmation stuck at opacity
zero is worse than one that never moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The toggle set a meta field and nothing else. With it off, every plan
control and every classic setting below it stayed live: you could connect
a product to a plan, set prices and edit billing terms on a product that
was not being sold as a subscription at all.

Those regions are now dimmed and taken out of reach while the toggle is
off, and come back when it is on. The regions are found by walking from
the toolbar up to the panel and taking each level's other children, so
whatever Pro adds to this panel is gated too without it having to know.

`inert`, not `disabled`: these are product meta fields that post with the
product form, and a disabled field posts nothing — switching a
subscription off and saving would have wiped the stored terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
SUBSCRPT_VERSION is a build-time placeholder in a git checkout —
release.sh substitutes the real number when packaging — so every script
and stylesheet is served under the same, never-changing version string.
The browser keeps its cached copy through edit after edit, and a fix
already on disk and already being served appears not to work. That has
cost real debugging time, twice today.

A loader filter swaps the version for the file's mtime, but only while
the placeholder is still there: on a release build the version is a real
number and nothing changes. Filtering the loader rather than each
registration catches the screens that call wp_enqueue_script() directly
with a version of their own, which is most of them.

Required::enqueue_assets() already did this for one stylesheet, for the
same stated reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
In the price table the One-time purchase row showed Regular and Offer
inputs whether or not one-time purchase was switched on, so they read as
a second regular price sitting under the plan's own with nothing to say
they did not apply. The standalone card on the Plans screen already
revealed its body on the toggle; the two table rows did not.

Both rows now hide the prices until one-time is on, matching the card.
The row keeps its label and its toggle, so there is still something to
switch.

The handler moves to plan-forms.js, which both the Plans screen and the
product editor load, and now drives all three shapes — the card's body
included, so plans.js no longer carries its own copy. On the Plans row
the prices sit in two nested wrappers rather than one: the outer is the
card's read/edit cycle, the inner is the toggle, and a single element
carrying both conditions would have them fight over `display`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
It was the last row of a table headed "Duration", between two things that
bill on a cycle. It has no cycle: it is a different way to buy the same
product, and as a row it read as one more line of the plan's own pricing.

It now sits below the table in its own block, header and toggle across
the top and the prices under it — the shape the Plans screen already uses
for simple products. Everything stays inside the plan card, so the save
path picks it up unchanged; the hook is `data-subscrpt-onetime`, which
all three shapes now carry.

The price inputs were 62px wide in a 159px column. `.wpsubs-input` sets
`width: 100%`, and under auto table layout that is circular — the column
is sized from the input's intrinsic width, then the percentage resolves
against the result. An explicit width is what the column can measure, so
Regular Price and Offer Price are 120px in every row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
The card only exists when the product is connected to a plan, so the
"Connected" badge restated its own container. Next to it an icon on its
own opened the plan on the Plans screen, with nothing on screen to say
so — the destination lived in a title attribute.

The badge goes; the link gets its label. "Edit in Plan", icon and text,
in the row with the plan name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kRMCGPohY5YKUMEY1H6XG
fix: admin polish — menu order, plan wording, plan detail, Settings navigation, Products tab
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants