Skip to content

Commit 4d6ebe2

Browse files
authored
feat: subscription tier modal (#4164)
1 parent c028cbf commit 4d6ebe2

5 files changed

Lines changed: 389 additions & 3 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
<?php
2+
/**
3+
* Subscription tiers functionality for WooCommerce Subscriptions.
4+
*
5+
* @package Newspack
6+
*/
7+
8+
namespace Newspack;
9+
10+
defined( 'ABSPATH' ) || exit;
11+
12+
/**
13+
* Main class.
14+
*/
15+
class Subscriptions_Tiers {
16+
/**
17+
* Get the frequency of a product.
18+
*
19+
* @param \WC_Product $product Product object.
20+
* @return string Frequency.
21+
*/
22+
public static function get_frequency( $product ) {
23+
$period = $product->get_meta( '_subscription_period', true );
24+
if ( empty( $period ) ) {
25+
$period = 'once';
26+
}
27+
$interval = $product->get_meta( '_subscription_period_interval', true );
28+
if ( empty( $interval ) ) {
29+
$interval = 1;
30+
}
31+
return $period . '_' . $interval;
32+
}
33+
34+
/**
35+
* Get tiered products by frequency given a grouped or
36+
* variable subscription product.
37+
*
38+
* If no product is provided, it will use all
39+
* non-donation subscription products.
40+
*
41+
* @param \WC_Product|null $product Optional product.
42+
* @param bool|null $sort_by_price Whether to sort by price.
43+
*
44+
* @return array<string, \WC_Product[]> Product tiers by frequency.
45+
*/
46+
public static function get_tiers_by_frequency( $product = null, $sort_by_price = null ) {
47+
if ( ! function_exists( 'wc_get_products' ) || ! function_exists( 'wcs_user_has_subscription' ) ) {
48+
return [];
49+
}
50+
51+
if ( empty( $product ) ) {
52+
$products = wc_get_products(
53+
[
54+
'type' => [ 'subscription', 'variable-subscription' ],
55+
'limit' => -1,
56+
]
57+
);
58+
$sort_by_price = $sort_by_price ?? true;
59+
} elseif ( $product->is_type( 'grouped' ) ) {
60+
$products = $product->get_children();
61+
$sort_by_price = $sort_by_price ?? false;
62+
} elseif ( $product->is_type( 'variable' ) || $product->is_type( 'variable_subscription' ) || $product->is_type( 'subscription' ) ) {
63+
$products = [ $product ];
64+
$sort_by_price = $sort_by_price ?? true;
65+
}
66+
67+
if ( empty( $products ) ) {
68+
return [];
69+
}
70+
71+
$selected_products = [];
72+
73+
foreach ( $products as $product ) {
74+
if ( is_int( $product ) ) {
75+
$product = wc_get_product( $product );
76+
}
77+
78+
if ( ! in_array( $product->get_type(), [ 'subscription', 'variable-subscription' ], true ) ) {
79+
continue;
80+
}
81+
82+
// Exclude donation products.
83+
if ( Donations::is_donation_product( $product->get_id() ) ) {
84+
continue;
85+
}
86+
87+
// Extract the variations if it's a variable subscription product.
88+
if ( $product->is_type( 'variable-subscription' ) ) {
89+
$variations = $product->get_available_variations();
90+
foreach ( $variations as $variation ) {
91+
$selected_products[] = new \WC_Product_Variation( $variation['variation_id'] );
92+
}
93+
} else {
94+
$selected_products[] = $product;
95+
}
96+
}
97+
98+
$products_by_frequency = [];
99+
foreach ( $selected_products as $product ) {
100+
$frequency = self::get_frequency( $product );
101+
if ( ! $frequency ) {
102+
continue;
103+
}
104+
$products_by_frequency[ $frequency ][] = $product;
105+
}
106+
107+
if ( $sort_by_price ) {
108+
foreach ( $products_by_frequency as $frequency => $products ) {
109+
usort(
110+
$products,
111+
function( $a, $b ) {
112+
return intval( $a->get_price() ) <=> intval( $b->get_price() );
113+
}
114+
);
115+
$products_by_frequency[ $frequency ] = $products;
116+
}
117+
}
118+
119+
return $products_by_frequency;
120+
}
121+
122+
/**
123+
* Render a subscription product card.
124+
*
125+
* @param \WC_Product $product Product.
126+
* @param bool $show_variation_attributes Whether the card should render the product variation attributes.
127+
* @param bool $current Whether the product should have the "current" badge.
128+
* @param bool $selected Whether the product should be checked.
129+
*/
130+
private static function render_product_card( $product, $show_variation_attributes = false, $current = false, $selected = false ) {
131+
if ( function_exists( 'wcs_price_string' ) ) {
132+
$price = wcs_price_string(
133+
[
134+
'recurring_amount' => $product->get_price(),
135+
'subscription_period' => $product->get_meta( '_subscription_period' ),
136+
'subscription_interval' => $product->get_meta( '_subscription_period_interval' ),
137+
]
138+
);
139+
} else {
140+
$price = $product->get_price_html();
141+
}
142+
$product_name = $product->get_title();
143+
if ( $product->is_type( 'variation' ) ) {
144+
if ( $show_variation_attributes ) {
145+
$product_name = sprintf(
146+
'%s (%s)',
147+
$product_name,
148+
implode( ', ', $product->get_variation_attributes() )
149+
);
150+
}
151+
}
152+
153+
?>
154+
<label class="newspack-ui__input-card">
155+
<?php if ( $current ) : ?>
156+
<span class="newspack-ui__badge newspack-ui__badge--primary"><?php _e( 'Current', 'newspack-plugin' ); ?></span>
157+
<?php endif; ?>
158+
<input type="radio" name="product_id" value="<?php echo esc_attr( $product->get_id() ); ?>" <?php echo esc_attr( $selected ? 'checked' : '' ); ?>>
159+
<strong><?php echo esc_html( $product_name ); ?></strong>
160+
<span class="newspack-ui__helper-text"><?php echo $price; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
161+
</label>
162+
<?php
163+
}
164+
165+
/**
166+
* Render subscription tiers form.
167+
*
168+
* @param \WC_Product $product Optional product.
169+
* @param string|null $title Optional title.
170+
* @param string|null $button_label Optional button label.
171+
*/
172+
public static function render_form( $product = null, $title = null, $button_label = null ) {
173+
$tiers = self::get_tiers_by_frequency( $product );
174+
if ( empty( $tiers ) ) {
175+
return;
176+
}
177+
178+
// Determine whether there's only 1 item per frequency so we can render a
179+
// single tier modal.
180+
$is_single_tier = array_reduce(
181+
$tiers,
182+
function( $carry, $frequency ) {
183+
return $carry && count( $frequency ) === 1;
184+
},
185+
true
186+
);
187+
188+
$frequencies = array_keys( $tiers );
189+
$current_frequency = null;
190+
$current_product = null;
191+
foreach ( $frequencies as $frequency ) {
192+
foreach ( $tiers[ $frequency ] as $product ) {
193+
if ( wcs_user_has_subscription( get_current_user_id(), $product->get_id(), 'active' ) ) {
194+
$current_frequency = $frequency;
195+
$current_product = $product;
196+
break 2;
197+
}
198+
}
199+
}
200+
201+
if ( ! $current_frequency ) {
202+
$current_frequency = $frequencies[0];
203+
}
204+
205+
if ( $current_product ) {
206+
$selected_product = $current_product;
207+
} else {
208+
$selected_product = $tiers[ $current_frequency ][0];
209+
}
210+
211+
$title = $title ?? __( 'Complete your transaction', 'newspack-plugin' );
212+
$button_label = $button_label ?? __( 'Purchase', 'newspack-plugin' );
213+
214+
?>
215+
<form class="newspack__subscription-tiers__form" target="newspack_modal_checkout_iframe" data-title="<?php echo esc_attr( $title ); ?>">
216+
<?php if ( ! $is_single_tier ) : ?>
217+
<div class="newspack-ui__segmented-control">
218+
<?php if ( count( $frequencies ) > 1 ) : ?>
219+
<div class="newspack-ui__segmented-control__tabs">
220+
<?php foreach ( $frequencies as $frequency ) : ?>
221+
<button type="button" class="newspack-ui__button newspack-ui__button--small <?php echo esc_attr( $frequency === $current_frequency ? 'selected' : '' ); ?>"><?php echo esc_html( WooCommerce_Subscriptions::get_frequency_label( $frequency ) ); ?></button>
222+
<?php endforeach; ?>
223+
</div>
224+
<?php endif; ?>
225+
<div class="newspack-ui__segmented-control__content">
226+
<?php foreach ( $tiers as $frequency => $products ) : ?>
227+
<div class="newspack-ui__segmented-control__panel">
228+
<?php
229+
foreach ( $products as $product ) {
230+
self::render_product_card( $product, false, $product === $current_product, $product === $selected_product );
231+
}
232+
?>
233+
</div>
234+
<?php endforeach; ?>
235+
</div>
236+
</div>
237+
<?php endif; ?>
238+
<?php
239+
if ( $is_single_tier ) {
240+
foreach ( $tiers as $products ) {
241+
foreach ( $products as $product ) {
242+
self::render_product_card( $product, true, $product === $current_product, $product === $selected_product );
243+
}
244+
}
245+
}
246+
?>
247+
<input type="hidden" name="newspack_checkout" value="1">
248+
<input type="hidden" name="modal_checkout" value="1">
249+
250+
<button type="submit" class="newspack-ui__button newspack-ui__button--primary newspack-ui__button--wide"><?php echo esc_html( $button_label ); ?></button>
251+
<button type="button" class="newspack-ui__button newspack-ui__button--ghost newspack-ui__button--wide newspack-ui__modal__cancel"><?php _e( 'Cancel', 'newspack-plugin' ); ?></button>
252+
</form>
253+
<?php
254+
}
255+
256+
/**
257+
* Render subscription tiers modal given a grouped or variable
258+
* subscription product.
259+
*
260+
* If no grouped or variable subscription product is provided,
261+
* all non-donation subscription products are rendered.
262+
*
263+
* @param \WC_Product|null $product Optional product.
264+
* @param string|null $title Optional title.
265+
* @param string|null $button_label Optional button label.
266+
*/
267+
public static function render_modal( $product = null, $title = null, $button_label = null ) {
268+
?>
269+
<div class="newspack-ui newspack-ui__modal-container newspack__subscription-tiers" data-state="closed" data-product-id="<?php echo esc_attr( $product ? $product->get_id() : '' ); ?>">
270+
<div class="newspack-ui__modal-container__overlay"></div>
271+
<div class="newspack-ui__modal newspack-ui__modal--small">
272+
<header class="newspack-ui__modal__header">
273+
<h2><?php echo esc_html( $title ?? __( 'Complete your transaction', 'newspack-plugin' ) ); ?></h2>
274+
<button class="newspack-ui__button newspack-ui__button--icon newspack-ui__button--ghost newspack-ui__modal__close">
275+
<span class="screen-reader-text"><?php esc_html_e( 'Close', 'newspack-plugin' ); ?></span>
276+
<?php \Newspack\Newspack_UI_Icons::print_svg( 'close' ); ?>
277+
</button>
278+
</header>
279+
<div class="newspack-ui__modal__content">
280+
<?php self::render_form( $product, $title, $button_label ); ?>
281+
</div>
282+
</div>
283+
</div>
284+
<?php
285+
}
286+
}

includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public static function woocommerce_subscriptions_integration_init() {
2828
include_once __DIR__ . '/class-renewal.php';
2929
include_once __DIR__ . '/class-subscriptions-meta.php';
3030
include_once __DIR__ . '/class-subscriptions-confirmation.php';
31+
include_once __DIR__ . '/class-subscriptions-tiers.php';
3132

3233
On_Hold_Duration::init();
3334
Renewal::init();
@@ -63,5 +64,39 @@ public static function is_enabled() {
6364
*/
6465
return apply_filters( 'newspack_subscriptions_expiration_enabled', $is_enabled );
6566
}
67+
68+
/**
69+
* Get the label for a frequency.
70+
*
71+
* @param string $frequency Frequency.
72+
*
73+
* @return string
74+
*/
75+
public static function get_frequency_label( $frequency ) {
76+
$frequencies = [
77+
'day' => __( 'Daily', 'newspack-plugin' ),
78+
'week' => __( 'Weekly', 'newspack-plugin' ),
79+
'week_2' => __( 'Bi-Weekly', 'newspack-plugin' ),
80+
'month' => __( 'Monthly', 'newspack-plugin' ),
81+
'month_3' => __( 'Quarterly', 'newspack-plugin' ),
82+
'month_6' => __( 'Semi-Annually', 'newspack-plugin' ),
83+
'year' => __( 'Yearly', 'newspack-plugin' ),
84+
];
85+
// If frequency is not in the array, try to find the frequency without the interval.
86+
if ( ! isset( $frequencies[ $frequency ] ) ) {
87+
$frequency = explode( '_', $frequency )[0];
88+
$label = $frequencies[ $frequency ] ?? ucfirst( $frequency );
89+
} else {
90+
$label = $frequencies[ $frequency ];
91+
}
92+
93+
/**
94+
* Filters the frequency label.
95+
*
96+
* @param string $label Frequency label.
97+
* @param string $frequency Frequency.
98+
*/
99+
return apply_filters( 'newspack_subscriptions_frequency_label', $label, $frequency );
100+
}
66101
}
67102
WooCommerce_Subscriptions::init();

src/newspack-ui/js/segmented-control/index.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,24 @@ import { domReady } from '../utils'; // Global utils.
33
domReady( function () {
44
function segmented_control( element ) {
55
const header = element.querySelector( '.newspack-ui__segmented-control__tabs' );
6-
const tab_headers = [ ...header.children ];
76
const tab_body = element.querySelector( '.newspack-ui__segmented-control__content' );
8-
let tab_contents = [];
97

10-
if ( null !== tab_body ) {
8+
let tab_contents = [];
9+
if ( tab_body ) {
1110
tab_contents = [ ...tab_body.children ];
1211
}
1312

13+
/**
14+
* If no header is present, add the selected class to the
15+
* first tab content and bail.
16+
*/
17+
if ( ! header && tab_contents.length ) {
18+
tab_contents[ 0 ].classList.add( 'selected' );
19+
return;
20+
}
21+
22+
const tab_headers = [ ...header.children ];
23+
1424
tab_headers.forEach( ( tab, i ) => {
1525
if ( tab_contents.length !== 0 && tab.classList.contains( 'selected' ) ) {
1626
tab_contents[ i ].classList.add( 'selected' );
@@ -23,6 +33,13 @@ domReady( function () {
2333
if ( tab_contents.length !== 0 ) {
2434
tab_contents.forEach( content => content.classList.remove( 'selected' ) );
2535
tab_contents[ i ].classList.add( 'selected' );
36+
37+
const radioInputs = tab_contents[ i ].querySelectorAll( 'input[type="radio"]' );
38+
const checkedRadio = [ ...radioInputs ].find( radio => radio.checked );
39+
40+
if ( radioInputs.length && ! checkedRadio ) {
41+
radioInputs[ 0 ].click();
42+
}
2643
}
2744
} );
2845
} );

src/reader-activation/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getCookie, setCookie, generateID, debugLog } from './utils.js';
99
import overlays from './overlays.js';
1010
import initAnalytics from './analytics.js';
1111
import setupArticleViewsAggregates from './article-view.js';
12+
import initSubscriptionTiersForm from './subscription-tiers-form.js';
1213

1314
/**
1415
* Reader Activation Library.
@@ -470,6 +471,7 @@ function init() {
470471
}
471472
emit( EVENTS.reader, reader );
472473
initAnalytics( readerActivation );
474+
initSubscriptionTiersForm( readerActivation );
473475
fixClientID();
474476
setupArticleViewsAggregates( readerActivation );
475477
attachAuthCookiesListener();

0 commit comments

Comments
 (0)