is_type( 'simple' )
&& subscrpt_plan_offered( $product->get_id() );
}
/**
* Enqueue selector assets on product pages that expose plans.
*
* @return void
*/
public function enqueue_assets() {
if ( ! $this->should_render() ) {
return;
}
// global $product is not set yet at wp_enqueue_scripts; resolve from the query.
$product = wc_get_product( get_queried_object_id() );
if ( ! $this->product_has_plans( $product ) ) {
return;
}
wp_enqueue_style(
'subscrpt_plans_selector_css',
SUBSCRPT_ASSETS . '/css/frontend/plans.css',
array(),
SUBSCRPT_VERSION
);
wp_enqueue_script(
'subscrpt_plans_selector_js',
SUBSCRPT_ASSETS . '/js/frontend/plans.js',
array(),
SUBSCRPT_VERSION,
true
);
}
/**
* Replace the price HTML with the resolved plan price when a plan is tied;
* otherwise return the price unchanged (classic suffix stands).
*
* A single tied plan shows the offer price (regular struck-through when
* discounted); multiple plans show a "min – max" range across every term. No
* cadence — the selector below lists each term's cadence.
*
* @param string $price_html Price HTML.
* @param \WC_Product|mixed $product Product.
*
* @return string
*/
public function plan_price_html( $price_html, $product ) {
if ( ! $this->product_has_plans( $product ) ) {
return $price_html;
}
$rows = PlanRepository::resolve_for_product( $product->get_id() );
if ( empty( $rows ) ) {
return $price_html;
}
// Single plan: offer price, with the regular struck-through when discounted.
if ( 1 === count( $rows ) ) {
$row = $rows[0];
$data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
$regular = isset( $data['regular_price'] ) && '' !== $data['regular_price'] ? (float) $data['regular_price'] : null;
$offer = $this->term_price( $row );
return ( null !== $regular && $offer < $regular )
? '' . wc_price( $regular ) . ' ' . wc_price( $offer ) . ''
: wc_price( $offer );
}
// Multiple plans: a min–max range across every attached term.
$prices = array();
foreach ( $rows as $row ) {
$prices[] = $this->term_price( $row );
}
$min = min( $prices );
$max = max( $prices );
// All plans the same price is not a range; show nothing (selector lists each).
return $min === $max
? ''
: wc_price( $min ) . ' – ' . wc_price( $max );
}
/**
* Render the plan selector inside the add-to-cart form.
*
* @return void
*/
public function render_selector() {
if ( ! $this->should_render() ) {
return;
}
global $product;
if ( ! $this->product_has_plans( $product ) ) {
return;
}
$groups = $this->build_groups( $product );
if ( empty( $groups ) ) {
return;
}
wc_get_template(
'product/plan-selector.php',
array( 'groups' => $groups ),
'subscription',
SUBSCRPT_TEMPLATES
);
}
/**
* Build the selector groups for a simple product from resolved plan data.
*
* One entry per plan group, each with its terms (id, label, price, note)
* and a discount badge when its offer price beats the regular one, followed
* by the One-Time card when the merchant offers one.
*
* @param \WC_Product $product Simple product.
*
* @return array
*/
private function build_groups( $product ) {
$resolved = PlanRepository::resolve_for_product( $product->get_id() );
if ( empty( $resolved ) ) {
return array();
}
$groups = array();
foreach ( $resolved as $row ) {
$gid = (int) $row['plan_group_id'];
if ( ! isset( $groups[ $gid ] ) ) {
$groups[ $gid ] = array(
'id' => 'grp_' . $gid,
'type' => PlanRepository::type_to_string( (int) $row['group_type'] ),
'label' => $row['group_title'],
'price' => '',
'terms' => array(),
'badge' => '',
'discount_percent' => 0,
'pcts' => array(),
);
}
$price_num = $this->term_price( $row );
// Each term's discount (offer below regular). Installments price on a
// different basis, so they never contribute a percentage.
$row_regular = isset( $row['relation_data']['regular_price'] ) ? (float) $row['relation_data']['regular_price'] : 0.0;
if ( 'installments' !== $groups[ $gid ]['type'] && $row_regular > 0 && $price_num < $row_regular ) {
$groups[ $gid ]['pcts'][] = (int) round( ( $row_regular - $price_num ) / $row_regular * 100 );
}
$groups[ $gid ]['terms'][] = array(
'id' => (int) $row['plan_id'],
'label' => $row['plan_title'],
'price' => wc_price( $price_num ),
'note' => $this->term_note( $row, $price_num ),
);
}
// Card header price = the first term of each group; the badge reports the
// group's best discount, and says "up to" when its terms differ.
foreach ( $groups as &$group ) {
$group['price'] = $group['terms'][0]['price'];
if ( ! empty( $group['pcts'] ) ) {
$max = max( $group['pcts'] );
$group['discount_percent'] = $max;
$group['badge'] = subscrpt_card_badge_text( $group, $product, $max, min( $group['pcts'] ) !== $max );
}
unset( $group['pcts'] );
}
unset( $group );
$groups = array_values( $groups );
// One-Time purchase card, after the plans so a subscription stays the
// pre-selected default. The base template already renders this type.
$one_time = subscrpt_one_time_group( $product );
if ( $one_time ) {
$groups[] = $one_time;
}
return $groups;
}
/**
* Compute the numeric offer price for a resolved plan term.
*
* @param array $row Resolved plan row.
*
* @return float
*/
private function term_price( $row ) {
$data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
$regular = isset( $data['regular_price'] ) ? (string) $data['regular_price'] : '';
$selling = isset( $data['sale_price'] ) ? (string) $data['sale_price'] : '';
$dtype = isset( $data['discount_type'] ) ? (string) $data['discount_type'] : 'percentage';
$dvalue = isset( $data['discount_value'] ) ? (string) $data['discount_value'] : '0';
return (float) PlanPresenter::offer_price( $regular, $selling, $dtype, $dvalue );
}
/**
* Build the billing-cadence note shown under a term ("Billed $10 / month").
*
* @param array $row Resolved plan row.
* @param float $price_num Computed term price.
*
* @return string
*/
private function term_note( $row, $price_num ) {
$interval = PlanPresenter::interval_label( (int) $row['billing_interval'] );
$freq = max( 1, (int) $row['billing_frequency'] );
$every = 1 === $freq ? strtolower( $interval ) : $freq . ' ' . strtolower( $interval ) . 's';
$data = is_array( $row['relation_data'] ) ? $row['relation_data'] : array();
$regular = isset( $data['regular_price'] ) && '' !== $data['regular_price'] ? (float) $data['regular_price'] : null;
$price_disp = ( null !== $regular && $price_num < $regular )
? '' . $this->price_text( $regular ) . ' ' . $this->price_text( $price_num )
: $this->price_text( $price_num );
return sprintf(
/* translators: 1: price (may include a struck-through regular price), 2: billing interval. */
__( 'Billed %1$s / %2$s', 'subscription' ),
$price_disp,
$every
);
}
/**
* Plain-text formatted price (currency symbol as a real character, not an
* HTML entity) so it renders cleanly inside the note / data attributes.
*
* @param float $amount Amount.
*
* @return string
*/
private function price_text( $amount ) {
return html_entity_decode( wp_strip_all_tags( wc_price( (float) $amount ) ), ENT_QUOTES, 'UTF-8' );
}
}