last_checked ?? null ) || $updates->last_checked > time() || $updates->last_checked < time() - 2 * DAY_IN_SECONDS ) { return false; } foreach ( array( 'response', 'no_update' ) as $bucket ) { $entries = $updates->$bucket ?? array(); $entry = is_array( $entries ) ? ( $entries['orderworth/orderworth.php'] ?? null ) : null; if ( ! is_object( $entry ) || 'orderworth' !== ( $entry->slug ?? '' ) || 'w.org/plugins/orderworth' !== ( $entry->id ?? '' ) || 'https://wordpress.org/plugins/orderworth/' !== ( $entry->url ?? '' ) ) { continue; } $package = is_string( $entry->package ?? null ) ? wp_parse_url( $entry->package ) : false; if ( is_array( $package ) && 'https' === ( $package['scheme'] ?? '' ) && 'downloads.wordpress.org' === ( $package['host'] ?? '' ) && ! isset( $package['user'] ) && ! isset( $package['pass'] ) && ! isset( $package['port'] ) && ! isset( $package['query'] ) && ! isset( $package['fragment'] ) && preg_match( '~^/plugin/orderworth(?:\.[0-9]+(?:\.[0-9]+)*(?:[-.][a-zA-Z0-9]+)*)?\.zip$~D', $package['path'] ?? '' ) ) { return true; } } return false; } public static function review_url(): string { return 'https://wordpress.org/support/plugin/orderworth/reviews/#new-post'; } private static function normalize( $raw, int $now ): array { $raw = is_array( $raw ) ? $raw : array(); $first = is_int( $raw['first'] ?? null ) && $raw['first'] > 0 && $raw['first'] <= $now ? $raw['first'] : 0; $days = array(); foreach ( is_array( $raw['days'] ?? null ) ? $raw['days'] : array() as $day ) { if ( $first && is_string( $day ) && preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/D', $day, $parts ) && checkdate( (int) $parts[2], (int) $parts[3], (int) $parts[1] ) && $day >= gmdate( 'Y-m-d', $first ) && $day <= gmdate( 'Y-m-d', $now ) ) { $days[] = $day; } } $days = array_values( array_unique( $days ) ); sort( $days ); $status = is_string( $raw['status'] ?? null ) && in_array( $raw['status'], array( 'waiting', 'snoozed', 'never', 'visited' ), true ) ? $raw['status'] : 'waiting'; return array( 'first' => $first, 'days' => array_slice( $days, 0, 3 ), 'status' => $status, 'until' => is_int( $raw['until'] ?? null ) && $raw['until'] > 0 ? $raw['until'] : 0 ); } public static function state(): array { return self::normalize( get_user_meta( get_current_user_id(), OWWC_User_State::key( self::META ), true ), time() ); } private static function revision( array $state ): string { return hash( 'sha256', wp_json_encode( $state ) ); } private static function eligible( array $state, int $now ): bool { return $state['first'] > 0 && $state['first'] <= $now - 30 * DAY_IN_SECONDS && count( $state['days'] ) >= 3 && ! in_array( $state['status'], array( 'never', 'visited' ), true ) && $state['until'] <= $now; } /** Serialized with dismissal so a late completed report cannot restore a prompt. */ private static function change( callable $change ): bool { $user_id = get_current_user_id(); $key = 'owwc_review_request_lock_' . $user_id; $lock = OWWC_Lock::acquire( $key, 30 ); if ( ! $lock ) { return false; } try { wp_cache_delete( $user_id, 'user_meta' ); $meta = OWWC_User_State::key( self::META ); $previous = get_user_meta( $user_id, $meta, true ); $next = $change( self::normalize( $previous, time() ) ); if ( ! is_array( $next ) ) { return false; } if ( $next === $previous ) { return true; } $saved = is_array( $previous ) ? update_user_meta( $user_id, $meta, $next, $previous ) : add_user_meta( $user_id, $meta, $next, true ); return (bool) $saved; } finally { OWWC_Lock::release( $key, $lock ); } } /** Called only on a server-verified scan's first complete transition. */ public static function report_completed( array $scan ): void { if ( ! get_current_user_id() || ! current_user_can( 'manage_woocommerce' ) || get_current_user_id() !== ( $scan['owner'] ?? 0 ) || ( 'complete' !== ( $scan['phase'] ?? '' ) && empty( $scan['guide_recorded'] ) ) || empty( $scan['groups'] ) || ! is_array( $scan['groups'] ) ) { return; } $has_orders = false; foreach ( $scan['groups'] as $group ) { if ( is_array( $group ) && ( ( is_int( $group['selected'] ?? null ) && $group['selected'] > 0 ) || ( is_array( $group['ids'] ?? null ) && count( $group['ids'] ) > 0 ) ) ) { $has_orders = true; } } if ( ! $has_orders ) { return; } self::change( static function ( array $state ): array { if ( in_array( $state['status'], array( 'never', 'visited' ), true ) ) { return $state; } if ( ! $state['first'] ) { $state['first'] = time(); } $today = gmdate( 'Y-m-d' ); if ( count( $state['days'] ) < 3 && ! in_array( $today, $state['days'], true ) ) { $state['days'][] = $today; } return $state; } ); } public static function assets( string $hook ): void { if ( 'toplevel_page_orderworth' === $hook && current_user_can( 'manage_woocommerce' ) ) { wp_enqueue_style( 'owwc-review-request', plugins_url( 'assets/review-request.css', OWWC_FILE ), array( 'owwc-admin' ), OWWC_VERSION ); } } private static function form( bool $prompt, bool $ready ): void { echo '
'; wp_nonce_field( 'owwc_review_request' ); echo ''; if ( $ready ) { echo ''; } if ( $prompt ) { echo ''; } elseif ( in_array( self::state()['status'], array( 'never', 'visited' ), true ) ) { echo ''; } else { echo ''; } echo '
'; } public static function prompt( string $view ): void { if ( '' !== $view || ! current_user_can( 'manage_woocommerce' ) || ! self::directory_ready() || ! self::eligible( self::state(), time() ) ) { return; } echo '

' . esc_html__( 'Share your experience with OrderWorth', 'orderworth' ) . '

' . esc_html__( 'An honest review on WordPress.org helps other store owners decide whether OrderWorth fits their work. All feedback is welcome.', 'orderworth' ) . '

'; self::form( true, true ); echo '
'; } public static function preferences(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $ready = self::directory_ready(); $state = self::state(); echo '

' . esc_html__( 'WordPress.org reviews', 'orderworth' ) . '

' . esc_html__( 'A review reminder can appear only inside OrderWorth after 30 days from your first completed, nonempty report and reports on three separate days. We keep only that first date, up to three report dates and your reminder preference in your user profile on this site. These are separate from optional workflow counts and are never sent to OrderWorth.', 'orderworth' ) . '

' . esc_html__( 'Choosing to review stops reminders; it does not tell us whether you posted a review. Dismissing the reminder stops further report-date collection for it. You can change this preference here.', 'orderworth' ) . '

'; if ( in_array( $state['status'], array( 'never', 'visited' ), true ) ) { echo '

' . esc_html__( 'Review reminders are off.', 'orderworth' ) . '

'; } elseif ( $state['until'] > time() ) { /* translators: %s: postponed reminder date in UTC, formatted YYYY-MM-DD. */ echo '

' . esc_html( sprintf( __( 'Review reminders are paused until %s (UTC).', 'orderworth' ), gmdate( 'Y-m-d', $state['until'] ) ) ) . '

'; } else { echo '

' . esc_html__( 'A review reminder is allowed once the conditions above are met.', 'orderworth' ) . '

'; } if ( ! $ready ) { echo '

' . esc_html__( 'The review link will be available once OrderWorth’s WordPress.org listing is public.', 'orderworth' ) . '

'; } self::form( false, $ready ); echo '
'; } public static function save(): void { OWWC_Admin::authorize(); OWWC_Admin::validate_nonce_input(); check_admin_referer( 'owwc_review_request' ); $choice = isset( $_POST['choice'] ) && is_string( $_POST['choice'] ) ? sanitize_key( wp_unslash( $_POST['choice'] ) ) : ''; $revision = isset( $_POST['revision'] ) && is_string( $_POST['revision'] ) ? sanitize_text_field( wp_unslash( $_POST['revision'] ) ) : ''; if ( ! preg_match( '/^[a-f0-9]{64}$/D', $revision ) || ! in_array( $choice, array( 'review', 'later', 'never', 'restore' ), true ) || ( 'review' === $choice && ! self::directory_ready() ) ) { wp_die( esc_html__( 'This review choice is unavailable. Reload the OrderWorth page and try again.', 'orderworth' ), '', array( 'response' => 400 ) ); } $saved = self::change( static function ( array $state ) use ( $choice, $revision ): ?array { if ( ! hash_equals( self::revision( $state ), $revision ) ) { // Return through finally before wp_die exits; a stale form must not leave a lease behind. return null; } $state['status'] = array( 'review' => 'visited', 'later' => 'snoozed', 'never' => 'never', 'restore' => 'waiting' )[ $choice ]; $state['until'] = 'later' === $choice ? time() + 30 * DAY_IN_SECONDS : 0; return $state; } ); if ( ! $saved ) { wp_die( esc_html__( 'Your preference could not be saved. Reload and try again.', 'orderworth' ), '', array( 'response' => 409 ) ); } if ( 'review' === $choice ) { // Explicit user navigation to a fixed WordPress.org address, never a submitted URL. add_filter( 'allowed_redirect_hosts', static function ( array $hosts ): array { $hosts[] = 'wordpress.org'; return $hosts; } ); wp_safe_redirect( self::review_url(), 303, 'OrderWorth' ); exit; } wp_safe_redirect( OWWC_Admin::url( array( 'view' => 'progress' ) ) . '#owwc-review-preferences', 303 ); exit; } public static function exporters( array $exporters ): array { $exporters['orderworth-review-reminder'] = array( 'exporter_friendly_name' => __( 'OrderWorth review reminder', 'orderworth' ), 'callback' => array( self::class, 'export_data' ) ); return $exporters; } public static function export_data( string $email, int $page = 1 ): array { $user = get_user_by( 'email', $email ); $raw = $user && 1 === $page ? get_user_meta( $user->ID, OWWC_User_State::key( self::META ), true ) : false; $data = array(); if ( is_array( $raw ) ) { $state = self::normalize( $raw, time() ); $data[] = array( 'group_id' => 'orderworth-review-reminder', 'group_label' => __( 'OrderWorth review reminder', 'orderworth' ), 'item_id' => 'orderworth-review-reminder', 'data' => array( array( 'name' => __( 'First report time (UTC)', 'orderworth' ), 'value' => $state['first'] ? gmdate( 'c', $state['first'] ) : '' ), array( 'name' => __( 'Report days (UTC, up to three)', 'orderworth' ), 'value' => implode( ', ', $state['days'] ) ), array( 'name' => __( 'Reminder preference', 'orderworth' ), 'value' => $state['status'] ), array( 'name' => __( 'Reminder postponed until (UTC)', 'orderworth' ), 'value' => $state['until'] ? gmdate( 'c', $state['until'] ) : '' ), ) ); } return array( 'data' => $data, 'done' => true ); } public static function erasers( array $erasers ): array { $erasers['orderworth-review-reminder'] = array( 'eraser_friendly_name' => __( 'OrderWorth review reminder', 'orderworth' ), 'callback' => array( self::class, 'erase_data' ) ); return $erasers; } public static function erase_data( string $email, int $page = 1 ): array { $user = get_user_by( 'email', $email ); if ( ! $user || 1 !== $page ) { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true ); } $key = 'owwc_review_request_lock_' . $user->ID; $lock = OWWC_Lock::acquire( $key, 30 ); if ( ! $lock ) { return array( 'items_removed' => false, 'items_retained' => true, 'messages' => array( __( 'The review reminder preference is busy. Retry erasing it.', 'orderworth' ) ), 'done' => true ); } try { $meta = OWWC_User_State::key( self::META ); wp_cache_delete( $user->ID, 'user_meta' ); $removed = delete_user_meta( $user->ID, $meta ); $retained = metadata_exists( 'user', $user->ID, $meta ); return array( 'items_removed' => (bool) $removed, 'items_retained' => $retained, 'messages' => $retained ? array( __( 'The review reminder preference could not be erased. Retry the request.', 'orderworth' ) ) : array(), 'done' => true ); } finally { OWWC_Lock::release( $key, $lock ); } } }