PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Admin / Product / Plans.php

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

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