diff --git a/assets/dev/js/hooks/index.js b/assets/dev/js/hooks/index.js index 8736a95d..6bd5c369 100644 --- a/assets/dev/js/hooks/index.js +++ b/assets/dev/js/hooks/index.js @@ -1 +1,2 @@ export { useToastNotification } from '@ea11y-apps/global/hooks/use-notifications'; +export { default as useStorage } from '@ea11y-apps/global/hooks/use-storage'; diff --git a/assets/dev/js/hooks/use-storage.js b/assets/dev/js/hooks/use-storage.js new file mode 100644 index 00000000..7bda6279 --- /dev/null +++ b/assets/dev/js/hooks/use-storage.js @@ -0,0 +1,26 @@ +import { store as coreDataStore } from '@wordpress/core-data'; +import { dispatch, useSelect } from '@wordpress/data'; + +const useStorage = () => { + const save = async (data) => { + return await dispatch(coreDataStore).saveEntityRecord('root', 'site', data); + }; + + // Fetch site data with useSelect and check resolution status + const get = useSelect((select) => { + return { + data: select(coreDataStore).getEntityRecord('root', 'site'), + hasFinishedResolution: select(coreDataStore).hasFinishedResolution( + 'getEntityRecord', + ['root', 'site'], + ), + }; + }, []); + + return { + save, + get, + }; +}; + +export default useStorage; diff --git a/assets/dev/js/services/mixpanel/mixpanel-events.js b/assets/dev/js/services/mixpanel/mixpanel-events.js index b3627e84..689ee84b 100644 --- a/assets/dev/js/services/mixpanel/mixpanel-events.js +++ b/assets/dev/js/services/mixpanel/mixpanel-events.js @@ -56,4 +56,17 @@ export const mixpanelEvents = { assistantDashboardScanCtaClicked: 'scan_cta_clicked', assistantDashboardSearchTriggered: 'search_triggered', scanLogActionsButtonClicked: 'scan_log_actions_button_clicked', + + // Onboarding modal + scanHomePageButtonClicked: 'scan_triggered', + introductionBannerShowed: 'banner_showed', + introductionBannerClosed: 'banner_dismissed', + + review: { + promptShown: 'review_prompt_shown', + dismissClicked: 'review_dismiss_clicked', + starSelected: 'review_star_selected', + feedbackSubmitted: 'review_feedback_submitted', + publicRedirectClicked: 'review_public_redirect_clicked', + }, }; diff --git a/classes/database/entry.php b/classes/database/entry.php index d7c689e9..d3e165a7 100644 --- a/classes/database/entry.php +++ b/classes/database/entry.php @@ -220,7 +220,7 @@ private function class_short_name(): string { * Optional. * Defaults to null. In this case, will raise only the defaults /changed/ event. */ - private function trigger_change( $data, string $event = null ) : void { + private function trigger_change( $data, ?string $event = null ) : void { if ( $event ) { /** * event specific diff --git a/classes/database/table.php b/classes/database/table.php index f8e84166..56fb752a 100644 --- a/classes/database/table.php +++ b/classes/database/table.php @@ -299,7 +299,7 @@ private static function get_columns_for_insert( $data ) { * * @return string|null The query result or NULL on error. */ - public static function select_var( $fields = '*', $where = '1', int $limit = null, int $offset = null, string $join = '' ): ?string { + public static function select_var( $fields = '*', $where = '1', ?int $limit = null, ?int $offset = null, string $join = '' ): ?string { return static::db()->get_var( static::build_sql_string( $fields, $where, $limit, $offset, $join ) ); } @@ -333,7 +333,7 @@ public static function select_var( $fields = '*', $where = '1', int $limit = nul * * @return string The SQL SELECT statement built according to the function parameters. */ - private static function build_sql_string( $fields = '*', $where = '1', int $limit = null, int $offset = null, string $join = '', array $order_by = [], $group_by = '' ): string { + private static function build_sql_string( $fields = '*', $where = '1', ?int $limit = null, ?int $offset = null, string $join = '', array $order_by = [], $group_by = '' ): string { if ( is_array( $fields ) ) { $fields = implode( ', ', $fields ); } @@ -414,7 +414,7 @@ public static function build_order_by_sql_string( array $order_by ): string { * * @return array|object|\stdClass[]|null On success, an array of objects. Null on error. */ - public static function select( $fields = '*', $where = '1', int $limit = null, int $offset = null, $join = '', array $order_by = [], $group_by = '' ) { + public static function select( $fields = '*', $where = '1', ?int $limit = null, ?int $offset = null, $join = '', array $order_by = [], $group_by = '' ) { // TODO: handle $wpdb->last_error $query = static::build_sql_string( $fields, $where, $limit, $offset, $join, $order_by, $group_by ); return static::db()->get_results( $query ); @@ -447,7 +447,7 @@ public static function select( $fields = '*', $where = '1', int $limit = null, i * * @return string[] Array of the values of the column as strings, or an empty one on error. */ - public static function get_col( string $column = '', $where = '1', int $limit = null, int $offset = null, string $join = '', array $order_by = [] ) : array { + public static function get_col( string $column = '', $where = '1', ?int $limit = null, ?int $offset = null, string $join = '', array $order_by = [] ) : array { return static::db()->get_col( static::build_sql_string( $column, $where, $limit, $offset, $join, $order_by ) ); } @@ -521,7 +521,7 @@ public static function insert( array $data = [] ) { * * @return false|int Number of rows affected or false on error */ - public static function insert_many( array $data = [], string $columns = null ) { + public static function insert_many( array $data = [], ?string $columns = null ) { if ( null === $columns ) { $columns = static::get_columns_for_insert( $data ); if ( ! $columns ) { diff --git a/classes/services/client.php b/classes/services/client.php index dc9cc457..6c0ef060 100644 --- a/classes/services/client.php +++ b/classes/services/client.php @@ -20,6 +20,8 @@ class Client { private const BASE_URL = 'https://my.elementor.com/apps/api/v1/a11y/'; + private const BASE_URL_FEEDBACK = 'https://feedback-api.prod.apps.elementor.red/apps/api/v1/'; + private bool $refreshed = false; public static ?Client $instance = null; @@ -71,6 +73,9 @@ private static function webhook_endpoint(): string { return get_rest_url( $blog_id, 'a11y/v1/webhooks/common' ); } + /** + * @throws Service_Exception + */ public function make_request( $method, $endpoint, $body = [], array $headers = [], $send_json = false, $file = false, $file_name = '' ) { $headers = array_replace_recursive( [ 'x-elementor-a11y' => EA11Y_VERSION, @@ -114,7 +119,14 @@ public static function get_client_base_url() { return apply_filters( 'ea11y_client_base_url', self::BASE_URL ); } + public static function get_feedback_base_url() { + return apply_filters( 'ea11y_feedback_base_url', self::BASE_URL_FEEDBACK ); + } + private static function get_remote_url( $endpoint ): string { + if ( strpos( $endpoint, 'feedback/' ) !== false ) { + return self::get_feedback_base_url() . $endpoint; + } return self::get_client_base_url() . $endpoint; } diff --git a/classes/utils.php b/classes/utils.php index f501958b..8417729c 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -23,6 +23,9 @@ public static function is_elementor_installed() :bool { $installed_plugins = get_plugins(); return isset( $installed_plugins[ $file_path ] ); } + public static function user_is_admin(): bool { + return current_user_can( 'manage_options' ); + } public static function sanitize_object( $input ) { // Convert an object to array if needed diff --git a/includes/manager.php b/includes/manager.php index edb38889..0fc9fcdb 100644 --- a/includes/manager.php +++ b/includes/manager.php @@ -25,6 +25,8 @@ public static function get_module_list(): array { 'whats-new', 'Remediation', 'Scanner', + 'Deactivation', + 'Reviews', ]; } diff --git a/modules/deactivation/assets/css/style.css b/modules/deactivation/assets/css/style.css new file mode 100644 index 00000000..b2a73087 --- /dev/null +++ b/modules/deactivation/assets/css/style.css @@ -0,0 +1,152 @@ +.ea11y-deactivation-modal { + display: none; +} + +.ea11y-deactivation-content { + padding: 20px; +} + +/* Thickbox auto-height adjustments */ +#TB_window.ea11y-feedback-thickbox { + height: auto !important; + max-height: 90vh; + overflow: auto; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + margin: 0 !important; + width: 600px !important; +} + +/* Custom styles for Ally feedback thickbox */ +#TB_window.ea11y-feedback-thickbox { + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +.ea11y-feedback-thickbox #TB_ajaxContent { + overflow: visible; + padding: 0; +} + +.ea11y-feedback-thickbox #TB_title { + padding: 5px; + display: flex; + flex-direction: row; +} + +.ea11y-feedback-thickbox #TB_closeWindowButton .tb-close-icon { + box-shadow: none !important; +} + +.ea11y-feedback-thickbox #TB_ajaxWindowTitle { + font-size: 14px; + letter-spacing: 1px; +} + +.ea11y-deactivation-content h3 { + margin-top: 0; + color: #23282d; + font-size: 18px; + font-weight: 600; +} + +.ea11y-deactivation-content p { + color: #666; + margin-bottom: 20px; +} + +.ea11y-feedback-options { + margin-bottom: 20px; +} + +.ea11y-feedback-option { + margin-bottom: 15px; +} + +.ea11y-feedback-option > label { + display: flex; + align-items: center; + cursor: pointer; + color: #23282d; + margin-bottom: 8px; +} + +.ea11y-feedback-option input[type="radio"] { + margin-right: 8px; +} + +.ea11y-feedback-text-field { + margin-left: 24px; + margin-top: 8px; +} + +.ea11y-feedback-text-field label { + display: block; + font-size: 12px; + color: #666; + margin-bottom: 4px; +} + +.ea11y-feedback-text-field input, +.ea11y-feedback-text-field textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 13px; + resize: vertical; +} + +.ea11y-feedback-text-field input:focus, +.ea11y-feedback-text-field textarea:focus { + border-color: #0073aa; + box-shadow: 0 0 0 1px #0073aa; + outline: none; +} + +.ea11y-deactivation-buttons { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + gap: 10px; + border-top: 1px solid #e1e1e1; + padding-top: 20px; +} + +.ea11y-btn { + padding: 8px 16px; + border: none; + background: none; + text-decoration: none; + border-radius: 3px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: #c0c0c0; + transition: all 0.3s ease; +} + +.ea11y-btn:hover { + background: none; + color: #000; +} + +.ea11y-btn-primary { + background: rgb(240, 171, 252); + border-color: rgb(240, 171, 252); + color: #000; + font-weight: 600; + transition: all 0.3s ease; +} + +.ea11y-btn-primary:hover { + background: #e881fa; + border-color: #e881fa; + color: #000; +} + +.ea11y-btn:focus { + box-shadow: 0 0 0 1px #5b9dd9, 0 0 2px 1px rgba(30, 140, 190, 0.8); + outline: none; +} \ No newline at end of file diff --git a/modules/deactivation/assets/js/deactivation-feedback.js b/modules/deactivation/assets/js/deactivation-feedback.js new file mode 100644 index 00000000..3986bdc9 --- /dev/null +++ b/modules/deactivation/assets/js/deactivation-feedback.js @@ -0,0 +1,115 @@ +import '../css/style.css'; + +const REASON_FIELDS = { + unclear_how_to_use: ['text_field_unclear', 'unclear_details'], + switched_solution: ['text_field_switched', 'switched_details'], + other: ['text_field_other', 'other_details'], +}; + +class Ea11yDeactivationHandler { + constructor() { + this.deactivationLink = document.getElementById( + 'deactivate-pojo-accessibility', + ); + + if (!this.deactivationLink) { + return; + } + + this.originalDeactivationLink = this.deactivationLink.getAttribute('href'); + + this.init(); + } + + modal(title, url, cssClass) { + window.tb_show?.(title, url); + setTimeout( + () => document.getElementById('TB_window')?.classList.add(cssClass), + 5, + ); + } + + hideFields() { + document + .querySelectorAll('.ea11y-feedback-text-field') + .forEach((f) => (f.style.display = 'none')); + } + + toggleField(reason) { + this.hideFields(); + const fieldId = REASON_FIELDS[reason]?.[0]; + if (fieldId) { + document.getElementById(fieldId).style.display = 'block'; + } + } + + sendRequest(data, done) { + fetch(window?.ea11yDeactivationFeedback?.ajaxurl || '', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(data), + }).finally(done); + } + + handleSubmit() { + const reason = document.querySelector( + 'input[name="ea11y_deactivation_reason"]:checked', + )?.value; + const detailsId = REASON_FIELDS[reason]?.[1]; + const extra = detailsId + ? document.getElementById(detailsId)?.value || '' + : ''; + + if (reason) { + this.sendRequest( + { + action: 'ea11y_deactivation_feedback', + reason, + additional_data: extra, + nonce: window?.ea11yDeactivationFeedback?.nonce || '', + }, + () => this.deactivate(), + ); + } else { + this.deactivate(); + } + } + + deactivate() { + window.tb_remove?.(); + window.location.href = this.originalDeactivationLink; + } + + init() { + this.deactivationLink.addEventListener('click', (e) => { + e.preventDefault(); + this.modal( + 'QUICK FEEDBACK', + '#TB_inline?width=550&height=auto&inlineId=ea11y-deactivation-modal', + 'ea11y-feedback-thickbox', + ); + }); + + document.addEventListener('change', (e) => { + if (e.target?.name === 'ea11y_deactivation_reason') { + this.toggleField(e.target.value); + } + }); + + document.addEventListener('click', (e) => { + if (e.target?.id === 'ea11y-submit-deactivate') { + e.preventDefault(); + this.handleSubmit(); + } + if (e.target?.id === 'ea11y-skip-deactivate') { + e.preventDefault(); + this.deactivate(); + } + }); + } +} + +document.addEventListener( + 'DOMContentLoaded', + () => new Ea11yDeactivationHandler(), +); diff --git a/modules/deactivation/module.php b/modules/deactivation/module.php new file mode 100644 index 00000000..931c086a --- /dev/null +++ b/modules/deactivation/module.php @@ -0,0 +1,260 @@ +should_show_feedback() ) { + return; + } + + // Enqueue thickbox for modal + add_thickbox(); + + Utils\Assets::enqueue_app_assets( 'deactivation-ally' ); + + wp_localize_script( + 'deactivation-ally', + 'ea11yDeactivationFeedback', + [ + 'nonce' => wp_create_nonce( 'ea11y_deactivation_feedback' ), + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + ] + ); + + } + + /** + * Add deactivation feedback modal HTML to footer + */ + public function add_deactivation_modal(): void { + if ( ! $this->should_show_feedback() ) { + return; + } + ?> +
+
+

+ +

+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ 'No reason provided' ] ); + return; + } + + // Send feedback to external service + $feedback_sent = $this->send_feedback_to_service( $reason, $additional_data ); + + if ( $feedback_sent ) { + wp_send_json_success( [ 'message' => 'Feedback sent successfully' ] ); + } else { + // Still return success to not block deactivation, but log the error + Logger::error( 'Failed to send deactivation feedback to service' ); + wp_send_json_success( [ 'message' => 'Feedback logged locally' ] ); + } + } + + /** + * Send feedback to external service + * + * @param string $reason The deactivation reason + * @param string $additional_data Additional feedback data from text fields + * @return bool Whether the feedback was sent successfully + */ + private function send_feedback_to_service( string $reason, string $additional_data = '' ): bool { + $feedback_data = $this->prepare_feedback_data( $reason, $additional_data ); + + $response = Client::get_instance()->make_request( + 'POST', + self::SERVICE_ENDPOINT, + $feedback_data + ); + + if ( empty( $response ) || is_wp_error( $response ) ) { + Logger::error( 'Failed to post feedback:' . $response->get_error_message() ); + return false; + } + + return true; + } + + /** + * Prepare feedback data for the service + * + * @param string $reason The deactivation reason + * @param string $additional_data Additional feedback data from text fields + * @return array Formatted feedback data + */ + private function prepare_feedback_data( string $reason, string $additional_data = '' ): array { + $data = [ + 'app' => 'ally', + 'app_version' => EA11Y_VERSION, + 'selected_answer' => $reason, + 'site_url' => home_url(), + 'wp_version' => get_bloginfo( 'version' ), + 'php_version' => PHP_VERSION, + 'timestamp' => current_time( 'mysql' ), + 'user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ), + 'locale' => get_locale(), + ]; + + // Add additional data if provided + if ( ! empty( $additional_data ) ) { + $data['feedback_text'] = $additional_data; + } + + return $data; + } + + /** + * Constructor + */ + public function __construct() { + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_deactivation_assets' ] ); + add_action( 'admin_footer', [ $this, 'add_deactivation_modal' ] ); + add_action( 'wp_ajax_ea11y_deactivation_feedback', [ $this, 'handle_deactivation_feedback' ] ); + } +} diff --git a/modules/remediation/components/cache-cleaner.php b/modules/remediation/components/cache-cleaner.php index 7d510775..9a0086cf 100644 --- a/modules/remediation/components/cache-cleaner.php +++ b/modules/remediation/components/cache-cleaner.php @@ -14,6 +14,36 @@ class Cache_Cleaner { const EA11Y_CLEAR_POST_CACHE_HOOK = 'ea11y_clear_post_cache'; + public static function clear_ally_cache() : void { + Page_Entry::clear_all_cache(); + } + + public static function clear_ally_post_cache( $post ) : void { + $url = get_permalink( $post->ID ); + $url_trimmed = rtrim( $url, '/' ); + Page_Entry::clear_cache( $url_trimmed ); + } + + public static function clear_ally_url_cache( $url ) : void { + $url_trimmed = rtrim( $url, '/' ); + Page_Entry::clear_cache( $url_trimmed ); + } + + public static function clear_ally_list_cache( $urls ) : void { + foreach ( $urls as $url ) { + $url_trimmed = rtrim( $url, '/' ); + Page_Entry::clear_cache( $url_trimmed ); + } + } + + public function add_wp_rocket_clean_action() { + add_action( 'rocket_after_clean_domain', [ self::class, 'clear_ally_cache' ] ); + add_action( 'rocket_after_clean_terms', [ self::class, 'clear_ally_list_cache' ] ); + add_action( 'after_rocket_clean_post', [ self::class, 'clear_ally_post_cache' ] ); + add_action( 'after_rocket_clean_home', [ self::class, 'clear_ally_url_cache' ] ); + add_action( 'after_rocket_clean_file', [ self::class, 'clear_ally_url_cache' ] ); + } + public function add_litespeed_clean_hook() { add_filter( 'litespeed_purge_post_events', function ( $events ) { $events[] = self::EA11Y_CLEAR_POST_CACHE_HOOK; @@ -56,6 +86,8 @@ public function clean_post_cache( $post_ID, $post, $update ) { public function __construct() { $this->add_litespeed_clean_hook(); + $this->add_wp_rocket_clean_action(); + add_action( 'created_term', [ $this, 'clean_taxonomy_cache' ], 10, 3 ); add_action( 'edited_term', [ $this, 'clean_taxonomy_cache' ], 10, 3 ); add_action( 'save_post', [ $this, 'clean_post_cache' ], 10, 3 ); diff --git a/modules/remediation/database/page-entry.php b/modules/remediation/database/page-entry.php index f001c30e..61680fed 100644 --- a/modules/remediation/database/page-entry.php +++ b/modules/remediation/database/page-entry.php @@ -159,4 +159,9 @@ public static function clear_cache( string $url ) : void { return; } } + + public static function clear_all_cache() : void { + $query = 'UPDATE `' . Page_Table::table_name() . '` SET `' . Page_Table::FULL_HTML . '` = NULL WHERE `' . Page_Table::FULL_HTML . '` IS NOT NULL'; + Page_Table::query( $query ); + } } diff --git a/modules/remediation/database/remediation-entry.php b/modules/remediation/database/remediation-entry.php index eef9dc36..37c927fc 100644 --- a/modules/remediation/database/remediation-entry.php +++ b/modules/remediation/database/remediation-entry.php @@ -50,7 +50,7 @@ public function create( string $id = 'id' ) { * @param string $by_value * @param string|null $group */ - public static function remove( string $by, string $by_value, string $group = null ) { + public static function remove( string $by, string $by_value, ?string $group = null ) { $where = $group ? [ $by => $by_value, 'group' => $group, @@ -68,19 +68,7 @@ public static function remove( string $by, string $by_value, string $group = nul * @return array */ public static function get_page_remediations( string $url, bool $total = false ) : array { - $where = $total ? [ - [ - 'column' => Remediation_Table::table_name() . '.' . Remediation_Table::URL, - 'value' => $url, - 'operator' => '=', - 'relation_after' => 'AND', - ], - [ - 'column' => Remediation_Table::table_name() . '.' . Remediation_Table::GROUP, - 'value' => 'altText', - 'operator' => '<>', - ], - ] : [ + $where = [ [ 'column' => Remediation_Table::URL, 'value' => $url, @@ -113,7 +101,7 @@ public static function get_all_remediations( int $period ) : array { * * @return void */ - public static function update_remediations_status( string $by, string $by_value, bool $status, string $group = null ): void { + public static function update_remediations_status( string $by, string $by_value, bool $status, ?string $group = null ): void { $where = $group ? [ $by => $by_value, 'group' => $group, diff --git a/modules/remediation/module.php b/modules/remediation/module.php index 8463c246..4de0999a 100644 --- a/modules/remediation/module.php +++ b/modules/remediation/module.php @@ -28,6 +28,7 @@ public static function routes_list(): array { 'Items', 'Item', 'Trigger_Save', + 'Clear_Cache', ]; } diff --git a/modules/remediation/rest/clear-cache.php b/modules/remediation/rest/clear-cache.php new file mode 100644 index 00000000..4a2fd123 --- /dev/null +++ b/modules/remediation/rest/clear-cache.php @@ -0,0 +1,56 @@ +verify_capability(); + + if ( $error ) { + return $error; + } + + $url = esc_url( $request->get_param( 'url' ) ); + if ( $url ) { + Cache_Cleaner::clear_ally_url_cache( $url ); + } else { + Cache_Cleaner::clear_ally_cache(); + } + + return $this->respond_success_json( [ + 'message' => 'Cache cleared.', + ] ); + } catch ( Throwable $t ) { + return $this->respond_error_json( [ + 'message' => $t->getMessage(), + 'code' => 'internal_server_error', + ] ); + } + } +} diff --git a/modules/reviews/assets/src/api/index.js b/modules/reviews/assets/src/api/index.js new file mode 100644 index 00000000..08c0ef47 --- /dev/null +++ b/modules/reviews/assets/src/api/index.js @@ -0,0 +1,19 @@ +import API from '@ea11y-apps/global/api/'; + +const v1Prefix = '/ea11y/v1'; + +class APIReview extends API { + /** + * @param {Object} data + * @return {Promise} result + */ + static async sendFeedback(data) { + return API.request({ + method: 'POST', + path: `${v1Prefix}/reviews/review`, + data, + }); + } +} + +export default APIReview; diff --git a/modules/reviews/assets/src/app.js b/modules/reviews/assets/src/app.js new file mode 100644 index 00000000..c7f23c21 --- /dev/null +++ b/modules/reviews/assets/src/app.js @@ -0,0 +1,20 @@ +import ReviewNotifications from './components/notification'; +import { useSettings } from './hooks/use-settings'; +import UserFeedbackForm from './layouts/user-feedback-form'; +import './style.css'; + +const ReviewsApp = () => { + const { notificationMessage, notificationType } = useSettings(); + + return ( + <> + + + + ); +}; + +export default ReviewsApp; diff --git a/modules/reviews/assets/src/components/dismiss-button.js b/modules/reviews/assets/src/components/dismiss-button.js new file mode 100644 index 00000000..0a337591 --- /dev/null +++ b/modules/reviews/assets/src/components/dismiss-button.js @@ -0,0 +1,26 @@ +import CloseButton from '@elementor/ui/CloseButton'; +import { useStorage } from '@ea11y-apps/global/hooks'; +import { date } from '@wordpress/date'; +import { useSettings } from '../hooks/use-settings'; + +const DismissButton = () => { + const { save, get } = useStorage(); + const { setIsOpened } = useSettings(); + const handleDismiss = async () => { + if (get.hasFinishedResolution) { + await save({ + ea11y_review_data: { + ...get.data.ea11y_review_data, + dismissals: get.data.ea11y_review_data.dismissals + 1, + hide_for_days: get.data.ea11y_review_data.hide_for_days + 30, + last_dismiss: date('Y-m-d H:i:s'), + }, + }); + } + + setIsOpened(false); + }; + return ; +}; + +export default DismissButton; diff --git a/modules/reviews/assets/src/components/feedback-form.js b/modules/reviews/assets/src/components/feedback-form.js new file mode 100644 index 00000000..8241fc8c --- /dev/null +++ b/modules/reviews/assets/src/components/feedback-form.js @@ -0,0 +1,49 @@ +import Button from '@elementor/ui/Button'; +import FormControl from '@elementor/ui/FormControl'; +import TextField from '@elementor/ui/TextField'; +import { styled } from '@elementor/ui/styles'; +import { __ } from '@wordpress/i18n'; +import { useSettings } from '../hooks/use-settings'; + +const FeedbackForm = ({ close, handleSubmitForm }) => { + const { feedback, setFeedback } = useSettings(); + + return ( + + setFeedback(e.target.value)} + minRows={5} + multiline + placeholder={__( + 'Share your thoughts on how we can improve Ally …', + 'pojo-accessibility', + )} + sx={{ marginBottom: 2 }} + value={feedback} + color="secondary" + /> + handleSubmitForm(close)} + > + {__('Submit', 'pojo-accessibility')} + + + ); +}; + +export default FeedbackForm; + +const StyledButton = styled(Button)` + min-width: 80px; + align-self: flex-end; +`; + +const StyledTextField = styled(TextField)` + textarea:focus, + textarea:active { + outline: none; + box-shadow: none; + } +`; diff --git a/modules/reviews/assets/src/components/notification.js b/modules/reviews/assets/src/components/notification.js new file mode 100644 index 00000000..bb6754ba --- /dev/null +++ b/modules/reviews/assets/src/components/notification.js @@ -0,0 +1,36 @@ +import CloseButton from '@elementor/ui/CloseButton'; +import Snackbar from '@elementor/ui/Snackbar'; +import SnackbarContent from '@elementor/ui/SnackbarContent'; +import { useSettings } from '../hooks/use-settings'; + +const ReviewNotifications = ({ type, message }) => { + const { + showNotification, + setShowNotification, + setNotificationMessage, + setNotificationType, + } = useSettings(); + + const closeNotification = () => { + setShowNotification(!showNotification); + setNotificationMessage(''); + setNotificationType(''); + }; + + return ( + + } + /> + + ); +}; + +export default ReviewNotifications; diff --git a/modules/reviews/assets/src/components/rating-form.js b/modules/reviews/assets/src/components/rating-form.js new file mode 100644 index 00000000..8a767014 --- /dev/null +++ b/modules/reviews/assets/src/components/rating-form.js @@ -0,0 +1,117 @@ +import Button from '@elementor/ui/Button'; +import FormControl from '@elementor/ui/FormControl'; +import FormControlLabel from '@elementor/ui/FormControlLabel'; +import ListItem from '@elementor/ui/ListItem'; +import ListItemIcon from '@elementor/ui/ListItemIcon'; +import Radio from '@elementor/ui/Radio'; +import RadioGroup from '@elementor/ui/RadioGroup'; +import { styled } from '@elementor/ui/styles'; +import { __ } from '@wordpress/i18n'; +import { useSettings } from '../hooks/use-settings'; +import { + MoodEmpty, + MoodHappy, + MoodSad, + MoodSadSquint, + MoodSmile, +} from '../icons'; + +const RatingForm = ({ close, handleSubmitForm }) => { + const { + rating, + setRating, + setCurrentPage, + nextButtonDisabled, + setNextButtonDisabled, + } = useSettings(); + + const ratingsMap = [ + { + value: 5, + label: __('Excellent', 'pojo-accessibility'), + icon: , + }, + { + value: 4, + label: __('Pretty good', 'pojo-accessibility'), + icon: , + }, + { + value: 3, + label: __("It's okay", 'pojo-accessibility'), + icon: , + }, + { + value: 2, + label: __('Could be better', 'pojo-accessibility'), + icon: , + }, + { + value: 1, + label: __('Needs improvement', 'pojo-accessibility'), + icon: , + }, + ]; + + const handleRatingChange = (event, value) => { + setRating(value); + setNextButtonDisabled(false); + }; + + const handleNextButton = async () => { + if (rating < 4) { + setCurrentPage('feedback'); + } else { + const submitted = await handleSubmitForm(close, true); + + if (submitted) { + setCurrentPage('review'); + } + } + }; + + return ( + + handleRatingChange(event, value)} + name="radio-buttons-group" + > + {ratingsMap.map(({ value, label, icon }) => { + return ( + + {icon} + } + label={label} + value={value} + labelPlacement="start" + /> + + ); + })} + + + {__('Next', 'pojo-accessibility')} + + + ); +}; + +export default RatingForm; + +const StyledFormControlLabel = styled(FormControlLabel)` + justify-content: space-between; + margin-left: 0; + width: 100%; +`; + +const StyledButton = styled(Button)` + min-width: 80px; + align-self: flex-end; +`; diff --git a/modules/reviews/assets/src/components/review-form.js b/modules/reviews/assets/src/components/review-form.js new file mode 100644 index 00000000..fade6fda --- /dev/null +++ b/modules/reviews/assets/src/components/review-form.js @@ -0,0 +1,56 @@ +import Button from '@elementor/ui/Button'; +import FormControl from '@elementor/ui/FormControl'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; +import { useStorage } from '@ea11y-apps/global/hooks'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { __ } from '@wordpress/i18n'; +import { WORDPRESS_REVIEW_LINK } from '../constants'; +import { useSettings } from '../hooks/use-settings'; + +const ReviewForm = ({ close }) => { + const { rating } = useSettings(); + const { save, get } = useStorage(); + + const handleSubmit = async () => { + mixpanelService.sendEvent(mixpanelEvents.review.publicRedirectClicked, { + rating: parseInt(rating), + timestamp: new Date().toISOString(), + }); + + await save({ + ea11y_review_data: { + ...get.data.ea11y_review_data, + repo_review_clicked: true, + }, + }); + + close(); + window.open(WORDPRESS_REVIEW_LINK, '_blank'); + }; + + return ( + + + {__( + 'It would mean a lot if you left us a quick review, so others can discover it too.', + 'pojo-accessibility', + )} + + + {__('Leave a review', 'pojo-accessibility')} + + + ); +}; + +export default ReviewForm; + +const StyledButton = styled(Button)` + min-width: 90px; + align-self: flex-end; +`; diff --git a/modules/reviews/assets/src/constants.js b/modules/reviews/assets/src/constants.js new file mode 100644 index 00000000..72764379 --- /dev/null +++ b/modules/reviews/assets/src/constants.js @@ -0,0 +1,2 @@ +export const WORDPRESS_REVIEW_LINK = + 'https://wordpress.org/support/plugin/pojo-accessibility/reviews/#new-post'; diff --git a/modules/reviews/assets/src/hooks/use-settings.js b/modules/reviews/assets/src/hooks/use-settings.js new file mode 100644 index 00000000..0ec99893 --- /dev/null +++ b/modules/reviews/assets/src/hooks/use-settings.js @@ -0,0 +1,72 @@ +import { useState, createContext, useContext } from '@wordpress/element'; + +/** + * Context Component. + */ +const SettingsContext = createContext(null); + +export function useSettings() { + return useContext(SettingsContext); +} + +const SettingsProvider = ({ children }) => { + const [rating, setRating] = useState(0); + const [feedback, setFeedback] = useState(''); + const [currentPage, setCurrentPage] = useState('ratings'); + const [nextButtonDisabled, setNextButtonDisabled] = useState(true); + const [isOpened, setIsOpened] = useState(true); + + // Notification + const [showNotification, setShowNotification] = useState(false); + const [notificationMessage, setNotificationMessage] = useState(''); + const [notificationType, setNotificationType] = useState(''); + + return ( + + {children} + + ); +}; + +export const useNotifications = () => { + const { setNotificationMessage, setNotificationType, setShowNotification } = + useContext(SettingsContext); + + const error = (message) => { + setNotificationMessage(message); + setNotificationType('error'); + setShowNotification(true); + }; + + const success = (message) => { + setNotificationMessage(message); + setNotificationType('success'); + setShowNotification(true); + }; + + return { + success, + error, + }; +}; + +export default SettingsProvider; diff --git a/modules/reviews/assets/src/icons/index.js b/modules/reviews/assets/src/icons/index.js new file mode 100644 index 00000000..70bde2f0 --- /dev/null +++ b/modules/reviews/assets/src/icons/index.js @@ -0,0 +1,5 @@ +export { default as MoodEmpty } from './mood-empty'; +export { default as MoodHappy } from './mood-happy'; +export { default as MoodSad } from './mood-sad'; +export { default as MoodSadSquint } from './mood-sad-squint'; +export { default as MoodSmile } from './mood-smile'; diff --git a/modules/reviews/assets/src/icons/mood-empty.js b/modules/reviews/assets/src/icons/mood-empty.js new file mode 100644 index 00000000..c16ff19d --- /dev/null +++ b/modules/reviews/assets/src/icons/mood-empty.js @@ -0,0 +1,16 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +function MoodEmpty(props) { + return ( + + + + ); +} + +export default MoodEmpty; diff --git a/modules/reviews/assets/src/icons/mood-happy.js b/modules/reviews/assets/src/icons/mood-happy.js new file mode 100644 index 00000000..b7acc508 --- /dev/null +++ b/modules/reviews/assets/src/icons/mood-happy.js @@ -0,0 +1,16 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +function MoodHappy(props) { + return ( + + + + ); +} + +export default MoodHappy; diff --git a/modules/reviews/assets/src/icons/mood-sad-squint.js b/modules/reviews/assets/src/icons/mood-sad-squint.js new file mode 100644 index 00000000..7184a277 --- /dev/null +++ b/modules/reviews/assets/src/icons/mood-sad-squint.js @@ -0,0 +1,16 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +function MoodSadSquint(props) { + return ( + + + + ); +} + +export default MoodSadSquint; diff --git a/modules/reviews/assets/src/icons/mood-sad.js b/modules/reviews/assets/src/icons/mood-sad.js new file mode 100644 index 00000000..d3bdb8e0 --- /dev/null +++ b/modules/reviews/assets/src/icons/mood-sad.js @@ -0,0 +1,16 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +function MoodSad(props) { + return ( + + + + ); +} + +export default MoodSad; diff --git a/modules/reviews/assets/src/icons/mood-smile.js b/modules/reviews/assets/src/icons/mood-smile.js new file mode 100644 index 00000000..3b0eca35 --- /dev/null +++ b/modules/reviews/assets/src/icons/mood-smile.js @@ -0,0 +1,16 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +function MoodSmile(props) { + return ( + + + + ); +} + +export default MoodSmile; diff --git a/modules/reviews/assets/src/layouts/user-feedback-form.js b/modules/reviews/assets/src/layouts/user-feedback-form.js new file mode 100644 index 00000000..c624e973 --- /dev/null +++ b/modules/reviews/assets/src/layouts/user-feedback-form.js @@ -0,0 +1,181 @@ +import Box from '@elementor/ui/Box'; +import Popover from '@elementor/ui/Popover'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; +import { useStorage } from '@ea11y-apps/global/hooks'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { useEffect, useRef } from '@wordpress/element'; +import { escapeHTML } from '@wordpress/escape-html'; +import { __ } from '@wordpress/i18n'; +import APIReview from '../api'; +import DismissButton from '../components/dismiss-button'; +import FeedbackForm from '../components/feedback-form'; +import RatingForm from '../components/rating-form'; +import ReviewForm from '../components/review-form'; +import { useNotifications, useSettings } from '../hooks/use-settings'; + +const UserFeedbackForm = () => { + const anchorEl = useRef(null); + + const { success, error } = useNotifications(); + const { save, get } = useStorage(); + const { rating, setRating, feedback, isOpened, setIsOpened, setCurrentPage } = + useSettings(); + + useEffect(() => { + /** + * Show the popover if the user has not submitted repo feedback. + */ + if ( + window?.ea11yReviewData?.reviewData?.rating > 3 && + !window?.ea11yReviewData?.reviewData?.repo_review_clicked + ) { + setCurrentPage('review'); + setRating(window?.ea11yReviewData?.reviewData?.rating); // re-add the saved rating + } + }, []); + + useEffect(() => { + if (isOpened) { + mixpanelService.init().then(() => { + mixpanelService.sendEvent(mixpanelEvents.review.promptShown, {}); + }); + } + }, [isOpened]); + + /** + * Close the popover. + * @param {Object} event + * @param {string} reason + */ + const handleClose = (event, reason) => { + if ('backdropClick' !== reason) { + setIsOpened(false); + } + + mixpanelService.sendEvent(mixpanelEvents.review.dismissClicked); + }; + + const id = isOpened ? 'reviews-popover' : undefined; + + const { currentPage } = useSettings(); + + const headerMessage = { + ratings: __('How would you rate Ally so far?', 'pojo-accessibility'), + feedback: __( + 'We’re thrilled to hear that! What would make it even better?', + 'pojo-accessibility', + ), + review: __("We're thrilled you're enjoying Ally", 'pojo-accessibility'), + }; + + const handleSubmit = async (close, avoidClosing = false) => { + try { + const response = await APIReview.sendFeedback({ rating, feedback }).then( + async (res) => { + await save({ + ea11y_review_data: { + ...get.data.ea11y_review_data, + rating: parseInt(rating), + feedback: escapeHTML(feedback), + submitted: true, + }, + }); + + return res; + }, + ); + + if (rating && !feedback) { + mixpanelService.sendEvent(mixpanelEvents.review.starSelected, { + rating: parseInt(rating), + }); + } + + if (feedback) { + mixpanelService.sendEvent(mixpanelEvents.review.feedbackSubmitted, { + feedback_text: escapeHTML(feedback), + rating: parseInt(rating), + }); + } + + if (!response?.success) { + /** + * Show success message if the feedback was already submitted. + */ + await success(__('Feedback already submitted', 'pojo-accessibility')); + } else { + await success(__('Thank you for your feedback!', 'pojo-accessibility')); + } + + if (!avoidClosing) { + await close(); + } + + return true; + } catch (e) { + error(__('Failed to submit!', 'pojo-accessibility')); + console.log(e); + return false; + } + }; + + return ( + + +
+ + {headerMessage?.[currentPage]} + + +
+ {'ratings' === currentPage && ( + + )} + {'feedback' === currentPage && ( + + )} + {'review' === currentPage && } +
+
+ ); +}; + +export default UserFeedbackForm; + +const StyledBox = styled(Box)` + width: 350px; + padding: ${({ theme }) => theme.spacing(1.5)}; +`; + +const Header = styled(Box)` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; diff --git a/modules/reviews/assets/src/reviews.js b/modules/reviews/assets/src/reviews.js new file mode 100644 index 00000000..1efbd40c --- /dev/null +++ b/modules/reviews/assets/src/reviews.js @@ -0,0 +1,29 @@ +import DirectionProvider from '@elementor/ui/DirectionProvider'; +import { ThemeProvider } from '@elementor/ui/styles'; +import domReady from '@wordpress/dom-ready'; +import { StrictMode, Fragment, createRoot } from '@wordpress/element'; +import ReviewsApp from './app'; +import SettingsProvider from './hooks/use-settings'; + +domReady(() => { + const rootNode = document.getElementById('reviews-app'); + + // Can't use the settings hook in the global scope so accessing directly + const isDevelopment = window?.ea11ySettingsData?.isDevelopment; + const isRTL = window?.ea11yReviewData?.isRTL; + const AppWrapper = Boolean(isDevelopment) ? StrictMode : Fragment; + + const root = createRoot(rootNode); + + root.render( + + + + + + + + + , + ); +}); diff --git a/modules/reviews/assets/src/style.css b/modules/reviews/assets/src/style.css new file mode 100644 index 00000000..e69de29b diff --git a/modules/reviews/classes/feedback-handler.php b/modules/reviews/classes/feedback-handler.php new file mode 100644 index 00000000..1163f440 --- /dev/null +++ b/modules/reviews/classes/feedback-handler.php @@ -0,0 +1,42 @@ +make_request( + 'POST', + self::SERVICE_ENDPOINT, + $params + ); + + if ( empty( $response ) || is_wp_error( $response ) ) { + throw new Exception( 'Failed to add the feedback.' ); + } + + return $response; + } +} diff --git a/modules/reviews/classes/route-base.php b/modules/reviews/classes/route-base.php new file mode 100644 index 00000000..a0bdcd9d --- /dev/null +++ b/modules/reviews/classes/route-base.php @@ -0,0 +1,39 @@ +get_path(); + } + + public function get_path(): string { + return $this->path; + } + + public function get_name(): string { + return 'reviews'; + } + + public function get_permission_callback( \WP_REST_Request $request ): bool { + $valid = $this->permission_callback( $request ); + + return $valid && user_can( $this->current_user_id, 'manage_options' ); + } +} diff --git a/modules/reviews/module.php b/modules/reviews/module.php new file mode 100644 index 00000000..19cb58f6 --- /dev/null +++ b/modules/reviews/module.php @@ -0,0 +1,218 @@ +'; + } + + /** + * Enqueue Scripts and Styles + */ + public function enqueue_scripts( $hook ): void { + if ( SettingsModule::SETTING_PAGE_SLUG !== $hook ) { + return; + } + + if ( ! Connect::is_connected() ) { + return; + } + + if ( ! $this->maybe_show_review_popup() ) { + return; + } + + Utils\Assets::enqueue_app_assets( 'reviews' ); + + wp_localize_script( + 'reviews', + 'ea11yReviewData', + [ + 'wpRestNonce' => wp_create_nonce( 'wp_rest' ), + 'reviewData' => $this->get_review_data(), + 'isRTL' => is_rtl(), + ] + ); + + $this->render_app(); + } + + public function register_base_data(): void { + + if ( get_option( self::REVIEW_DATA_OPTION ) ) { + return; + } + + $data = [ + 'dismissals' => 0, + 'hide_for_days' => 0, + 'last_dismiss' => null, + 'rating' => null, + 'feedback' => null, + 'added_on' => gmdate( 'Y-m-d H:i:s' ), + 'submitted' => false, + 'repo_review_clicked' => false, + ]; + + update_option( self::REVIEW_DATA_OPTION, $data, false ); + } + + /** + * Register settings. + * + * Register settings for the plugin. + * + * @return void + * @throws Throwable + */ + public function register_settings(): void { + $settings = [ + 'review_data' => [ + 'type' => 'object', + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + ], + ], + ]; + + foreach ( $settings as $setting => $args ) { + if ( ! isset( $args['show_in_rest'] ) ) { + $args['show_in_rest'] = true; + } + register_setting( 'options', SettingsModule::SETTING_PREFIX . $setting, $args ); + } + } + + public function get_review_data(): array { + return get_option( self::REVIEW_DATA_OPTION ); + } + + /** + * Get the number of days since the plugin was installed. + * + * @return int The number of days since the plugin was installed. + */ + public function get_days_since_installed() { + $registered_at = Settings::get( Settings::PLAN_DATA )->site->registered_at ?? null; + if ( ! $registered_at ) { + return 0; + } + $days = floor( ( time() - strtotime( $registered_at ) ) / DAY_IN_SECONDS ); + return max( 0, $days ); + } + + /** + * Check if the settings have been modified by comparing them with the default settings. + * @return bool + */ + public function check_if_settings_modified() { + + // Get the current settings. + $current_widget_menu_settings = Settings::get( Settings::WIDGET_MENU_SETTINGS ); + $current_widget_icon_settings = Settings::get( Settings::WIDGET_ICON_SETTINGS ); + $current_skip_to_content_settings = Settings::get( Settings::SKIP_TO_CONTENT ); + + if ( ! $current_widget_menu_settings || ! $current_widget_icon_settings || ! $current_skip_to_content_settings ) { + return false; + } + + // Get the default settings. + $widget_menu_settings = SettingsModule::get_default_settings( 'widget_menu_settings' ); + $widget_icon_settings = SettingsModule::get_default_settings( 'widget_icon_settings' ); + $skip_to_content_settings = SettingsModule::get_default_settings( 'skip_to_content_settings' ); + + // Check if the current settings match the default settings. + if ( $current_widget_menu_settings !== $widget_menu_settings || $current_widget_icon_settings !== $widget_icon_settings || $current_skip_to_content_settings !== $skip_to_content_settings ) { + return true; + } + + return false; + } + + /** + * Maybe show the review popup. + * Check if the review popup should be shown based on various conditions. + * @return bool + */ + public function maybe_show_review_popup() { + if ( $this->check_if_settings_modified() && $this->get_days_since_installed() > 1 ) { + + $review_data = $this->get_review_data(); + + // Don't show if user has already submitted feedback when rating is less than 4. + if ( isset( $review_data['rating'] ) && (int) $review_data['rating'] < 4 ) { + return false; + } + + // Hide if rating is submitted but repo review is not clicked. + if ( (int) $review_data['rating'] > 3 && $review_data['repo_review_clicked'] ) { + return false; + } + + // Don't show if user has dismissed the popup 3 times. + if ( 3 === (int) $review_data['dismissals'] ) { + return false; + } + + if ( isset( $review_data['hide_for_days'] ) && $review_data['hide_for_days'] > 0 ) { + $hide_for_days = $review_data['hide_for_days']; + $last_dismiss = strtotime( $review_data['last_dismiss'] ); + $days_since_dismiss = floor( ( time() - $last_dismiss ) / DAY_IN_SECONDS ); + + if ( $days_since_dismiss < $hide_for_days ) { + return false; + } + } + + return true; + } + + return false; + } + + public function __construct() { + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); + add_action( 'admin_init', [ $this, 'register_base_data' ] ); + add_action( 'rest_api_init', [ $this, 'register_settings' ] ); + + $this->register_routes(); + } +} diff --git a/modules/reviews/rest/feedback.php b/modules/reviews/rest/feedback.php new file mode 100644 index 00000000..55ef7f52 --- /dev/null +++ b/modules/reviews/rest/feedback.php @@ -0,0 +1,58 @@ +verify_capability(); + + if ( $error ) { + return $error; + } + + $params = $request->get_json_params(); + // Prepare for use + $params['feedback'] = sanitize_text_field( $params['feedback'] ); + $params['rating'] = sanitize_text_field( $params['rating'] ); + $params['app_name'] = 'ally'; + + $response = Feedback_Handler::post_feedback( $params ); + + return $this->respond_success_json( $response ); + + } catch ( Throwable $t ) { + return $this->respond_error_json( [ + 'message' => $t->getMessage(), + 'code' => 'internal_server_error', + ] ); + } + } +} diff --git a/modules/scanner/assets/js/api/APIScanner.js b/modules/scanner/assets/js/api/APIScanner.js index 5935ce7e..294b43fb 100644 --- a/modules/scanner/assets/js/api/APIScanner.js +++ b/modules/scanner/assets/js/api/APIScanner.js @@ -119,4 +119,12 @@ export class APIScanner extends API { data, }); } + + static async clearCache(data) { + return APIScanner.request({ + method: 'DELETE', + path: `${v1Prefix}/remediation/clear-cache`, + data, + }); + } } diff --git a/modules/scanner/assets/js/components/color-contrast-form/color-set-disabled.js b/modules/scanner/assets/js/components/color-contrast-form/color-set-disabled.js new file mode 100644 index 00000000..140addcc --- /dev/null +++ b/modules/scanner/assets/js/components/color-contrast-form/color-set-disabled.js @@ -0,0 +1,106 @@ +import LockFilledIcon from '@elementor/icons/LockFilledIcon'; +import RotateIcon from '@elementor/icons/RotateIcon'; +import Box from '@elementor/ui/Box'; +import Button from '@elementor/ui/Button'; +import InputAdornment from '@elementor/ui/InputAdornment'; +import Slider from '@elementor/ui/Slider'; +import TextField from '@elementor/ui/TextField'; +import Tooltip from '@elementor/ui/Tooltip'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; +import { UnstableColorIndicator } from '@elementor/ui/unstable'; +import PropTypes from 'prop-types'; +import { SunIcon, SunOffIcon } from '@ea11y-apps/scanner/images'; +import { __ } from '@wordpress/i18n'; + +export const ColorSetDisabled = ({ title, description }) => { + return ( + + + + {title} + + + + + + + + + + + + + ), + }} + /> + + + + + + + ); +}; + +const StyledColorSet = styled(Box)` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +ColorSetDisabled.propTypes = { + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, +}; diff --git a/modules/scanner/assets/js/components/color-contrast-form/index.js b/modules/scanner/assets/js/components/color-contrast-form/index.js index 28a674cc..6176623e 100644 --- a/modules/scanner/assets/js/components/color-contrast-form/index.js +++ b/modules/scanner/assets/js/components/color-contrast-form/index.js @@ -1,22 +1,21 @@ import Alert from '@elementor/ui/Alert'; import AlertTitle from '@elementor/ui/AlertTitle'; -import Box from '@elementor/ui/Box'; import Button from '@elementor/ui/Button'; import Divider from '@elementor/ui/Divider'; import Typography from '@elementor/ui/Typography'; import PropTypes from 'prop-types'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; import { ColorSet } from '@ea11y-apps/scanner/components/color-contrast-form/color-set'; +import { ColorSetDisabled } from '@ea11y-apps/scanner/components/color-contrast-form/color-set-disabled'; import { ParentSelector } from '@ea11y-apps/scanner/components/color-contrast-form/parent-selector'; import { BLOCKS } from '@ea11y-apps/scanner/constants'; import { useColorContrastForm } from '@ea11y-apps/scanner/hooks/use-color-contrast-form'; import { StyledBox } from '@ea11y-apps/scanner/styles/app.styles'; import { scannerItem } from '@ea11y-apps/scanner/types/scanner-item'; -import { - checkContrastAA, - isLargeText, -} from '@ea11y-apps/scanner/utils/calc-color-ratio'; -import { __, sprintf } from '@wordpress/i18n'; +import { checkContrastAA } from '@ea11y-apps/scanner/utils/calc-color-ratio'; +import { rgbOrRgbaToHex } from '@ea11y-apps/scanner/utils/convert-colors'; +import { useEffect, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; export const ColorContrastForm = ({ items, current, setCurrent }) => { const item = items[current]; @@ -38,17 +37,19 @@ export const ColorContrastForm = ({ items, current, setCurrent }) => { setCurrent, }); - const isPossibleToResolve = item.messageArgs[3] && item.messageArgs[4]; + const colorRef = useRef(null); - const passRatio = isLargeText(item.node) ? '3:1' : '4.5:1'; + useEffect(() => { + if (item?.node) { + colorRef.current = rgbOrRgbaToHex( + window.getComputedStyle(item.node).getPropertyValue('color'), + ); + } + }, [item]); - const colorData = - color && background - ? checkContrastAA(color, background, item.node) - : { - ratio: item.messageArgs[0], - passesAA: false, - }; + const isBackgroundEnabled = item.messageArgs[3] && item.messageArgs[4]; + + const colorData = checkContrastAA(item.node); const handleSubmit = async () => { await onSubmit(); @@ -65,58 +66,37 @@ export const ColorContrastForm = ({ items, current, setCurrent }) => { return ( - {!isPossibleToResolve ? ( - <> - - - {__('What’s the issue?', 'pojo-accessibility')} - - - {__( - 'Adjust the text or background lightness until the indicator shows an accessible level.', - 'pojo-accessibility', - )} - - - - - {__('How to resolve?', 'pojo-accessibility')} - - - {sprintf( - // Translators: %s - color ratio - __( - 'To meet accessibility standards, update the text or background color to reach a contrast ratio of at least %s', - 'pojo-accessibility', - ), - passRatio, - )} - - - + + + {__( + 'Adjust the text or background lightness until the indicator shows an accessible level.', + 'pojo-accessibility', + )} + + + + {isBackgroundEnabled ? ( + ) : ( - <> - - {__( - 'Adjust the text or background lightness until the indicator shows an accessible level.', - 'pojo-accessibility', - )} - - - - + )} {backgroundChanged && ( @@ -132,18 +112,17 @@ export const ColorContrastForm = ({ items, current, setCurrent }) => { {colorData.ratio} - {isPossibleToResolve && ( - - )} + + ); }; diff --git a/modules/scanner/assets/js/components/header/dropdown-menu.js b/modules/scanner/assets/js/components/header/dropdown-menu.js index af810db1..117505f9 100644 --- a/modules/scanner/assets/js/components/header/dropdown-menu.js +++ b/modules/scanner/assets/js/components/header/dropdown-menu.js @@ -1,4 +1,5 @@ import CalendarDollarIcon from '@elementor/icons/CalendarDollarIcon'; +import ClearIcon from '@elementor/icons/ClearIcon'; import DotsHorizontalIcon from '@elementor/icons/DotsHorizontalIcon'; import ExternalLinkIcon from '@elementor/icons/ExternalLinkIcon'; import RefreshIcon from '@elementor/icons/RefreshIcon'; @@ -11,7 +12,9 @@ import MenuItemIcon from '@elementor/ui/MenuItemIcon'; import MenuItemText from '@elementor/ui/MenuItemText'; import Tooltip from '@elementor/ui/Tooltip'; import { ELEMENTOR_URL } from '@ea11y-apps/global/constants'; +import { useToastNotification } from '@ea11y-apps/global/hooks'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner'; import { BLOCKS } from '@ea11y-apps/scanner/constants'; import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; import { DisabledMenuItemText } from '@ea11y-apps/scanner/styles/app.styles'; @@ -21,7 +24,9 @@ import { __ } from '@wordpress/i18n'; export const DropdownMenu = () => { const { remediations, isManage, setOpenedBlock, setIsManage, runNewScan } = useScannerWizardContext(); + const { error } = useToastNotification(); const [isOpened, setIsOpened] = useState(false); + const [loading, setLoading] = useState(false); const anchorEl = useRef(null); const handleOpen = () => { @@ -41,6 +46,21 @@ export const DropdownMenu = () => { sendOnClickEvent('Rescan'); }; + const onClearCache = async () => { + try { + setLoading(true); + await APIScanner.clearCache({ + url: window.ea11yScannerData?.pageData?.url, + }); + sendOnClickEvent('Clear cache'); + handleClose(); + } catch (e) { + error(__('An error occurred.', 'pojo-accessibility')); + } finally { + setLoading(false); + } + }; + const goToManagement = () => { handleClose(); setIsManage(true); @@ -86,6 +106,31 @@ export const DropdownMenu = () => { {__('Rescan', 'pojo-accessibility')} + {remediations.length > 0 ? ( + + + + + + + {__('Clear cache', 'pojo-accessibility')} + + + ) : ( + + + + + + {__('Clear page cache', 'pojo-accessibility')} + + + )} {!remediations.length ? ( { isChanged, setOpenedBlock, setIsManage, + violation, } = useScannerWizardContext(); - const violation = results?.summary?.counts?.violation; + const onClose = () => { if (isManage) { setIsManage(false); diff --git a/modules/scanner/assets/js/constants/index.js b/modules/scanner/assets/js/constants/index.js index b4efd439..e3980de2 100644 --- a/modules/scanner/assets/js/constants/index.js +++ b/modules/scanner/assets/js/constants/index.js @@ -1,6 +1,8 @@ import { __ } from '@wordpress/i18n'; export const TOP_BAR_LINK = '#wp-admin-bar-ea11y-scanner-wizard a'; +export const SCAN_LINK = '#wp-admin-bar-ea11y-scan-page a'; +export const CLEAR_CACHE_LINK = '#wp-admin-bar-ea11y-clear-cache a'; export const SCANNER_URL_PARAM = 'open-ea11y-assistant'; export const MANAGE_URL_PARAM = 'open-ea11y-manage'; diff --git a/modules/scanner/assets/js/context/scanner-wizard-context.js b/modules/scanner/assets/js/context/scanner-wizard-context.js index e633a512..ea5b68c2 100644 --- a/modules/scanner/assets/js/context/scanner-wizard-context.js +++ b/modules/scanner/assets/js/context/scanner-wizard-context.js @@ -81,6 +81,7 @@ export const ScannerWizardContextProvider = ({ children }) => { structuredClone(MANUAL_GROUPS), ); const [openIndex, setOpenIndex] = useState(null); + const [violation, setViolation] = useState(null); useEffect(() => { const items = isManage @@ -113,6 +114,16 @@ export const ScannerWizardContextProvider = ({ children }) => { } }, [sortedRemediation]); + useEffect(() => { + if (results?.summary?.counts) { + const total = Object.values(sortedViolations).reduce( + (sum, arr) => sum + arr.length, + 0, + ); + setViolation(total); + } + }, [sortedViolations, results]); + const updateRemediationList = async () => { try { const items = await APIScanner.getRemediations( @@ -145,13 +156,6 @@ export const ScannerWizardContextProvider = ({ children }) => { setOpenIndex(null); }; - const initialViolations = - window.ea11yScannerData.initialScanResult?.counts?.violation ?? 0; - const violation = - results?.summary?.counts?.violation >= 0 - ? results?.summary?.counts?.violation - : null; - const registerPage = async (data, sorted) => { try { if (window?.ea11yScannerData?.pageData?.unregistered) { @@ -159,16 +163,13 @@ export const ScannerWizardContextProvider = ({ children }) => { window?.ea11yScannerData?.pageData, data.summary, ); + window.ea11yScannerData.pageData.unregistered = false; } + setResults(data); setSortedViolations(sorted); setAltTextData([]); setManualData(structuredClone(MANUAL_GROUPS)); - setResolved( - initialViolations >= data.summary?.counts?.issuesResolved - ? data.summary?.counts?.issuesResolved - : 0, - ); } catch (e) { if (e?.message === 'Quota exceeded') { setQuotaExceeded(true); diff --git a/modules/scanner/assets/js/hooks/use-color-contrast-form.js b/modules/scanner/assets/js/hooks/use-color-contrast-form.js index 185c4961..a7161666 100644 --- a/modules/scanner/assets/js/hooks/use-color-contrast-form.js +++ b/modules/scanner/assets/js/hooks/use-color-contrast-form.js @@ -10,6 +10,7 @@ import { } from '@ea11y-apps/scanner/constants'; import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; import { scannerItem } from '@ea11y-apps/scanner/types/scanner-item'; +import { rgbOrRgbaToHex } from '@ea11y-apps/scanner/utils/convert-colors'; import { focusOnElement, removeExistingFocus, @@ -28,6 +29,7 @@ export const useColorContrastForm = ({ item, current, setCurrent }) => { setOpenedBlock, setManualData, updateRemediationList, + currentScanId, } = useScannerWizardContext(); const [loading, setLoading] = useState(false); @@ -68,7 +70,10 @@ export const useColorContrastForm = ({ item, current, setCurrent }) => { }, [item]); const { - color = item.messageArgs[3], + color = item.messageArgs[3] || + rgbOrRgbaToHex( + window.getComputedStyle(item.node).getPropertyValue('color'), + ), background = item.messageArgs[4], parents = [item.path.dom], resolved = false, @@ -163,7 +168,7 @@ export const useColorContrastForm = ({ item, current, setCurrent }) => { }; const isValidHexColor = (str) => - /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(str.trim()); + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(str.trim()); const isValidCSS = (cssText) => { try { @@ -203,9 +208,13 @@ export const useColorContrastForm = ({ item, current, setCurrent }) => { }; const buildCSSRule = () => { - if (!isValidHexColor(color) || !isValidHexColor(background)) { + if ( + !isValidHexColor(color) || + (background && !isValidHexColor(background)) + ) { throw new Error('Invalid hex color input detected'); } + try { const colorSelector = getElementCSSSelector(item.path.dom); const bgSelector = getElementCSSSelector( @@ -216,8 +225,9 @@ export const useColorContrastForm = ({ item, current, setCurrent }) => { color !== item.messageArgs[3] ? `${colorSelector} {color: ${color} !important;}` : ''; + const bgRule = - background !== item.messageArgs[4] + background && background !== item.messageArgs[4] ? `${bgSelector} {background-color: ${background} !important;}` : ''; @@ -245,6 +255,8 @@ export const useColorContrastForm = ({ item, current, setCurrent }) => { group: BLOCKS.colorContrast, }); + await APIScanner.resolveIssue(currentScanId); + updateData({ resolved: true }); item.node?.removeAttribute(DATA_INITIAL_COLOR); diff --git a/modules/scanner/assets/js/index.js b/modules/scanner/assets/js/index.js index 75097d87..b4b85d62 100644 --- a/modules/scanner/assets/js/index.js +++ b/modules/scanner/assets/js/index.js @@ -5,11 +5,14 @@ import { CacheProvider } from '@emotion/react'; import { prefixer } from 'stylis'; import rtlPlugin from 'stylis-plugin-rtl'; import { NotificationsProvider } from '@ea11y-apps/global/hooks/use-notifications'; +import { APIScanner } from '@ea11y-apps/scanner/api/APIScanner'; import App from '@ea11y-apps/scanner/app'; import { + CLEAR_CACHE_LINK, isRTL, MANAGE_URL_PARAM, ROOT_ID, + SCAN_LINK, SCANNER_URL_PARAM, TOP_BAR_LINK, } from '@ea11y-apps/scanner/constants'; @@ -20,20 +23,36 @@ import { __ } from '@wordpress/i18n'; document.addEventListener('DOMContentLoaded', function () { const params = new URLSearchParams(window.location.search); - document.querySelector(TOP_BAR_LINK)?.addEventListener('click', (event) => { - event.preventDefault(); - const rootNode = document.getElementById(ROOT_ID); - const url = new URL(window.location.href); - url.searchParams.delete('open-ea11y-assistant-src'); - url.searchParams.append('open-ea11y-assistant-src', 'top_bar'); - history.replaceState(null, '', url); - if (rootNode) { - closeWidget(rootNode); - } else { - initApp(); - } - }); + document + .querySelector(CLEAR_CACHE_LINK) + ?.addEventListener('click', async (event) => { + event.preventDefault(); + try { + await APIScanner.clearCache(); + window.location.reload(); + } catch (e) { + console.error(e); + } + }); + document + .querySelectorAll(`${TOP_BAR_LINK}, ${SCAN_LINK}`) + ?.forEach((link) => { + link.addEventListener('click', (event) => { + event.preventDefault(); + const rootNode = document.getElementById(ROOT_ID); + const url = new URL(window.location.href); + url.searchParams.delete('open-ea11y-assistant-src'); + url.searchParams.append('open-ea11y-assistant-src', 'top_bar'); + history.replaceState(null, '', url); + + if (rootNode) { + closeWidget(rootNode); + } else { + initApp(); + } + }); + }); if ( params.get(SCANNER_URL_PARAM) === '1' || params.get(MANAGE_URL_PARAM) === '1' @@ -72,6 +91,7 @@ const initApp = () => { // Can't use the settings hook in the global scope so accessing directly const isDevelopment = window?.ea11ySettingsData?.isDevelopment; const AppWrapper = Boolean(isDevelopment) ? StrictMode : Fragment; + const cache = createCache({ key: 'css', prepend: true, diff --git a/modules/scanner/assets/js/utils/calc-color-ratio.js b/modules/scanner/assets/js/utils/calc-color-ratio.js index 4d4bb116..38859936 100644 --- a/modules/scanner/assets/js/utils/calc-color-ratio.js +++ b/modules/scanner/assets/js/utils/calc-color-ratio.js @@ -1,4 +1,4 @@ -import { hexToRGB } from '@ea11y-apps/scanner/utils/convert-colors'; +import { ColorUtil } from '@ea11y-apps/scanner/utils/colorUtil'; export const getLuminance = (r, g, b) => { const toLinear = (c) => { @@ -27,10 +27,16 @@ export const isLargeText = (el) => { return size >= threshold; }; -export const checkContrastAA = (fgHex, bgHex, el) => { - const fg = hexToRGB(fgHex); - const bg = hexToRGB(bgHex); - const ratio = contrastRatio(fg, bg); +export const checkContrastAA = (el) => { + // First determine the color contrast ratio + const colorCombo = ColorUtil.ColorCombo(el); + if (colorCombo === null) { + //some exception occurred, or not able to get color combo for some reason + throw new Error('unable to get color combo for element: ' + el.nodeName); + } + const fg = colorCombo.fg; + const bg = colorCombo.bg; + const ratio = fg.contrastRatio(bg); const large = isLargeText(el); const passesAA = ratio >= (large ? 3 : 4.5); return { diff --git a/modules/scanner/assets/js/utils/colorUtil.js b/modules/scanner/assets/js/utils/colorUtil.js new file mode 100644 index 00000000..037fd7ec --- /dev/null +++ b/modules/scanner/assets/js/utils/colorUtil.js @@ -0,0 +1,639 @@ +const parentNode = (node) => { + if (node === null) { + return null; + } + let p = node.parentNode; + if (node.slotOwner) { + p = node.slotOwner; + } else if (node.ownerElement) { + p = node.ownerElement; + } else if (p && p.nodeType === 11) { + if (p.host) { + p = p.host; + } else { + p = null; + } + } + return p; +}; +const parentElement = (node) => { + let elem = node; + do { + elem = parentNode(elem); + } while (elem && elem.nodeType !== 1); + return elem; +}; + +export class ColorUtil { + static CSSColorLookup = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgreen: '#006400', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + green: '#008000', + greenyellow: '#adff2f', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgrey: '#d3d3d3', + lightgreen: '#90ee90', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370d8', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#d87093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32', + buttontext: 'rgba(0, 0, 0, 0.847)', + buttonface: '#ffffff', + graytext: 'rgba(0, 0, 0, 0.247)', + }; + + // Rewrite the color object to account for alpha + static Color(cssStyleColor) { + if (!cssStyleColor) { + return null; + } + cssStyleColor = cssStyleColor.toLowerCase(); + if (cssStyleColor === 'transparent') { + return new ColorObj(255, 255, 255, 0); + } + if (cssStyleColor in ColorUtil.CSSColorLookup) { + cssStyleColor = ColorUtil.CSSColorLookup[cssStyleColor]; + } + if (cssStyleColor.startsWith('rgb(')) { + const rgbRegex = /\s*rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/; + const m = cssStyleColor.match(rgbRegex); + if (m === null) { + return null; + } + + return new ColorObj(m[1], m[2], m[3]); + } else if (cssStyleColor.startsWith('rgba(')) { + const rgbRegex = + /\s*rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(.+)\s*\)/; + const m = cssStyleColor.match(rgbRegex); + if (m === null) { + return null; + } + + return new ColorObj(m[1], m[2], m[3], m[4]); + } else if (cssStyleColor.charAt(0) !== '#') { + return null; + } + if (cssStyleColor.length === 4) { + // The three-digit RGB (#rgb) is converted to six-digit form (#rrggbb) by replicating digits + // (https://www.w3.org/TR/css-color-3/#rgb-color) + cssStyleColor = + '#' + + cssStyleColor.charAt(1).repeat(2) + + cssStyleColor.charAt(2).repeat(2) + + cssStyleColor.charAt(3).repeat(2); + } + const thisRed = parseInt(cssStyleColor.substring(1, 3), 16); + const thisGreen = parseInt(cssStyleColor.substring(3, 5), 16); + const thisBlue = parseInt(cssStyleColor.substring(5, 7), 16); + return new ColorObj(thisRed, thisGreen, thisBlue); + + // return null; // Unreachable + } + + static ColorCombo(ruleContext) { + try { + const doc = ruleContext.ownerDocument; + if (!doc) { + return null; + } + const win = doc.defaultView; + if (!win) { + return null; + } + + const ancestors = []; + let walkNode = ruleContext; + while (walkNode) { + if (walkNode.nodeType === 1) { + ancestors.push(walkNode); + } + walkNode = parentElement(walkNode); + } + + const retVal = { + hasGradient: false, + hasBGImage: false, + textShadow: false, + fg: null, + bg: null, + }; + + // start + let cStyle = win.getComputedStyle(ruleContext); + let compStyleColor = cStyle.color; + if (!compStyleColor) { + compStyleColor = 'black'; + } + let fg = ColorUtil.Color(compStyleColor); + const reColor = /transparent|rgba?\([^)]+\)/gi; + const guessGradColor = function (gradList, bgColor, fgColor) { + try { + // If there's only one color, return that + if (typeof gradList.length === 'undefined') { + return gradList; + } + + let overallWorst = null; + let overallWorstRatio = null; + for (let iGrad = 1; iGrad < gradList.length; ++iGrad) { + let worstColor = gradList[iGrad - 1]; + let worstRatio = fgColor.contrastRatio(gradList[iGrad - 1]); + let step = 0.1; + let idx = 0; + while (step > 0.0001) { + while ( + idx + step <= 1 && + worstRatio > + fgColor.contrastRatio( + gradList[iGrad] + .mix(gradList[iGrad - 1], idx + step) + .getOverlayColor(bgColor), + ) + ) { + worstColor = gradList[iGrad] + .mix(gradList[iGrad - 1], idx + step) + .getOverlayColor(bgColor); + worstRatio = fgColor.contrastRatio(worstColor); + idx = idx + step; + } + while ( + idx - step >= 0 && + worstRatio > + fgColor.contrastRatio( + gradList[iGrad] + .mix(gradList[iGrad - 1], idx - step) + .getOverlayColor(bgColor), + ) + ) { + worstColor = gradList[iGrad] + .mix(gradList[iGrad - 1], idx - step) + .getOverlayColor(bgColor); + worstRatio = fgColor.contrastRatio(worstColor); + idx = idx - step; + } + step = step / 10; + } + if (overallWorstRatio === null || overallWorstRatio > worstRatio) { + overallWorstRatio = worstRatio; + overallWorst = worstColor; + } + } + return overallWorst; // return the darkest color + } catch (e) { + console.log(e); + } + return bgColor; + }; + + let priorStackBG = ColorUtil.Color('white'); + let thisStackOpacity = null; + let thisStackAlpha = null; + let thisStackBG = null; + // Ancestors processed from the topmost parent toward the child + while (ancestors.length > 0) { + const procNext = ancestors.pop(); + //let procNext = ancestors.splice(0, 1)[0]; + // cStyle is the computed style of this layer + cStyle = win.getComputedStyle(procNext); + if (cStyle === null) { + continue; + } + + // thisBgColor is the color of this layer or null if the layer is transparent + let thisBgColor = null; + if ( + cStyle.backgroundColor && + cStyle.backgroundColor !== 'transparent' && + cStyle.backgroundColor !== 'rgba(0, 0, 0, 0)' + ) { + thisBgColor = ColorUtil.Color(cStyle.backgroundColor); + } + // If there is a gradient involved, set thisBgColor to the worst color combination available against the foreground + if ( + cStyle.backgroundImage && + cStyle.backgroundImage.indexOf && + cStyle.backgroundImage.indexOf('gradient') !== -1 + ) { + const gradColors = cStyle.backgroundImage.match(reColor); + if (gradColors) { + const gradColorComp = []; + for (let i = 0; i < gradColors.length; ++i) { + if (!gradColors[i].length) { + gradColors.splice(i--, 1); + } else { + let colorComp = ColorUtil.Color(gradColors[i]); + if (colorComp.alpha !== undefined && colorComp.alpha < 1) { + // mix the grdient bg color wit parent bg if alpha < 1 + const compStackBg = thisStackBG || priorStackBG; + colorComp = colorComp.getOverlayColor(compStackBg); + } + gradColorComp.push(colorComp); + } + } + thisBgColor = guessGradColor( + gradColorComp, + thisStackBG || priorStackBG, + fg, + ); + } + } + + // Handle non-solid opacity + if ( + thisStackOpacity === null || + (cStyle.opacity && + cStyle.opacity.length > 0 && + parseFloat(cStyle.opacity) < 1) + ) { + // New stack, reset + if (thisStackBG !== null) { + // Overlay + thisStackBG.alpha = thisStackOpacity * thisStackAlpha; + priorStackBG = thisStackBG.getOverlayColor(priorStackBG); + } + thisStackOpacity = 1.0; + thisStackAlpha = null; + thisStackBG = null; + if (cStyle.opacity && cStyle.opacity.length > 0) { + thisStackOpacity = parseFloat(cStyle.opacity); + } + if (thisBgColor !== null) { + thisStackBG = thisBgColor; + thisStackAlpha = thisStackBG.alpha || 1.0; + delete thisStackBG.alpha; + if (thisStackOpacity === 1.0 && thisStackAlpha === 1.0) { + retVal.hasBGImage = false; + retVal.hasGradient = false; + } + } + } + // Handle solid color backgrounds and gradient color backgrounds + else if (thisBgColor !== null) { + // If this stack already has a background color, blend it + if (thisStackBG === null) { + thisStackBG = thisBgColor; + thisStackAlpha = thisStackBG.alpha || 1.0; + delete thisStackBG.alpha; + } else { + thisStackBG = thisBgColor.getOverlayColor(thisStackBG); + //thisStackAlpha = thisBgColor.alpha || 1.0; + thisStackAlpha = thisStackBG.alpha || 1.0; + } + // #526: If thisBgColor had an alpha value, it may not expose through thisStackBG in the above code + // We can't wipe out the gradient info if this layer was transparent + if ( + thisStackOpacity === 1.0 && + thisStackAlpha === 1.0 && + (thisStackBG.alpha || 1.0) === 1.0 && + (thisBgColor.alpha || 1.0) === 0 + ) { + retVal.hasBGImage = false; + retVal.hasGradient = false; + } + } + if (cStyle.backgroundImage && cStyle.backgroundImage !== 'none') { + if ( + cStyle.backgroundImage.indexOf && + cStyle.backgroundImage.indexOf('gradient') !== -1 + ) { + retVal.hasGradient = true; + } else { + retVal.hasBGImage = true; + } + } + } + if (thisStackBG !== null) { + fg = fg.getOverlayColor(thisStackBG); + delete fg.alpha; + } + fg.alpha = (fg.alpha || 1) * thisStackOpacity; + fg = fg.getOverlayColor(priorStackBG); + if (thisStackBG !== null) { + thisStackBG.alpha = thisStackOpacity * thisStackAlpha; + priorStackBG = thisStackBG.getOverlayColor(priorStackBG); + } + retVal.fg = fg; + retVal.bg = priorStackBG; + + if (cStyle.textShadow && cStyle.textShadow !== 'none') { + retVal.textShadow = true; + } + + return retVal; + } catch (err) { + // something happened, then... + return null; + } + } +} + +export class ColorObj { + red; + green; + blue; + alpha; + + constructor(red, green, blue, alpha) { + function fixComponent(comp) { + if (typeof comp !== typeof '') { + return comp; + } + let compStr = comp; + compStr = compStr.trim(); + if (compStr[compStr.length - 1] !== '%') { + return parseInt(compStr); + } + return Math.round( + parseFloat(compStr.substring(0, compStr.length - 1)) * 2.55, + ); + } + this.red = fixComponent(red); + this.green = fixComponent(green); + this.blue = fixComponent(blue); + if (typeof alpha !== 'undefined') { + this.alpha = typeof alpha === typeof '' ? parseFloat(alpha) : alpha; + } + } + + toHexHelp(value) { + const retVal = Math.round(value).toString(16); + if (retVal.length === 1) { + return '0' + retVal; + } + return retVal; + } + + toHex() { + return ( + '#' + + this.toHexHelp(this.red) + + this.toHexHelp(this.green) + + this.toHexHelp(this.blue) + ); + } + + contrastRatio(bgColor) { + let fgColor = this; + + if (typeof this.alpha !== 'undefined') { + fgColor = this.getOverlayColor(bgColor); + } + + const lum1 = fgColor.relativeLuminance(); + + const lum2 = bgColor.relativeLuminance(); + + return lum1 > lum2 + ? (lum1 + 0.05) / (lum2 + 0.05) + : (lum2 + 0.05) / (lum1 + 0.05); + } + + relativeLuminance() { + let R = this.red / 255.0; + let G = this.green / 255.0; + let B = this.blue / 255.0; + R = R <= 0.04045 ? R / 12.92 : Math.pow((R + 0.055) / 1.055, 2.4); + G = G <= 0.04045 ? G / 12.92 : Math.pow((G + 0.055) / 1.055, 2.4); + B = B <= 0.04045 ? B / 12.92 : Math.pow((B + 0.055) / 1.055, 2.4); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; + } + + mix(color2, percThis) { + if ( + typeof this.alpha === 'undefined' && + typeof color2.alpha === 'undefined' + ) { + return new ColorObj( + percThis * this.red + (1 - percThis) * color2.red, + percThis * this.green + (1 - percThis) * color2.green, + percThis * this.blue + (1 - percThis) * color2.blue, + ); + } + const alphaThis = this.alpha ? this.alpha : 1; + const alphaOther = color2.alpha ? color2.alpha : 1; + return new ColorObj( + percThis * this.red + (1 - percThis) * color2.red, + percThis * this.green + (1 - percThis) * color2.green, + percThis * this.blue + (1 - percThis) * color2.blue, + percThis * alphaThis + (1 - percThis) * alphaOther, + ); + } + + getOverlayColor(bgColor) { + if (typeof this.alpha === 'undefined' || this.alpha >= 1) { + // No mixing required - it's opaque + return this; + } + if (this.alpha < 0) { + // Haac.Error.logError("Invalid alpha value"); + return null; + } + if (typeof bgColor.alpha !== 'undefined' && bgColor.alpha < 1) { + // Haac.Error.logError("Cannot mix with a background alpha"); + return null; + } + const retVal = this.mix(bgColor, this.alpha); + delete retVal.alpha; + return retVal; + } + + static fromCSSColor(cssStyleColor) { + let thisRed = -1; + let thisGreen = -1; + let thisBlue = -1; + + cssStyleColor = cssStyleColor.toLowerCase(); + if (cssStyleColor.startsWith('rgb(')) { + const rgbRegex = /\s*rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/; + const m = cssStyleColor.match(rgbRegex); + if (m === null) { + return null; + } + + thisRed = m[1]; + thisGreen = m[2]; + thisBlue = m[3]; + } else if (cssStyleColor.startsWith('rgba(')) { + const rgbRegex = + /\s*rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(.+)\s*\)/; + const m = cssStyleColor.match(rgbRegex); + if (m === null) { + return null; + } + + thisRed = m[1]; + thisGreen = m[2]; + thisBlue = m[3]; + } else { + if (cssStyleColor.charAt(0) !== '#') { + if (cssStyleColor in ColorUtil.CSSColorLookup) { + cssStyleColor = ColorUtil.CSSColorLookup[cssStyleColor]; + } else { + return null; + } + } + const fromHex = function (val) { + const lookup = { + a: 10, + b: 11, + c: 12, + d: 13, + e: 14, + f: 15, + }; + let retVal = 0; + for (let i = 0; i < val.length; ++i) { + retVal = + retVal * 16 + + parseInt( + val.charAt(i) in lookup ? lookup[val.charAt(i)] : val.charAt(i), + ); + } + return retVal; + }; + if (cssStyleColor.length === 4) { + // The three-digit RGB (#rgb) is converted to six-digit form (#rrggbb) by replicating digits + // (https://www.w3.org/TR/css-color-3/#rgb-color) + cssStyleColor = + '#' + + cssStyleColor.charAt(1).repeat(2) + + cssStyleColor.charAt(2).repeat(2) + + cssStyleColor.charAt(3).repeat(2); + } + thisRed = fromHex(cssStyleColor.substring(1, 3)); + thisGreen = fromHex(cssStyleColor.substring(3, 5)); + thisBlue = fromHex(cssStyleColor.substring(5, 7)); + } + return new ColorObj(thisRed, thisGreen, thisBlue); + } +} diff --git a/modules/scanner/assets/js/utils/convert-colors.js b/modules/scanner/assets/js/utils/convert-colors.js index 26e9b17a..f0edad97 100644 --- a/modules/scanner/assets/js/utils/convert-colors.js +++ b/modules/scanner/assets/js/utils/convert-colors.js @@ -9,6 +9,29 @@ export const expandHex = (hex) => { return `#${hex}`; }; +export const rgbOrRgbaToHex = (color) => { + const match = color.match( + /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i, + ); + if (!match) { + return null; + } // Not an RGB or RGBA string + + const r = parseInt(match[1]).toString(16).padStart(2, '0'); + const g = parseInt(match[2]).toString(16).padStart(2, '0'); + const b = parseInt(match[3]).toString(16).padStart(2, '0'); + + // If alpha present and less than 1, include it + if (match[4] !== undefined && parseFloat(match[4]) < 1) { + const a = Math.round(parseFloat(match[4]) * 255) + .toString(16) + .padStart(2, '0'); + return `#${r}${g}${b}${a}`.toUpperCase(); // 8-digit hex with alpha + } + + return `#${r}${g}${b}`.toUpperCase(); // 6-digit hex +}; + export const hexToRGB = (hex) => { hex = expandHex(hex).replace(/^#/, ''); const num = parseInt(hex, 16); @@ -18,14 +41,17 @@ export const hexToRGB = (hex) => { export const hexToHsl = (hex) => { hex = expandHex(hex).replace(/^#/, ''); + + // Handle optional alpha (default 255) + const hasAlpha = hex.length === 8; const r = parseInt(hex.slice(0, 2), 16) / 255; const g = parseInt(hex.slice(2, 4), 16) / 255; const b = parseInt(hex.slice(4, 6), 16) / 255; + const a = hasAlpha ? parseInt(hex.slice(6, 8), 16) / 255 : 1; const max = Math.max(r, g, b); const min = Math.min(r, g, b); - let h = (max + min) / 2; - let s = (max + min) / 2; + let h, s; const l = (max + min) / 2; if (max === min) { @@ -51,10 +77,11 @@ export const hexToHsl = (hex) => { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100), + a: parseFloat(a.toFixed(2)), // keep 2 decimal alpha }; }; -export const hslToHex = ({ h, s, l }) => { +export const hslToHex = ({ h, s, l, a = 1 }) => { s /= 100; l /= 100; @@ -62,10 +89,7 @@ export const hslToHex = ({ h, s, l }) => { const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; - let r; - let g; - let b; - + let r, g, b; if (h < 60) { [r, g, b] = [c, x, 0]; } else if (h < 120) { @@ -80,10 +104,16 @@ export const hslToHex = ({ h, s, l }) => { [r, g, b] = [c, 0, x]; } - const toHex = (v) => { - const hex = Math.round((v + m) * 255).toString(16); - return hex.length === 1 ? '0' + hex : hex; - }; + const toHex = (v) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, '0'); + + const alphaHex = Math.round(a * 255) + .toString(16) + .padStart(2, '0'); - return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + return a < 1 + ? `#${toHex(r)}${toHex(g)}${toHex(b)}${alphaHex}`.toUpperCase() + : `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); }; diff --git a/modules/scanner/components/top-bar-link.php b/modules/scanner/components/top-bar-link.php index cc429454..25b5adcf 100644 --- a/modules/scanner/components/top-bar-link.php +++ b/modules/scanner/components/top-bar-link.php @@ -31,6 +31,20 @@ public function add_bar_link() { 'title' => $svg_icon . esc_html__( 'Accessibility Assistant', 'pojo-accessibility' ), 'href' => '#', // Click event is handled by JS. ] ); + // Add scan page + $wp_admin_bar->add_node( [ + 'id' => 'ea11y-scan-page', + 'title' => esc_html__( 'Scan page', 'pojo-accessibility' ), + 'href' => '#', // Click event is handled by JS. + 'parent' => 'ea11y-scanner-wizard', + ] ); + // Add clear all cache + $wp_admin_bar->add_node( [ + 'id' => 'ea11y-clear-cache', + 'title' => esc_html__( 'Clear all cache', 'pojo-accessibility' ), + 'href' => '#', // Click event is handled by JS. + 'parent' => 'ea11y-scanner-wizard', + ] ); }, 200 ); } diff --git a/modules/scanner/module.php b/modules/scanner/module.php index 1f07d485..f3059e6b 100644 --- a/modules/scanner/module.php +++ b/modules/scanner/module.php @@ -106,6 +106,7 @@ public function enqueue_assets() : void { 'pluginVersion' => EA11Y_VERSION, 'isConnected' => Connect::is_connected(), 'isRTL' => is_rtl(), + 'isDevelopment' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG, ] ); } diff --git a/modules/settings/assets/js/app.js b/modules/settings/assets/js/app.js index 6104350e..f85b9a3c 100644 --- a/modules/settings/assets/js/app.js +++ b/modules/settings/assets/js/app.js @@ -9,6 +9,7 @@ import { Notifications, PostConnectModal, UrlMismatchModal, + OnboardingModal, } from '@ea11y/components'; import { useNotificationSettings, @@ -52,6 +53,7 @@ const App = () => { )} {isConnected && !closePostConnectModal && } {isUrlMismatch && !isConnected && } + diff --git a/modules/settings/assets/js/components/analytics/charts/pie-chart.js b/modules/settings/assets/js/components/analytics/charts/pie-chart.js index 735d7698..9872b129 100644 --- a/modules/settings/assets/js/components/analytics/charts/pie-chart.js +++ b/modules/settings/assets/js/components/analytics/charts/pie-chart.js @@ -71,7 +71,7 @@ export const PieChart = () => { series={[ { data: formatted, - innerRadius: chartWidth < 100 ? chartWidth - 20 : 80, + innerRadius: chartWidth < 100 ? chartWidth - 15 : 85, outerRadius: chartWidth < 100 ? chartWidth : 100, paddingAngle: 0, cornerRadius: 0, diff --git a/modules/settings/assets/js/components/index.js b/modules/settings/assets/js/components/index.js index 8a545d3a..700f2914 100644 --- a/modules/settings/assets/js/components/index.js +++ b/modules/settings/assets/js/components/index.js @@ -36,3 +36,4 @@ export { default as QuotaIndicator } from './quota-bar/quota-indicator'; export { default as MenuItem } from './sidebar-menu/menu-item'; export { default as QuotaBarPopupMenu } from './quota-bar/quota-popup-menu'; export { default as QuotaBarGroup } from './quota-bar/quota-bar-group'; +export { default as OnboardingModal } from './onboarding-modal'; diff --git a/modules/settings/assets/js/components/onboarding-modal/index.js b/modules/settings/assets/js/components/onboarding-modal/index.js new file mode 100644 index 00000000..668e3a70 --- /dev/null +++ b/modules/settings/assets/js/components/onboarding-modal/index.js @@ -0,0 +1,108 @@ +import Button from '@elementor/ui/Button'; +import Dialog from '@elementor/ui/Dialog'; +import DialogActions from '@elementor/ui/DialogActions'; +import DialogContent from '@elementor/ui/DialogContent'; +import DialogContentText from '@elementor/ui/DialogContentText'; +import DialogHeader from '@elementor/ui/DialogHeader'; +import DialogTitle from '@elementor/ui/DialogTitle'; +import { useModal, useStorage } from '@ea11y/hooks'; +import { AppLogo } from '@ea11y/icons'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { usePluginSettingsContext } from '../../contexts/plugin-settings'; + +const OnboardingModal = () => { + const { isOpen, close } = useModal(); + const { save } = useStorage(); + const { homeUrl, isConnected, closePostConnectModal, closeOnboardingModal } = + usePluginSettingsContext(); + const [shouldShowModal, setShouldShowModal] = useState(false); + + // Check if URL has source=admin_banner parameter + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const source = urlParams.get('source'); + setShouldShowModal(source === 'admin_banner'); + }, []); + + // Check if modal should be displayed based on all conditions + const shouldDisplayModal = + isOpen && + shouldShowModal && + isConnected && + closePostConnectModal && + !closeOnboardingModal; + + useEffect(() => { + if (shouldDisplayModal) { + mixpanelService.sendEvent(mixpanelEvents.introductionBannerShowed, { + source: 'page_view', + }); + } + }, [shouldDisplayModal]); + + const onClose = async () => { + await save({ + ea11y_close_onboarding_modal: true, + }); + + await mixpanelService.sendEvent(mixpanelEvents.introductionBannerClosed); + + close(); + }; + + return ( + + } onClose={onClose}> + {__('Ally', 'pojo-accessibility')} + + + + + + {__('See Ally’s new assistant in action', 'pojo-accessibility')} + + + {__( + 'Watch a quick demo to see how it works. Then launch your first scan to uncover issues and start improving your site.', + 'pojo-accessibility', + )} + + + + + + + + + ); +}; + +export default OnboardingModal; diff --git a/modules/settings/assets/js/components/sidebar-menu/menu-item.js b/modules/settings/assets/js/components/sidebar-menu/menu-item.js index aa5705e9..44a7761d 100644 --- a/modules/settings/assets/js/components/sidebar-menu/menu-item.js +++ b/modules/settings/assets/js/components/sidebar-menu/menu-item.js @@ -116,7 +116,7 @@ const MenuItem = ({ keyName, item }) => { {showProIcon(item) && openSidebar && ( } /> diff --git a/modules/settings/assets/js/components/upgrade-modal/index.js b/modules/settings/assets/js/components/upgrade-modal/index.js index afe7b635..649bf3e7 100644 --- a/modules/settings/assets/js/components/upgrade-modal/index.js +++ b/modules/settings/assets/js/components/upgrade-modal/index.js @@ -61,7 +61,7 @@ const UpgradeModal = () => { href={GOLINKS.ANALYTICS_POPUP} target="_blank" size="large" - color="accent" + color="promotion" startIcon={} variant="contained" sx={{ width: 300 }} diff --git a/modules/settings/assets/js/contexts/plugin-settings.js b/modules/settings/assets/js/contexts/plugin-settings.js index 68d31614..3d7e91d8 100644 --- a/modules/settings/assets/js/contexts/plugin-settings.js +++ b/modules/settings/assets/js/contexts/plugin-settings.js @@ -29,6 +29,12 @@ export const PluginSettingsProvider = ({ children }) => { ); } + if ('closeOnboardingModal' in settings) { + settings.closeOnboardingModal = Boolean( + settings.closeOnboardingModal, + ); + } + if ('isUrlMismatch' in settings) { settings.isUrlMismatch = Boolean(settings.isUrlMismatch); } @@ -37,6 +43,10 @@ export const PluginSettingsProvider = ({ children }) => { settings.unfilteredUploads = Boolean(settings.unfilteredUploads); } + if ('homeUrl' in settings) { + settings.homeUrl = settings.homeUrl; + } + setPluginSettings(settings); setLoaded(true); }) diff --git a/modules/settings/assets/js/pages/assistant/stats/category-pie-chart.js b/modules/settings/assets/js/pages/assistant/stats/category-pie-chart.js deleted file mode 100644 index 58ef2dfa..00000000 --- a/modules/settings/assets/js/pages/assistant/stats/category-pie-chart.js +++ /dev/null @@ -1,160 +0,0 @@ -import { - ColorBlue100, - ColorBlue200, - ColorBlue300, - ColorBlue400, - ColorBlue500, - ColorBlue700, - ColorBlue900, -} from '@elementor/design-tokens/primitives'; -import Box from '@elementor/ui/Box'; -import { styled } from '@elementor/ui/styles'; -import PropTypes from 'prop-types'; -import { BLOCK_TITLES } from '@ea11y-apps/scanner/constants'; -import { __ } from '@wordpress/i18n'; - -const CategoryPieChart = ({ issueByCategory, loading }) => { - // Loading state - if (loading) { - const loadingBackground = ` - radial-gradient(closest-side, white 84%, transparent 85% 100%), - conic-gradient(#e5e7eb 0%, #f3f3f4 50%, #e5e7eb 100%) - `; - return ; - } - - // Process categories similar to issue-by-category.js - const processedCategories = () => { - // Convert to array and sort by count (descending) - const sortedCategories = Object.entries(issueByCategory || {}) - .map(([key, count]) => ({ - key, - title: BLOCK_TITLES[key] || key, - count: count || 0, - })) - .sort((a, b) => b.count - a.count); - - // Calculate total issues across all categories - const totalIssues = sortedCategories.reduce( - (sum, category) => sum + category.count, - 0, - ); - - // Take top 6 categories - const top6 = sortedCategories.slice(0, 6); - // Calculate "other" count from remaining categories - const otherCount = sortedCategories - .slice(6) - .reduce((sum, category) => sum + category.count, 0); - - // Add "other" category if there are remaining categories or if it has count - const result = [...top6]; - if (otherCount > 0 || sortedCategories.length > 6) { - result.push({ - key: 'other', - title: __('Other', 'pojo-accessibility'), - count: otherCount, - }); - } - - // Convert counts to percentages and add colors - return result.map((category, index) => ({ - key: category.key, - percentage: - totalIssues > 0 ? Math.round((category.count / totalIssues) * 100) : 0, - color: - [ - ColorBlue900, - ColorBlue700, - ColorBlue500, - ColorBlue400, - ColorBlue300, - ColorBlue200, - ColorBlue100, // for "other" - ][index] || ColorBlue100, - })); - }; - - const categories = processedCategories(); - - // Create conic-gradient string - const createConicGradient = () => { - if (categories.length === 0) { - return 'conic-gradient(#f3f3f4 0%, #f3f3f4 100%)'; - } - - let cumulativePercentage = 0; - const gradientStops = []; - - categories.forEach((category) => { - const startPercentage = cumulativePercentage; - const endPercentage = cumulativePercentage + category.percentage; - - gradientStops.push( - `${category.color} ${startPercentage}% ${endPercentage}%`, - ); - cumulativePercentage += category.percentage; - }); - - // Fill remaining space with light gray if total is less than 100% - if (cumulativePercentage < 100) { - gradientStops.push(`#f3f3f4 ${cumulativePercentage}% 100%`); - } - - return `conic-gradient(${gradientStops.join(', ')})`; - }; - - const background = ` - radial-gradient(closest-side, white 84%, transparent 85% 100%), - ${createConicGradient()} - `; - - return ; -}; - -CategoryPieChart.propTypes = { - issueByCategory: PropTypes.shape({ - altText: PropTypes.number, - dynamicContent: PropTypes.number, - formsInputsError: PropTypes.number, - keyboardAssistiveTech: PropTypes.number, - pageStructureNav: PropTypes.number, - tables: PropTypes.number, - colorContrast: PropTypes.number, - other: PropTypes.number, - }), - loading: PropTypes.bool.isRequired, -}; - -const StyledCategoryPieChart = styled(Box)` - width: 176px; - height: 176px; - display: flex; - justify-content: center; - align-items: center; - border-radius: 100%; - background: ${({ background }) => background}; - margin-right: ${({ theme }) => theme.spacing(1.5)}; -`; - -const StyledLoadingPieChart = styled(Box)` - width: 176px; - height: 176px; - display: flex; - justify-content: center; - align-items: center; - border-radius: 100%; - background: ${({ background }) => background}; - animation: rotate 3s linear infinite; - - @keyframes rotate { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } -`; - -export default CategoryPieChart; diff --git a/modules/settings/assets/js/pages/assistant/stats/index.js b/modules/settings/assets/js/pages/assistant/stats/index.js index 279d8e47..c4a9e607 100644 --- a/modules/settings/assets/js/pages/assistant/stats/index.js +++ b/modules/settings/assets/js/pages/assistant/stats/index.js @@ -1,135 +1,46 @@ import Box from '@elementor/ui/Box'; -import Typography from '@elementor/ui/Typography'; import { styled } from '@elementor/ui/styles'; import PropTypes from 'prop-types'; -import PieChartLoader from '@ea11y/pages/assistant/loaders/pie-chart-loader'; -import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader'; -import AccessibilityAssistantStatsIssueResovledBYCategory from '@ea11y/pages/assistant/stats/issue-by-category'; -import AccessibilityAssistantStatsIssueLevels from '@ea11y/pages/assistant/stats/issue-levels'; -import StatsPieChart from '@ea11y/pages/assistant/stats/pie-chart'; -import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip'; +import IssuesByCategory from '@ea11y/pages/assistant/stats/issues-by-category/'; +import IssuesByLevel from '@ea11y/pages/assistant/stats/issues-by-level/'; +import StatsCounter from '@ea11y/pages/assistant/stats/stats-counter/'; import { __ } from '@wordpress/i18n'; -import CategoryPieChart from './category-pie-chart'; const AccessibilityAssistantStats = ({ stats, loading, noResultsState }) => { - const levelsTotal = - stats.issue_levels.a + stats.issue_levels.aa + stats.issue_levels.aaa; - - const firstLevelPercentage = stats.issue_levels.a - ? Math.round((stats.issue_levels.a / levelsTotal) * 100) - : 0; - - const secondLevelPercentage = stats.issue_levels.aa - ? Math.round((stats.issue_levels.aa / levelsTotal) * 100) - : 0; - const openIssues = stats.issues_total - stats.issues_fixed; return ( - - - - {__('Scanned URLs', 'pojo-accessibility')} - - - - - - {loading ? : stats.scans} - - - - - - - - {__('Open Issues', 'pojo-accessibility')} - - - - - - {loading ? : openIssues} - - - - - - - - {__('Resolved issues by level', 'pojo-accessibility')} - - - - - {loading ? ( - - ) : ( - - )} - - - - {loading ? ( - - ) : ( - - )} - - - - - - - {__('Resolved issues by category', 'pojo-accessibility')} - - - - - {loading ? ( - - ) : ( - - )} - - - - - - + + + + + + ); }; @@ -155,87 +66,4 @@ const StyledStatsContainer = styled(Box)` } `; -const StyledStatsItem = styled(Box)` - display: flex; - justify-content: space-between; - align-items: center; - - padding: ${({ theme }) => `${theme.spacing(2)} ${theme.spacing(2.5)}`}; - - border-radius: ${({ theme }) => theme.shape.borderRadius * 2}px; - background: ${({ theme }) => theme.palette.background.default}; - - :nth-of-type(1) { - grid-area: 1 / 1 / 2 / 2; - } - - :nth-of-type(2) { - grid-area: 1 / 2 / 2 / 3; - } - - :nth-of-type(3) { - grid-area: 2 / 1 / 3 / 3; - } - - :nth-of-type(4) { - grid-area: 1 / 3 / 3 / 4; - } - - @media screen and (max-width: 960px) { - :nth-of-type(1) { - grid-area: 1 / 1 / 2 / 2; - } - - :nth-of-type(2) { - grid-area: 2 / 1 / 3 / 2; - } - - :nth-of-type(3) { - grid-area: 3 / 1 / 4 / 2; - } - - :nth-of-type(4) { - grid-area: 4 / 1 / 5 / 2; - } - } -`; - -const StyledStatsItemContent = styled(Box)` - min-width: 150px; - min-height: 50px; - height: 100%; -`; - -const StyledStatsItemChart = styled(Box)` - margin-inline-start: ${({ theme }) => theme.spacing(2)}; - - @media screen and (max-width: 1200px) { - & { - display: none; - } - } -`; - -const StyledStatsItemTitle = styled(Typography)` - display: flex; - justify-content: flex-start; - align-items: center; - - margin: 0; - margin-bottom: ${({ spacing, theme }) => theme.spacing(spacing || 2)}; - - color: ${({ theme }) => theme.palette.text.primary}; - font-feature-settings: - 'liga' off, - 'clig' off; - font-size: 16px; - font-weight: 500; - line-height: 130%; - letter-spacing: 0.15px; - - & svg { - margin-inline-start: ${({ theme }) => theme.spacing(1)}; - } -`; - export default AccessibilityAssistantStats; diff --git a/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js new file mode 100644 index 00000000..9de11beb --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/index.js @@ -0,0 +1,46 @@ +import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader'; +import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip'; +import { __ } from '@wordpress/i18n'; +import { + StyledStatsItem, + StyledStatsItemContent, + StyledStatsItemChart, + StyledStatsItemTitle, +} from '../stats.styles'; +import IssueList from './issue-list'; +import PieChart from './pie-chart'; + +const IssuesByCategory = ({ stats, loading, noResultsState }) => { + return ( + + + + {__('Resolved issues by category', 'pojo-accessibility')} + + + + + {loading ? ( + + ) : ( + + )} + + + + + + + ); +}; + +export default IssuesByCategory; diff --git a/modules/settings/assets/js/pages/assistant/stats/issue-by-category.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/issue-list.js similarity index 87% rename from modules/settings/assets/js/pages/assistant/stats/issue-by-category.js rename to modules/settings/assets/js/pages/assistant/stats/issues-by-category/issue-list.js index 0a024335..6d61f077 100644 --- a/modules/settings/assets/js/pages/assistant/stats/issue-by-category.js +++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/issue-list.js @@ -1,3 +1,12 @@ +import { + ColorBlue100, + ColorBlue200, + ColorBlue300, + ColorBlue400, + ColorBlue500, + ColorBlue700, + ColorBlue900, +} from '@elementor/design-tokens/primitives'; import Box from '@elementor/ui/Box'; import Typography from '@elementor/ui/Typography'; import { styled } from '@elementor/ui/styles'; @@ -11,9 +20,7 @@ const CATEGORY_TITLE_OVERRIDES = { dynamicContent: __('Dynamic/Aria', 'pojo-accessibility'), }; -const AccessibilityAssistantStatsIssueResovledBYCategory = ({ - issueByCategory, -}) => { +const IssueList = ({ issueByCategory }) => { // Process categories to show top 6 by usage + "other" const processedCategories = () => { // Convert to array and sort by count (descending) @@ -73,7 +80,7 @@ const AccessibilityAssistantStatsIssueResovledBYCategory = ({ ); }; -AccessibilityAssistantStatsIssueResovledBYCategory.propTypes = { +IssueList.propTypes = { issueByCategory: PropTypes.object.isRequired, }; @@ -97,13 +104,13 @@ const StyledIssueLevel = styled(Box)` border-radius: 100%; background-color: ${({ colorIndex }) => { const colors = [ - '#1e3a8a', // Blue 900 - '#1d4ed8', // Blue 700 - '#3b82f6', // Blue 500 - '#60a5fa', // Blue 400 - '#93c5fd', // Blue 300 - '#BFDBFE', // Blue 200 - '#DBEAFE', // Blue 100 (for "other") + ColorBlue900, + ColorBlue700, + ColorBlue500, + ColorBlue400, + ColorBlue300, + ColorBlue200, + ColorBlue100, // (for "other") ]; return colors[colorIndex % colors.length]; }}; @@ -123,4 +130,4 @@ const StyledIssuesCount = styled(Typography)` text-align: left; `; -export default AccessibilityAssistantStatsIssueResovledBYCategory; +export default IssueList; diff --git a/modules/settings/assets/js/pages/assistant/stats/issues-by-category/pie-chart.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/pie-chart.js new file mode 100644 index 00000000..18130fed --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-category/pie-chart.js @@ -0,0 +1,148 @@ +import { + ColorBlue100, + ColorBlue200, + ColorBlue300, + ColorBlue400, + ColorBlue500, + ColorBlue700, + ColorBlue900, +} from '@elementor/design-tokens/primitives'; +import Box from '@elementor/ui/Box'; +import { + PieChart as MuiPieChart, + pieArcLabelClasses, +} from '@mui/x-charts/PieChart'; +import PropTypes from 'prop-types'; +import { BLOCK_TITLES } from '@ea11y-apps/scanner/constants'; +import { __ } from '@wordpress/i18n'; +import StatsPieTooltip from '../tooltip'; + +const PieChart = ({ issueByCategory, loading, noResultsState }) => { + const processedCategories = () => { + // Convert to array and sort by count (descending) + const sortedCategories = Object.entries(issueByCategory || {}) + .map(([key, count]) => ({ + key, + title: BLOCK_TITLES[key] || key, + count: count || 0, + })) + .sort((a, b) => b.count - a.count); + + // Calculate total issues across all categories + const totalIssues = sortedCategories.reduce( + (sum, category) => sum + category.count, + 0, + ); + + // Take top 6 categories + const top6 = sortedCategories.slice(0, 6); + // Calculate "other" count from remaining categories + const otherCount = sortedCategories + .slice(6) + .reduce((sum, category) => sum + category.count, 0); + + // Add "other" category if there are remaining categories or if it has count + const result = [...top6]; + if (otherCount > 0 || sortedCategories.length > 6) { + result.push({ + key: 'other', + title: __('Other', 'pojo-accessibility'), + count: otherCount, + }); + } + + if (loading || totalIssues === 0 || noResultsState) { + return [{ label: 'Loading...', value: 100, color: '#f3f3f4' }]; + } + + // Convert to MUI PieChart format with percentages and colors + return result.map((category, index) => { + const percentage = + totalIssues > 0 + ? parseFloat(((category.count / totalIssues) * 100).toFixed(2)) + : 0; + const color = + [ + ColorBlue900, + ColorBlue700, + ColorBlue500, + ColorBlue400, + ColorBlue300, + ColorBlue200, + ColorBlue100, // for "other" + ][index] || ColorBlue100; + + return { + label: `${category.title}: ${percentage}%`, + value: percentage, + color, + categoryTitle: category.title, + categoryCount: category.count, + }; + }); + }; + + const categories = processedCategories(); + + return ( + + + + ); +}; + +PieChart.propTypes = { + issueByCategory: PropTypes.shape({ + altText: PropTypes.number, + dynamicContent: PropTypes.number, + formsInputsError: PropTypes.number, + keyboardAssistiveTech: PropTypes.number, + pageStructureNav: PropTypes.number, + tables: PropTypes.number, + colorContrast: PropTypes.number, + other: PropTypes.number, + }), + loading: PropTypes.bool.isRequired, +}; + +export default PieChart; diff --git a/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js new file mode 100644 index 00000000..2e0a590c --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/index.js @@ -0,0 +1,64 @@ +import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader'; +import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip'; +import { __ } from '@wordpress/i18n'; +import { + StyledStatsItem, + StyledStatsItemContent, + StyledStatsItemChart, + StyledStatsItemTitle, +} from '../stats.styles'; +import IssueList from './issue-list'; +import PieChart from './pie-chart'; + +const IssuesByLevel = ({ stats, loading, noResultsState }) => { + const levelsTotal = + stats.issue_levels.a + stats.issue_levels.aa + stats.issue_levels.aaa; + + const firstLevelPercentage = stats.issue_levels.a + ? Math.round((stats.issue_levels.a / levelsTotal) * 100) + : 0; + + const secondLevelPercentage = stats.issue_levels.aa + ? Math.round((stats.issue_levels.aa / levelsTotal) * 100) + : 0; + + const thirdLevelPercentage = stats.issue_levels.aaa + ? Math.round((stats.issue_levels.aaa / levelsTotal) * 100) + : 0; + + return ( + + + + {__('Resolved issues by level', 'pojo-accessibility')} + + + + + {loading ? ( + + ) : ( + + )} + + + + + + + ); +}; + +export default IssuesByLevel; diff --git a/modules/settings/assets/js/pages/assistant/stats/issue-levels.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/issue-list.js similarity index 92% rename from modules/settings/assets/js/pages/assistant/stats/issue-levels.js rename to modules/settings/assets/js/pages/assistant/stats/issues-by-level/issue-list.js index 41d911c3..fc4a835c 100644 --- a/modules/settings/assets/js/pages/assistant/stats/issue-levels.js +++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/issue-list.js @@ -4,7 +4,7 @@ import { styled } from '@elementor/ui/styles'; import PropTypes from 'prop-types'; import { __, sprintf } from '@wordpress/i18n'; -const AccessibilityAssistantStatsIssueLevels = ({ issueLevels }) => { +const IssueList = ({ issueLevels }) => { return ( <> @@ -52,7 +52,7 @@ const AccessibilityAssistantStatsIssueLevels = ({ issueLevels }) => { ); }; -AccessibilityAssistantStatsIssueLevels.propTypes = { +IssueList.propTypes = { issueLevels: PropTypes.object.isRequired, }; @@ -104,4 +104,4 @@ const StyledIssuesCount = styled(Typography)` letter-spacing: 0.1px; `; -export default AccessibilityAssistantStatsIssueLevels; +export default IssueList; diff --git a/modules/settings/assets/js/pages/assistant/stats/issues-by-level/pie-chart.js b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/pie-chart.js new file mode 100644 index 00000000..805b32ef --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/issues-by-level/pie-chart.js @@ -0,0 +1,105 @@ +import { + ColorGreen900, + ColorGreen500, + ColorGreen200, +} from '@elementor/design-tokens/primitives'; +import Box from '@elementor/ui/Box'; +import Typography from '@elementor/ui/Typography'; +import { + PieChart as MuiPieChart, + pieArcLabelClasses, +} from '@mui/x-charts/PieChart'; +import PropTypes from 'prop-types'; +import StatsPieTooltip from '../tooltip'; + +const PieChart = ({ + loading, + value, + firstSectorPercentage, + secondSectorPercentage, + thirdSectorPercentage, + noResultsState, +}) => { + const hasNoData = + firstSectorPercentage === 0 && + secondSectorPercentage === 0 && + thirdSectorPercentage === 0; + + let pieData = [ + { label: 'A', value: firstSectorPercentage, color: ColorGreen900 }, + { label: 'AA', value: secondSectorPercentage, color: ColorGreen500 }, + { label: 'AAA', value: thirdSectorPercentage, color: ColorGreen200 }, + ]; + + if (loading || noResultsState || hasNoData) { + pieData = [{ label: 'No Issues', value: 100, color: '#f3f3f4' }]; + } + + return ( + + + + + {value} + + + + ); +}; + +PieChart.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + firstSectorPercentage: PropTypes.number.isRequired, + secondSectorPercentage: PropTypes.number, + thirdSectorPercentage: PropTypes.number, + noResultsState: PropTypes.bool, +}; + +export default PieChart; diff --git a/modules/settings/assets/js/pages/assistant/stats/pie-chart.js b/modules/settings/assets/js/pages/assistant/stats/pie-chart.js deleted file mode 100644 index df8fb881..00000000 --- a/modules/settings/assets/js/pages/assistant/stats/pie-chart.js +++ /dev/null @@ -1,118 +0,0 @@ -import Box from '@elementor/ui/Box'; -import Typography from '@elementor/ui/Typography'; -import { styled, useTheme } from '@elementor/ui/styles'; -import PropTypes from 'prop-types'; -import { __ } from '@wordpress/i18n'; - -const StatsPieChart = ({ - value, - firstSectorPercentage, - secondSectorPercentage, - noResultsState, -}) => { - const theme = useTheme(); - - if (noResultsState) { - return ( - - - 0 - - {__('Issues', 'pojo-accessibility')} - - - - ); - } - - if (typeof value !== 'string') { - let sectorColor = theme.palette.success.light; - - if (firstSectorPercentage <= 25) { - sectorColor = theme.palette.error.main; - } - - if (firstSectorPercentage > 25 && firstSectorPercentage <= 60) { - sectorColor = theme.palette.warning.light; - } - - const background = ` - radial-gradient(closest-side, white 77%, transparent 78% 100%), - conic-gradient(${sectorColor} ${firstSectorPercentage}%, #f3f3f4 0) - `; - - return ( - - {value} - - ); - } - - return ( - - - {value} - - - ); -}; - -StatsPieChart.propTypes = { - value: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, - firstSectorPercentage: PropTypes.number.isRequired, - secondSectorPercentage: PropTypes.number, - thirdSectorPercentage: PropTypes.number, - noResultsState: PropTypes.bool, -}; - -const StyledProgressCircle = styled(Box)` - width: 104px; - height: 104px; - display: flex; - - justify-content: center; - align-items: center; - border-radius: 100%; - margin-right: ${({ theme }) => theme.spacing(0.5)}; - - background: ${({ background }) => background}; -`; - -const StyledEmptyStateValue = styled(Typography)` - display: flex; - flex-direction: column; - align-items: center; - - color: ${({ theme }) => theme.palette.text.secondary}; - font-feature-settings: - 'liga' off, - 'clig' off; - font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif; - font-size: 32px; - font-weight: 700; - line-height: 78%; - letter-spacing: 0.25px; -`; - -const StyledEmptyStateLabel = styled(Typography)` - color: ${({ theme }) => theme.palette.text.tertiary}; - font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif; - font-size: 14px; - font-weight: 400; - line-height: 143%; - letter-spacing: 0.17px; -`; - -export default StatsPieChart; diff --git a/modules/settings/assets/js/pages/assistant/stats/stats-counter.js b/modules/settings/assets/js/pages/assistant/stats/stats-counter.js new file mode 100644 index 00000000..2b2643ea --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/stats-counter.js @@ -0,0 +1,28 @@ +import Typography from '@elementor/ui/Typography'; +import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader'; +import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip'; +import { + StyledStatsItem, + StyledStatsItemContent, + StyledStatsItemTitle, +} from './stats.styles'; + +const StatsCounter = ({ stat, loading, title, tooltip }) => { + return ( + + + + {title} + + + + + + {loading ? : stat} + + + + ); +}; + +export default StatsCounter; diff --git a/modules/settings/assets/js/pages/assistant/stats/stats.styles.js b/modules/settings/assets/js/pages/assistant/stats/stats.styles.js new file mode 100644 index 00000000..2715f461 --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/stats.styles.js @@ -0,0 +1,86 @@ +import Box from '@elementor/ui/Box'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; + +export const StyledStatsItem = styled(Box)` + display: flex; + justify-content: space-between; + align-items: center; + + padding: ${({ theme }) => `${theme.spacing(2)} ${theme.spacing(2.5)}`}; + + border-radius: ${({ theme }) => theme.shape.borderRadius * 2}px; + background: ${({ theme }) => theme.palette.background.default}; + + :nth-of-type(1) { + grid-area: 1 / 1 / 2 / 2; + } + + :nth-of-type(2) { + grid-area: 1 / 2 / 2 / 3; + } + + :nth-of-type(3) { + grid-area: 2 / 1 / 3 / 3; + } + + :nth-of-type(4) { + grid-area: 1 / 3 / 3 / 4; + } + + @media screen and (max-width: 960px) { + :nth-of-type(1) { + grid-area: 1 / 1 / 2 / 2; + } + + :nth-of-type(2) { + grid-area: 2 / 1 / 3 / 2; + } + + :nth-of-type(3) { + grid-area: 3 / 1 / 4 / 2; + } + + :nth-of-type(4) { + grid-area: 4 / 1 / 5 / 2; + } + } +`; + +export const StyledStatsItemContent = styled(Box)` + min-width: 150px; + min-height: 50px; + height: 100%; +`; + +export const StyledStatsItemChart = styled(Box)` + margin-inline-start: ${({ theme }) => theme.spacing(2)}; + + @media screen and (max-width: 1200px) { + & { + display: none; + } + } +`; + +export const StyledStatsItemTitle = styled(Typography)` + display: flex; + justify-content: flex-start; + align-items: center; + + margin: 0; + margin-bottom: ${({ spacing, theme }) => theme.spacing(spacing || 2)}; + + color: ${({ theme }) => theme.palette.text.primary}; + font-feature-settings: + 'liga' off, + 'clig' off; + font-size: 16px; + font-weight: 500; + line-height: 130%; + letter-spacing: 0.15px; + + & svg { + margin-inline-start: ${({ theme }) => theme.spacing(1)}; + } +`; diff --git a/modules/settings/assets/js/pages/assistant/stats/tooltip.js b/modules/settings/assets/js/pages/assistant/stats/tooltip.js new file mode 100644 index 00000000..98796b11 --- /dev/null +++ b/modules/settings/assets/js/pages/assistant/stats/tooltip.js @@ -0,0 +1,38 @@ +import Paper from '@elementor/ui/Paper'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; + +const StatsPieTooltip = (props) => { + const { itemData, series } = props; + const data = series.data[itemData.dataIndex]; + return ( + + + {data.label} + + {`${data.value}%`} + + ); +}; + +export default StatsPieTooltip; + +const StyledStatsPieTooltipTitle = styled(Typography)` + position: relative; + padding-left: 18px; + &:before { + content: ''; + position: absolute; + left: 0; + top: calc(50% - 5px); + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: ${(props) => props.itemColor}; + } +`; diff --git a/modules/settings/assets/js/utils/index.js b/modules/settings/assets/js/utils/index.js index 7fa5a5de..6aad0043 100644 --- a/modules/settings/assets/js/utils/index.js +++ b/modules/settings/assets/js/utils/index.js @@ -69,11 +69,13 @@ export const calculatePlanUsage = (allowed, used) => { */ export const formatPlanValue = (value) => { if (value >= 1000000) { - return `${Math.floor(value / 1000000)}M`; + const millions = value / 1000000; + return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`; } if (value >= 1000) { - return `${Math.floor(value / 1000)}K`; + const thousands = value / 1000; + return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`; } return value; diff --git a/modules/settings/banners/onboarding-banner.php b/modules/settings/banners/onboarding-banner.php new file mode 100644 index 00000000..fa429d6b --- /dev/null +++ b/modules/settings/banners/onboarding-banner.php @@ -0,0 +1,212 @@ + 0; + } + + /** + * Get banner markup + * @throws Throwable + */ + public static function get_banner() { + + if ( ! Connect::is_connected() ) { + return; + } + + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( self::user_viewed_banner() || self::user_has_scanned_pages() ) { + return; + } + + $url = admin_url( 'admin-ajax.php' ); + $nonce = wp_create_nonce( self::POINTER_NONCE_KEY ); + $link = admin_url( 'admin.php?page=accessibility-settings&source=admin_banner' ); + ?> + +
+
+
+
+ + + + + +
+
+

+ +

+

+ +

+ + + +
+
+
+ +
+
+
+ + + + + Connect::is_connected(), 'closePostConnectModal' => Settings::get( Settings::CLOSE_POST_CONNECT_MODAL ), + 'closeOnboardingModal' => Settings::get( Settings::CLOSE_ONBOARDING_MODAL ), 'isRTL' => is_rtl(), 'isUrlMismatch' => ! Connect_Utils::is_valid_home_url(), 'unfilteredUploads' => Svg::are_unfiltered_uploads_enabled(), + 'homeUrl' => home_url(), ]; } @@ -200,7 +218,6 @@ public static function save_plan_data( $register_response ) : void { * @return void */ public static function refresh_plan_data() : void { - if ( ! Connect::is_connected() ) { return; } @@ -219,27 +236,31 @@ public static function refresh_plan_data() : void { return; } - $response = Utils::get_api_client()->make_request( - 'GET', - 'site/info', - [ 'api_key' => $plan_data->public_api_key ] - ); + try { + $response = Utils::get_api_client()->make_request( + 'GET', + 'site/info', + [ 'api_key' => $plan_data->public_api_key ] + ); - if ( ! is_wp_error( $response ) ) { - Settings::set( Settings::PLAN_DATA, $response ); - Settings::set( Settings::IS_VALID_PLAN_DATA, true ); - self::set_plan_data_refresh_transient(); - } else { - Logger::error( esc_html( $response->get_error_message() ) ); + if ( ! is_wp_error( $response ) ) { + Settings::set( Settings::PLAN_DATA, $response ); + Settings::set( Settings::IS_VALID_PLAN_DATA, true ); + self::set_plan_data_refresh_transient(); + } else { + Logger::error( esc_html( $response->get_error_message() ) ); + Settings::set( Settings::IS_VALID_PLAN_DATA, false ); + } + } catch ( Service_Exception $se ) { + Logger::error( esc_html( $se->getMessage() ) ); Settings::set( Settings::IS_VALID_PLAN_DATA, false ); } } /** - * Set default values after successful registration. - * @return void + * Get default settings for the plugin. */ - private static function set_default_settings() : void { + public static function get_default_settings( $setting ): array { $widget_menu_settings = [ 'bigger-text' => [ 'enabled' => true, @@ -332,16 +353,34 @@ private static function set_default_settings() : void { 'anchor' => '#content', ]; + switch ( $setting ) { + case 'widget_menu_settings': + return $widget_menu_settings; + case 'widget_icon_settings': + return $widget_icon_settings; + case 'skip_to_content_settings': + return $skip_to_content_setting; + default: + return []; + } + } + + /** + * Set default values after successful registration. + * @return void + */ + private static function set_default_settings() : void { + if ( ! get_option( Settings::WIDGET_MENU_SETTINGS ) ) { - update_option( Settings::WIDGET_MENU_SETTINGS, $widget_menu_settings ); + update_option( Settings::WIDGET_MENU_SETTINGS, self::get_default_settings( 'widget_menu_settings' ) ); } if ( ! get_option( Settings::WIDGET_ICON_SETTINGS ) ) { - update_option( Settings::WIDGET_ICON_SETTINGS, $widget_icon_settings ); + update_option( Settings::WIDGET_ICON_SETTINGS, self::get_default_settings( 'widget_icon_settings' ) ); } if ( ! get_option( Settings::SKIP_TO_CONTENT ) ) { - update_option( Settings::SKIP_TO_CONTENT, $skip_to_content_setting ); + update_option( Settings::SKIP_TO_CONTENT, self::get_default_settings( 'skip_to_content_settings' ) ); } } @@ -448,6 +487,9 @@ public function register_settings(): void { 'unfiltered_files_upload' => [ 'type' => 'boolean', ], + 'close_onboarding_modal' => [ + 'type' => 'boolean', + ], ]; foreach ( $settings as $setting => $args ) { @@ -551,8 +593,9 @@ public function __construct() { add_action( 'on_connect_' . Config::APP_PREFIX . '_connected', [ $this, 'on_connect' ] ); add_action( 'current_screen', [ $this, 'check_plan_data' ] ); add_action( 'admin_head', [ $this, 'hide_admin_notices' ] ); + // Register notices - // Removed visits quota - // add_action( 'ea11y_register_notices', [ $this, 'register_notices' ] ); + //add_action( 'ea11y_register_notices', [ $this, 'register_notices' ] ); + add_action( 'admin_notices', [ $this, 'admin_banners' ] ); } } diff --git a/package-lock.json b/package-lock.json index 0502377d..ac10397f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pojo-accessibility", - "version": "3.6.0", + "version": "3.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pojo-accessibility", - "version": "3.6.0", + "version": "3.7.0", "dependencies": { "@elementor/design-tokens": "^1.1.4", "@elementor/icons": "^1.46.0", diff --git a/package.json b/package.json index 30fb8c99..d9cf9902 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,10 @@ "slug": "pojo-accessibility", "homepage": "http://pojo.me/", "description": "", - "version": "3.6.0", + "version": "3.7.0", "scripts": { - "build": "wp-scripts build", - "start": "wp-scripts start", + "build": "NODE_ENV=production wp-scripts build", + "start": "NODE_ENV=development wp-scripts start", "format": "wp-scripts format", "lint:js": "wp-scripts lint-js", "lint:js:fix": "wp-scripts lint-js --fix", diff --git a/pojo-accessibility.php b/pojo-accessibility.php index f65c6d32..03394bfe 100644 --- a/pojo-accessibility.php +++ b/pojo-accessibility.php @@ -5,7 +5,7 @@ * Description: Improve your website’s accessibility with ease. Customize capabilities such as text resizing, contrast modes, link highlights, and easily generate an accessibility statement to demonstrate your commitment to inclusivity. * Author: Elementor.com * Author URI: https://elementor.com/ - * Version: 3.6.0 + * Version: 3.7.0 * Text Domain: pojo-accessibility * Domain Path: /languages/ */ @@ -15,7 +15,7 @@ // Legacy define( 'POJO_A11Y_CUSTOMIZER_OPTIONS', 'pojo_a11y_customizer_options' ); -define( 'EA11Y_VERSION', '3.6.0' ); +define( 'EA11Y_VERSION', '3.7.0' ); define( 'EA11Y_MAIN_FILE', __FILE__ ); define( 'EA11Y_BASE', plugin_basename( EA11Y_MAIN_FILE ) ); define( 'EA11Y_PATH', plugin_dir_path( __FILE__ ) ); diff --git a/readme.txt b/readme.txt index 482b4fc8..945904b9 100644 --- a/readme.txt +++ b/readme.txt @@ -4,13 +4,15 @@ Tags: Web Accessibility, Accessibility, A11Y, WCAG, Accessibility Statement Requires at least: 6.6 Tested up to: 6.8 Requires PHP: 7.4 -Stable tag: 3.6.0 +Stable tag: 3.7.0 License: GPLv2 or later Ally: Make your site more inclusive by scanning for accessibility violations, fixing them easily, and adding a usability widget and accessibility statement. == Description == +https://www.youtube.com/watch?v=-2ig5D348vo + Ally (formerly One Click Accessibility) is a free, powerful, and user-friendly plugin that helps WordPress creators build more accessible websites with ease. It simplifies accessibility with three essential tools: @@ -187,6 +189,13 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro 5. Widget on Site: This is how the accessibility widget appears on a live website. == Changelog == += 3.7.0 - 2025-09-02 = +* New: Redesigned Accessibility Widget – Clearer structure, Wider accessible buttons, and Improved mobile view +* New: Intro banner for users who connected Ally +* New: Reviews & CSAT flow to gather user feedback +* Tweak: Added cache clearing option in Assistant panel and WordPress Admin Bar +* Tweak: Enhanced color contrast evaluation to fix issues with gradient/video backgrounds +* Fix: found issue count wrong in edge cases = 3.6.0 - 2025-08-02 = * New: Smart color contrast remediation flow in the accessibility assistant diff --git a/webpack.config.js b/webpack.config.js index c24a4372..9680e40d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -28,6 +28,16 @@ const entryPoints = { 'modules/widget/assets/js', 'ally-gutenberg-block.js', ), + 'deactivation-ally': path.resolve( + process.cwd(), + 'modules/deactivation/assets/js', + 'deactivation-feedback.js', + ), + reviews: path.resolve( + process.cwd(), + 'modules/reviews/assets/src', + 'reviews.js', + ), }; // React JSX Runtime Polyfill @@ -95,7 +105,7 @@ module.exports = [ }, optimization: { ...defaultConfig.optimization, - minimize: true, + minimize: process.env.NODE_ENV === 'production', minimizer: [ ...defaultConfig.optimization.minimizer, new CssMinimizerPlugin(), // Minimize CSS