[ 'href' => true, 'target' => true, 'rel' => true, ], 'strong' => [], 'em' => [], ]; public function register(): void { $permission = [ $this, 'canManage' ]; register_rest_route( self::NS, '/admin/settings', [ [ 'methods' => 'GET', 'callback' => [ $this, 'getSettings' ], 'permission_callback' => $permission, ], [ 'methods' => 'POST', 'callback' => [ $this, 'saveSettings' ], 'permission_callback' => $permission, 'args' => [ 'config' => [ 'type' => 'object', 'required' => true, ], 'seed' => [ 'type' => 'boolean', 'default' => false, ], ], ], ] ); register_rest_route( self::NS, '/admin/page', [ 'methods' => 'POST', 'callback' => [ $this, 'createPage' ], 'permission_callback' => $permission, ] ); register_rest_route( self::NS, '/admin/pages', [ 'methods' => 'GET', 'callback' => [ $this, 'pages' ], 'permission_callback' => $permission, 'args' => self::lookupArgs(), ] ); register_rest_route( self::NS, '/admin/report', [ 'methods' => 'GET', 'callback' => [ $this, 'report' ], 'permission_callback' => $permission, ] ); register_rest_route( self::NS, '/admin/subscribers', [ 'methods' => 'GET', 'callback' => [ $this, 'subscribers' ], 'permission_callback' => $permission, 'args' => [ 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1, ], 'per_page' => [ 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, ], 'search' => [ 'type' => 'string', 'default' => '', ], ], ] ); register_rest_route( self::NS, '/admin/subscribers/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [ $this, 'deleteSubscriber' ], 'permission_callback' => $permission, ] ); register_rest_route( self::NS, '/admin/products', [ 'methods' => 'GET', 'callback' => [ $this, 'products' ], 'permission_callback' => $permission, 'args' => self::lookupArgs(), ] ); register_rest_route( self::NS, '/admin/categories', [ 'methods' => 'GET', 'callback' => [ $this, 'categories' ], 'permission_callback' => $permission, 'args' => self::lookupArgs(), ] ); } public function canManage(): bool { return current_user_can( 'manage_woocommerce' ); } public function getSettings(): \WP_REST_Response { return self::settingsResponse( Option::get() ); } public function saveSettings( \WP_REST_Request $request ): \WP_REST_Response { $raw = $request->get_param( 'config' ); $raw = is_array( $raw ) ? $raw : []; if ( true === $request->get_param( 'seed' ) ) { // The wizard's last step: fill every door with the ready-made calendar. $doors = Config::sanitize( $raw )['doors']; /** * Filters the product the ready-made calendar gives away on its free-gift doors. * Without one (0), those doors get a percentage coupon instead. * * @param int $product_id Product id, or 0. */ $free = (int) apply_filters( 'advently_seed_free_product', 0 ); // Gift ideas suggest something else than the free gift. $gifts = array_values( array_diff( self::giftProducts(), [ $free ] ) ); $raw['door_list'] = Seed::doors( $doors, $gifts, $free, self::seedTexts() ); } return self::settingsResponse( Option::save( self::clean( $raw ) ) ); } /** * Best sellers first (newest when nothing has sold yet), for gift-idea doors. * * @return int[] */ private static function giftProducts(): array { $ids = get_posts( [ 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 6, 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- runs once, when the wizard finishes. 'meta_key' => 'total_sales', 'orderby' => [ 'meta_value_num' => 'DESC', 'date' => 'DESC', ], ] ); return array_values( array_filter( array_map( 'intval', $ids ), [ self::class, 'buyable' ] ) ); } private static function buyable( int $id ): bool { $product = wc_get_product( $id ); return $product && $product->is_purchasable() && $product->is_in_stock() && $product->is_visible(); } /** * @return array */ private static function seedTexts(): array { return [ 'gift' => [ 'title' => __( 'Gift idea of the day', 'advently' ), 'message' => __( 'Stuck for a present? This one is a favourite with our customers.', 'advently' ), 'button' => __( 'Take a look', 'advently' ), ], 'halfway' => [ 'title' => __( 'Halfway to Christmas', 'advently' ), 'message' => __( 'Twelve doors down, twelve to go. Here is a bigger thank-you for coming back every day.', 'advently' ), 'button' => '', ], 'last_shipping' => [ 'title' => __( 'Order in time for Christmas', 'advently' ), 'message' => __( 'These are the last days to order and have it under the tree. Shipping is on us today.', 'advently' ), 'button' => '', ], 'finale' => [ 'title' => __( 'Merry Christmas!', 'advently' ), 'message' => __( 'Thank you for opening every door with us. Our biggest offer of the season is yours until the calendar ends.', 'advently' ), 'button' => '', ], ]; } /** * WordPress-side cleaning on top of Option::sanitize(), which owns the * schema (types, ranges, allowed values) but knows nothing of HTML. */ public static function clean( array $raw ): array { $config = Option::sanitize( $raw, (int) wp_date( 'Y' ) ); $config['consent_text'] = wp_kses( $config['consent_text'], self::CONSENT_HTML ); foreach ( $config['door_list'] as $n => $door ) { $content = $door['content']; $config['door_list'][ $n ]['content'] = [ 'title' => sanitize_text_field( $content['title'] ), 'message' => wp_kses_post( $content['message'] ), 'image_id' => self::attachment( $content['image_id'] ), 'product_id' => $content['product_id'], 'button_label' => sanitize_text_field( $content['button_label'] ), ]; } $config['background_id'] = self::attachment( $config['background_id'] ); return $config; } /** * Creates the calendar page and stores it as the calendar's page. */ public function createPage(): \WP_REST_Response { $id = Page::create(); if ( ! $id ) { return new \WP_REST_Response( [ 'message' => __( 'The page could not be created.', 'advently' ) ], 500 ); } $config = Option::get(); $config['page_id'] = $id; return self::settingsResponse( Option::save( $config ) ); } public function pages( \WP_REST_Request $request ): \WP_REST_Response { $include = self::includeIds( $request ); $args = [ 'post_type' => 'page', 'post_status' => 'publish', 'posts_per_page' => 20, 'orderby' => 'title', 'order' => 'ASC', ]; if ( $include ) { $args['post__in'] = $include; } else { $args['s'] = sanitize_text_field( (string) $request->get_param( 'search' ) ); } $items = []; foreach ( get_posts( $args ) as $page ) { $items[] = [ 'id' => (int) $page->ID, 'name' => wp_strip_all_tags( get_the_title( $page ) ), ]; } return new \WP_REST_Response( $items ); } public function report(): \WP_REST_Response { $response = new \WP_REST_Response( Report::build( Option::get() ) ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } public function subscribers( \WP_REST_Request $request ): \WP_REST_Response { $perPage = (int) $request->get_param( 'per_page' ); $result = ( new SubscriberList( Option::get()['start'] ) )->page( (int) $request->get_param( 'page' ), $perPage, sanitize_text_field( (string) $request->get_param( 'search' ) ) ); $response = new \WP_REST_Response( $result ); $response->header( 'X-WP-Total', (string) $result['total'] ); $response->header( 'X-WP-TotalPages', (string) max( 1, (int) ceil( $result['total'] / $perPage ) ) ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } /** * Removes a subscriber: their sign-up, door history and unused coupons. */ public function deleteSubscriber( \WP_REST_Request $request ): \WP_REST_Response { if ( ! Privacy::erase( (int) $request->get_param( 'id' ) ) ) { return new \WP_REST_Response( [ 'message' => __( 'That subscriber no longer exists.', 'advently' ) ], 404 ); } return new \WP_REST_Response( [ 'ok' => true ] ); } public function products( \WP_REST_Request $request ): \WP_REST_Response { $include = self::includeIds( $request ); $args = [ 'status' => 'publish', 'limit' => $include ? count( $include ) : 20, 'return' => 'objects', ]; if ( $include ) { $args['include'] = $include; } else { $search = sanitize_text_field( (string) $request->get_param( 'search' ) ); if ( '' !== $search ) { $args['s'] = $search; } $args['orderby'] = 'title'; $args['order'] = 'ASC'; } $items = []; foreach ( wc_get_products( $args ) as $product ) { $image = wp_get_attachment_image_src( $product->get_image_id(), 'thumbnail' ); $items[] = [ 'id' => $product->get_id(), 'name' => wp_strip_all_tags( $product->get_name() ), 'price' => html_entity_decode( wp_strip_all_tags( wc_price( (float) $product->get_price() ) ), ENT_QUOTES, 'UTF-8' ), 'image' => $image ? $image[0] : '', 'type' => $product->get_type(), ]; } return new \WP_REST_Response( $items ); } public function categories( \WP_REST_Request $request ): \WP_REST_Response { $include = self::includeIds( $request ); $args = [ 'taxonomy' => 'product_cat', 'hide_empty' => false, 'number' => 50, ]; if ( $include ) { $args['include'] = $include; } else { $search = sanitize_text_field( (string) $request->get_param( 'search' ) ); if ( '' !== $search ) { $args['search'] = $search; } } $terms = get_terms( $args ); $items = []; foreach ( is_array( $terms ) ? $terms : [] as $term ) { $items[] = [ 'id' => (int) $term->term_id, 'name' => wp_strip_all_tags( $term->name ), 'count' => (int) $term->count, ]; } return new \WP_REST_Response( $items ); } private static function settingsResponse( array $config ): \WP_REST_Response { $schedule = Option::schedule( $config ); $response = new \WP_REST_Response( [ 'config' => $config, 'saved' => Option::exists(), 'today' => $schedule->statuses( Option::now() ), 'end' => Config::endDate( $config ), 'page' => [ 'id' => Page::id( $config ), 'url' => Page::url( $config ), 'title' => Page::id( $config ) ? wp_strip_all_tags( get_the_title( Page::id( $config ) ) ) : '', ], ] ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } /** * Only images in the media library are kept. */ private static function attachment( int $id ): int { return $id && wp_attachment_is_image( $id ) ? $id : 0; } /** * @return int[] */ private static function includeIds( \WP_REST_Request $request ): array { $raw = $request->get_param( 'include' ); $ids = is_array( $raw ) ? $raw : explode( ',', (string) $raw ); return array_values( array_filter( array_map( 'absint', $ids ) ) ); } private static function lookupArgs(): array { return [ 'search' => [ 'type' => 'string', 'default' => '', ], 'include' => [ 'type' => [ 'string', 'array' ], 'default' => '', ], ]; } }