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 ? (
+
+ ) : (
+
+ )}
{!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 (
+
+ );
+};
+
+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