array( 'name' => __( 'Products', 'paygrab' ), 'singular_name' => __( 'Product', 'paygrab' ), 'add_new' => __( 'Add new product', 'paygrab' ), 'add_new_item' => __( 'Add new product', 'paygrab' ), 'edit_item' => __( 'Edit product', 'paygrab' ), 'new_item' => __( 'New product', 'paygrab' ), 'view_item' => __( 'View product', 'paygrab' ), 'search_items' => __( 'Search products', 'paygrab' ), 'not_found' => __( 'No products found.', 'paygrab' ), 'not_found_in_trash' => __( 'No products found in Trash.', 'paygrab' ), 'all_items' => __( 'Products', 'paygrab' ), 'menu_name' => __( 'Products', 'paygrab' ), ), 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, 'show_ui' => true, 'show_in_menu' => false, 'show_in_nav_menus' => false, 'show_in_rest' => false, 'supports' => array( 'title', 'excerpt' ), // Only people who manage the shop (administrators) can see or change products. 'map_meta_cap' => true, 'capabilities' => array( 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'edit_private_posts' => 'manage_options', 'edit_published_posts' => 'manage_options', 'publish_posts' => 'manage_options', 'read_private_posts' => 'manage_options', 'delete_posts' => 'manage_options', 'delete_others_posts' => 'manage_options', 'delete_private_posts' => 'manage_options', 'delete_published_posts' => 'manage_options', 'create_posts' => 'manage_options', ), ) ); } /** * Sale settings of a product. * * @param int $product_id Product ID. * @return array{id:int,price:int,tax_rate:int,file:array|null,limit:int,days:int,on_sale:bool,image:int} */ public static function data( $product_id ) { $product_id = (int) $product_id; $file = get_post_meta( $product_id, '_paygrab_file', true ); $data = array( 'id' => $product_id, 'price' => (int) get_post_meta( $product_id, '_paygrab_price', true ), 'tax_rate' => self::TAX_RATE, 'file' => is_array( $file ) && ! empty( $file['path'] ) ? $file : null, 'limit' => metadata_exists( 'post', $product_id, '_paygrab_download_limit' ) ? max( 0, (int) get_post_meta( $product_id, '_paygrab_download_limit', true ) ) : 5, 'days' => metadata_exists( 'post', $product_id, '_paygrab_download_days' ) ? max( 0, (int) get_post_meta( $product_id, '_paygrab_download_days', true ) ) : 30, 'on_sale' => '1' === (string) get_post_meta( $product_id, '_paygrab_on_sale', true ), 'image' => (int) get_post_meta( $product_id, '_paygrab_image', true ), ); /** * Filters a product's sale settings. * * @param array $data Settings. * @param int $product_id Product ID. */ return apply_filters( 'paygrab_product_data', $data, $product_id ); } /** * What a product still needs before it can be sold. * * @param int $product_id Product ID. * @return string[] Any of 'name', 'price', 'file'. */ public static function missing( $product_id ) { $data = self::data( $product_id ); $missing = array(); if ( '' === trim( (string) get_post_field( 'post_title', $product_id ) ) ) { $missing[] = 'name'; } if ( $data['price'] <= 0 ) { $missing[] = 'price'; } if ( ! $data['file'] || ! Paygrab_Files::path( $data['file']['path'] ) ) { $missing[] = 'file'; } return $missing; } /** * Labels of the missing items. * * @return array */ public static function missing_labels() { return array( 'name' => __( 'product name', 'paygrab' ), 'price' => __( 'price', 'paygrab' ), 'file' => __( 'file', 'paygrab' ), ); } /** * Whether a product can be bought now. * * @param int $product_id Product ID. * @return bool */ public static function purchasable( $product_id ) { $post = get_post( $product_id ); if ( ! $post || self::TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return false; } $ok = self::data( $product_id )['on_sale'] && ! self::missing( $product_id ); /** * Filters whether a product can be bought. * * @param bool $ok Purchasable. * @param int $product_id Product ID. */ return (bool) apply_filters( 'paygrab_product_purchasable', $ok, $product_id ); } /** * Price charged for one unit (Pro applies sales and coupons here). * * @param int $product_id Product ID. * @return int */ public static function line_price( $product_id ) { $data = self::data( $product_id ); return max( 0, (int) apply_filters( 'paygrab_line_price', $data['price'], $product_id ) ); } /** * [paygrab_buy id="123"] shortcode. * * @param array $atts Attributes. * @return string */ public static function shortcode( $atts ) { $atts = shortcode_atts( array( 'id' => 0 ), $atts, self::SHORTCODE ); return self::buy_box( (int) $atts['id'], array( 'source' => paygrab_current_source() ) ); } /** * Shortcode text for a product. * * @param int $product_id Product ID. * @return string */ public static function shortcode_text( $product_id ) { return sprintf( '[%s id="%d"]', self::SHORTCODE, (int) $product_id ); } /** * The buy box for a product, in the state that fits the visitor. * * @param int $product_id Product ID. * @param array $args { * @type int $source Post the box is shown in (the buyer comes back to it). * @type array $preview Values to show instead of the saved ones (admin preview): * title, excerpt, data, state (buy|pending|owned|unavailable). * } * @return string HTML. */ public static function buy_box( $product_id, $args = array() ) { $args = wp_parse_args( $args, array( 'source' => 0, 'preview' => null ) ); $preview = is_array( $args['preview'] ) ? $args['preview'] : null; $post = $product_id ? get_post( $product_id ) : null; $exists = $post && self::TYPE === $post->post_type && 'publish' === $post->post_status; if ( ! $exists && ! $preview ) { // A wrong number or a product in the Trash shows nothing to visitors. return current_user_can( 'manage_options' ) ? paygrab_get_template( 'buy-box-missing', array( 'product_id' => (int) $product_id ) ) : ''; } Paygrab_Frontend::enqueue(); if ( $preview ) { $vars = self::preview_vars( $product_id, $preview ); } else { $vars = self::visitor_vars( $post, (int) $args['source'] ); } return paygrab_get_template( 'buy-box', $vars ); } /** * Template variables for the real visitor. * * @param WP_Post $post Product. * @param int $source Post the box is shown in. * @return array */ private static function visitor_vars( WP_Post $post, $source ) { $data = self::data( $post->ID ); $user_id = get_current_user_id(); $state = 'buy'; $order = null; $access = null; if ( $user_id ) { $access = Paygrab_Downloads::active_for( $user_id, $post->ID ); if ( $access ) { $state = 'owned'; } else { $order = Paygrab_Orders::pending_for( $user_id, $post->ID ); if ( $order ) { $state = 'pending'; } } } if ( 'buy' === $state && ! self::purchasable( $post->ID ) ) { $state = 'unavailable'; } $access_order = $access ? Paygrab_Orders::get( $access->order_id ) : null; // An order awaiting payment: a bank transfer the buyer makes, or an online payment not finished yet. $pay = array( 'kind' => 'transfer', 'url' => '', ); if ( $order ) { $gateway = Paygrab_Gateways::get( $order->gateway ); $online = $gateway && $gateway->confirms_payments(); $pay = array( 'kind' => $online ? 'online' : 'transfer', 'url' => $online ? Paygrab_Checkout::complete_url( $order ) : paygrab_mypage_url( $order->order_number ), ); /** * Filters how the buy box speaks about an order awaiting payment. * * kind "transfer": "Please pay by …" and "See where to pay" (bank transfers the buyer makes). * kind "online": "The payment is not complete yet" and "Continue to payment". * PAYGRAB Pro returns "transfer" for Stripe's bank transfer. * * @param array{kind:string,url:string} $pay Kind and where the button goes. * @param Paygrab_Order $order Order. * @param Paygrab_Gateway|null $gateway Gateway. */ $pay = wp_parse_args( (array) apply_filters( 'paygrab_buy_box_pending', $pay, $order, $gateway ), $pay ); } return array( 'title' => get_the_title( $post ), 'excerpt' => self::excerpt( $post ), 'data' => $data, 'state' => $state, 'price' => self::line_price( $post->ID ), 'deadline' => $order && $order->pay_deadline ? paygrab_deadline( $order->pay_deadline ) : '', 'left' => $access ? Paygrab_Downloads::remaining( $access ) : null, 'until' => $access && $access->expires_at ? paygrab_short_date( $access->expires_at ) : '', 'checkout_url' => Paygrab_Checkout::url( $post->ID, $source ), 'order_url' => (string) $pay['url'], 'pending_kind' => 'online' === $pay['kind'] ? 'online' : 'transfer', 'mypage_url' => paygrab_mypage_url( $access_order ? $access_order->order_number : '' ), 'law_url' => paygrab_page_url( 'tokushoho' ), 'gateways' => Paygrab_Gateways::available(), 'logged_in' => (bool) $user_id, 'image' => self::image_html( $data['image'] ), ); } /** * The product image for the buy box. Where it goes depends on the design (paygrab.css). * * @param int|string $image Attachment ID, or 'sample' for the settings preview. * @return string , or '' when there is no usable image. */ public static function image_html( $image ) { if ( 'sample' === $image ) { return ''; } $image = (int) $image; if ( ! $image || ! wp_attachment_is_image( $image ) ) { return ''; } /** * Filters the image size used in the buy box. * * @param string $size Image size name. * @param int $image Attachment ID. */ $size = (string) apply_filters( 'paygrab_buy_box_image_size', 'large', $image ); // Some themes write a size into the image's style attribute (Twenty Twenty-One: height:66%), // which would beat the stylesheet; the box sets the size itself, so it is taken out here. $no_style = static function ( $attr ) { unset( $attr['style'] ); return $attr; }; add_filter( 'wp_get_attachment_image_attributes', $no_style, PHP_INT_MAX ); $html = (string) wp_get_attachment_image( $image, $size, false, array( 'loading' => 'lazy', 'sizes' => '(max-width: 600px) 100vw, 600px', ) ); remove_filter( 'wp_get_attachment_image_attributes', $no_style, PHP_INT_MAX ); return $html; } /** * Template variables for the admin preview (unsaved values, example dates). * * @param int $product_id Product ID (0 for a new product). * @param array $preview Values. * @return array */ private static function preview_vars( $product_id, array $preview ) { $data = wp_parse_args( (array) ( $preview['data'] ?? array() ), self::data( $product_id ) ); $state = in_array( $preview['state'] ?? '', array( 'buy', 'pending', 'owned', 'unavailable' ), true ) ? $preview['state'] : 'buy'; if ( empty( $data['on_sale'] ) && 'buy' === $state ) { $state = 'unavailable'; } $days = (int) Paygrab_Settings::get( 'pay_days' ); return array( 'title' => (string) ( $preview['title'] ?? '' ), 'excerpt' => (string) ( $preview['excerpt'] ?? '' ), 'data' => $data, 'state' => $state, 'price' => (int) $data['price'], 'deadline' => paygrab_deadline( paygrab_now( max( 1, $days ) * DAY_IN_SECONDS ) ), 'left' => $data['limit'] > 0 ? (int) $data['limit'] : null, 'until' => $data['days'] > 0 ? paygrab_short_date( paygrab_now( (int) $data['days'] * DAY_IN_SECONDS ) ) : '', 'checkout_url' => '#', 'order_url' => '#', 'pending_kind' => 'transfer', 'mypage_url' => '#', 'law_url' => paygrab_page_url( 'tokushoho' ), 'gateways' => Paygrab_Gateways::available(), 'logged_in' => true, 'image' => self::image_html( $data['image'] ), ); } /** * The one-line description. * * @param WP_Post|int $post Product. * @return string */ public static function excerpt( $post ) { $post = get_post( $post ); return $post ? self::clean_excerpt( (string) $post->post_excerpt ) : ''; } /** * Clean the one-line description: any length, line breaks kept (shown as they are in the buy box), * runs of half-width spaces and tabs made single (full-width spaces are kept), runs of empty lines made single. * * @param string $text Text. * @return string */ public static function clean_excerpt( $text ) { $text = sanitize_textarea_field( str_replace( array( "\r\n", "\r" ), "\n", (string) $text ) ); $text = (string) preg_replace( array( '/[ \t]+/', '/ *\n */', '/\n{3,}/' ), array( ' ', "\n", "\n\n" ), $text ); return trim( $text ); } /** * Posts that contain the shortcode of a product. * * @param int $product_id Product ID. * @param int $limit Most posts to return. * @return WP_Post[] */ public static function used_in( $product_id, $limit = 20 ) { global $wpdb; $types = array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) ); if ( ! $types ) { return array(); } $id = (int) $product_id; $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $likes = array(); foreach ( array( 'id="' . $id . '"', "id='" . $id . "'", 'id=' . $id . ']', 'id=' . $id . ' ' ) as $needle ) { $likes[] = '%' . $wpdb->esc_like( '[' . self::SHORTCODE . ' ' ) . '%' . $wpdb->esc_like( $needle ) . '%'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $in is a list of %s placeholders, one for each post type. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ($in) AND post_status IN ('publish','future','draft','pending','private') AND ( post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s ) ORDER BY post_modified DESC LIMIT %d", array_merge( $types, $likes, array( (int) $limit ) ) ) ); return array_values( array_filter( array_map( 'get_post', $ids ) ) ); } }