PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Admin / Product / Plans.php

Plans.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.4.0, at includes/Admin/Product/Plans.php

1,102 lines 55.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Product-editor plan view (free).
4 *
5 * Renders, on the product editor Subscription tab, the plan(s) this product is
6 * connected to plus a connect control to attach it to a Recurring plan group at
7 * a per-product price. The classic `_subscrpt_*` meta inputs stay in the DOM
8 * (hidden) behind a "Switch to classic settings" toggle. Writes go through the
9 * wpsubscription/v1 REST API (see assets/js/admin/product-plans.js).
10 *
11 * Free mounts this for simple products; Pro also reuses render_toolbar() /
12 * render_plan_view() / render_modals() for variable products (product-level
13 * plan connection), with the per-variation classic fields behind the toggle.
14 *
15 * @package SpringDevs\Subscription\Admin\Product
16 */
17
18 namespace SpringDevs\Subscription\Admin\Product;
19
20 use SpringDevs\Subscription\Illuminate\Helper;
21 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
22
23 /**
24 * Product-editor plan view.
25 */
26 class Plans {
27
28 /**
29 * Register the mount point used when Pro renders the Subscription panel.
30 *
31 * With Pro active, Pro's `Admin/Product/Simple` renders the panel and fires
32 * `subscrpt_simple_plan_panel` at the top; free mounts its plan view there,
33 * toggling against Pro's classic fields (`.subscrpt-classic-fields`). With
34 * Pro inactive, free renders its own panel (see Admin\Product::subscription_forms).
35 */
36 public function __construct() {
37 add_action( 'subscrpt_simple_plan_panel', array( $this, 'render_mount' ) );
38 }
39
40 /**
41 * Mount the plan view inside Pro's Subscription panel.
42 *
43 * @param int $product_id Product being edited.
44 *
45 * @return void
46 */
47 public function render_mount( $product_id ) {
48 $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;
49 if ( ! $product || ! $product->is_type( 'simple' ) ) {
50 return;
51 }
52 ?>
53 <div data-subscrpt-product-plans data-product-id="<?php echo esc_attr( $product->get_id() ); ?>"<?php echo self::should_default_classic( $product ) ? ' data-subscrpt-default-classic="1"' : ''; ?> style="margin-bottom:16px;">
54 <?php self::render_toolbar( $product ); ?>
55 <div data-subscrpt-plan-view>
56 <?php self::render_plan_view( $product ); ?>
57 </div>
58 </div>
59 <?php
60 self::render_checkout_link_modal( $product );
61 self::render_modals();
62 }
63
64 /**
65 * Render the Create-Plan-Group + Add-Selling-Plan modals once per page, so
66 * merchants can build a new plan group + plan without leaving the product
67 * editor. Pro-only: free's product editor is attach-only. The modals sit
68 * outside the [data-subscrpt-plan-view] region so an in-place refresh never
69 * duplicates them. Driven by the shared plan-forms.js module.
70 *
71 * @return void
72 */
73 public static function render_modals() {
74 if ( ! function_exists( 'subscrpt_pro_activated' ) || ! subscrpt_pro_activated() ) {
75 return;
76 }
77 // modal-term.php expects a $plan (id + type); product-plans.js rewrites
78 // the modal's group id/type before opening it for a freshly made group.
79 $plan = array(
80 'id' => 0,
81 'type' => 'recurring',
82 );
83 require __DIR__ . '/../views/plans/modal-create.php';
84 require __DIR__ . '/../views/plans/modal-term.php';
85 }
86
87 /**
88 * Render the "Generate Checkout Link" modal for a plan-connected product.
89 *
90 * Builds a per-context (product / per-variation) list of the plans the product
91 * offers — plus a "One-time purchase" option when enabled — and embeds it as a
92 * JSON blob the modal JS uses to compose a direct add-to-cart or checkout link.
93 * The link carries `subscrpt_plan_id` (the term id) so PlanCheckout resolves the
94 * chosen plan; the empty id (one-time) omits it and relies on the bare-link
95 * fallback. Renders nothing when the product has no plan connections.
96 *
97 * @param \WC_Product|null $product Product being edited.
98 *
99 * @return void
100 */
101 public static function render_checkout_link_modal( $product ) {
102 if ( ! $product || empty( PlanRepository::get_product_connections( $product->get_id() ) ) ) {
103 return;
104 }
105
106 $is_variable = $product->is_type( 'variable' );
107 $product_id = $product->get_id();
108
109 // Plans a context offers, in the same order (and with the same ids) the
110 // storefront resolver / generated link will use. One-time first when on.
111 // One-time uses the sentinel value "onetime" (JS omits subscrpt_plan_id for
112 // it) so the adv-select still resolves a non-empty default label.
113 $build_plans = static function ( $vid, $one_time_enabled ) use ( $product_id ) {
114 $plans = array();
115 if ( $one_time_enabled ) {
116 $plans[] = array(
117 'value' => 'onetime',
118 'label' => __( 'One-time purchase', 'subscription' ),
119 );
120 }
121 foreach ( PlanRepository::resolve_for_product( $product_id, $vid ) as $subscrpt_row ) {
122 $plans[] = array(
123 'value' => (string) (int) $subscrpt_row['plan_id'],
124 'label' => $subscrpt_row['group_title'] . ' · ' . $subscrpt_row['plan_title'],
125 );
126 }
127 return $plans;
128 };
129
130 $contexts = array();
131 if ( $is_variable ) {
132 foreach ( $product->get_children() as $subscrpt_child_id ) {
133 $subscrpt_variation = wc_get_product( $subscrpt_child_id );
134 if ( ! $subscrpt_variation ) {
135 continue;
136 }
137 $subscrpt_vid = (int) $subscrpt_child_id;
138 $subscrpt_plans = $build_plans( $subscrpt_vid, 'yes' === $subscrpt_variation->get_meta( '_subscrpt_one_time_enabled' ) );
139 if ( empty( $subscrpt_plans ) ) {
140 continue;
141 }
142 // An "Any …" attribute stores an empty value; such a variation can't
143 // be resolved by id alone, so the checkout-link endpoint can't add it.
144 $subscrpt_attrs = $subscrpt_variation->get_variation_attributes();
145 $subscrpt_has_any = in_array( '', array_map( 'strval', $subscrpt_attrs ), true );
146 $contexts[] = array(
147 'vid' => $subscrpt_vid,
148 'label' => self::variation_display_name( $subscrpt_variation, $product->get_name() ),
149 'attrs' => (object) $subscrpt_attrs,
150 'hasAny' => $subscrpt_has_any,
151 'plans' => $subscrpt_plans,
152 );
153 }
154 } else {
155 $subscrpt_plans = $build_plans( 0, 'yes' === $product->get_meta( '_subscrpt_one_time_enabled' ) );
156 if ( ! empty( $subscrpt_plans ) ) {
157 $contexts[] = array(
158 'vid' => 0,
159 'label' => '',
160 'attrs' => (object) array(),
161 'plans' => $subscrpt_plans,
162 );
163 }
164 }
165
166 if ( empty( $contexts ) ) {
167 return;
168 }
169
170 // JS only needs the ids/attributes to compose the link; plans are rendered
171 // as adv-selects below, so keep the blob slim.
172 $subscrpt_ctx_data = array();
173 foreach ( $contexts as $subscrpt_ctx ) {
174 $subscrpt_ctx_data[] = array(
175 'vid' => $subscrpt_ctx['vid'],
176 'attrs' => $subscrpt_ctx['attrs'],
177 'hasAny' => ! empty( $subscrpt_ctx['hasAny'] ),
178 );
179 }
180
181 // Checkout link: WooCommerce Blocks' native checkout-link endpoint, which
182 // empties the cart, adds `products=ID:QTY`, and redirects to checkout. It
183 // resolves via the pretty path `/checkout-link/` when permalinks are on,
184 // or the registered `?checkout-link=true` query var when they are plain.
185 $subscrpt_checkout_base = get_option( 'permalink_structure' )
186 ? home_url( 'checkout-link/' )
187 : add_query_arg( 'checkout-link', 'true', home_url( '/' ) );
188
189 $subscrpt_data = array(
190 'productId' => $product_id,
191 'type' => $is_variable ? 'variable' : 'simple',
192 // Add-to-cart link: WooCommerce's classic `?add-to-cart=` on the site root.
193 'cartBase' => home_url( '/' ),
194 'checkoutLinkBase' => $subscrpt_checkout_base,
195 'contexts' => $subscrpt_ctx_data,
196 );
197
198 $subscrpt_field = 'display:flex;flex-direction:column;gap:6px;';
199 $subscrpt_field_l = 'font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:var(--wpsubs-text-muted);';
200 $subscrpt_default_vid = (int) $contexts[0]['vid'];
201 ?>
202 <div class="wpsubs-modal" id="subscrpt-checkout-link" hidden data-subscrpt-checkout-data="<?php echo esc_attr( wp_json_encode( $subscrpt_data ) ); ?>">
203 <div class="wpsubs-modal__backdrop" data-wpsubs-modal-close></div>
204 <div class="wpsubs-modal__dialog" style="max-width:520px;">
205 <div class="wpsubs-modal__head" style="align-items:flex-start;">
206 <div style="display:flex;flex-direction:column;gap:8px;min-width:0;">
207 <h2 class="wpsubs-modal__title" style="display:flex;align-items:center;gap:8px;margin:0;">
208 <span class="dashicons dashicons-admin-links" style="color:var(--wpsubs-brand,#ff4d00);font-size:18px;width:18px;height:18px;line-height:1;"></span>
209 <?php esc_html_e( 'Generate Checkout Link', 'subscription' ); ?>
210 </h2>
211 <p style="margin:0;font-size:12.5px;line-height:1.6;color:var(--wpsubs-text-muted);">
212 <?php esc_html_e( 'Share a link that drops this product into a customer’s cart with the chosen plan already selected.', 'subscription' ); ?>
213 </p>
214 </div>
215 <button type="button" class="wpsubs-modal__close" data-wpsubs-modal-close aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>" style="flex:0 0 auto;">&times;</button>
216 </div>
217 <?php // overflow:visible lets the adv-select dropdowns escape the body (short modal, no scroll needed). ?>
218 <div class="wpsubs-modal__body" style="display:flex;flex-direction:column;gap:18px;overflow:visible;">
219 <div style="<?php echo esc_attr( $subscrpt_field ); ?>">
220 <span style="<?php echo esc_attr( $subscrpt_field_l ); ?>"><?php esc_html_e( 'Link type', 'subscription' ); ?></span>
221 <?php
222 wpsubs_render_adv_select(
223 array(
224 'name' => 'subscrpt_checkout_type',
225 'value' => 'checkout',
226 'options' => array(
227 array(
228 'value' => 'cart',
229 'label' => __( 'Add to cart', 'subscription' ),
230 ),
231 array(
232 'value' => 'checkout',
233 'label' => __( 'Checkout', 'subscription' ),
234 ),
235 ),
236 'attrs' => array(
237 'data-subscrpt-checkout-type' => '1',
238 'style' => 'width:100%;',
239 ),
240 )
241 );
242 ?>
243 </div>
244
245 <div style="display:grid;grid-template-columns:<?php echo $is_variable ? '1fr 1fr' : '1fr'; ?>;gap:14px;align-items:start;">
246 <?php if ( $is_variable ) : ?>
247 <div style="<?php echo esc_attr( $subscrpt_field ); ?>">
248 <span style="<?php echo esc_attr( $subscrpt_field_l ); ?>"><?php esc_html_e( 'Variation', 'subscription' ); ?></span>
249 <?php
250 $subscrpt_var_opts = array();
251 foreach ( $contexts as $subscrpt_ctx ) {
252 $subscrpt_var_opts[] = array(
253 'value' => (string) $subscrpt_ctx['vid'],
254 'label' => $subscrpt_ctx['label'],
255 );
256 }
257 wpsubs_render_adv_select(
258 array(
259 'name' => 'subscrpt_checkout_variation',
260 'value' => (string) $subscrpt_default_vid,
261 'options' => $subscrpt_var_opts,
262 'attrs' => array(
263 'data-subscrpt-checkout-variation' => '1',
264 'style' => 'width:100%;',
265 ),
266 )
267 );
268 ?>
269 </div>
270 <?php endif; ?>
271
272 <div style="<?php echo esc_attr( $subscrpt_field ); ?>">
273 <span style="<?php echo esc_attr( $subscrpt_field_l ); ?>"><?php esc_html_e( 'Plan', 'subscription' ); ?></span>
274 <?php
275 foreach ( $contexts as $subscrpt_ctx ) :
276 // Separate one-time from recurring with a divider so the two
277 // read as distinct sets (one-time is always listed first).
278 $subscrpt_onetime = array();
279 $subscrpt_recurring = array();
280 foreach ( $subscrpt_ctx['plans'] as $subscrpt_p ) {
281 if ( 'onetime' === $subscrpt_p['value'] ) {
282 $subscrpt_onetime[] = $subscrpt_p;
283 } else {
284 $subscrpt_recurring[] = $subscrpt_p;
285 }
286 }
287 $subscrpt_plan_opts = $subscrpt_onetime;
288 // Divider between the one-time option and the recurring plans.
289 if ( ! empty( $subscrpt_onetime ) && ! empty( $subscrpt_recurring ) ) {
290 $subscrpt_plan_opts[] = [ 'divider' => true ];
291 }
292 foreach ( $subscrpt_recurring as $subscrpt_p ) {
293 $subscrpt_plan_opts[] = $subscrpt_p;
294 }
295 $subscrpt_plan_default = isset( $subscrpt_ctx['plans'][0]['value'] ) ? $subscrpt_ctx['plans'][0]['value'] : '';
296 ?>
297 <div data-subscrpt-checkout-plan-wrap data-vid="<?php echo esc_attr( $subscrpt_ctx['vid'] ); ?>" <?php echo (int) $subscrpt_ctx['vid'] === $subscrpt_default_vid ? '' : 'hidden'; ?>>
298 <?php
299 wpsubs_render_adv_select(
300 array(
301 'name' => 'subscrpt_checkout_plan_' . $subscrpt_ctx['vid'],
302 'value' => $subscrpt_plan_default,
303 'options' => $subscrpt_plan_opts,
304 'attrs' => array(
305 'data-subscrpt-checkout-plan' => '1',
306 'style' => 'width:100%;',
307 ),
308 )
309 );
310 ?>
311 </div>
312 <?php endforeach; ?>
313 </div>
314 </div>
315
316 <div style="<?php echo esc_attr( $subscrpt_field ); ?>">
317 <span style="<?php echo esc_attr( $subscrpt_field_l ); ?>"><?php esc_html_e( 'Link preview', 'subscription' ); ?></span>
318 <div style="display:flex;align-items:stretch;gap:0;border:1px solid var(--wpsubs-border,#e5e7eb);border-radius:var(--wpsubs-radius,8px);overflow:hidden;background:var(--wpsubs-surface-muted,#f6f7f7);">
319 <input type="text" data-subscrpt-checkout-preview readonly style="flex:1 1 auto;min-width:0;border:0;background:transparent;padding:9px 11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:var(--wpsubs-text);outline:none;" />
320 <button type="button" data-subscrpt-checkout-copy title="<?php esc_attr_e( 'Copy link', 'subscription' ); ?>" style="flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;border:0;border-left:1px solid var(--wpsubs-border,#e5e7eb);background:transparent;color:var(--wpsubs-text-muted);cursor:pointer;padding:0 12px;align-self:stretch;">
321 <span class="dashicons dashicons-admin-page" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
322 </button>
323 </div>
324 <?php // URL structure reference for the selected link type (JS toggles). ?>
325 <span data-subscrpt-checkout-ref="cart" style="font-size:10.5px;line-height:1.5;color:var(--wpsubs-text-subtle,#8a8f98);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;"><strong style="font-family:inherit;"><?php esc_html_e( 'Ref.', 'subscription' ); ?></strong> /?add-to-cart=PRODUCT_ID&amp;subscrpt_plan_id=PLAN_ID</span>
326 <span data-subscrpt-checkout-ref="checkout" hidden style="font-size:10.5px;line-height:1.5;color:var(--wpsubs-text-subtle,#8a8f98);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;"><strong style="font-family:inherit;"><?php esc_html_e( 'Ref.', 'subscription' ); ?></strong> /checkout-link/?products=PRODUCT_ID:QTY&amp;subscrpt_plan_id=PLAN_ID</span>
327 </div>
328
329 <div data-subscrpt-checkout-warning style="display:none;gap:8px;align-items:flex-start;padding:10px 12px;border-radius:var(--wpsubs-radius,8px);background:#fcf3d9;border:1px solid #f0d98a;font-size:12px;line-height:1.55;color:#8a6d1a;">
330 <span class="dashicons dashicons-warning" style="flex:0 0 auto;font-size:16px;width:16px;height:16px;color:#b9902a;"></span>
331 <span><?php esc_html_e( 'This variation uses an “Any” attribute, so the checkout link can’t pre-select it. Use the “Add to cart” link type instead.', 'subscription' ); ?></span>
332 </div>
333 </div>
334 <div class="wpsubs-modal__footer">
335 <button type="button" class="wpsubs-btn wpsubs-btn--outline" data-wpsubs-modal-close><?php esc_html_e( 'Close', 'subscription' ); ?></button>
336 <button type="button" class="wpsubs-btn wpsubs-btn--primary" data-subscrpt-checkout-copy>
337 <span class="dashicons dashicons-admin-page" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
338 <?php esc_html_e( 'Copy link', 'subscription' ); ?>
339 </button>
340 </div>
341 </div>
342 </div>
343 <?php
344 }
345
346 /**
347 * Whether the editor should open in classic (simple) mode by default.
348 *
349 * A product carrying legacy classic subscription settings but not tied to any
350 * plan is an "old" product — open it in the classic view it was configured in.
351 * Fresh products (and plan-connected ones) default to the plan view.
352 *
353 * @param \WC_Product|null $product Product being edited.
354 *
355 * @return bool
356 */
357 public static function should_default_classic( $product = null ) {
358 if ( ! $product ) {
359 return false;
360 }
361
362 // A product is "classic/legacy" only when it is enabled as a classic
363 // subscription (`_subscrpt_enabled`) but tied to no plan. `_subscrpt_timing_option`
364 // is written with a default on every product save, so it can't distinguish a
365 // real classic subscription from a plain product — never key on it here.
366 if ( $product->is_type( 'variable' ) ) {
367 $has_classic = false;
368 foreach ( $product->get_children() as $variation_id ) {
369 if ( function_exists( 'subscrpt_product_has_plan' ) && subscrpt_product_has_plan( $product->get_id(), $variation_id ) ) {
370 return false;
371 }
372 // Ever plan-connected: stay in plan mode.
373 if ( 'yes' === get_post_meta( $variation_id, '_subscrpt_plan_connected_before', true ) ) {
374 return false;
375 }
376 if ( ! $has_classic ) {
377 $variation = wc_get_product( $variation_id );
378 if ( $variation && (bool) $variation->get_meta( '_subscrpt_enabled' ) ) {
379 $has_classic = true;
380 }
381 }
382 }
383 return $has_classic;
384 }
385
386 if ( function_exists( 'subscrpt_product_has_plan' ) && subscrpt_product_has_plan( $product->get_id() ) ) {
387 return false;
388 }
389 // Ever plan-connected: stay in plan mode.
390 if ( 'yes' === $product->get_meta( '_subscrpt_plan_connected_before' ) ) {
391 return false;
392 }
393 return (bool) $product->get_meta( '_subscrpt_enabled' );
394 }
395
396 /**
397 * Render the view toggle bar (plan view ⇄ classic settings).
398 *
399 * @param \WC_Product|null $product Product being edited (null when no product context).
400 *
401 * @return void
402 */
403 public static function render_toolbar( $product = null ) {
404 // A connected plan enables the subscription by default (until saved off).
405 $subscrpt_enabled = $product ? subscrpt_is_subscription_enabled( $product->get_id() ) : false;
406 // Show the checkout-link generator only when the product is tied to a plan.
407 $subscrpt_has_plans = $product && ! empty( PlanRepository::get_product_connections( $product->get_id() ) );
408 ?>
409 <div data-subscrpt-plan-toolbar style="display:flex;align-items:center;gap:12px;margin:0 0 14px;flex-wrap:wrap;">
410 <strong style="margin-left:10px;font-size:13px;color:var(--wpsubs-text);"><?php esc_html_e( 'WPSubscription', 'subscription' ); ?></strong>
411 <?php // Variable products enable per variation (toggle lives on each variation card); simple products enable at the product level here. ?>
412 <?php if ( ! ( $product && $product->is_type( 'variable' ) ) ) : ?>
413 <label class="wpsubs-settings-toggle-label" style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--wpsubs-text-muted);" title="<?php esc_attr_e( 'Sell this product as a subscription', 'subscription' ); ?>">
414 <input type="checkbox" class="wpsubs-toggle" id="subscrpt_enable" name="subscrpt_enable" value="yes" <?php checked( $subscrpt_enabled ); ?> />
415 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
416 <span><?php esc_html_e( 'Enable subscription', 'subscription' ); ?></span>
417 </label>
418 <?php endif; ?>
419 <span style="flex:1 1 auto;"></span>
420 <?php if ( $subscrpt_has_plans ) : ?>
421 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-wpsubs-modal-open="subscrpt-checkout-link">
422 <span class="dashicons dashicons-admin-links" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
423 <?php esc_html_e( 'Generate Checkout Link', 'subscription' ); ?>
424 </button>
425 <?php endif; ?>
426 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-subscrpt-show-classic>
427 <?php esc_html_e( 'Switch to simple mode', 'subscription' ); ?>
428 </button>
429 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-subscrpt-show-plan style="display:none;">
430 <span class="dashicons dashicons-arrow-left-alt2" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
431 <?php esc_html_e( 'Back to plan mode', 'subscription' ); ?>
432 </button>
433 </div>
434 <?php
435 }
436
437 /**
438 * Render the plan view: connected plan(s) + connect control.
439 *
440 * @param \WC_Product $product Simple product being edited.
441 *
442 * @return void
443 */
444 public static function render_plan_view( $product ) {
445 $connections = PlanRepository::get_product_connections( $product->get_id() );
446 $connected = self::group_connections( $connections );
447 $available = self::available_groups( array_keys( $connected ) );
448 // Pro adds "create plan group / plan" shortcuts here; free is attach-only.
449 $pro_active = function_exists( 'subscrpt_pro_activated' ) && subscrpt_pro_activated();
450
451 // One-time purchase (simple products): the product's native WooCommerce
452 // price + an enabled flag, rendered as a row after the plan rows (matching
453 // the variable-product layout), not a separate card. Null hides the row.
454 $subscrpt_simple_ot = ( ! $product->is_type( 'variable' ) )
455 ? array(
456 'enabled' => 'yes' === $product->get_meta( '_subscrpt_one_time_enabled' ),
457 'regular' => \SpringDevs\Subscription\Admin\PlanPresenter::one_time_price( $product ),
458 'offer' => (string) $product->get_sale_price(),
459 )
460 : null;
461 ?>
462 <style>
463 /* Neutralise WooCommerce's floated-label layout inside the toolbar +
464 plan view (not the classic fields, which keep the native layout). */
465 #sdevs_subscription_options [data-subscrpt-plan-toolbar] label,
466 #sdevs_subscription_options [data-subscrpt-plan-view] label {
467 float: none;
468 width: auto;
469 margin: 0;
470 }
471 /* Compact adv-select triggers on this tab only (not the modals, which
472 are relocated to <body>). */
473 #sdevs_subscription_options [data-subscrpt-plan-view] .wpsubs-adv-select__trigger {
474 height: 30px;
475 min-height: 30px;
476 padding-top: 0;
477 padding-bottom: 0;
478 }
479 /* Keep the group picker a fixed width and ellipsis a long plan name. */
480 #sdevs_subscription_options [data-subscrpt-connect-group] {
481 width: 240px;
482 max-width: 100%;
483 }
484 #sdevs_subscription_options [data-subscrpt-connect-group] .wpsubs-adv-select__trigger {
485 min-width: 0;
486 }
487 #sdevs_subscription_options [data-subscrpt-connect-group] .wpsubs-adv-select__label {
488 overflow: hidden;
489 text-overflow: ellipsis;
490 white-space: nowrap;
491 }
492 </style>
493
494 <?php if ( ! empty( $connected ) ) : ?>
495 <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:18px;">
496 <?php
497 foreach ( $connected as $subscrpt_gid => $group ) :
498 $all_terms = PlanRepository::get_plans( $subscrpt_gid );
499 ?>
500 <div class="wpsubs-table-card" data-subscrpt-plan-card data-group-id="<?php echo esc_attr( $subscrpt_gid ); ?>" style="padding:12px 14px;">
501 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
502 <span style="flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:var(--wpsubs-radius,8px);background:var(--wpsubs-brand-light,#fff1eb);color:var(--wpsubs-brand,#ff4d00);">
503 <span class="dashicons dashicons-update"></span>
504 </span>
505 <div style="flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;">
506 <strong style="font-size:13.5px;color:var(--wpsubs-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?php echo esc_html( $group['title'] ); ?></strong>
507 <?php
508 // No "Connected" badge: the card only exists when the product is
509 // connected, so the badge restated its own container. The link keeps
510 // its label instead — an icon alone did not say where it went.
511 ?>
512 <a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-subscription-plans&view=detail&plan=' . (int) $subscrpt_gid ) ); ?>" target="_blank" rel="noopener" title="<?php esc_attr_e( 'Open this plan on the Plans screen', 'subscription' ); ?>" style="flex:0 0 auto;display:inline-flex;align-items:center;gap:4px;font-size:12.5px;color:var(--wpsubs-text-muted);text-decoration:none;white-space:nowrap;">
513 <span class="dashicons dashicons-external" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
514 <?php esc_html_e( 'Edit in Plan', 'subscription' ); ?>
515 </a>
516 </div>
517 <div style="flex:0 0 auto;display:flex;align-items:center;gap:8px;">
518 <?php if ( ! $product->is_type( 'variable' ) ) : ?>
519 <?php // Simple: one Edit/Save for the whole card. Variable edits per variation (buttons live on each variation sub-card). ?>
520 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-subscrpt-edit-prices>
521 <span class="dashicons dashicons-edit" style="font-size:14px;width:14px;height:14px;line-height:1;"></span>
522 <?php esc_html_e( 'Edit prices', 'subscription' ); ?>
523 </button>
524 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-subscrpt-cancel-prices style="display:none;"><?php esc_html_e( 'Cancel', 'subscription' ); ?></button>
525 <button type="button" class="wpsubs-btn wpsubs-btn--primary wpsubs-btn--sm" data-subscrpt-save-prices style="display:none;"><?php esc_html_e( 'Save', 'subscription' ); ?></button>
526 <?php endif; ?>
527 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm wpsubs-btn--danger" data-subscrpt-detach data-relation-ids="<?php echo esc_attr( implode( ',', $group['relation_ids'] ) ); ?>">
528 <?php esc_html_e( 'Detach', 'subscription' ); ?>
529 </button>
530 </div>
531 </div>
532
533 <?php if ( $product->is_type( 'variable' ) ) : ?>
534 <div style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
535 <?php self::render_variation_price_cards( $product, $all_terms, $group['term_map_by_vid'], false ); ?>
536 </div>
537 <?php else : ?>
538 <!-- Read view: offered plans + one-time as chips. -->
539 <div class="subscrpt-pe-view" style="display:flex;flex-wrap:wrap;gap:6px;margin:12px -14px 0;padding:12px 14px 0;border-top:1px solid var(--wpsubs-border,#e5e7eb);">
540 <?php self::render_variation_summary( $all_terms, $group['term_map'], $subscrpt_simple_ot, 0 ); ?>
541 </div>
542
543 <!-- Edit view: plan rows + the one-time row (mirrors the Plans page). -->
544 <div class="subscrpt-pe-edit" style="display:none;margin-top:12px;">
545 <?php self::render_price_table( $all_terms, $group['term_map'], false, 0, $subscrpt_simple_ot ); ?>
546 </div>
547 <?php endif; ?>
548 </div>
549 <?php endforeach; ?>
550 </div>
551 <?php else : ?>
552 <div style="display:flex;align-items:flex-start;gap:10px;padding:14px;margin-bottom:18px;border-radius:8px;background:var(--wpsubs-surface-muted,#f9fafb);border:1px solid var(--wpsubs-border,#e5e7eb);">
553 <span class="dashicons dashicons-info-outline" style="flex:0 0 auto;color:var(--wpsubs-text-subtle);"></span>
554 <span style="font-size:13px;color:var(--wpsubs-text-muted);line-height:1.5;">
555 <?php esc_html_e( 'Not connected to any plan yet. Connect this product to a plan to sell it as a subscription.', 'subscription' ); ?>
556 </span>
557 </div>
558 <?php endif; ?>
559
560 <?php if ( empty( $available ) && empty( $connected ) ) : ?>
561 <?php if ( $pro_active ) : ?>
562 <div class="wpsubs-table-card" style="padding:14px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
563 <span style="flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:var(--wpsubs-radius,8px);background:var(--wpsubs-brand-light,#fff1eb);color:var(--wpsubs-brand,#ff4d00);">
564 <span class="dashicons dashicons-admin-links"></span>
565 </span>
566 <div style="flex:1 1 auto;min-width:140px;">
567 <div style="font-size:13.5px;font-weight:600;color:var(--wpsubs-text);line-height:1.3;"><?php esc_html_e( 'No plans yet', 'subscription' ); ?></div>
568 <div style="font-size:12px;color:var(--wpsubs-text-muted);line-height:1.4;"><?php esc_html_e( 'Create a plan and its first duration, then connect this product to it.', 'subscription' ); ?></div>
569 </div>
570 <button type="button" class="wpsubs-btn wpsubs-btn--primary" data-wpsubs-modal-open="subscrpt-create-plan">
571 <span class="dashicons dashicons-plus-alt2" style="font-size:16px;width:16px;height:16px;line-height:1;"></span>
572 <?php esc_html_e( 'New plan', 'subscription' ); ?>
573 </button>
574 </div>
575 <?php else : ?>
576 <p style="margin:0;font-size:13px;color:var(--wpsubs-text-muted);">
577 <?php esc_html_e( 'No plans available.', 'subscription' ); ?>
578 <a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-subscription-plans' ) ); ?>"><?php esc_html_e( 'Create a plan', 'subscription' ); ?></a>
579 </p>
580 <?php endif; ?>
581 <?php elseif ( ! empty( $available ) && empty( $connected ) ) : ?>
582 <div class="wpsubs-table-card" data-subscrpt-connect-card style="padding:14px;">
583 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
584 <span style="flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:var(--wpsubs-radius,8px);background:var(--wpsubs-brand-light,#fff1eb);color:var(--wpsubs-brand,#ff4d00);">
585 <span class="dashicons dashicons-admin-links"></span>
586 </span>
587 <div style="flex:1 1 auto;min-width:140px;">
588 <div style="font-size:13.5px;font-weight:600;color:var(--wpsubs-text);line-height:1.3;"><?php esc_html_e( 'Connect to a plan', 'subscription' ); ?></div>
589 <div style="font-size:12px;color:var(--wpsubs-text-muted);line-height:1.4;"><?php esc_html_e( 'Pick a plan, then set the prices for each of its durations.', 'subscription' ); ?></div>
590 </div>
591 <div style="flex:0 0 auto;display:flex;align-items:center;gap:8px;">
592 <?php
593 $options = array();
594 foreach ( $available as $group ) {
595 $options[] = array(
596 'value' => (string) $group['id'],
597 'label' => $group['title'],
598 );
599 }
600 wpsubs_render_adv_select(
601 array(
602 'name' => 'subscrpt_connect_group',
603 'placeholder' => __( 'Select a plan…', 'subscription' ),
604 'options' => $options,
605 'attrs' => array( 'data-subscrpt-connect-group' => '1' ),
606 'align' => 'right',
607 )
608 );
609 ?>
610 <?php if ( $pro_active ) : ?>
611 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-wpsubs-modal-open="subscrpt-create-plan" title="<?php esc_attr_e( 'Create a new plan', 'subscription' ); ?>">
612 <span class="dashicons dashicons-plus-alt2" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
613 <?php esc_html_e( 'New', 'subscription' ); ?>
614 </button>
615 <?php endif; ?>
616 </div>
617 </div>
618
619 <div data-subscrpt-connect-divider style="display:none;border-top:1px dashed var(--wpsubs-border-strong,#d1d5db);margin-top:14px;"></div>
620
621 <!-- One price table per available group; shown when its group is picked. -->
622 <?php foreach ( $available as $group ) : ?>
623 <?php $subscrpt_group_terms = PlanRepository::get_plans( (int) $group['id'] ); ?>
624 <div data-subscrpt-plan-card data-connect-block data-group-id="<?php echo esc_attr( $group['id'] ); ?>" style="display:none;margin-top:14px;">
625 <?php if ( empty( $subscrpt_group_terms ) ) : ?>
626 <div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:20px 16px;text-align:center;">
627 <p style="margin:0;color:var(--wpsubs-text-subtle);font-size:13px;">
628 <?php esc_html_e( 'This plan has no durations yet. Add a duration before connecting this product.', 'subscription' ); ?>
629 </p>
630 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center;">
631 <?php if ( $pro_active ) : ?>
632 <button type="button" class="wpsubs-btn wpsubs-btn--primary wpsubs-btn--sm" data-subscrpt-create-plan-for="<?php echo esc_attr( $group['id'] ); ?>">
633 <span class="dashicons dashicons-plus-alt2" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
634 <?php esc_html_e( 'Create plan', 'subscription' ); ?>
635 </button>
636 <?php endif; ?>
637 <a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-subscription-plans&view=detail&plan=' . (int) $group['id'] ) ); ?>" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" target="_blank" rel="noopener">
638 <span class="dashicons dashicons-external" style="font-size:14px;width:14px;height:14px;line-height:1;"></span>
639 <?php esc_html_e( 'Manage plans', 'subscription' ); ?>
640 </a>
641 </div>
642 </div>
643 <?php else : ?>
644 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
645 <span style="flex:1 1 auto;"></span>
646 <button type="button" class="wpsubs-btn wpsubs-btn--primary wpsubs-btn--sm" data-subscrpt-save-prices>
647 <span class="dashicons dashicons-yes" style="font-size:15px;width:15px;height:15px;line-height:1;"></span>
648 <?php esc_html_e( 'Save', 'subscription' ); ?>
649 </button>
650 </div>
651 <?php if ( $product->is_type( 'variable' ) ) : ?>
652 <div style="display:flex;flex-direction:column;gap:10px;">
653 <?php self::render_variation_price_cards( $product, $subscrpt_group_terms, array(), true ); ?>
654 </div>
655 <?php else : ?>
656 <div>
657 <?php self::render_price_table( $subscrpt_group_terms, array(), true, 0, $subscrpt_simple_ot ); ?>
658 </div>
659 <?php endif; ?>
660 <?php endif; ?>
661 </div>
662 <?php endforeach; ?>
663 </div>
664 <?php endif; ?>
665
666 <?php if ( ! $product->is_type( 'variable' ) ) : ?>
667 <?php
668 // No name: the classic `subscrpt_user_cancel` select is what the product
669 // save reads. product-plans.js keeps the two in step, so there is one
670 // value on the form whichever view is showing. Variable products set it
671 // per variation instead.
672 ?>
673 <div class="wpsubs-table-card" style="padding:12px 14px;margin-top:18px;">
674 <label class="wpsubs-settings-toggle-label" style="display:flex;align-items:center;gap:10px;cursor:pointer;">
675 <input type="checkbox" class="wpsubs-toggle" data-subscrpt-plan-user-cancel <?php checked( Helper::can_user_cancel( $product->get_meta( '_subscrpt_user_cancel' ) ) ); ?> />
676 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
677 <span style="display:flex;flex-direction:column;gap:2px;">
678 <strong style="font-size:13px;color:var(--wpsubs-text);"><?php esc_html_e( 'Allow User Cancellation', 'subscription' ); ?></strong>
679 <span style="font-size:12px;color:var(--wpsubs-text-muted);"><?php esc_html_e( 'Allow subscribers to cancel their subscription manually from their account dashboard.', 'subscription' ); ?></span>
680 </span>
681 </label>
682 </div>
683 <?php endif; ?>
684
685 <?php
686 /**
687 * Fires at the end of the product-editor plan view. Pro hooks this to add
688 * more plan-related inputs to the Subscription tab.
689 *
690 * @param \WC_Product $product The product being edited.
691 */
692 do_action( 'subscrpt_product_plan_view', $product );
693 ?>
694 <?php
695 }
696
697 /**
698 * Render the per-term price table (Regular / Offer + an enable toggle)
699 * shared by the edit and connect views. One-time purchase is product-level
700 * (its own section), not part of this per-plan table.
701 *
702 * @param array $all_terms All terms of the group (from PlanRepository::get_plans()).
703 * @param array $term_map Map plan_id => relation values (empty for the connect view).
704 * @param bool $connect Connect view: no relation ids, every term enabled by default.
705 * @param int $vid Variation id these rows price (0 = the product itself).
706 * @param array|null $one_time One-time purchase values for this variation
707 * (enabled, regular, offer) — appended as a row;
708 * null omits it (simple products use a separate card).
709 *
710 * @return void
711 */
712 protected static function render_price_table( $all_terms, $term_map, $connect, $vid = 0, $one_time = null ) {
713 ?>
714 <table class="wpsubs-table">
715 <thead>
716 <tr>
717 <th><?php esc_html_e( 'Duration', 'subscription' ); ?></th>
718 <th>
719 <?php esc_html_e( 'Regular Price', 'subscription' ); ?>
720 <?php echo wp_kses_post( wpsubs_render_hint( __( 'The recurring price charged each billing cycle.', 'subscription' ) ) ); ?>
721 </th>
722 <th>
723 <?php esc_html_e( 'Offer Price', 'subscription' ); ?>
724 <?php echo wp_kses_post( wpsubs_render_hint( __( 'A discounted recurring price shown in place of the regular price. Leave empty for no discount.', 'subscription' ) ) ); ?>
725 </th>
726 <th><?php esc_html_e( 'Actions', 'subscription' ); ?></th>
727 </tr>
728 </thead>
729 <tbody>
730 <?php
731 foreach ( $all_terms as $subscrpt_term ) :
732 $subscrpt_tid = (int) $subscrpt_term['id'];
733 $subscrpt_map = $connect ? null : ( isset( $term_map[ $subscrpt_tid ] ) ? $term_map[ $subscrpt_tid ] : null );
734 $subscrpt_relid = $subscrpt_map ? $subscrpt_map['relation_id'] : '';
735 $subscrpt_enabled = $connect ? true : ( $subscrpt_map && empty( $subscrpt_map['excluded'] ) );
736 $subscrpt_vals = $subscrpt_map ? $subscrpt_map : array(
737 'regular' => '',
738 'offer' => '',
739 );
740 ?>
741 <tr data-subscrpt-term-row data-plan-id="<?php echo esc_attr( $subscrpt_tid ); ?>" data-vid="<?php echo esc_attr( $vid ); ?>" data-relation-id="<?php echo esc_attr( $subscrpt_relid ); ?>">
742 <td>
743 <div style="font-size:12.5px;color:var(--wpsubs-text);"><?php echo esc_html( $subscrpt_term['title'] ); ?></div>
744 <?php $subscrpt_meta = \SpringDevs\Subscription\Admin\PlanPresenter::term_meta( $subscrpt_term ); ?>
745 <?php if ( ! empty( $subscrpt_meta ) ) : ?>
746 <div style="margin-top:3px;font-size:11px;color:var(--wpsubs-text-muted);line-height:1.5;">
747 <?php echo esc_html( implode( ' · ', $subscrpt_meta ) ); ?>
748 </div>
749 <?php endif; ?>
750 </td>
751 <td><input type="number" min="0" step="0.01" class="wpsubs-input" data-field="regular_price" value="<?php echo esc_attr( $subscrpt_vals['regular'] ); ?>" placeholder="0.00" style="width:120px;max-width:100%;" /></td>
752 <td><input type="number" min="0" step="0.01" class="wpsubs-input" data-field="sale_price" value="<?php echo esc_attr( $subscrpt_vals['offer'] ); ?>" placeholder="0.00" style="width:120px;max-width:100%;" /></td>
753 <td>
754 <div style="display:inline-flex;align-items:center;gap:8px;">
755 <label class="wpsubs-settings-toggle-label" style="display:inline-flex;align-items:center;" title="<?php esc_attr_e( 'Enable this plan for the product', 'subscription' ); ?>">
756 <input type="checkbox" class="wpsubs-toggle" data-subscrpt-term-toggle <?php checked( $subscrpt_enabled ); ?> />
757 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
758 </label>
759 <?php if ( ! $connect && '' !== trim( (string) $subscrpt_vals['regular'] ) ) : ?>
760 <button type="button" class="wpsubs-icon-action" data-subscrpt-copy-checkout data-plan-id="<?php echo esc_attr( $subscrpt_tid ); ?>" data-vid="<?php echo esc_attr( $vid ); ?>" title="<?php esc_attr_e( 'Copy checkout link', 'subscription' ); ?>" aria-label="<?php esc_attr_e( 'Copy checkout link', 'subscription' ); ?>">
761 <span class="dashicons dashicons-admin-links"></span>
762 </button>
763 <?php endif; ?>
764 </div>
765 </td>
766 </tr>
767 <?php endforeach; ?>
768 </tbody>
769 </table>
770
771 <?php
772 /*
773 * One-time purchase sits below the table, not in it. It is not a duration:
774 * it has no billing cycle, and as a row it read as one more line of the
775 * plan's own pricing. Its own block says it is a separate way to buy.
776 */
777 if ( is_array( $one_time ) ) :
778 $subscrpt_ot_on = ! empty( $one_time['enabled'] );
779 ?>
780 <div data-subscrpt-onetime data-vid="<?php echo esc_attr( $vid ); ?>" style="margin-top:12px;border:1px solid var(--wpsubs-border,#e5e7eb);border-radius:var(--wpsubs-radius,8px);background:var(--wpsubs-surface,#fff);">
781 <div style="display:flex;align-items:center;gap:10px;padding:10px 12px;">
782 <span class="dashicons dashicons-cart" style="flex:0 0 auto;font-size:15px;width:15px;height:15px;color:var(--wpsubs-text-subtle);"></span>
783 <strong style="font-size:12.5px;color:var(--wpsubs-text);"><?php esc_html_e( 'One-time purchase', 'subscription' ); ?></strong>
784 <?php echo wp_kses_post( wpsubs_render_hint( __( 'A single, non-recurring purchase at the product’s regular WooCommerce price.', 'subscription' ) ) ); ?>
785 <span style="flex:1 1 auto;"></span>
786 <label class="wpsubs-settings-toggle-label" style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--wpsubs-text-muted);" title="<?php esc_attr_e( 'Offer this product for one-time purchase', 'subscription' ); ?>">
787 <input type="checkbox" class="wpsubs-toggle" data-subscrpt-onetime-enable <?php checked( $subscrpt_ot_on ); ?> />
788 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
789 <span><?php esc_html_e( 'Allow one-time purchase', 'subscription' ); ?></span>
790 </label>
791 <?php if ( ! $connect && '' !== trim( (string) $one_time['regular'] ) ) : ?>
792 <button type="button" class="wpsubs-icon-action" data-subscrpt-copy-checkout data-plan-id="onetime" data-vid="<?php echo esc_attr( $vid ); ?>" title="<?php esc_attr_e( 'Copy checkout link', 'subscription' ); ?>" aria-label="<?php esc_attr_e( 'Copy checkout link', 'subscription' ); ?>">
793 <span class="dashicons dashicons-admin-links"></span>
794 </button>
795 <?php endif; ?>
796 </div>
797 <?php
798 // The gate clears the inline `display` to reveal, which falls back
799 // to a div's `block` — so the flex row is a child, not this element.
800 ?>
801 <div data-subscrpt-onetime-price style="padding:12px;border-top:1px solid var(--wpsubs-border,#e5e7eb);<?php echo $subscrpt_ot_on ? '' : 'display:none;'; ?>">
802 <div style="display:flex;gap:16px;">
803 <label style="display:block;font-size:12px;color:var(--wpsubs-text-muted);">
804 <span style="display:block;margin-bottom:4px;"><?php esc_html_e( 'Regular Price', 'subscription' ); ?></span>
805 <input type="number" min="0" step="0.01" class="wpsubs-input" data-ot-field="price" value="<?php echo esc_attr( $one_time['regular'] ); ?>" placeholder="0.00" style="width:120px;max-width:100%;" />
806 </label>
807 <label style="display:block;font-size:12px;color:var(--wpsubs-text-muted);">
808 <span style="display:block;margin-bottom:4px;"><?php esc_html_e( 'Offer Price', 'subscription' ); ?></span>
809 <input type="number" min="0" step="0.01" class="wpsubs-input" data-ot-field="offer" value="<?php echo esc_attr( $one_time['offer'] ); ?>" placeholder="<?php esc_attr_e( 'No offer', 'subscription' ); ?>" style="width:120px;max-width:100%;" />
810 </label>
811 </div>
812 </div>
813 </div>
814 <?php endif; ?>
815 <?php
816 }
817
818 /**
819 * Render one price sub-card per variation for a variable product, so each
820 * variation can carry its own plan prices under a single connected group
821 * ("one group, price per variation"). A variation with no relation of its
822 * own inherits the product-level (vid 0) seed values; Save then creates that
823 * variation's own relation instead of editing the shared seed.
824 *
825 * @param \WC_Product $product Variable product being edited.
826 * @param array $all_terms All terms of the group.
827 * @param array $map_by_vid Relation values keyed [vid][plan_id].
828 * @param bool $connect Connect flow: tables shown immediately, no
829 * relation ids yet.
830 *
831 * @return void
832 */
833 protected static function render_variation_price_cards( $product, $all_terms, $map_by_vid, $connect ) {
834 $seed = isset( $map_by_vid[0] ) ? $map_by_vid[0] : array();
835
836 foreach ( $product->get_children() as $subscrpt_child_id ) {
837 $subscrpt_vid = (int) $subscrpt_child_id;
838 $subscrpt_variation = function_exists( 'wc_get_product' ) ? wc_get_product( $subscrpt_vid ) : null;
839 if ( ! $subscrpt_variation ) {
840 continue;
841 }
842
843 // Per-variation term map: the variation's own relations, falling back to
844 // the product-level seed (with a blank relation id so Save creates this
845 // variation's relation instead of editing the shared seed).
846 $subscrpt_vmap = array();
847 if ( ! $connect ) {
848 $subscrpt_own = isset( $map_by_vid[ $subscrpt_vid ] ) ? $map_by_vid[ $subscrpt_vid ] : array();
849 foreach ( $all_terms as $subscrpt_term ) {
850 $subscrpt_pid = (int) $subscrpt_term['id'];
851 if ( isset( $subscrpt_own[ $subscrpt_pid ] ) ) {
852 $subscrpt_vmap[ $subscrpt_pid ] = $subscrpt_own[ $subscrpt_pid ];
853 } elseif ( isset( $seed[ $subscrpt_pid ] ) ) {
854 $subscrpt_seed_row = $seed[ $subscrpt_pid ];
855 $subscrpt_seed_row['relation_id'] = '';
856 $subscrpt_vmap[ $subscrpt_pid ] = $subscrpt_seed_row;
857 }
858 }
859 }
860
861 // One-time purchase is the variation's own native WooCommerce price
862 // (regular = one-time, sale = offer) + an enabled flag, saved with this
863 // variation's plan prices on Save.
864 $subscrpt_one_time = array(
865 'enabled' => 'yes' === $subscrpt_variation->get_meta( '_subscrpt_one_time_enabled' ),
866 'regular' => \SpringDevs\Subscription\Admin\PlanPresenter::one_time_price( $subscrpt_variation ),
867 'offer' => (string) $subscrpt_variation->get_sale_price(),
868 );
869
870 // "Enable subscription" is per variation. Connecting a plan group turns
871 // it on by default (until saved off); the connect toggle starts checked
872 // and Save persists the variation's _subscrpt_enabled meta.
873 $subscrpt_var_on = $connect ? true : subscrpt_is_subscription_enabled( $product->get_id(), $subscrpt_vid );
874 ?>
875 <div data-subscrpt-variation-card data-variation-id="<?php echo esc_attr( $subscrpt_vid ); ?>" <?php echo $connect ? '' : 'data-subscrpt-plan-card'; ?> style="border:1px solid var(--wpsubs-border,#e5e7eb);border-radius:8px;background:var(--wpsubs-surface,#fff);">
876 <div style="display:flex;align-items:center;gap:10px;padding:10px 12px;">
877 <span class="dashicons dashicons-image-filter" style="flex:0 0 auto;font-size:15px;width:15px;height:15px;color:var(--wpsubs-text-subtle);"></span>
878 <strong style="font-size:12.5px;color:var(--wpsubs-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?php echo esc_html( self::variation_display_name( $subscrpt_variation, $product->get_name() ) ); ?></strong>
879 <label class="wpsubs-settings-toggle-label" style="flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--wpsubs-text-muted);" title="<?php esc_attr_e( 'Sell this variation as a subscription', 'subscription' ); ?>">
880 <input type="checkbox" class="wpsubs-toggle" data-subscrpt-var-enable <?php checked( $subscrpt_var_on ); ?> <?php disabled( ! $connect ); ?> />
881 <span class="wpsubs-toggle-ui" aria-hidden="true"></span>
882 <span><?php esc_html_e( 'Enable subscription', 'subscription' ); ?></span>
883 </label>
884 <span style="flex:1 1 auto;"></span>
885 <?php if ( ! $connect ) : ?>
886 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-subscrpt-edit-prices>
887 <span class="dashicons dashicons-edit" style="font-size:14px;width:14px;height:14px;line-height:1;"></span>
888 <?php esc_html_e( 'Edit prices', 'subscription' ); ?>
889 </button>
890 <button type="button" class="wpsubs-btn wpsubs-btn--outline wpsubs-btn--sm" data-subscrpt-cancel-prices style="display:none;"><?php esc_html_e( 'Cancel', 'subscription' ); ?></button>
891 <button type="button" class="wpsubs-btn wpsubs-btn--primary wpsubs-btn--sm" data-subscrpt-save-prices style="display:none;"><?php esc_html_e( 'Save', 'subscription' ); ?></button>
892 <?php endif; ?>
893 </div>
894 <?php if ( $connect ) : ?>
895 <div style="padding:2px 0;border-top:1px solid var(--wpsubs-border,#e5e7eb);">
896 <?php self::render_price_table( $all_terms, array(), true, $subscrpt_vid, $subscrpt_one_time ); ?>
897 </div>
898 <?php else : ?>
899 <div class="subscrpt-pe-view" style="display:flex;flex-wrap:wrap;gap:6px;padding:10px 12px 12px;border-top:1px solid var(--wpsubs-border,#e5e7eb);">
900 <?php self::render_variation_summary( $all_terms, $subscrpt_vmap, $subscrpt_one_time, $subscrpt_vid ); ?>
901 </div>
902 <div class="subscrpt-pe-edit" style="display:none;padding:2px 0;border-top:1px solid var(--wpsubs-border,#e5e7eb);">
903 <?php self::render_price_table( $all_terms, $subscrpt_vmap, false, $subscrpt_vid, $subscrpt_one_time ); ?>
904 </div>
905 <?php endif; ?>
906 </div>
907 <?php
908 }
909 }
910
911 /**
912 * Render the read-mode price summary chips for one variation (each plan term
913 * with its price, plus a one-time chip when enabled), mirroring the simple
914 * product's connected-card chips.
915 *
916 * @param array $all_terms All terms of the group.
917 * @param array $vmap This variation's plan_id => relation values.
918 * @param array|null $one_time One-time values (enabled, regular, offer).
919 * @param int $vid Variation id (0 for simple), for the checkout link.
920 *
921 * @return void
922 */
923 protected static function render_variation_summary( $all_terms, $vmap, $one_time, $vid = 0 ) {
924 foreach ( $all_terms as $subscrpt_term ) :
925 $subscrpt_tid = (int) $subscrpt_term['id'];
926 $subscrpt_row = isset( $vmap[ $subscrpt_tid ] ) ? $vmap[ $subscrpt_tid ] : null;
927 $subscrpt_off = ! $subscrpt_row || ! empty( $subscrpt_row['excluded'] );
928 $subscrpt_reg = $subscrpt_row ? (string) $subscrpt_row['regular'] : '';
929 $subscrpt_offer = $subscrpt_row ? (string) $subscrpt_row['offer'] : '';
930 ?>
931 <span style="display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:20px;background:var(--wpsubs-surface-muted,#f9fafb);border:1px solid var(--wpsubs-border,#e5e7eb);font-size:11.5px;color:var(--wpsubs-text-muted);<?php echo $subscrpt_off ? 'opacity:0.55;' : ''; ?>">
932 <?php echo esc_html( $subscrpt_term['title'] ); ?>
933 <?php self::price_pair( $subscrpt_reg, $subscrpt_offer ); ?>
934 <?php if ( $subscrpt_off ) : ?>
935 <span style="font-style:italic;">(<?php esc_html_e( 'off', 'subscription' ); ?>)</span>
936 <?php elseif ( '' !== trim( $subscrpt_reg ) ) : ?>
937 <?php self::render_chip_copy_link( (string) $subscrpt_tid, $vid ); ?>
938 <?php endif; ?>
939 </span>
940 <?php
941 endforeach;
942
943 if ( is_array( $one_time ) && ! empty( $one_time['enabled'] ) ) :
944 ?>
945 <span style="display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:20px;background:var(--wpsubs-surface-muted,#f9fafb);border:1px solid var(--wpsubs-border,#e5e7eb);font-size:11.5px;color:var(--wpsubs-text-muted);">
946 <span class="dashicons dashicons-cart" style="font-size:13px;width:13px;height:13px;line-height:1;color:var(--wpsubs-text-subtle);"></span>
947 <?php esc_html_e( 'One-time', 'subscription' ); ?>
948 <?php self::price_pair( (string) $one_time['regular'], (string) $one_time['offer'] ); ?>
949 <?php if ( '' !== trim( (string) $one_time['regular'] ) ) : ?>
950 <?php self::render_chip_copy_link( 'onetime', $vid ); ?>
951 <?php endif; ?>
952 </span>
953 <?php
954 endif;
955 }
956
957 /**
958 * Compact "Copy checkout link" icon button for a summary chip.
959 *
960 * @param string $plan_id Plan (term) id, or the 'onetime' sentinel.
961 * @param int $vid Variation id (0 for simple).
962 *
963 * @return void
964 */
965 protected static function render_chip_copy_link( $plan_id, $vid ) {
966 ?>
967 <button type="button" data-subscrpt-copy-checkout data-plan-id="<?php echo esc_attr( $plan_id ); ?>" data-vid="<?php echo esc_attr( $vid ); ?>" title="<?php esc_attr_e( 'Copy checkout link', 'subscription' ); ?>" aria-label="<?php esc_attr_e( 'Copy checkout link', 'subscription' ); ?>" style="display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:var(--wpsubs-text-subtle);cursor:pointer;line-height:1;">
968 <span class="dashicons dashicons-admin-links" style="font-size:13px;width:13px;height:13px;line-height:1;"></span>
969 </button>
970 <?php
971 }
972
973 /**
974 * Echo a price for a summary chip: the offer price when one is set (with the
975 * regular price shown struck-through beside it), otherwise the regular price.
976 *
977 * @param string $regular Regular price (raw).
978 * @param string $offer Offer / sale price (raw, '' when none).
979 *
980 * @return void
981 */
982 protected static function price_pair( $regular, $offer ) {
983 $money = '\SpringDevs\Subscription\Admin\PlanPresenter';
984 if ( '' !== $offer ) :
985 ?>
986 <?php if ( '' !== $regular ) : ?>
987 <span style="text-decoration:line-through;opacity:0.7;"><?php echo esc_html( $money::money( (float) $regular ) ); ?></span>
988 <?php endif; ?>
989 <span style="color:var(--wpsubs-text);font-weight:600;"><?php echo esc_html( $money::money( (float) $offer ) ); ?></span>
990 <?php
991 elseif ( '' !== $regular ) :
992 ?>
993 <span style="color:var(--wpsubs-text);font-weight:600;"><?php echo esc_html( $money::money( (float) $regular ) ); ?></span>
994 <?php
995 endif;
996 }
997
998 /**
999 * Human label for a variation (its attribute values, e.g. "Large, Red"),
1000 * falling back to the WC formatted name or the parent name.
1001 *
1002 * @param \WC_Product $variation Variation product.
1003 * @param string $parent_name Parent product name.
1004 *
1005 * @return string
1006 */
1007 protected static function variation_display_name( $variation, $parent_name ) {
1008 $attributes = array_filter( array_values( $variation->get_variation_attributes() ) );
1009 if ( $attributes ) {
1010 return implode( ', ', $attributes );
1011 }
1012 $name = $variation->get_name();
1013 return $name ? $name : $parent_name;
1014 }
1015
1016 /**
1017 * Group flat relation rows by plan group.
1018 *
1019 * @param array $connections Rows from PlanRepository::get_product_connections().
1020 *
1021 * @return array<int,array> Keyed by group id: title, terms[], relation_ids[].
1022 */
1023 protected static function group_connections( $connections ) {
1024 $by_group = array();
1025
1026 foreach ( $connections as $row ) {
1027 $gid = (int) $row['plan_group_id'];
1028
1029 if ( ! isset( $by_group[ $gid ] ) ) {
1030 $by_group[ $gid ] = array(
1031 'title' => $row['group_title'],
1032 'terms' => array(),
1033 'relation_ids' => array(),
1034 'term_map' => array(),
1035 'term_map_by_vid' => array(),
1036 );
1037 }
1038
1039 $vid = (int) $row['vid'];
1040 $data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
1041 $price = isset( $data['regular_price'] ) ? (string) $data['regular_price'] : '';
1042 $excluded = ! empty( $row['exclude'] );
1043 $entry = array(
1044 'relation_id' => (int) $row['relation_id'],
1045 'excluded' => $excluded,
1046 'regular' => $price,
1047 'offer' => isset( $data['sale_price'] ) ? (string) $data['sale_price'] : '',
1048 );
1049 $by_group[ $gid ]['relation_ids'][] = (int) $row['relation_id'];
1050 $by_group[ $gid ]['term_map_by_vid'][ $vid ][ (int) $row['plan_id'] ] = $entry;
1051
1052 // Product-level (vid 0) rows drive the simple-product table + the read
1053 // summary chips; variation rows are consumed through term_map_by_vid.
1054 if ( 0 === $vid ) {
1055 $by_group[ $gid ]['terms'][] = array(
1056 'title' => $row['plan_title'],
1057 'price' => $price,
1058 'relation_id' => (int) $row['relation_id'],
1059 'excluded' => $excluded,
1060 );
1061 $by_group[ $gid ]['term_map'][ (int) $row['plan_id'] ] = $entry;
1062 }
1063 }
1064
1065 return $by_group;
1066 }
1067
1068 /**
1069 * Plan groups not already connected to this product.
1070 *
1071 * Free is Recurring-only, so it offers only Recurring groups; Pro unlocks
1072 * Subscribe & Save and Installments, so all plan types are selectable.
1073 *
1074 * @param array $connected_ids Group ids already connected.
1075 *
1076 * @return array<int,array> Each: id, title.
1077 */
1078 protected static function available_groups( $connected_ids ) {
1079 $pro_active = function_exists( 'subscrpt_pro_activated' ) && subscrpt_pro_activated();
1080 $recurring = PlanRepository::type_to_int( 'recurring' );
1081 $available = array();
1082
1083 foreach ( PlanRepository::get_groups() as $group ) {
1084 if ( ! $pro_active && (int) $group['type'] !== $recurring ) {
1085 continue;
1086 }
1087 if ( 'trash' === ( $group['status'] ?? '' ) ) {
1088 continue;
1089 }
1090 if ( in_array( (int) $group['id'], array_map( 'intval', $connected_ids ), true ) ) {
1091 continue;
1092 }
1093 $available[] = array(
1094 'id' => (int) $group['id'],
1095 'title' => $group['title'],
1096 );
1097 }
1098
1099 return $available;
1100 }
1101 }
1102