PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
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 / Frontend / Plans.php

Plans.php in Subscriptions for WooCommerce with Stripe Recurring Payments trunk, at includes/Frontend/Plans.php

308 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Storefront plan selector (free).
4 *
5 * Renders the plan selector — a radio card per plan group, with the group's
6 * terms as buttons — on a simple product tied to a plan, and carries the chosen
7 * plan-term id onto the add-to-cart request. Guarded by `subscrpt_plan_offered()`:
8 * with no tied plan this class does nothing and the classic price suffix stands.
9 *
10 * Runs only when Pro is inactive (see Frontend::__construct). Pro ships a superset
11 * on the same hooks — One-Time card, discount badges, variable products and the
12 * Subscribe & Save / Installments plan types.
13 *
14 * The storefront never calls REST; plan data is read directly through
15 * `PlanRepository::resolve_for_product()` (object cache → DB).
16 *
17 * @package SpringDevs\Subscription
18 */
19
20 namespace SpringDevs\Subscription\Frontend;
21
22 use SpringDevs\Subscription\Admin\PlanPresenter;
23 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
24
25 /**
26 * Frontend plan selector for simple products.
27 */
28 class Plans {
29
30 /**
31 * Register storefront hooks.
32 */
33 public function __construct() {
34 // Runs after Frontend\Product::change_price_html (priority 10) so the plan
35 // price replaces the classic suffix rather than appending to it.
36 add_filter( 'woocommerce_get_price_html', array( $this, 'plan_price_html' ), 20, 2 );
37 add_action( 'woocommerce_before_add_to_cart_button', array( $this, 'render_selector' ) );
38 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
39 }
40
41 /**
42 * Whether the plan selector should render on this request.
43 *
44 * @return bool
45 */
46 private function should_render() {
47 return function_exists( 'is_product' ) && is_product();
48 }
49
50 /**
51 * Whether a simple product offers a subscription: tied to a plan AND
52 * subscription-enabled. Free is simple-only.
53 *
54 * @param mixed $product Product object.
55 *
56 * @return bool
57 */
58 private function product_has_plans( $product ) {
59 return $product instanceof \WC_Product
60 && $product->is_type( 'simple' )
61 && subscrpt_plan_offered( $product->get_id() );
62 }
63
64 /**
65 * Enqueue selector assets on product pages that expose plans.
66 *
67 * @return void
68 */
69 public function enqueue_assets() {
70 if ( ! $this->should_render() ) {
71 return;
72 }
73
74 // global $product is not set yet at wp_enqueue_scripts; resolve from the query.
75 $product = wc_get_product( get_queried_object_id() );
76 if ( ! $this->product_has_plans( $product ) ) {
77 return;
78 }
79
80 wp_enqueue_style(
81 'subscrpt_plans_selector_css',
82 SUBSCRPT_ASSETS . '/css/frontend/plans.css',
83 array(),
84 SUBSCRPT_VERSION
85 );
86
87 wp_enqueue_script(
88 'subscrpt_plans_selector_js',
89 SUBSCRPT_ASSETS . '/js/frontend/plans.js',
90 array(),
91 SUBSCRPT_VERSION,
92 true
93 );
94 }
95
96 /**
97 * Replace the price HTML with the resolved plan price when a plan is tied;
98 * otherwise return the price unchanged (classic suffix stands).
99 *
100 * A single tied plan shows the offer price (regular struck-through when
101 * discounted); multiple plans show a "min – max" range across every term. No
102 * cadence — the selector below lists each term's cadence.
103 *
104 * @param string $price_html Price HTML.
105 * @param \WC_Product|mixed $product Product.
106 *
107 * @return string
108 */
109 public function plan_price_html( $price_html, $product ) {
110 if ( ! $this->product_has_plans( $product ) ) {
111 return $price_html;
112 }
113
114 $rows = PlanRepository::resolve_for_product( $product->get_id() );
115 if ( empty( $rows ) ) {
116 return $price_html;
117 }
118
119 // Single plan: offer price, with the regular struck-through when discounted.
120 if ( 1 === count( $rows ) ) {
121 $row = $rows[0];
122 $data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
123 $regular = isset( $data['regular_price'] ) && '' !== $data['regular_price'] ? (float) $data['regular_price'] : null;
124 $offer = $this->term_price( $row );
125
126 return ( null !== $regular && $offer < $regular )
127 ? '<del aria-hidden="true">' . wc_price( $regular ) . '</del> <ins>' . wc_price( $offer ) . '</ins>'
128 : wc_price( $offer );
129 }
130
131 // Multiple plans: a min–max range across every attached term.
132 $prices = array();
133 foreach ( $rows as $row ) {
134 $prices[] = $this->term_price( $row );
135 }
136
137 $min = min( $prices );
138 $max = max( $prices );
139
140 // All plans the same price is not a range; show nothing (selector lists each).
141 return $min === $max
142 ? ''
143 : wc_price( $min ) . ' &ndash; ' . wc_price( $max );
144 }
145
146 /**
147 * Render the plan selector inside the add-to-cart form.
148 *
149 * @return void
150 */
151 public function render_selector() {
152 if ( ! $this->should_render() ) {
153 return;
154 }
155
156 global $product;
157 if ( ! $this->product_has_plans( $product ) ) {
158 return;
159 }
160
161 $groups = $this->build_groups( $product );
162 if ( empty( $groups ) ) {
163 return;
164 }
165
166 wc_get_template(
167 'product/plan-selector.php',
168 array( 'groups' => $groups ),
169 'subscription',
170 SUBSCRPT_TEMPLATES
171 );
172 }
173
174 /**
175 * Build the selector groups for a simple product from resolved plan data.
176 *
177 * One entry per plan group, each with its terms (id, label, price, note)
178 * and a discount badge when its offer price beats the regular one, followed
179 * by the One-Time card when the merchant offers one.
180 *
181 * @param \WC_Product $product Simple product.
182 *
183 * @return array
184 */
185 private function build_groups( $product ) {
186 $resolved = PlanRepository::resolve_for_product( $product->get_id() );
187 if ( empty( $resolved ) ) {
188 return array();
189 }
190
191 $groups = array();
192 foreach ( $resolved as $row ) {
193 $gid = (int) $row['plan_group_id'];
194
195 if ( ! isset( $groups[ $gid ] ) ) {
196 $groups[ $gid ] = array(
197 'id' => 'grp_' . $gid,
198 'type' => PlanRepository::type_to_string( (int) $row['group_type'] ),
199 'label' => $row['group_title'],
200 'price' => '',
201 'terms' => array(),
202 'badge' => '',
203 'discount_percent' => 0,
204 'pcts' => array(),
205 );
206 }
207
208 $price_num = $this->term_price( $row );
209
210 // Each term's discount (offer below regular). Installments price on a
211 // different basis, so they never contribute a percentage.
212 $row_regular = isset( $row['relation_data']['regular_price'] ) ? (float) $row['relation_data']['regular_price'] : 0.0;
213 if ( 'installments' !== $groups[ $gid ]['type'] && $row_regular > 0 && $price_num < $row_regular ) {
214 $groups[ $gid ]['pcts'][] = (int) round( ( $row_regular - $price_num ) / $row_regular * 100 );
215 }
216
217 $groups[ $gid ]['terms'][] = array(
218 'id' => (int) $row['plan_id'],
219 'label' => $row['plan_title'],
220 'price' => wc_price( $price_num ),
221 'note' => $this->term_note( $row, $price_num ),
222 );
223 }
224
225 // Card header price = the first term of each group; the badge reports the
226 // group's best discount, and says "up to" when its terms differ.
227 foreach ( $groups as &$group ) {
228 $group['price'] = $group['terms'][0]['price'];
229
230 if ( ! empty( $group['pcts'] ) ) {
231 $max = max( $group['pcts'] );
232 $group['discount_percent'] = $max;
233 $group['badge'] = subscrpt_card_badge_text( $group, $product, $max, min( $group['pcts'] ) !== $max );
234 }
235 unset( $group['pcts'] );
236 }
237 unset( $group );
238
239 $groups = array_values( $groups );
240
241 // One-Time purchase card, after the plans so a subscription stays the
242 // pre-selected default. The base template already renders this type.
243 $one_time = subscrpt_one_time_group( $product );
244 if ( $one_time ) {
245 $groups[] = $one_time;
246 }
247
248 return $groups;
249 }
250
251 /**
252 * Compute the numeric offer price for a resolved plan term.
253 *
254 * @param array $row Resolved plan row.
255 *
256 * @return float
257 */
258 private function term_price( $row ) {
259 $data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
260 $regular = isset( $data['regular_price'] ) ? (string) $data['regular_price'] : '';
261 $selling = isset( $data['sale_price'] ) ? (string) $data['sale_price'] : '';
262 $dtype = isset( $data['discount_type'] ) ? (string) $data['discount_type'] : 'percentage';
263 $dvalue = isset( $data['discount_value'] ) ? (string) $data['discount_value'] : '0';
264
265 return (float) PlanPresenter::offer_price( $regular, $selling, $dtype, $dvalue );
266 }
267
268 /**
269 * Build the billing-cadence note shown under a term ("Billed $10 / month").
270 *
271 * @param array $row Resolved plan row.
272 * @param float $price_num Computed term price.
273 *
274 * @return string
275 */
276 private function term_note( $row, $price_num ) {
277 $interval = PlanPresenter::interval_label( (int) $row['billing_interval'] );
278 $freq = max( 1, (int) $row['billing_frequency'] );
279 $every = 1 === $freq ? strtolower( $interval ) : $freq . ' ' . strtolower( $interval ) . 's';
280
281 $data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
282 $regular = isset( $data['regular_price'] ) && '' !== $data['regular_price'] ? (float) $data['regular_price'] : null;
283
284 $price_disp = ( null !== $regular && $price_num < $regular )
285 ? '<del>' . $this->price_text( $regular ) . '</del> ' . $this->price_text( $price_num )
286 : $this->price_text( $price_num );
287
288 return sprintf(
289 /* translators: 1: price (may include a struck-through regular price), 2: billing interval. */
290 __( 'Billed %1$s / %2$s', 'subscription' ),
291 $price_disp,
292 $every
293 );
294 }
295
296 /**
297 * Plain-text formatted price (currency symbol as a real character, not an
298 * HTML entity) so it renders cleanly inside the note / data attributes.
299 *
300 * @param float $amount Amount.
301 *
302 * @return string
303 */
304 private function price_text( $amount ) {
305 return html_entity_decode( wp_strip_all_tags( wc_price( (float) $amount ) ), ENT_QUOTES, 'UTF-8' );
306 }
307 }
308