diff --git a/.github/workflows/update-wp-one-package.yml b/.github/workflows/update-wp-one-package.yml new file mode 100644 index 00000000..d19bef1a --- /dev/null +++ b/.github/workflows/update-wp-one-package.yml @@ -0,0 +1,50 @@ +name: Update wp-one-package + +on: + workflow_dispatch: + inputs: + version: + description: 'New version of elementor/wp-one-package (e.g. 1.0.51)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update-package: + runs-on: ubuntu-latest + steps: + - name: Checkout source branch + uses: actions/checkout@v4 + with: + token: ${{ secrets.CLOUD_DEVOPS_TOKEN }} + + - name: Create branch + run: | + BRANCH_NAME="update/wp-one-package-${{ inputs.version }}" + git checkout -b "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV + + - name: Update wp-one-package version in composer.json + run: | + jq '.require["elementor/wp-one-package"] = "${{ inputs.version }}"' composer.json > composer.tmp && mv composer.tmp composer.json + + - name: Commit and push changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add composer.json + git commit -m "Update elementor/wp-one-package to ${{ inputs.version }}" + git push origin "${BRANCH_NAME}" + + - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.CLOUD_DEVOPS_TOKEN }} + run: | + gh pr create \ + --base "${{ github.ref_name }}" \ + --head "${BRANCH_NAME}" \ + --title "Update elementor/wp-one-package to ${{ inputs.version }}" \ + --body "Updates \`elementor/wp-one-package\` composer package to version \`${{ inputs.version }}\`." diff --git a/.husky/pre-commit b/.husky/pre-commit index 3867a0fe..39c0ee35 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,19 @@ -npm run lint +# Get list of staged files +STAGED_JS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$' || true) +STAGED_PHP_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.php$' || true) +STAGED_CSS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(css|scss)' || true) + +# Run JS linting if there are staged JS files +if [ -n "$STAGED_JS_FILES" ]; then + npm run lint:js || exit 1 +fi + +# Run PHP linting if there are staged PHP files +if [ -n "$STAGED_PHP_FILES" ]; then + npm run lint:php:report || exit 1 +fi + +# Run CSS linting if there are staged CSS files +if [ -n "$STAGED_CSS_FILES" ]; then + npm run lint:css || exit 1 +fi \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 63b178a1..00000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -//npm.pkg.github.com/:_authToken=${token} -//@elementor:registry=https://npm.pkg.github.com diff --git a/README.md b/README.md index 950d75f0..b798a5fd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Requires at least:** 6.6 \ **Tested up to:** 6.9 \ **Requires PHP:** 7.4 \ -**Stable tag:** 4.0.1 \ +**Stable tag:** 4.1.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. @@ -227,6 +227,26 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro ## Changelog +### 4.1.0 – 2026-02-23 + +* New: Added bulk remediation flow to generate AI alt text or mark multiple images as decorative +* Tweak: Added the ability to disable the accessibility widget to prevent it from loading on your site +* Tweak: Security enhancement to prevent potential SQL injection +* Tweak: Security enhancement to remove unsecure composer package +* Fix: Display WordPress admin notices inside the settings page +* Fix: Resolved conflict in Beaver Builder by preventing remediation runner from executing during page builder sessions + +### 4.0.3 – 2026-01-28 + +* Tweak: Security enhancements for access control + +### 4.0.2 – 2026-01-28 + +* Tweak: Added a dashboard widget to trigger scans and view results +* Fix: Resolved layout issues on the settings page for RTL websites +* Fix: Resolved widget trigger functionality when users are logged out +* Fix: Missing styles warning when reviews popup is displayed + ### 4.0.1 – 2026-01-20 * Fix: Fix ally widget action to toggle open/close state. diff --git a/assets/dev/js/api/exceptions/APIError.js b/assets/dev/js/api/exceptions/APIError.js index 2750d975..9b85fd64 100644 --- a/assets/dev/js/api/exceptions/APIError.js +++ b/assets/dev/js/api/exceptions/APIError.js @@ -1,8 +1,10 @@ class APIError extends Error { - constructor(message) { + constructor(message, code = null, data = null) { super(message); this.name = 'APIError'; + this.code = code; + this.data = data; } } diff --git a/assets/dev/js/api/index.js b/assets/dev/js/api/index.js index bb7b613c..cb9e8513 100644 --- a/assets/dev/js/api/index.js +++ b/assets/dev/js/api/index.js @@ -24,16 +24,23 @@ class API { } if (!response.success) { - throw new APIError(response.data.message); + throw new APIError( + response.data.message, + response.data.code, + response.data, + ); } return response.data; } catch (e) { if (e instanceof APIError) { throw e; - } else { - throw new APIError(e.message); } + + // apiFetch throws an error with code and message at root level + // when WordPress REST API returns a WP_Error + // WordPress REST API error structure: { code, message, data } + throw new APIError(e.message, e.code, e.data); } } diff --git a/assets/dev/js/services/mixpanel/mixpanel-events.js b/assets/dev/js/services/mixpanel/mixpanel-events.js index 9e5b25fa..304cbcf9 100644 --- a/assets/dev/js/services/mixpanel/mixpanel-events.js +++ b/assets/dev/js/services/mixpanel/mixpanel-events.js @@ -76,4 +76,8 @@ export const mixpanelEvents = { // Heading Structure headingClicked: 'heading_clicked', headingSelected: 'heading_selected', + + // Bulk Alt Text + stopButtonClicked: 'stop_button_clicked', + bulkAltTextClicked: 'bulk_alt_text_clicked', }; diff --git a/assets/dev/js/services/mixpanel/mixpanel-service.js b/assets/dev/js/services/mixpanel/mixpanel-service.js index 880fec3b..e147cf6a 100644 --- a/assets/dev/js/services/mixpanel/mixpanel-service.js +++ b/assets/dev/js/services/mixpanel/mixpanel-service.js @@ -1,8 +1,8 @@ -import mixpanel from 'mixpanel-browser'; - const SHARE_USAGE_DATA = 'share_usage_data'; const MIXPANEL_TOKEN = '150605b3b9f979922f2ac5a52e2dcfe9'; +let mixpanel = null; + const init = async () => { const { ea11ySettingsData, ea11yScannerData } = window; const planData = ea11ySettingsData?.planData || ea11yScannerData?.planData; @@ -16,6 +16,14 @@ const init = async () => { return; } + // Lazy load mixpanel + if (!mixpanel) { + const mixpanelModule = await import( + /* webpackChunkName: "chunk-mixpanel-browser" */ 'mixpanel-browser' + ); + mixpanel = mixpanelModule.default; + } + const pluginEnv = ea11ySettingsData?.pluginEnv || ea11yScannerData?.pluginEnv; const pluginVersion = ea11ySettingsData?.pluginVersion || ea11yScannerData?.pluginVersion; @@ -24,6 +32,8 @@ const init = async () => { debug: pluginEnv === 'dev', track_pageview: false, persistence: 'localStorage', + record_sessions_percent: 2, + record_heatmap_data: true, }); mixpanel.register({ @@ -51,7 +61,7 @@ const init = async () => { }; const sendEvent = (name, event) => { - if (mixpanel.__loaded) { + if (mixpanel?.__loaded) { mixpanel.track(name, event); } }; diff --git a/classes/client/client-response.php b/classes/client/client-response.php new file mode 100644 index 00000000..4227b6aa --- /dev/null +++ b/classes/client/client-response.php @@ -0,0 +1,45 @@ +response ) ) { + return $this->response; + } + + $message = $this->response->get_error_message(); + + if ( isset( $this->known_errors[ $message ] ) ) { + throw $this->known_errors[ $message ]; + } + + throw new Exception( $message ); + } + + public function __construct( $response ) { + $this->known_errors = [ + "Quota Status Guard Request Failed!: plan.features.ai_credits Quota exceeded" => new Quota_Exceeded_Error(), + "Quota Api Request Failed!: Failed checking if allowed to use quota" => new Quota_API_Error(), + ]; + + $this->response = $response; + } +} diff --git a/classes/exceptions/quota-api-error.php b/classes/exceptions/quota-api-error.php new file mode 100644 index 00000000..57f95e92 --- /dev/null +++ b/classes/exceptions/quota-api-error.php @@ -0,0 +1,13 @@ +is_builders_view() ) { + return false; + } + // Skip remediation during template_redirect AJAX requests if ( $this->is_template_redirect_ajax_request() ) { return false; diff --git a/modules/remediation/database/remediation-entry.php b/modules/remediation/database/remediation-entry.php index 4a7dbc28..9a61f08e 100644 --- a/modules/remediation/database/remediation-entry.php +++ b/modules/remediation/database/remediation-entry.php @@ -212,7 +212,11 @@ public static function get_global_remediations( string $url ) : array { 'operator' => '=', ], ]; - $join = "LEFT JOIN $excluded_table ON $remediation_table.id = $excluded_table.remediation_id AND $excluded_table.page_url = '$url'"; + // Use prepare() to safely bind the URL; never concatenate user input into SQL. + $join = Remediation_Table::db()->prepare( + "LEFT JOIN $excluded_table ON $remediation_table.id = $excluded_table.remediation_id AND $excluded_table.page_url = %s", + $url + ); return Remediation_Table::select( "$remediation_table.*, COALESCE($excluded_table.active, $remediation_table.active) AS active_for_page", $global_where, null, null, $join ); } diff --git a/modules/scanner/assets/js/api/APIScanner.js b/modules/scanner/assets/js/api/APIScanner.js index 8b84b6c3..269c0450 100644 --- a/modules/scanner/assets/js/api/APIScanner.js +++ b/modules/scanner/assets/js/api/APIScanner.js @@ -73,12 +73,16 @@ export class APIScanner extends API { }); } - static async generateAltText(data) { - return APIScanner.request({ + static async generateAltText(data, signal = null) { + const config = { method: 'POST', path: `${v1Prefix}/scanner/generate-alt-text`, data, - }); + }; + if (signal) { + config.signal = signal; + } + return APIScanner.request(config); } static async resolveWithAI(data) { diff --git a/modules/scanner/assets/js/components/alt-text-form/index.js b/modules/scanner/assets/js/components/alt-text-form/index.js index aa77e3bc..13612472 100644 --- a/modules/scanner/assets/js/components/alt-text-form/index.js +++ b/modules/scanner/assets/js/components/alt-text-form/index.js @@ -17,6 +17,7 @@ import PropTypes from 'prop-types'; import { useToastNotification } from '@ea11y-apps/global/hooks'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; import { ImagePreview } from '@ea11y-apps/scanner/components/alt-text-form/image-preview'; +import BulkAltTextBanner from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-alt-text-banner'; import { SetGlobal } from '@ea11y-apps/scanner/components/manage-footer-actions/page/set-global'; import { UpgradeContent } from '@ea11y-apps/scanner/components/upgrade-info-tip/upgrade-content'; import { AI_QUOTA_LIMIT, IS_PRO_PLAN } from '@ea11y-apps/scanner/constants'; @@ -82,158 +83,162 @@ export const AltTextForm = ({ item, current, setCurrent, setIsEdit }) => { : __('Apply fix', 'pojo-accessibility'); return ( - + <> + + + - - - - - - - {__('Mark image as decorative', 'pojo-accessibility')} - - - {__( - "(decorative images don't need description)", + + + + + {__('Mark image as decorative', 'pojo-accessibility')} + + + {__( + "(decorative images don't need description)", + 'pojo-accessibility', + )} + + + + {!data?.[current]?.makeDecorative ? ( + - - - {!data?.[current]?.makeDecorative ? ( - - {IS_PRO_PLAN && AI_QUOTA_LIMIT ? ( - + {IS_PRO_PLAN && AI_QUOTA_LIMIT ? ( + - - {loadingAiText ? ( - - ) : ( - - )} - - - ) : ( - } - > - + {loadingAiText ? ( + + ) : ( + + )} + + + ) : ( + } > - - - - )} - - ), - }} - /> - ) : ( - - - - {__('no description needed', 'pojo-accessibility')} - - - )} - - - - {__('Tips:', 'pojo-accessibility')} - - {__( - "Keep descriptions short and simple, describing what the image shows or why it's on the page.", - 'pojo-accessibility', - )} - - - - {!isManage && ( - + + + + )} + + ), + }} /> + ) : ( + + + + {__('no description needed', 'pojo-accessibility')} + + )} + + + + {__('Tips:', 'pojo-accessibility')} + + {__( + "Keep descriptions short and simple, describing what the image shows or why it's on the page.", + 'pojo-accessibility', + )} + + + + {!isManage && ( + + )} - - {isManage && ( - + )} + - )} - + - - + + ); }; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-banner.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-banner.js new file mode 100644 index 00000000..baa46678 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-banner.js @@ -0,0 +1,63 @@ +import Chip from '@elementor/ui/Chip'; +import Grid from '@elementor/ui/Grid'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; +import BulkAltTextButton from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-alt-text-button'; +import BulkBannerImageStack from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-banner-image-stack'; +import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; +import bulk1 from '@ea11y-apps/scanner/images/bulk-1.png'; +import bulk2 from '@ea11y-apps/scanner/images/bulk-2.png'; +import bulk3 from '@ea11y-apps/scanner/images/bulk-3.png'; +import bulk4 from '@ea11y-apps/scanner/images/bulk-4.png'; +import { sprintf, _n } from '@wordpress/i18n'; + +const BulkAltTextBanner = () => { + const { sortedViolations } = useScannerWizardContext(); + const bulkImages = [bulk1, bulk2, bulk3, bulk4]; + + if (sortedViolations.altText.length <= 2) { + return null; + } + + const imagesToShow = + sortedViolations.altText.length > 3 + ? 4 + : Number(sortedViolations.altText.length); + + return ( + + + + + {sprintf( + // Translators: %d number of images + _n('%d image', '%d images', 14, 'pojo-accessibility'), + sortedViolations.altText.length, + )} + + } + size="tiny" + color="info" + sx={{ marginInlineStart: -2 }} + /> + + + + + + ); +}; + +export default BulkAltTextBanner; + +const StyledBannerGrid = styled(Grid)(({ theme }) => ({ + justifyContent: 'space-between', + alignItems: 'center', + paddingInline: theme.spacing(3), + flexWrap: 'nowrap', + marginBottom: theme.spacing(2), + boxShadow: 'rgba(0, 0, 0, 0.12) 0px 10px 14px -8px', + paddingBlock: theme.spacing(1.25), +})); diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-button.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-button.js new file mode 100644 index 00000000..fa221155 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-button.js @@ -0,0 +1,71 @@ +import CrownFilledIcon from '@elementor/icons/CrownFilledIcon'; +import Button from '@elementor/ui/Button'; +import Infotip from '@elementor/ui/Infotip'; +import { useModal } from '@ea11y/hooks'; +import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events'; +import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service'; +import BulkAltTextManager from '@ea11y-apps/scanner/components/bulk-alt-text/bulk-alt-text-manager'; +import UpgradeInfotip from '@ea11y-apps/scanner/components/bulk-alt-text/upgrade-infotip'; +import { IS_PRO_PLAN } from '@ea11y-apps/scanner/constants'; +import WandIcon from '@ea11y-apps/scanner/icons/wand-icon'; +import { __ } from '@wordpress/i18n'; + +const BulkAltTextButton = () => { + const { open, close, isOpen } = useModal(false); + + const handleBulkAltTextClick = () => { + mixpanelService.sendEvent(mixpanelEvents.bulkAltTextClicked); + open(); + }; + + if (!IS_PRO_PLAN) { + return ( + + } + placement="bottom" + PopperProps={{ + disablePortal: true, + sx: { width: 300 }, + }} + > + + + ); + } + + return ( + <> + + + + ); +}; + +export default BulkAltTextButton; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-manager.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-manager.js new file mode 100644 index 00000000..81f2e6fa --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-alt-text-manager.js @@ -0,0 +1,315 @@ +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 DialogHeader from '@elementor/ui/DialogHeader'; +import DialogTitle from '@elementor/ui/DialogTitle'; +import Divider from '@elementor/ui/Divider'; +import { FocusTrap } from 'focus-trap-react'; +import PropTypes from 'prop-types'; +import { useToastNotification } from '@ea11y-apps/global/hooks'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import ConfirmCloseDialog from '@ea11y-apps/scanner/components/bulk-alt-text/confirm-close-dialog'; +import ConfirmStopGenerationDialog from '@ea11y-apps/scanner/components/bulk-alt-text/confirm-stop-generation-dialog'; +import ImageGrid from '@ea11y-apps/scanner/components/bulk-alt-text/image-grid'; +import BulkAltTextProgress from '@ea11y-apps/scanner/components/bulk-alt-text/progress-bar'; +import QuotaErrorAlert from '@ea11y-apps/scanner/components/bulk-alt-text/quota-error-alert'; +import { BLOCKS } from '@ea11y-apps/scanner/constants'; +import { BulkGenerationProvider } from '@ea11y-apps/scanner/context/bulk-generation-context'; +import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; +import usePreventAriaHidden from '@ea11y-apps/scanner/hooks/use-prevent-aria-hidden'; +import WandIcon from '@ea11y-apps/scanner/icons/wand-icon'; +import { submitAltTextRemediation } from '@ea11y-apps/scanner/utils/submit-alt-text'; +import { speak } from '@wordpress/a11y'; +import { useState, useCallback, useRef, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +const BulkAltTextManager = ({ open, close }) => { + usePreventAriaHidden(open); + + const { success, error } = useToastNotification(); + const { + sortedViolations, + altTextData, + setAltTextData, + resolved, + setResolved, + currentScanId, + updateRemediationList, + isManage, + } = useScannerWizardContext(); + const [loading, setLoading] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showStopGenerationDialog, setShowStopGenerationDialog] = + useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const stopGenerationFnRef = useRef(null); + const altTextViolations = sortedViolations.altText; + const type = isManage ? 'manage' : 'main'; + + const selectedItemsCount = useMemo(() => { + let count = 0; + for (let i = 0; i < altTextViolations.length; i++) { + const itemData = altTextData?.[type]?.[i]; + if (itemData?.selected === true && itemData?.hasValidAltText === true) { + count++; + } + } + return count; + }, [altTextViolations, altTextData, type]); + + const hasUnsavedChanges = selectedItemsCount > 0; + + const handleClose = () => { + mixpanelService.sendEvent(mixpanelEvents.popupButtonClicked, { + popupType: 'bulk_alt_text', + buttonName: 'x_button', + }); + if (isGenerating) { + setShowStopGenerationDialog(true); + } else if (hasUnsavedChanges && !loading) { + setShowConfirmDialog(true); + } else { + close(); + } + }; + + const handleLeaveWhileGenerating = () => { + mixpanelService.sendEvent(mixpanelEvents.popupButtonClicked, { + popupType: 'bulk_alt_text', + buttonName: 'Leave without generating', + }); + if (stopGenerationFnRef.current) { + stopGenerationFnRef.current(); + } + setShowStopGenerationDialog(false); + close(); + }; + + const handleKeepGenerating = () => { + setShowStopGenerationDialog(false); + }; + + const handleGeneratingChange = useCallback((generating, stopFn) => { + setIsGenerating(generating); + stopGenerationFnRef.current = stopFn; + }, []); + + const handleDiscard = () => { + mixpanelService.sendEvent(mixpanelEvents.popupButtonClicked, { + popupType: 'bulk_alt_text', + buttonName: 'Discard changes', + }); + setShowConfirmDialog(false); + close(); + }; + + const handleApply = async ( + closeAfter = false, + source = 'bulk_main_action', + ) => { + setLoading(true); + let successCount = 0; + let errorCount = 0; + const updatedData = [...(altTextData?.[type] || [])]; + + try { + for (let i = 0; i < altTextViolations.length; i++) { + const item = altTextViolations[i]; + const itemData = altTextData?.[type]?.[i]; + + if (!itemData?.selected || !itemData?.hasValidAltText) { + continue; + } + + const isDecorative = itemData.makeDecorative || false; + if (!isDecorative && !itemData.altText?.trim()) { + console.warn(`Skipping item ${i}: No alt text provided`); + errorCount++; + continue; + } + + try { + const remediation = await submitAltTextRemediation({ + item, + altText: itemData.altText || '', + makeDecorative: isDecorative, + isGlobal: itemData.isGlobal || item.global || false, + apiId: itemData.apiId, + currentScanId, + updateRemediationList, + }); + updatedData[i] = { + ...(updatedData[i] || {}), + remediation, + resolved: true, + }; + successCount++; + } catch (e) { + errorCount++; + console.error(`Failed to submit item ${i}:`, e); + } + } + + setAltTextData({ + ...altTextData, + [type]: updatedData, + }); + + setResolved(resolved + successCount); + + // Send mixpanel event for successful submissions + if (successCount > 0) { + mixpanelService.sendEvent(mixpanelEvents.applyFixButtonClicked, { + fix_method: 'Bulk Alt Text', + source, + num_of_images: successCount, + category_name: BLOCKS.altText, + page_url: window.ea11yScannerData?.pageData?.url, + is_global: 'no', + }); + } + + if (successCount > 0 && errorCount === 0) { + const message = __( + 'All selected items applied successfully!', + 'pojo-accessibility', + ); + success(message); + speak(message, 'assertive'); + setShowConfirmDialog(false); + close(); + } else if (successCount > 0 && errorCount > 0) { + const message = sprintf( + // Translators: %1$d successful count, %2$d failed count + __( + '%1$d items applied successfully. %2$d items failed.', + 'pojo-accessibility', + ), + successCount, + errorCount, + ); + success(message); + speak(message, 'assertive'); + if (closeAfter) { + setShowConfirmDialog(false); + close(); + } + } else if (errorCount > 0) { + const message = __( + 'Failed to apply items. Please try again.', + 'pojo-accessibility', + ); + error(message); + speak(message, 'assertive'); + } + } catch (e) { + console.error(e); + const message = __( + 'An error occurred while applying changes.', + 'pojo-accessibility', + ); + error(message); + speak(message, 'assertive'); + } finally { + setLoading(false); + } + }; + + const handleApplyAndClose = () => { + handleApply(true, 'bulk_close_popup'); + }; + + return ( + <> + +
+ + + } + > + + {__('Alt Text Manager', 'pojo-accessibility')} + + + + + + + + + + + + + + + + +
+
+ + + + setShowConfirmDialog(false)} + loading={loading} + /> + + ); +}; + +BulkAltTextManager.propTypes = { + open: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, +}; + +export default BulkAltTextManager; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/bulk-banner-image-stack.js b/modules/scanner/assets/js/components/bulk-alt-text/bulk-banner-image-stack.js new file mode 100644 index 00000000..310244e1 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/bulk-banner-image-stack.js @@ -0,0 +1,28 @@ +import Image from '@elementor/ui/Image'; +import PropTypes from 'prop-types'; +import { memo } from '@wordpress/element'; + +const BulkBannerImageStack = ({ images, count }) => { + return ( + <> + {Array.from({ length: count }).map((_, index) => ( + 0 ? -2 : 0, + }} + /> + ))} + + ); +}; + +BulkBannerImageStack.propTypes = { + images: PropTypes.arrayOf(PropTypes.string).isRequired, + count: PropTypes.number.isRequired, +}; + +export default memo(BulkBannerImageStack); diff --git a/modules/scanner/assets/js/components/bulk-alt-text/confirm-close-dialog.js b/modules/scanner/assets/js/components/bulk-alt-text/confirm-close-dialog.js new file mode 100644 index 00000000..7a80598e --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/confirm-close-dialog.js @@ -0,0 +1,68 @@ +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 DialogTitle from '@elementor/ui/DialogTitle'; +import Typography from '@elementor/ui/Typography'; +import { FocusTrap } from 'focus-trap-react'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; + +const ConfirmCloseDialog = ({ + open, + onDiscard, + onApply, + onCancel, + loading, +}) => { + return ( + + + + {__('Apply changes before leaving?', 'pojo-accessibility')} + + + + {__( + "If you leave now, your alt text updates won't be applied.", + 'pojo-accessibility', + )} + + + + + + + + + ); +}; + +ConfirmCloseDialog.propTypes = { + open: PropTypes.bool.isRequired, + onDiscard: PropTypes.func.isRequired, + onApply: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, +}; + +export default ConfirmCloseDialog; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/confirm-stop-generation-dialog.js b/modules/scanner/assets/js/components/bulk-alt-text/confirm-stop-generation-dialog.js new file mode 100644 index 00000000..50e1d1c3 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/confirm-stop-generation-dialog.js @@ -0,0 +1,58 @@ +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 DialogTitle from '@elementor/ui/DialogTitle'; +import Typography from '@elementor/ui/Typography'; +import { FocusTrap } from 'focus-trap-react'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; + +const ConfirmStopGenerationDialog = ({ open, onKeepGenerating, onLeave }) => { + return ( + + + + {__('Alt text generation is still in progress', 'pojo-accessibility')} + + + + {__( + "Leaving now will stop the process. Any generated alt text won't be applied.", + 'pojo-accessibility', + )} + + + + + + + + + ); +}; + +ConfirmStopGenerationDialog.propTypes = { + open: PropTypes.bool.isRequired, + onKeepGenerating: PropTypes.func.isRequired, + onLeave: PropTypes.func.isRequired, +}; + +export default ConfirmStopGenerationDialog; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/ai-generate-button.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/ai-generate-button.js new file mode 100644 index 00000000..e1330a8f --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/ai-generate-button.js @@ -0,0 +1,95 @@ +import AIIcon from '@elementor/icons/AIIcon'; +import IconButton from '@elementor/ui/IconButton'; +import Infotip from '@elementor/ui/Infotip'; +import InputAdornment from '@elementor/ui/InputAdornment'; +import Tooltip from '@elementor/ui/Tooltip'; +import PropTypes from 'prop-types'; +import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events'; +import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service'; +import { UpgradeContent } from '@ea11y-apps/scanner/components/upgrade-info-tip/upgrade-content'; +import { AI_QUOTA_LIMIT, IS_PRO_PLAN } from '@ea11y-apps/scanner/constants'; +import { __ } from '@wordpress/i18n'; + +const AIGenerateButton = ({ onGenerate, disabled, isLoading }) => { + const onUpgradeHover = () => { + mixpanelService.sendEvent(mixpanelEvents.upgradeSuggestionViewed, { + current_plan: window.ea11yScannerData?.planData?.plan?.name, + component: 'bulk_wizard_single_image', + feature: 'bulk_alt_text', + }); + }; + + return ( + + {IS_PRO_PLAN && AI_QUOTA_LIMIT ? ( + + + + + + ) : ( + } + > + + + + + )} + + ); +}; + +AIGenerateButton.propTypes = { + onGenerate: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +export default AIGenerateButton; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/alt-text-input.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/alt-text-input.js new file mode 100644 index 00000000..3c7c80ae --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/alt-text-input.js @@ -0,0 +1,80 @@ +import InfoCircleIcon from '@elementor/icons/InfoCircleIcon'; +import Chip from '@elementor/ui/Chip'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; +import AIGenerateButton from './ai-generate-button'; +import CardActionButtons from './card-action-buttons'; +import { StyledTextField } from './styled-components'; + +const AltTextInput = ({ + isDecorative, + altText, + isLoading, + isDraft, + onChange, + onGenerate, + onSave, + onCancel, +}) => { + if (isDecorative) { + return ( + } + variant="standard" + /> + ); + } + + return ( + <> + + ), + }} + /> + {isDraft && !isDecorative && ( + + )} + + ); +}; + +AltTextInput.propTypes = { + isDecorative: PropTypes.bool.isRequired, + altText: PropTypes.string, + isLoading: PropTypes.bool, + isDraft: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onGenerate: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +export default AltTextInput; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-action-buttons.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-action-buttons.js new file mode 100644 index 00000000..c89b2c81 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-action-buttons.js @@ -0,0 +1,31 @@ +import Button from '@elementor/ui/Button'; +import Grid from '@elementor/ui/Grid'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; + +const CardActionButtons = ({ onSave, onCancel, altText }) => { + return ( + + + + + ); +}; + +CardActionButtons.propTypes = { + onSave: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + altText: PropTypes.string, +}; + +export default CardActionButtons; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-selection-indicator.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-selection-indicator.js new file mode 100644 index 00000000..f1296a39 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/card-selection-indicator.js @@ -0,0 +1,58 @@ +import CircleCheckFilledIcon from '@elementor/icons/CircleCheckFilledIcon'; +import CircularProgress from '@elementor/ui/CircularProgress'; +import Radio from '@elementor/ui/Radio'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; + +const CardSelectionIndicator = ({ + isLoading, + isSelected, + hasValidAltText, + onRadioClick, +}) => { + if (isLoading) { + return ( + + ); + } + + return ( + } + sx={{ + position: 'absolute', + top: 0, + right: 0, + color: 'action.disabled', + cursor: isSelected && hasValidAltText ? 'not-allowed' : 'pointer', + }} + color={isSelected && hasValidAltText ? 'success' : 'info'} + onClick={onRadioClick} + tabIndex={0} + aria-label={__( + 'Add image to bulk alt text editing', + 'pojo-accessibility', + )} + aria-disabled={isSelected && hasValidAltText} + /> + ); +}; + +CardSelectionIndicator.propTypes = { + imageLabel: PropTypes.string, + isLoading: PropTypes.bool, + isSelected: PropTypes.bool.isRequired, + hasValidAltText: PropTypes.bool, + onRadioClick: PropTypes.func.isRequired, +}; + +export default CardSelectionIndicator; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/decorative-checkbox.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/decorative-checkbox.js new file mode 100644 index 00000000..59b3d4d5 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/decorative-checkbox.js @@ -0,0 +1,27 @@ +import Checkbox from '@elementor/ui/Checkbox'; +import FormControlLabel from '@elementor/ui/FormControlLabel'; +import Typography from '@elementor/ui/Typography'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; + +const DecorativeCheckbox = ({ checked, onChange }) => { + return ( + } + label={ + + {__('Mark as decorative', 'pojo-accessibility')} + + } + onChange={onChange} + sx={{ paddingInline: 1.7, paddingBlockEnd: 1.5 }} + /> + ); +}; + +DecorativeCheckbox.propTypes = { + checked: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default DecorativeCheckbox; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/index.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/index.js new file mode 100644 index 00000000..61943110 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/index.js @@ -0,0 +1,143 @@ +import CardContent from '@elementor/ui/CardContent'; +import Grid from '@elementor/ui/Grid'; +import PropTypes from 'prop-types'; +import { ImagePreview } from '@ea11y-apps/scanner/components/alt-text-form/image-preview'; +import { useAltTextForm } from '@ea11y-apps/scanner/hooks/use-alt-text-form'; +import { speak } from '@wordpress/a11y'; +import { memo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import AltTextInput from './alt-text-input'; +import CardSelectionIndicator from './card-selection-indicator'; +import DecorativeCheckbox from './decorative-checkbox'; +import { StyledCard, StyledPreviewWrapper } from './styled-components'; + +const getImageLabel = (node) => { + if (!node) { + return __('Image', 'pojo-accessibility'); + } + if (node.src) { + try { + const pathname = new URL(node.src).pathname; + const encodedFilename = pathname.split('/').pop(); + if (!encodedFilename) { + return __('Image', 'pojo-accessibility'); + } + try { + return decodeURIComponent(encodedFilename); + } catch { + return encodedFilename; + } + } catch { + return __('Image', 'pojo-accessibility'); + } + } + return __('Image', 'pojo-accessibility'); +}; + +const ImageCard = ({ item, current }) => { + const { + data, + loadingAiText, + handleChange, + handleCheck, + handleSave, + handleCancel, + generateAltText, + updateData, + } = useAltTextForm({ + current, + item, + }); + + const handleRadioClick = () => { + const hasValidAltText = data?.[current]?.hasValidAltText; + const isCurrentlySelected = data?.[current]?.selected; + + if (isCurrentlySelected && hasValidAltText) { + return; + } + + const willBeSelected = !isCurrentlySelected; + updateData({ + selected: willBeSelected, + }); + + const message = willBeSelected + ? __('Image selected', 'pojo-accessibility') + : __('Image deselected', 'pojo-accessibility'); + speak(message, 'polite'); + }; + + const isLoading = loadingAiText || data?.[current]?.isGenerating; + const imageLabel = getImageLabel(item?.node); + const cardLabel = sprintf( + /* translators: %s: image file name or "Image" */ + __('Alt text card for %s', 'pojo-accessibility'), + imageLabel, + ); + + return ( + + + + + + + + + + + + {!data?.[current]?.isDraft && ( + + )} + + + ); +}; + +ImageCard.propTypes = { + item: PropTypes.object.isRequired, + current: PropTypes.number.isRequired, +}; + +export default memo(ImageCard); diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-card/styled-components.js b/modules/scanner/assets/js/components/bulk-alt-text/image-card/styled-components.js new file mode 100644 index 00000000..c587907e --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-card/styled-components.js @@ -0,0 +1,67 @@ +import Card from '@elementor/ui/Card'; +import Grid from '@elementor/ui/Grid'; +import TextField from '@elementor/ui/TextField'; +import { styled } from '@elementor/ui/styles'; + +export const StyledCard = styled(Card, { + shouldForwardProp: (prop) => + prop !== 'isLoading' && + prop !== 'isCurrentlySelected' && + prop !== 'hasValidAltText' && + prop !== 'isDraft' && + prop !== 'isDecorative', +})` + border-radius: ${({ theme }) => theme.shape.borderRadius * 2}px; + height: 282px; + width: 248px; + + & .MuiCardContent-root:last-child { + padding-bottom: 0; + } + + ${({ theme, isLoading, isCurrentlySelected, hasValidAltText, isDraft }) => + (isLoading || (isCurrentlySelected && !hasValidAltText)) && !isDraft + ? `border: 2px solid; border-color: ${theme.palette.info.main};` + : ''} +`; + +export const StyledPreviewWrapper = styled(Grid)` + display: flex; + justify-content: center; + align-items: center; + + & .MuiPaper-root { + background-color: transparent; + width: 130px; + height: 96px; + } + + & img { + width: 130px; + height: 96px; + font-size: auto; + object-fit: cover; + border-radius: ${({ theme }) => theme.shape.borderRadius * 2}px; + } +`; + +export const StyledTextField = styled(TextField)` + & .MuiInputBase-root { + padding: 0; + font-size: 14px; + } + + & .MuiInputBase-input { + padding: ${({ theme }) => + `${theme.spacing(1)} ${theme.spacing(1.5)} ${theme.spacing(1)} ${theme.spacing(1)}`}; + } + + & fieldset { + border-color: transparent; + overflow: auto; + + &:selected { + border: auto; + } + } +`; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/image-grid.js b/modules/scanner/assets/js/components/bulk-alt-text/image-grid.js new file mode 100644 index 00000000..b2346b3f --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/image-grid.js @@ -0,0 +1,40 @@ +import Alert from '@elementor/ui/Alert'; +import AlertTitle from '@elementor/ui/AlertTitle'; +import ErrorBoundary from '@elementor/ui/ErrorBoundary'; +import Grid from '@elementor/ui/Grid'; +import ImageCard from '@ea11y-apps/scanner/components/bulk-alt-text/image-card'; +import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; +import { __ } from '@wordpress/i18n'; + +const ImageCardErrorFallback = () => ( + + {__('Error', 'pojo-accessibility')} + {__( + 'This image card failed to load. Please refresh and try again.', + 'pojo-accessibility', + )} + +); + +const ImageGrid = () => { + const { sortedViolations } = useScannerWizardContext(); + const altTextViolations = sortedViolations.altText; + + return ( + + {altTextViolations.map((image, index) => { + const stableKey = + image.path?.dom || image.node?.src || `img-card-${index}`; + return ( + + }> + + + + ); + })} + + ); +}; + +export default ImageGrid; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/default-progress.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/default-progress.js new file mode 100644 index 00000000..13089320 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/default-progress.js @@ -0,0 +1,73 @@ +import Button from '@elementor/ui/Button'; +import LinearProgress from '@elementor/ui/LinearProgress'; +import Typography from '@elementor/ui/Typography'; +import PropTypes from 'prop-types'; +import PencilTickIcon from '@ea11y-apps/scanner/icons/pencil-tick-icon'; +import PencilUndoIcon from '@ea11y-apps/scanner/icons/pencil-undo-icon'; +import { __ } from '@wordpress/i18n'; +import GenerateAllButton from './generate-all-button'; +import { StyledMainWrapperGrid, StyledActionsGrid } from './styled-components'; + +const DefaultProgress = ({ + completedSelectedCount, + totalImages, + areAllMarkedAsDecorative, + isGenerating, + onToggleAllDecorative, + onGenerateAll, + generateButtonText, +}) => { + return ( + + + + {`${completedSelectedCount}/${totalImages}`}{' '} + {__('ready to apply', 'pojo-accessibility')} + + + 0 ? (completedSelectedCount / totalImages) * 100 : 0 + } + variant="determinate" + color={completedSelectedCount > 0 ? 'success' : 'secondary'} + sx={{ flexGrow: 1, marginInlineEnd: 5 }} + /> + + + + + + ); +}; + +DefaultProgress.propTypes = { + completedSelectedCount: PropTypes.number.isRequired, + totalImages: PropTypes.number.isRequired, + areAllMarkedAsDecorative: PropTypes.bool.isRequired, + isGenerating: PropTypes.bool.isRequired, + onToggleAllDecorative: PropTypes.func.isRequired, + onGenerateAll: PropTypes.func.isRequired, + generateButtonText: PropTypes.string.isRequired, +}; + +export default DefaultProgress; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generate-all-button.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generate-all-button.js new file mode 100644 index 00000000..7d4b9a39 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generate-all-button.js @@ -0,0 +1,63 @@ +import AIIcon from '@elementor/icons/AIIcon'; +import Button from '@elementor/ui/Button'; +import Infotip from '@elementor/ui/Infotip'; +import PropTypes from 'prop-types'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { UpgradeContent } from '@ea11y-apps/scanner/components/upgrade-info-tip/upgrade-content'; +import { AI_QUOTA_LIMIT, IS_PRO_PLAN } from '@ea11y-apps/scanner/constants'; + +const GenerateAllButton = ({ onClick, disabled, text }) => { + const onUpgradeHover = () => { + mixpanelService.sendEvent(mixpanelEvents.upgradeTooltipTriggered, { + feature: 'bulk_alt_text', + component: 'bulk_wizard_main_button', + }); + }; + + if (IS_PRO_PLAN && AI_QUOTA_LIMIT) { + return ( + + ); + } + + return ( + } + > + + + ); +}; + +GenerateAllButton.propTypes = { + onClick: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, + text: PropTypes.string.isRequired, +}; + +export default GenerateAllButton; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generating-progress.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generating-progress.js new file mode 100644 index 00000000..703b01d9 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/generating-progress.js @@ -0,0 +1,74 @@ +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 LinearProgress from '@elementor/ui/LinearProgress'; +import Typography from '@elementor/ui/Typography'; +import PropTypes from 'prop-types'; +import PlayerStopIcon from '@ea11y-apps/scanner/icons/player-stop-icon'; +import { __, sprintf } from '@wordpress/i18n'; + +const GeneratingProgress = ({ progress, onStop }) => { + const progressText = sprintf( + // Translators: %1$d current count, %2$d total count + __('%1$d/%2$d completed', 'pojo-accessibility'), + progress.completed, + progress.total, + ); + + return ( + + 0 ? (progress.completed / progress.total) * 100 : 0 + } + variant="determinate" + color="info" + sx={{ flexGrow: 1 }} + aria-label={__('Generation progress', 'pojo-accessibility')} + /> + + {progressText} + + } + action={ + + } + > + + {__('Generating alt text for images…', 'pojo-accessibility')} + + + + ); +}; + +GeneratingProgress.propTypes = { + progress: PropTypes.shape({ + completed: PropTypes.number.isRequired, + total: PropTypes.number.isRequired, + }).isRequired, + onStop: PropTypes.func.isRequired, +}; + +export default GeneratingProgress; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/index.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/index.js new file mode 100644 index 00000000..43feacb3 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/index.js @@ -0,0 +1 @@ +export { default } from './progress-bar-container'; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/progress-bar-container.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/progress-bar-container.js new file mode 100644 index 00000000..546ff48b --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/progress-bar-container.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import { useProgressBarLogic } from '@ea11y-apps/scanner/hooks/use-progress-bar-logic'; +import DefaultProgress from './default-progress'; +import GeneratingProgress from './generating-progress'; +import SelectionModeProgress from './selection-mode-progress'; + +const ProgressBarContainer = ({ onGeneratingChange }) => { + const { + isGenerating, + progress, + showManualSelectionMode, + manuallySelectedCount, + completedSelectedCount, + totalImages, + areAllMarkedAsDecorative, + handleStopGenerating, + handleClearSelection, + handleMarkSelectedAsDecorative, + handleToggleAllDecorative, + handleGenerateAll, + generateButtonText, + } = useProgressBarLogic(onGeneratingChange); + + if (isGenerating) { + return ( + + ); + } + + if (showManualSelectionMode) { + return ( + + ); + } + + return ( + + ); +}; + +ProgressBarContainer.propTypes = { + onGeneratingChange: PropTypes.func, +}; + +export default ProgressBarContainer; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/selection-mode-progress.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/selection-mode-progress.js new file mode 100644 index 00000000..b0d78372 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/selection-mode-progress.js @@ -0,0 +1,61 @@ +import Button from '@elementor/ui/Button'; +import Typography from '@elementor/ui/Typography'; +import PropTypes from 'prop-types'; +import PencilTickIcon from '@ea11y-apps/scanner/icons/pencil-tick-icon'; +import { __, sprintf } from '@wordpress/i18n'; +import GenerateAllButton from './generate-all-button'; +import { StyledMainWrapperGrid, StyledActionsGrid } from './styled-components'; + +const SelectionModeProgress = ({ + manuallySelectedCount, + isGenerating, + onClear, + onMarkAsDecorative, + onGenerate, +}) => { + return ( + + + + {sprintf( + // Translators: %d number of selected images + __('%d selected', 'pojo-accessibility'), + manuallySelectedCount, + )} + + + + + + + + + ); +}; + +SelectionModeProgress.propTypes = { + manuallySelectedCount: PropTypes.number.isRequired, + isGenerating: PropTypes.bool.isRequired, + onClear: PropTypes.func.isRequired, + onMarkAsDecorative: PropTypes.func.isRequired, + onGenerate: PropTypes.func.isRequired, +}; + +export default SelectionModeProgress; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/styled-components.js b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/styled-components.js new file mode 100644 index 00000000..e6f98138 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/progress-bar/styled-components.js @@ -0,0 +1,18 @@ +import Grid from '@elementor/ui/Grid'; +import { styled } from '@elementor/ui/styles'; + +export const StyledMainWrapperGrid = styled(Grid)` + padding: 16px; + gap: 8px; + align-items: center; + position: sticky; + top: 0; + z-index: 1000; +`; + +export const StyledActionsGrid = styled(Grid)` + display: flex; + gap: 8px; + align-items: center; + flex-wrap: nowrap; +`; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/quota-error-alert.js b/modules/scanner/assets/js/components/bulk-alt-text/quota-error-alert.js new file mode 100644 index 00000000..4c41c4cf --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/quota-error-alert.js @@ -0,0 +1,57 @@ +import Alert from '@elementor/ui/Alert'; +import AlertTitle from '@elementor/ui/AlertTitle'; +import Button from '@elementor/ui/Button'; +import { COMPARE_PLAN_URL } from '@ea11y-apps/scanner/constants'; +import { useBulkGeneration } from '@ea11y-apps/scanner/context/bulk-generation-context'; +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +const QuotaErrorAlert = () => { + const { quotaError, progress } = useBulkGeneration(); + const [isDismissed, setIsDismissed] = useState(false); + + if (!quotaError || isDismissed) { + return null; + } + + const generatedCount = progress?.completed || 0; + const totalCount = progress?.total || 0; + + return ( + setIsDismissed(true)} + action={ + + } + > + + {__('Some images could not be generated.', 'pojo-accessibility')} + + {sprintf( + // Translators: %1$d generated count, %2$d total count + __( + 'We generated %1$d/%2$d images before credits ran out — upgrade your plan or wait until next month.', + 'pojo-accessibility', + ), + generatedCount, + totalCount, + )} + + ); +}; + +export default QuotaErrorAlert; diff --git a/modules/scanner/assets/js/components/bulk-alt-text/upgrade-infotip.js b/modules/scanner/assets/js/components/bulk-alt-text/upgrade-infotip.js new file mode 100644 index 00000000..498d5b64 --- /dev/null +++ b/modules/scanner/assets/js/components/bulk-alt-text/upgrade-infotip.js @@ -0,0 +1,60 @@ +import Button from '@elementor/ui/Button'; +import Card from '@elementor/ui/Card'; +import CardActions from '@elementor/ui/CardActions'; +import CardContent from '@elementor/ui/CardContent'; +import CardHeader from '@elementor/ui/CardHeader'; +import Typography from '@elementor/ui/Typography'; +import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events'; +import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service'; +import { BULK_UPGRADE_URL } from '@ea11y-apps/scanner/constants'; +import { useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +const UpgradeInfotip = ({ trigger, action }) => { + useEffect(() => { + mixpanelService.sendEvent(mixpanelEvents.upgradeTooltipTriggered, { + feature: trigger?.feature, + component: trigger?.component, + }); + }, []); + + const onUpgrade = () => { + mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, { + current_plan: window.ea11yScannerData?.planData?.plan?.name, + feature: action?.feature, + component: action?.component, + }); + }; + return ( + + + + + {__( + 'Upgrade to handle alt text in bulk: generate or mark as decorative, in one click.', + 'pojo-accessibility', + )} + + + + + + + ); +}; + +export default UpgradeInfotip; diff --git a/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js b/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js index 22828d01..c3256d59 100644 --- a/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js +++ b/modules/scanner/assets/js/components/upgrade-info-tip/upgrade-content.js @@ -10,21 +10,55 @@ import { COMPARE_PLAN_URL, IS_PRO_PLAN, UPGRADE_URL, + BULK_UPGRADE_URL, } from '@ea11y-apps/scanner/constants'; import { UpgradeContentContainer } from '@ea11y-apps/scanner/styles/app.styles'; import { InfotipBox, InfotipFooter, } from '@ea11y-apps/scanner/styles/manual-fixes.styles'; +import { useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -export const UpgradeContent = ({ closeUpgrade, isAlt = false }) => { - const onUpgrade = () => { - mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, { +export const UpgradeContent = ({ + closeUpgrade, + isAlt = false, + isBulkAlt = false, + isBulkSingleImage = false, +}) => { + useEffect(() => { + mixpanelService.sendEvent(mixpanelEvents.upgradeTooltipTriggered, { current_plan: window.ea11yScannerData?.planData?.plan?.name, - action_trigger: 'ai_suggestion_accepted', - feature_locked: isAlt ? 'AI alt-text' : 'AI manual', + component: 'button_wizard_main_button', + feature: 'bulk_alt_text', }); + }, [isBulkAlt]); + const onUpgrade = () => { + if (isBulkAlt) { + mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, { + current_plan: window.ea11yScannerData?.planData?.plan?.name, + component: 'button', + feature: isBulkSingleImage + ? 'bulk_wizard_single_image' + : 'bulk_wizard_main_cta', + }); + } else { + mixpanelService.sendEvent(mixpanelEvents.upgradeButtonClicked, { + current_plan: window.ea11yScannerData?.planData?.plan?.name, + action_trigger: 'ai_suggestionf_accepted', + feature_locked: isAlt ? 'AI alt-text' : 'AI manual', + }); + } + }; + + const getURL = () => { + if (isBulkAlt) { + return getUpgradeLink(BULK_UPGRADE_URL); + } + if (IS_PRO_PLAN) { + return getUpgradeLink(COMPARE_PLAN_URL); + } + return getUpgradeLink(UPGRADE_URL); }; return ( @@ -62,7 +96,7 @@ export const UpgradeContent = ({ closeUpgrade, isAlt = false }) => { size="small" color="promotion" variant="contained" - href={IS_PRO_PLAN ? COMPARE_PLAN_URL : getUpgradeLink(UPGRADE_URL)} + href={getURL()} target="_blank" rel="noreferrer" startIcon={!IS_PRO_PLAN ? : null} diff --git a/modules/scanner/assets/js/constants/index.js b/modules/scanner/assets/js/constants/index.js index 08bc81a8..1ec0d60e 100644 --- a/modules/scanner/assets/js/constants/index.js +++ b/modules/scanner/assets/js/constants/index.js @@ -23,6 +23,7 @@ export const RATIO_EXCLUDED = 1; export const UPGRADE_URL = 'https://go.elementor.com/acc-free-no-AI-scanner'; export const UPGRADE_GLOBAL_URL = 'https://go.elementor.com/acc-global-remediation'; +export const BULK_UPGRADE_URL = 'https://go.elementor.com/acc-bulk-alt-text'; export const COMPARE_PLAN_URL = 'https://go.elementor.com/acc-AI-limit-scanner'; export const PAGE_LIMIT_URL = 'https://go.elementor.com/acc-URL-limit-scanner'; diff --git a/modules/scanner/assets/js/context/bulk-generation-context.js b/modules/scanner/assets/js/context/bulk-generation-context.js new file mode 100644 index 00000000..cbec9417 --- /dev/null +++ b/modules/scanner/assets/js/context/bulk-generation-context.js @@ -0,0 +1,143 @@ +import PropTypes from 'prop-types'; +import { speak } from '@wordpress/a11y'; +import { + createContext, + useContext, + useState, + useRef, + useCallback, +} from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +const BulkGenerationContext = createContext(null); + +export const BulkGenerationProvider = ({ children }) => { + const [currentGeneratingIndex, setCurrentGeneratingIndex] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [quotaError, setQuotaError] = useState(null); + const shouldAbortRef = useRef(false); + const queueRef = useRef([]); + const progressRef = useRef({ completed: 0, total: 0, errors: 0 }); + const [progress, setProgress] = useState({ + completed: 0, + total: 0, + errors: 0, + }); + + const startBulkGeneration = useCallback((cardIndices) => { + shouldAbortRef.current = false; + queueRef.current = [...cardIndices]; + progressRef.current = { + completed: 0, + total: cardIndices.length, + errors: 0, + }; + setProgress({ completed: 0, total: cardIndices.length, errors: 0 }); + setQuotaError(null); + setIsGenerating(true); + + if (cardIndices.length > 0) { + setCurrentGeneratingIndex(cardIndices[0]); + } else { + setIsGenerating(false); + } + }, []); + + const stopBulkGeneration = useCallback(() => { + shouldAbortRef.current = true; + queueRef.current = []; + setCurrentGeneratingIndex(null); + setIsGenerating(false); + }, []); + + const onCardComplete = useCallback((success) => { + progressRef.current.completed += 1; + if (!success) { + progressRef.current.errors += 1; + } + setProgress({ ...progressRef.current }); + + queueRef.current.shift(); + + if (shouldAbortRef.current || queueRef.current.length === 0) { + setCurrentGeneratingIndex(null); + setIsGenerating(false); + + // Announce completion + if (queueRef.current.length === 0) { + const { completed, errors } = progressRef.current; + if (errors > 0) { + const message = sprintf( + // Translators: %1$d successful count, %2$d failed count + __( + 'Generation complete. %1$d succeeded, %2$d failed', + 'pojo-accessibility', + ), + completed - errors, + errors, + ); + speak(message, 'assertive'); + } else { + const message = sprintf( + // Translators: %d number of images + __( + 'Generation complete. %d images processed successfully', + 'pojo-accessibility', + ), + completed, + ); + speak(message, 'assertive'); + } + } + } else { + setCurrentGeneratingIndex(queueRef.current[0]); + } + }, []); + + const resetProgress = useCallback(() => { + setIsGenerating(false); + shouldAbortRef.current = false; + queueRef.current = []; + setCurrentGeneratingIndex(null); + progressRef.current = { completed: 0, total: 0, errors: 0 }; + setProgress({ completed: 0, total: 0, errors: 0 }); + setQuotaError(null); + }, []); + + const setQuotaExceeded = useCallback((errorMessage) => { + setQuotaError(errorMessage); + shouldAbortRef.current = true; + queueRef.current = []; + setCurrentGeneratingIndex(null); + setIsGenerating(false); + speak(errorMessage, 'assertive'); + }, []); + + const value = { + currentGeneratingIndex, + isGenerating, + shouldAbort: shouldAbortRef, + progress, + quotaError, + startBulkGeneration, + stopBulkGeneration, + onCardComplete, + resetProgress, + setQuotaExceeded, + }; + + return ( + + {children} + + ); +}; + +BulkGenerationProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const useBulkGeneration = () => { + const context = useContext(BulkGenerationContext); + return context; +}; diff --git a/modules/scanner/assets/js/hooks/use-alt-text-form.js b/modules/scanner/assets/js/hooks/use-alt-text-form.js index 0e220aa0..bcdc34ab 100644 --- a/modules/scanner/assets/js/hooks/use-alt-text-form.js +++ b/modules/scanner/assets/js/hooks/use-alt-text-form.js @@ -3,6 +3,7 @@ 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 { useBulkGeneration } from '@ea11y-apps/scanner/context/bulk-generation-context'; import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; import { scannerItem } from '@ea11y-apps/scanner/types/scanner-item'; import { removeExistingFocus } from '@ea11y-apps/scanner/utils/focus-on-element'; @@ -12,9 +13,36 @@ import { convertSvgToPngBase64, svgNodeToPngBase64, } from '@ea11y-apps/scanner/utils/svg-to-png-base64'; +import { speak } from '@wordpress/a11y'; import { useEffect, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +export const getPayload = async (item) => { + if (item.node?.src) { + return item.node.src.toLowerCase().endsWith('.svg') + ? { svg: await convertSvgToPngBase64(item.node.src) } + : { image: item.node.src }; + } + return { svg: await svgNodeToPngBase64(item.node) }; +}; + +export const generateAiAltText = async (item, signal = null) => { + const data = await getPayload(item); + const result = await APIScanner.generateAltText(data, signal); + const descriptions = splitDescriptions(result.data.response); + + if (!descriptions[0]) { + throw new Error('No description generated'); + } + + return { + altText: descriptions[0], + aiText: descriptions, + apiId: result.data.apiId, + aiTextIndex: 0, + }; +}; + export const useAltTextForm = ({ current, item }) => { const { altTextData, @@ -29,6 +57,12 @@ export const useAltTextForm = ({ current, item }) => { setIsManageChanged, } = useScannerWizardContext(); const { error } = useToastNotification(); + const bulkGeneration = useBulkGeneration(); + + const currentGeneratingIndex = bulkGeneration?.currentGeneratingIndex; + const shouldAbort = bulkGeneration?.shouldAbort || { current: false }; + const onCardComplete = bulkGeneration?.onCardComplete || (() => {}); + const setQuotaExceeded = bulkGeneration?.setQuotaExceeded || (() => {}); const [loadingAiText, setLoadingAiText] = useState(false); const [loading, setLoading] = useState(false); @@ -39,14 +73,35 @@ export const useAltTextForm = ({ current, item }) => { altTextData?.[type]?.[current]?.isGlobal || item.global || false; const isGlobalRef = useRef(null); + const savedAltTextRef = useRef(''); + const abortControllerRef = useRef(null); + const hasInitializedRef = useRef(false); useEffect(() => { if (item?.node) { isGlobalRef.current = isGlobal; + if (altTextData?.[type]?.[current]?.hasValidAltText) { + savedAltTextRef.current = altTextData?.[type]?.[current]?.altText || ''; + } } }, [current]); useEffect(() => { + if (hasInitializedRef.current === current) { + return; + } + + const hasExistingData = + altTextData?.[type]?.[current]?.hasValidAltText || + altTextData?.[type]?.[current]?.altText || + altTextData?.[type]?.[current]?.isGenerating || + altTextData?.[type]?.[current]?.makeDecorative !== undefined; + + if (hasExistingData) { + hasInitializedRef.current = current; + return; + } + if (isManage) { updateData({ makeDecorative: item.data.attribute_name === 'role', @@ -57,6 +112,8 @@ export const useAltTextForm = ({ current, item }) => { } else { updateData({ isGlobal }); } + + hasInitializedRef.current = current; }, [isManage, current]); useEffect(() => { @@ -67,6 +124,97 @@ export const useAltTextForm = ({ current, item }) => { setFirstOpen(false); }, [isResolved(BLOCKS.altText)]); + useEffect(() => { + const isMyTurn = currentGeneratingIndex === current; + + if (!isMyTurn) { + return; + } + + const itemData = altTextData?.[type]?.[current]; + const hasValidAlt = itemData?.hasValidAltText; + const isDecorative = itemData?.makeDecorative; + + const shouldGenerate = !hasValidAlt && !isDecorative; + + if (shouldGenerate) { + const generateForCard = async () => { + if (shouldAbort.current) { + return; + } + + updateData({ isGenerating: true }); + abortControllerRef.current = new AbortController(); + + try { + const aiData = await generateAiAltText( + item, + abortControllerRef.current.signal, + ); + + if (shouldAbort.current) { + updateData({ isGenerating: false }); + return; + } + + updateData({ + ...aiData, + selected: true, + resolved: false, + hasValidAltText: true, + isDraft: false, + isGenerating: false, + }); + + speak( + __('Alt text generated successfully', 'pojo-accessibility'), + 'polite', + ); + sendMixpanelEvent(aiData.altText); + onCardComplete(true); + } catch (e) { + updateData({ isGenerating: false }); + + if (!shouldAbort.current) { + if (e?.code === 'quota_exceeded') { + const errorMessage = + e?.message || + __( + 'AI credits quota has been exceeded. Please upgrade your plan or wait for the next billing cycle.', + 'pojo-accessibility', + ); + speak(errorMessage, 'assertive'); + setQuotaExceeded(errorMessage); + return; // Don't call onCardComplete + } + + if (e?.code === 'quota_api_error') { + const errorMessage = + e?.message || + __( + 'There was an error in generating Alt text using AI. Please try again after sometime.', + 'pojo-accessibility', + ); + speak(errorMessage, 'assertive'); + return; // Don't call onCardComplete + } + + console.error(`Failed to generate AI text for card ${current}:`, e); + onCardComplete(false); + } + } + }; + + generateForCard(); + } + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [currentGeneratingIndex]); + const setIsGlobal = (value) => { updateData({ isGlobal: value, @@ -74,17 +222,19 @@ export const useAltTextForm = ({ current, item }) => { }; const updateData = (data) => { - const updData = [...altTextData?.[type]]; - if (altTextData?.[type]?.[current]?.resolved && !data.resolved) { - setResolved(resolved - 1); - } - updData[current] = { - ...(altTextData?.[type]?.[current] || {}), - ...data, - }; - setAltTextData({ - ...altTextData, - [type]: updData, + setAltTextData((prevAltTextData) => { + const updData = [...(prevAltTextData?.[type] || [])]; + if (prevAltTextData?.[type]?.[current]?.resolved && !data.resolved) { + setResolved((prev) => prev - 1); + } + updData[current] = { + ...(prevAltTextData?.[type]?.[current] || {}), + ...data, + }; + return { + ...prevAltTextData, + [type]: updData, + }; }); }; @@ -145,24 +295,100 @@ export const useAltTextForm = ({ current, item }) => { }; const handleCheck = (e) => { - updateData({ - makeDecorative: e.target.checked, - apiId: null, - resolved: false, - }); - if (e.target.checked) { + const isChecking = e.target.checked; + const currentAltText = altTextData?.[type]?.[current]?.altText?.trim(); + + // Calculate the number of decorative images after this action + const currentDecorativeCount = (altTextData?.[type] || []).filter( + (itm) => itm?.makeDecorative === true, + ).length; + const numOfImages = isChecking + ? currentDecorativeCount + 1 + : Math.max(0, currentDecorativeCount - 1); + + // Determine if we're in bulk mode + const isBulkMode = + currentGeneratingIndex !== null && currentGeneratingIndex !== undefined; + + if (isChecking) { + updateData({ + makeDecorative: true, + apiId: null, + resolved: false, + hasValidAltText: true, + isDraft: false, + selected: true, + }); + speak(__('Image marked as decorative', 'pojo-accessibility'), 'polite'); + mixpanelService.sendEvent(mixpanelEvents.markAsDecorativeSelected, { + category_name: BLOCKS.altText, + type: isBulkMode ? 'bulk' : 'single', + num_of_images: numOfImages, + action_type: 'mark', + }); + } else { + const hasAltText = !!currentAltText; + updateData({ + makeDecorative: false, + apiId: null, + resolved: false, + hasValidAltText: hasAltText, + isDraft: false, + selected: hasAltText, + }); + speak(__('Image unmarked as decorative', 'pojo-accessibility'), 'polite'); mixpanelService.sendEvent(mixpanelEvents.markAsDecorativeSelected, { category_name: BLOCKS.altText, + type: isBulkMode ? 'bulk' : 'single', + num_of_images: numOfImages, + action_type: 'undo', }); } }; const handleChange = (e) => { + const wasValidBefore = altTextData?.[type]?.[current]?.hasValidAltText; + + if (!altTextData?.[type]?.[current]?.isDraft) { + savedAltTextRef.current = altTextData?.[type]?.[current]?.altText || ''; + } + updateData({ altText: e.target.value, apiId: null, resolved: false, + isDraft: true, + hasValidAltText: false, + selected: wasValidBefore + ? false + : altTextData?.[type]?.[current]?.selected, + }); + }; + + const handleSave = () => { + const altText = altTextData?.[type]?.[current]?.altText?.trim(); + if (altText) { + savedAltTextRef.current = altText; + updateData({ + hasValidAltText: true, + isDraft: false, + selected: true, + }); + speak(__('Alt text saved', 'pojo-accessibility'), 'polite'); + } + }; + + const handleCancel = () => { + const restoredHasValidAlt = !!savedAltTextRef.current; + updateData({ + altText: savedAltTextRef.current, + isDraft: false, + hasValidAltText: restoredHasValidAlt, + selected: restoredHasValidAlt + ? true + : altTextData?.[type]?.[current]?.selected, }); + speak(__('Changes discarded', 'pojo-accessibility'), 'polite'); }; const handleSubmit = async () => { @@ -239,16 +465,9 @@ export const useAltTextForm = ({ current, item }) => { } }; - const getPayload = async () => { - if (item.node?.src) { - return item.node.src.toLowerCase().endsWith('.svg') - ? { svg: await convertSvgToPngBase64(item.node.src) } - : { image: item.node.src }; - } - return { svg: await svgNodeToPngBase64(item.node) }; - }; - const sendMixpanelEvent = (text) => { + const isBulkMode = + currentGeneratingIndex !== null && currentGeneratingIndex !== undefined; mixpanelService.sendEvent(mixpanelEvents.fixWithAiButtonClicked, { issue_type: item.message, rule_id: item.ruleId, @@ -256,28 +475,51 @@ export const useAltTextForm = ({ current, item }) => { category_name: BLOCKS.altText, ai_text_response: text, page_url: window.ea11yScannerData?.pageData?.url, + type: isBulkMode ? 'bulk' : 'single', }); }; const getAiText = async () => { setLoadingAiText(true); - const data = await getPayload(); try { - const result = await APIScanner.generateAltText(data); - const descriptions = splitDescriptions(result.data.response); - if (descriptions[0]) { - updateData({ - altText: descriptions[0], - aiText: descriptions, - apiId: result.data.apiId, - aiTextIndex: 0, - resolved: false, - }); - sendMixpanelEvent(descriptions[0]); - } + const aiData = await generateAiAltText(item); + updateData({ + ...aiData, + resolved: false, + hasValidAltText: true, + isDraft: false, + selected: true, + }); + speak( + __('Alt text generated successfully', 'pojo-accessibility'), + 'polite', + ); + sendMixpanelEvent(aiData.altText); } catch (e) { console.log(e); - error(__('An error occurred.', 'pojo-accessibility')); + + let errorMessage; + + if (e?.code === 'quota_exceeded') { + errorMessage = + e?.message || + __( + 'AI credits quota has been exceeded. Please upgrade your plan or wait for the next billing cycle.', + 'pojo-accessibility', + ); + } else if (e?.code === 'quota_api_error') { + errorMessage = + e?.message || + __( + 'Quota API error. Try again after sometime.', + 'pojo-accessibility', + ); + } else { + errorMessage = __('An error occurred.', 'pojo-accessibility'); + } + + error(errorMessage); + speak(errorMessage, 'assertive'); } finally { setLoadingAiText(false); } @@ -297,6 +539,10 @@ export const useAltTextForm = ({ current, item }) => { resolved: false, }); + speak( + __('Alternative suggestion loaded', 'pojo-accessibility'), + 'polite', + ); sendMixpanelEvent(altTextData?.[type]?.[current]?.aiText[index]); } else { await getAiText(); @@ -324,9 +570,12 @@ export const useAltTextForm = ({ current, item }) => { loading, handleCheck, handleChange, + handleSave, + handleCancel, handleSubmit, handleUpdate, generateAltText, + updateData, }; }; @@ -334,3 +583,66 @@ useAltTextForm.propTypes = { current: PropTypes.number.isRequired, item: scannerItem.isRequired, }; + +export const submitAltTextRemediation = async ({ + item, + altText, + makeDecorative, + isGlobal, + apiId, + currentScanId, + updateRemediationList, +}) => { + const makeAttributeData = () => { + if (makeDecorative) { + return { + attribute_name: 'role', + attribute_value: 'presentation', + }; + } + + if (item.node.tagName === 'svg') { + return { + attribute_name: 'aria-label', + attribute_value: altText, + }; + } + + return { + attribute_name: 'alt', + attribute_value: altText, + }; + }; + + const match = item.node.className.toString().match(/wp-image-(\d+)/); + const finalAltText = !makeDecorative ? altText : ''; + const find = item.snippet; + + try { + if (match && item.node.tagName !== 'svg') { + void APIScanner.submitAltText(item.node.src, finalAltText); + } + const response = await APIScanner.submitRemediation({ + url: window?.ea11yScannerData?.pageData.url, + remediation: { + ...makeAttributeData(), + action: 'add', + xpath: item.path.dom, + find, + category: item.reasonCategory.match(/\((AAA?|AA?|A)\)/)?.[1] || '', + type: 'ATTRIBUTE', + }, + global: isGlobal, + rule: item.ruleId, + group: BLOCKS.altText, + apiId, + }); + + await APIScanner.resolveIssue(currentScanId); + void updateRemediationList(); + return response.remediation; + } catch (e) { + console.warn(e); + throw e; + } +}; diff --git a/modules/scanner/assets/js/hooks/use-prevent-aria-hidden.js b/modules/scanner/assets/js/hooks/use-prevent-aria-hidden.js new file mode 100644 index 00000000..5ff3062a --- /dev/null +++ b/modules/scanner/assets/js/hooks/use-prevent-aria-hidden.js @@ -0,0 +1,79 @@ +import { useLayoutEffect, useRef } from '@wordpress/element'; + +/** + * Prevents MUI's ModalManager from adding aria-hidden="true" to document.body + * children. Inside a shadow root the ModalManager can't associate the modal + * with its shadow host, so it inadvertently hides the shadow host (and + * therefore the dialog itself) from screen readers. + * + * MUI sets aria-hidden via a ref callback during React's commit phase, before + * any effects run. We capture a snapshot of pre-existing aria-hidden state + * during render (before commit), then use useLayoutEffect to clean up what MUI + * added and attach an observer for any future mutations. + * + * MUI issue: https://github.com/mui/material-ui/issues/19450 + * + * @param {boolean} active + */ +const usePreventAriaHidden = (active) => { + const snapshot = useRef(null); + + // Capture which body children already have aria-hidden during the render + // phase, before React's commit phase where MUI's ref callbacks fire. + if (active && snapshot.current === null) { + const preserved = new Set(); + + for (const child of document.body.children) { + if (child.getAttribute('aria-hidden') !== null) { + preserved.add(child); + } + } + snapshot.current = preserved; + } + + if (!active) { + snapshot.current = null; + } + + useLayoutEffect(() => { + if (!active) { + return; + } + + const preserved = snapshot.current || new Set(); + + const strip = (element) => { + if ( + !preserved.has(element) && + element.getAttribute('aria-hidden') === 'true' + ) { + element.removeAttribute('aria-hidden'); + } + }; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.attributeName === 'aria-hidden') { + strip(mutation.target); + } + } + }); + + for (const child of document.body.children) { + observer.observe(child, { + attributes: true, + attributeFilter: ['aria-hidden'], + }); + + // Clean up aria-hidden that MUI already set via ref callbacks + // during the commit phase (before this layout effect ran). + strip(child); + } + + return () => { + observer.disconnect(); + }; + }, [active]); +}; + +export default usePreventAriaHidden; diff --git a/modules/scanner/assets/js/hooks/use-progress-bar-logic.js b/modules/scanner/assets/js/hooks/use-progress-bar-logic.js new file mode 100644 index 00000000..a1b02c0b --- /dev/null +++ b/modules/scanner/assets/js/hooks/use-progress-bar-logic.js @@ -0,0 +1,248 @@ +import { mixpanelEvents } from '@ea11y-apps/global/services/mixpanel/mixpanel-events'; +import { mixpanelService } from '@ea11y-apps/global/services/mixpanel/mixpanel-service'; +import { useBulkGeneration } from '@ea11y-apps/scanner/context/bulk-generation-context'; +import { useScannerWizardContext } from '@ea11y-apps/scanner/context/scanner-wizard-context'; +import { speak } from '@wordpress/a11y'; +import { useEffect, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +export const useProgressBarLogic = (onGeneratingChange) => { + const { sortedViolations, altTextData, setAltTextData, isManage } = + useScannerWizardContext(); + const { + isGenerating, + progress, + startBulkGeneration, + stopBulkGeneration, + resetProgress, + } = useBulkGeneration(); + + const altTextViolations = sortedViolations.altText; + const type = isManage ? 'manage' : 'main'; + const totalImages = altTextViolations.length; + + const handleStopGenerating = () => { + mixpanelService.sendEvent(mixpanelEvents.stopButtonClicked); + stopBulkGeneration(); + speak(__('Generation stopped', 'pojo-accessibility'), 'assertive'); + }; + + useEffect(() => { + if (onGeneratingChange) { + onGeneratingChange(isGenerating, handleStopGenerating); + } + }, [isGenerating, onGeneratingChange]); + + useEffect(() => { + return () => { + resetProgress(); + }; + }, [resetProgress]); + + const { + manuallySelectedCount, + completedSelectedCount, + areAllMarkedAsDecorative, + } = useMemo(() => { + let manuallySelected = 0; + let completedSelected = 0; + let allDecorative = true; + + altTextViolations.forEach((item, index) => { + const itemData = altTextData?.[type]?.[index]; + const isSelected = itemData?.selected === true; + const isDecorative = itemData?.makeDecorative === true; + const hasValidAlt = itemData?.hasValidAltText === true; + + if (isSelected && !hasValidAlt) { + manuallySelected++; + } + + if (isSelected && hasValidAlt) { + completedSelected++; + } + + if (!isDecorative) { + allDecorative = false; + } + }); + + return { + manuallySelectedCount: manuallySelected, + completedSelectedCount: completedSelected, + areAllMarkedAsDecorative: allDecorative, + }; + }, [altTextViolations, altTextData, type]); + + const showManualSelectionMode = manuallySelectedCount > 0; + + const handleClearSelection = () => { + const updatedData = [...(altTextData?.[type] || [])]; + + altTextViolations.forEach((item, index) => { + if ( + altTextData?.[type]?.[index]?.selected && + !altTextData?.[type]?.[index]?.hasValidAltText + ) { + updatedData[index] = { + ...(updatedData[index] || {}), + selected: false, + }; + } + }); + + setAltTextData({ + ...altTextData, + [type]: updatedData, + }); + speak(__('Selection cleared', 'pojo-accessibility'), 'polite'); + }; + + const handleMarkSelectedAsDecorative = () => { + const updatedData = [...(altTextData?.[type] || [])]; + let count = 0; + + altTextViolations.forEach((item, index) => { + if ( + altTextData?.[type]?.[index]?.selected && + !altTextData?.[type]?.[index]?.hasValidAltText + ) { + updatedData[index] = { + ...(updatedData[index] || {}), + makeDecorative: true, + hasValidAltText: true, + isDraft: false, + altText: '', + apiId: null, + resolved: false, + }; + count++; + } + }); + + setAltTextData({ + ...altTextData, + [type]: updatedData, + }); + + const message = sprintf( + // Translators: %d number of images marked as decorative + __('%d images marked as decorative', 'pojo-accessibility'), + count, + ); + speak(message, 'polite'); + }; + + const handleToggleAllDecorative = () => { + const updatedData = [...(altTextData?.[type] || [])]; + const isMarking = !areAllMarkedAsDecorative; + + altTextViolations.forEach((item, index) => { + if (isMarking) { + updatedData[index] = { + ...(updatedData[index] || {}), + makeDecorative: true, + selected: true, + hasValidAltText: true, + apiId: null, + resolved: false, + }; + } else { + const currentAltText = updatedData[index]?.altText?.trim(); + const hasAltText = !!currentAltText; + + updatedData[index] = { + ...(updatedData[index] || {}), + makeDecorative: false, + selected: hasAltText, + hasValidAltText: hasAltText, + apiId: null, + resolved: false, + }; + } + }); + + setAltTextData({ + ...altTextData, + [type]: updatedData, + }); + + const message = isMarking + ? sprintf( + // Translators: %d number of images + __('All %d images marked as decorative', 'pojo-accessibility'), + totalImages, + ) + : __('All images unmarked as decorative', 'pojo-accessibility'); + speak(message, 'polite'); + }; + + const handleGenerateAll = () => { + const hasManuallySelectedItems = altTextViolations.some( + (item, index) => + altTextData?.[type]?.[index]?.selected === true && + !altTextData?.[type]?.[index]?.hasValidAltText, + ); + + const cardIndicesToProcess = []; + altTextViolations.forEach((item, index) => { + const itemData = altTextData?.[type]?.[index]; + const isMarkedDecorative = itemData?.makeDecorative; + const hasValidAlt = itemData?.hasValidAltText; + + if (isMarkedDecorative || hasValidAlt) { + return; + } + + const shouldInclude = hasManuallySelectedItems + ? itemData?.selected === true + : true; + if (shouldInclude) { + cardIndicesToProcess.push(index); + } + }); + + const message = sprintf( + // Translators: %d number of images to generate + __('Generating alt text for %d images', 'pojo-accessibility'), + cardIndicesToProcess.length, + ); + speak(message, 'polite'); + startBulkGeneration(cardIndicesToProcess); + }; + + const getGenerateButtonText = () => { + if (isGenerating) { + return __('Generating…', 'pojo-accessibility'); + } + if (manuallySelectedCount > 0) { + return sprintf( + // Translators: %d number of images to generate + __('Generate (%d)', 'pojo-accessibility'), + manuallySelectedCount, + ); + } + return __('Generate all', 'pojo-accessibility'); + }; + + return { + // State + isGenerating, + progress, + showManualSelectionMode, + manuallySelectedCount, + completedSelectedCount, + totalImages, + areAllMarkedAsDecorative, + + // Handlers + handleStopGenerating, + handleClearSelection, + handleMarkSelectedAsDecorative, + handleToggleAllDecorative, + handleGenerateAll, + + // Computed values + generateButtonText: getGenerateButtonText(), + }; +}; diff --git a/modules/scanner/assets/js/icons/pencil-tick-icon.js b/modules/scanner/assets/js/icons/pencil-tick-icon.js new file mode 100644 index 00000000..bd5520b1 --- /dev/null +++ b/modules/scanner/assets/js/icons/pencil-tick-icon.js @@ -0,0 +1,21 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +const PencilTickIcon = (props, { size = 'tiny' }) => { + return ( + + + + + + + + + + + ); +}; + +export default PencilTickIcon; diff --git a/modules/scanner/assets/js/icons/pencil-undo-icon.js b/modules/scanner/assets/js/icons/pencil-undo-icon.js new file mode 100644 index 00000000..b2b93440 --- /dev/null +++ b/modules/scanner/assets/js/icons/pencil-undo-icon.js @@ -0,0 +1,21 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +const PencilUndoIcon = (props, { size = 'tiny' }) => { + return ( + + + + + + + + + + + ); +}; + +export default PencilUndoIcon; diff --git a/modules/scanner/assets/js/icons/player-stop-icon.js b/modules/scanner/assets/js/icons/player-stop-icon.js new file mode 100644 index 00000000..4be2ee84 --- /dev/null +++ b/modules/scanner/assets/js/icons/player-stop-icon.js @@ -0,0 +1,14 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +const PlayerStopIcon = (props, { size = 'tiny' }) => { + return ( + + + + ); +}; + +export default PlayerStopIcon; diff --git a/modules/scanner/assets/js/icons/wand-icon.js b/modules/scanner/assets/js/icons/wand-icon.js new file mode 100644 index 00000000..1434ca71 --- /dev/null +++ b/modules/scanner/assets/js/icons/wand-icon.js @@ -0,0 +1,28 @@ +import SvgIcon from '@elementor/ui/SvgIcon'; + +const WandIcon = (props, { size = 'tiny' }) => { + return ( + + + + + + ); +}; + +export default WandIcon; diff --git a/modules/scanner/assets/js/images/bulk-1.png b/modules/scanner/assets/js/images/bulk-1.png new file mode 100644 index 00000000..04d4ab36 Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-1.png differ diff --git a/modules/scanner/assets/js/images/bulk-2.png b/modules/scanner/assets/js/images/bulk-2.png new file mode 100644 index 00000000..fce3ebd5 Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-2.png differ diff --git a/modules/scanner/assets/js/images/bulk-3.png b/modules/scanner/assets/js/images/bulk-3.png new file mode 100644 index 00000000..15d7eaa5 Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-3.png differ diff --git a/modules/scanner/assets/js/images/bulk-4.png b/modules/scanner/assets/js/images/bulk-4.png new file mode 100644 index 00000000..d30d3121 Binary files /dev/null and b/modules/scanner/assets/js/images/bulk-4.png differ diff --git a/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js b/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js index 06b0e55c..dd99fb2f 100644 --- a/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js +++ b/modules/scanner/assets/js/layouts/scanner/alt-text-layout.js @@ -41,7 +41,7 @@ export const AltTextLayout = () => { }; return ( - + { + const makeAttributeData = () => { + if (makeDecorative) { + return { + attribute_name: 'role', + attribute_value: 'presentation', + }; + } + + if (item.node.tagName === 'svg') { + return { + attribute_name: 'aria-label', + attribute_value: altText, + }; + } + + return { + attribute_name: 'alt', + attribute_value: altText, + }; + }; + + const match = item.node.className.toString().match(/wp-image-(\d+)/); + const finalAltText = !makeDecorative ? altText : ''; + const find = item.snippet; + + try { + if (match && item.node.tagName !== 'svg') { + void APIScanner.submitAltText(item.node.src, finalAltText); + } + const response = await APIScanner.submitRemediation({ + url: window?.ea11yScannerData?.pageData.url, + remediation: { + ...makeAttributeData(), + action: 'add', + xpath: item.path.dom, + find, + category: item.reasonCategory.match(/\((AAA?|AA?|A)\)/)?.[1] || '', + type: 'ATTRIBUTE', + }, + global: isGlobal, + rule: item.ruleId, + group: BLOCKS.altText, + apiId, + }); + + await APIScanner.resolveIssue(currentScanId); + void updateRemediationList(); + return response.remediation; + } catch (e) { + console.warn(e); + throw e; + } +}; diff --git a/modules/scanner/rest/generate-alt-text.php b/modules/scanner/rest/generate-alt-text.php index 1fcce3b9..07162102 100644 --- a/modules/scanner/rest/generate-alt-text.php +++ b/modules/scanner/rest/generate-alt-text.php @@ -5,6 +5,9 @@ use EA11y\Modules\Scanner\Classes\Route_Base; use EA11y\Modules\Scanner\Classes\Utils; use EA11y\Classes\Utils as Global_Utils; +use EA11y\Classes\Client\Client_Response; +use EA11y\Classes\Exceptions\Quota_Exceeded_Error; +use EA11y\Classes\Exceptions\Quota_API_Error; use Throwable; use WP_Error; use WP_REST_Response; @@ -49,21 +52,16 @@ public function POST( $request ) { $src = $image ?? Utils::create_tmp_file_from_png_base64( $svg ); - $result = Global_Utils::get_api_client()->make_request( - 'POST', - 'ai/image-alt', - [], - [], - false, - $src - ); - - if ( is_wp_error( $result ) ) { - return $this->respond_error_json( [ - 'message' => 'Failed to generate Alt Text', - 'code' => 'internal_server_error', - ] ); - } + $result = ( new Client_Response( + Global_Utils::get_api_client()->make_request( + 'POST', + 'ai/image-alt', + [], + [], + false, + $src + ) + ) )->handle(); return $this->respond_success_json( [ 'message' => 'Alt text generated', @@ -73,6 +71,16 @@ public function POST( $request ) { ], ] ); + } catch ( Quota_Exceeded_Error $e ) { + return $this->respond_error_json( [ + 'message' => 'AI credits quota has been exceeded.', + 'code' => 'quota_exceeded', + ] ); + } catch ( Quota_API_Error $e ) { + return $this->respond_error_json( [ + 'message' => 'Quota API error. Try again after sometime.', + 'code' => 'quota_api_error', + ] ); } catch ( Throwable $t ) { return $this->respond_error_json( [ 'message' => $t->getMessage(), diff --git a/modules/settings/assets/css/style.css b/modules/settings/assets/css/style.css index 113a5611..f156afb6 100644 --- a/modules/settings/assets/css/style.css +++ b/modules/settings/assets/css/style.css @@ -17,17 +17,28 @@ body { } #ea11y-app { - position: relative; + position: fixed; z-index: 1; + height: calc(100vh - 32px); + inset-block: 32px 0; /* 32px is for WP admin bar. */ + inset-inline: 180px 0; margin-inline-start: -20px; background: #fff; - height: calc(100vh - 32px); +} + +body.folded #ea11y-app { + inset-inline-start: 36px; + margin-inline-start: 0; } #ea11y-app * { box-sizing: border-box; } +#ea11y-app .wrap { + padding: 20px 40px 0; +} + .ea11y-textfield .MuiInputBase-input { height: 40px; padding: 8px 12px; diff --git a/modules/settings/assets/js/admin.js b/modules/settings/assets/js/admin.js index ca065f1e..014681dd 100644 --- a/modules/settings/assets/js/admin.js +++ b/modules/settings/assets/js/admin.js @@ -3,6 +3,7 @@ import { StrictMode, Fragment, createRoot } from '@wordpress/element'; import App from './app'; import { AnalyticsContextProvider } from './contexts/analytics-context'; import { PluginSettingsProvider } from './contexts/plugin-settings'; +import { initNoticeRelocation } from './utils/move-notices'; const rootNode = document.getElementById('ea11y-app'); @@ -10,18 +11,23 @@ const rootNode = document.getElementById('ea11y-app'); const isDevelopment = window?.ea11ySettingsData?.isDevelopment; const AppWrapper = Boolean(isDevelopment) ? StrictMode : Fragment; -const root = createRoot(rootNode); +if (rootNode) { + const root = createRoot(rootNode); -root.render( - - - - - - - - - - - , -); + root.render( + + + + + + + + + + + , + ); + + // Move WordPress admin notices into the React app after rendering starts + initNoticeRelocation(); +} diff --git a/modules/settings/assets/js/app.js b/modules/settings/assets/js/app.js index cbc564ba..8b9d094d 100644 --- a/modules/settings/assets/js/app.js +++ b/modules/settings/assets/js/app.js @@ -7,14 +7,6 @@ import Box from '@elementor/ui/Box'; import DirectionProvider from '@elementor/ui/DirectionProvider'; import Grid from '@elementor/ui/Grid'; import { styled, ThemeProvider } from '@elementor/ui/styles'; -import { - ConnectModal, - GetStartedModal, - MenuItems, - OnboardingModal, - PostConnectModal, - UrlMismatchModal, -} from '@ea11y/components'; import { useNotificationSettings, useSavedSettings, @@ -23,11 +15,44 @@ import { import { QuotaNotices, Sidebar } from '@ea11y/layouts'; import Notifications from '@ea11y-apps/global/components/notifications'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; -import { useEffect } from '@wordpress/element'; +import { lazy, Suspense, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { MenuItems } from './components/sidebar-menu/menu'; import { usePluginSettingsContext } from './contexts/plugin-settings'; import PageContent from './page-content'; +// Lazy load modals - load them only when needed +const ConnectModal = lazy( + () => + import( + /* webpackChunkName: "chunk-modal-connect" */ './components/connect-modal' + ), +); +const PostConnectModal = lazy( + () => + import( + /* webpackChunkName: "chunk-modal-post-connect" */ './components/post-connect-modal' + ), +); +const UrlMismatchModal = lazy( + () => + import( + /* webpackChunkName: "chunk-modal-url-mismatch" */ './components/url-mismatch-modal' + ), +); +const OnboardingModal = lazy( + () => + import( + /* webpackChunkName: "chunk-modal-onboarding" */ './components/onboarding-modal' + ), +); +const GetStartedModal = lazy( + () => + import( + /* webpackChunkName: "chunk-modal-get-started" */ './components/help-menu/get-started-modal' + ), +); + const App = () => { const { ea11ySettingsData } = window; const { hasFinishedResolution, loading } = useSavedSettings(); @@ -74,13 +99,15 @@ const App = () => { onDisconnect={refreshPluginSettings} /> - {isConnected !== undefined && !isUrlMismatch && !isConnected && ( - - )} - {isConnected && !closePostConnectModal && } - {isUrlMismatch && } - - + + {isConnected !== undefined && !isUrlMismatch && !isConnected && ( + + )} + {isConnected && !closePostConnectModal && } + {isUrlMismatch && } + + + diff --git a/modules/settings/assets/js/components/analytics/charts-list.js b/modules/settings/assets/js/components/analytics/charts-list.js index 80b2df74..49562695 100644 --- a/modules/settings/assets/js/components/analytics/charts-list.js +++ b/modules/settings/assets/js/components/analytics/charts-list.js @@ -23,6 +23,7 @@ import { } from '@ea11y/components/analytics/skeleton'; import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; import { dateI18n } from '@wordpress/date'; +import { Suspense } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { useAnalyticsContext } from '../../contexts/analytics-context'; @@ -152,10 +153,22 @@ export const ChartsList = () => { - {isLoading ? : } + {isLoading ? ( + + ) : ( + }> + + + )} - {isLoading ? : } + {isLoading ? ( + + ) : ( + }> + + + )} {isLoading ? ( diff --git a/modules/settings/assets/js/components/analytics/charts/index.js b/modules/settings/assets/js/components/analytics/charts/index.js index 28254e20..99a3c93a 100644 --- a/modules/settings/assets/js/components/analytics/charts/index.js +++ b/modules/settings/assets/js/components/analytics/charts/index.js @@ -1,3 +1,16 @@ -export { LineChart } from './line-chart'; -export { PieChart } from './pie-chart'; +import { lazy } from '@wordpress/element'; + +// Lazy-load chart components to split @mui/x-charts into separate webpack chunks +export const LineChart = lazy(() => + import(/* webpackChunkName: "chunk-charts-analytics" */ './line-chart').then( + (module) => ({ default: module.LineChart }), + ), +); + +export const PieChart = lazy(() => + import(/* webpackChunkName: "chunk-charts-analytics" */ './pie-chart').then( + (module) => ({ default: module.PieChart }), + ), +); + export { UsageTable } from './usage-table'; diff --git a/modules/settings/assets/js/components/bottom-bar/index.js b/modules/settings/assets/js/components/bottom-bar/index.js index e39d8cf4..74bbd8bf 100644 --- a/modules/settings/assets/js/components/bottom-bar/index.js +++ b/modules/settings/assets/js/components/bottom-bar/index.js @@ -11,6 +11,7 @@ const BottomBar = () => { selectedMenu, widgetMenuSettings, skipToContentSettings, + widgetActivationSettings, iconDesign, iconPosition, hasChanges, @@ -27,6 +28,7 @@ const BottomBar = () => { savedData = { ea11y_widget_menu_settings: widgetMenuSettings, ea11y_skip_to_content_settings: skipToContentSettings, + ea11y_widget_activation_settings: widgetActivationSettings, }; } else if (selectedMenu.child === 'design') { savedData = { diff --git a/modules/settings/assets/js/components/index.js b/modules/settings/assets/js/components/index.js index 7ec90b9b..57546444 100644 --- a/modules/settings/assets/js/components/index.js +++ b/modules/settings/assets/js/components/index.js @@ -1,4 +1,5 @@ -export { default as ConnectModal } from './connect-modal'; +// ConnectModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling +// export { default as ConnectModal } from './connect-modal'; export { default as MyAccountMenu } from './my-account-menu'; export { default as PopupMenu } from './my-account-menu/popup-menu'; export { default as HelpMenu } from './help-menu'; @@ -13,11 +14,13 @@ export { default as AlignmentMatrixControl } from './alignment-matrix-control'; export { default as PositionControl } from './position-control'; export { MenuItems } from '../components/sidebar-menu/menu'; export { default as BottomBar } from './bottom-bar'; -export { default as PostConnectModal } from './post-connect-modal'; +// PostConnectModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling +// export { default as PostConnectModal } from './post-connect-modal'; export { default as StatementGenerator } from './statement-generator'; export { default as AlertError } from './error'; export { default as HtmlToTypography } from './html-to-typography'; export { default as WidgetLoader } from './widget-loader'; +export { default as WidgetActivationSettings } from './widget-activation-settings'; export { default as CopyLink } from './copy-link'; export { default as EditLink } from './edit-link'; export { default as GeneratedPageInfoTipCard } from './generated-page-infotip-card'; @@ -26,7 +29,8 @@ export { default as CapabilitiesItem } from './capabilities-item'; export { default as ProItemInfotip } from './capabilities-item/pro-item-infotip'; export { default as CustomSwitch } from './switch'; export { default as ConfirmDialog } from '@ea11y-apps/global/components/confirm-dialog'; -export { default as UrlMismatchModal } from './url-mismatch-modal'; +// UrlMismatchModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling +// export { default as UrlMismatchModal } from './url-mismatch-modal'; export { default as WidgetIcon } from './widget-icon'; export { default as CustomIcon } from './custom-icon'; export { default as IconOptionWrapper } from './icon-option-wrapper'; @@ -35,5 +39,7 @@ export { default as QuotaBar } from './quota-bar'; export { default as QuotaIndicator } from './quota-bar/quota-indicator'; export { default as MenuItem } from './sidebar-menu/menu-item'; export { default as QuotaBarGroup } from './quota-bar/quota-bar-group'; -export { default as OnboardingModal } from './onboarding-modal'; -export { default as GetStartedModal } from './help-menu/get-started-modal'; +// OnboardingModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling +// export { default as OnboardingModal } from './onboarding-modal'; +// GetStartedModal is lazy-loaded in app.js - do not export from barrel to prevent eager bundling +// export { default as GetStartedModal } from './help-menu/get-started-modal'; diff --git a/modules/settings/assets/js/components/url-mismatch-modal/index.js b/modules/settings/assets/js/components/url-mismatch-modal/index.js index b70cc76d..aaf06455 100644 --- a/modules/settings/assets/js/components/url-mismatch-modal/index.js +++ b/modules/settings/assets/js/components/url-mismatch-modal/index.js @@ -3,8 +3,8 @@ import Button from '@elementor/ui/Button'; import Grid from '@elementor/ui/Grid'; import Typography from '@elementor/ui/Typography'; import { styled } from '@elementor/ui/styles'; -import { ConfirmDialog } from '@ea11y/components'; import { useModal } from '@ea11y/hooks'; +import ConfirmDialog from '@ea11y-apps/global/components/confirm-dialog'; import { ONE_MISMATCH_URL } from '@ea11y-apps/global/constants'; import { useToastNotification } from '@ea11y-apps/global/hooks'; import { useState } from '@wordpress/element'; diff --git a/modules/settings/assets/js/components/widget-activation-settings/index.js b/modules/settings/assets/js/components/widget-activation-settings/index.js new file mode 100644 index 00000000..408f277d --- /dev/null +++ b/modules/settings/assets/js/components/widget-activation-settings/index.js @@ -0,0 +1,113 @@ +import InfoCircleIcon from '@elementor/icons/InfoCircleIcon'; +import Box from '@elementor/ui/Box'; +import Card from '@elementor/ui/Card'; +import Infotip from '@elementor/ui/Infotip'; +import Switch from '@elementor/ui/Switch'; +import Typography from '@elementor/ui/Typography'; +import { styled } from '@elementor/ui/styles'; +import { useSettings } from '@ea11y/hooks'; +import { mixpanelEvents, mixpanelService } from '@ea11y-apps/global/services'; +import { __ } from '@wordpress/i18n'; + +const WidgetActivationSettings = () => { + const { + widgetActivationSettings, + setWidgetActivationSettings, + setHasChanges, + } = useSettings(); + + const toggleSetting = () => { + const newValue = !widgetActivationSettings.enabled; + + setWidgetActivationSettings({ + ...widgetActivationSettings, + enabled: newValue, + }); + + setHasChanges(true); + + mixpanelService.sendEvent(mixpanelEvents.toggleClicked, { + state: newValue ? 'on' : 'off', + type: 'Widget activation', + }); + }; + + return ( + + + + {__('Widget Activation', 'pojo-accessibility')} + + + + {__('Widget Activation', 'pojo-accessibility')} + + + + {__( + 'Disabling it will prevent the widget from loading entirely.', + 'pojo-accessibility', + )} + + + } + placement="right" + arrow={true} + > + + + + + + + + + {__( + 'Enable or disable the accessibility widget on your website.', + 'pojo-accessibility', + )} + + + ); +}; + +const StyledCard = styled(Card)` + padding: ${({ theme }) => theme.spacing(2)}; + margin-block: ${({ theme }) => theme.spacing(4)}; + margin-inline: auto; + max-width: 1200px; +`; + +const StyledBox = styled(Box)` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const StyledSwitch = styled(Switch)` + input { + height: 56px !important; + } +`; + +const StyledTypography = styled(Typography)` + display: flex; + align-items: center; + gap: 8px; +`; + +export default WidgetActivationSettings; diff --git a/modules/settings/assets/js/hooks/use-saved-settings.js b/modules/settings/assets/js/hooks/use-saved-settings.js index f6301305..a7ed1d16 100644 --- a/modules/settings/assets/js/hooks/use-saved-settings.js +++ b/modules/settings/assets/js/hooks/use-saved-settings.js @@ -18,6 +18,7 @@ export const useSavedSettings = () => { setAccessibilityStatementData, setShowAccessibilityGeneratedInfotip, setSkipToContentSettings, + setWidgetActivationSettings, setDismissedQuotaNotices, } = useSettings(); @@ -92,6 +93,7 @@ export const useSavedSettings = () => { result.data.ea11y_accessibility_statement_data, ); } + if (result?.data?.ea11y_show_accessibility_generated_page_infotip) { setShowAccessibilityGeneratedInfotip( result.data.ea11y_show_accessibility_generated_page_infotip, @@ -102,6 +104,12 @@ export const useSavedSettings = () => { setSkipToContentSettings(result?.data?.ea11y_skip_to_content_settings); } + if (result?.data?.ea11y_widget_activation_settings) { + setWidgetActivationSettings( + result?.data?.ea11y_widget_activation_settings, + ); + } + if (result?.data?.ea11y_analytics_enabled) { setIsAnalyticsEnabled(result?.data?.ea11y_analytics_enabled); } diff --git a/modules/settings/assets/js/hooks/use-settings.js b/modules/settings/assets/js/hooks/use-settings.js index c90b4294..8ea586d8 100644 --- a/modules/settings/assets/js/hooks/use-settings.js +++ b/modules/settings/assets/js/hooks/use-settings.js @@ -101,6 +101,10 @@ export const SettingsProvider = ({ children }) => { enabled: true, }); + const [widgetActivationSettings, setWidgetActivationSettings] = useState({ + enabled: true, + }); + const [planData, setPlanData] = useState(null); // Track settings changes to enable/disable Save Changes button @@ -196,6 +200,8 @@ export const SettingsProvider = ({ children }) => { setWidgetMenuSettings, skipToContentSettings, setSkipToContentSettings, + widgetActivationSettings, + setWidgetActivationSettings, skipToContentHasChanges, setSkipToContentHasChanges, hideMinimumOptionAlert, diff --git a/modules/settings/assets/js/page-content.js b/modules/settings/assets/js/page-content.js index 12820ba6..63fb6f3b 100644 --- a/modules/settings/assets/js/page-content.js +++ b/modules/settings/assets/js/page-content.js @@ -1,3 +1,4 @@ +import { Suspense } from '@wordpress/element'; import SettingsLoader from './page-content-loader'; const PageContent = ({ isLoading, page }) => { @@ -5,7 +6,7 @@ const PageContent = ({ isLoading, page }) => { return ; } - return page; + return }>{page}; }; export default PageContent; 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 index 9de11beb..29adf09f 100644 --- 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 @@ -1,5 +1,6 @@ import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader'; import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip'; +import { lazy, Suspense } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { StyledStatsItem, @@ -8,7 +9,12 @@ import { StyledStatsItemTitle, } from '../stats.styles'; import IssueList from './issue-list'; -import PieChart from './pie-chart'; + +const PieChart = lazy(() => + import(/* webpackChunkName: "chunk-charts-assistant" */ './pie-chart').then( + (module) => ({ default: module.default }), + ), +); const IssuesByCategory = ({ stats, loading, noResultsState }) => { return ( @@ -33,11 +39,13 @@ const IssuesByCategory = ({ stats, loading, noResultsState }) => { - + }> + + ); 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 index 2e0a590c..e5080370 100644 --- 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 @@ -1,5 +1,6 @@ import ValueLoader from '@ea11y/pages/assistant/loaders/value-loader'; import AccessibilityAssistantTooltip from '@ea11y/pages/assistant/tooltip'; +import { lazy, Suspense } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { StyledStatsItem, @@ -8,7 +9,12 @@ import { StyledStatsItemTitle, } from '../stats.styles'; import IssueList from './issue-list'; -import PieChart from './pie-chart'; + +const PieChart = lazy(() => + import(/* webpackChunkName: "chunk-charts-assistant" */ './pie-chart').then( + (module) => ({ default: module.default }), + ), +); const IssuesByLevel = ({ stats, loading, noResultsState }) => { const levelsTotal = @@ -48,14 +54,16 @@ const IssuesByLevel = ({ stats, loading, noResultsState }) => { - + }> + + ); diff --git a/modules/settings/assets/js/pages/index.js b/modules/settings/assets/js/pages/index.js index 8c051f68..c327e6bc 100644 --- a/modules/settings/assets/js/pages/index.js +++ b/modules/settings/assets/js/pages/index.js @@ -1,5 +1,23 @@ -export { default as AccessibilityStatement } from './accessibility-statement'; -export { default as Analytics } from './analytics'; -export { default as IconSettings } from './icon-settings'; -export { default as Menu } from './menu'; -export { default as AccessibilityAssistant } from './assistant'; +import { lazy } from '@wordpress/element'; + +export const AccessibilityAssistant = lazy( + () => + import( + /* webpackChunkName: "chunk-page-accessibility-scans" */ './assistant' + ), +); +export const IconSettings = lazy( + () => import(/* webpackChunkName: "chunk-page-design" */ './icon-settings'), +); +export const Menu = lazy( + () => import(/* webpackChunkName: "chunk-page-capabilities" */ './menu'), +); +export const Analytics = lazy( + () => import(/* webpackChunkName: "chunk-page-analytics" */ './analytics'), +); +export const AccessibilityStatement = lazy( + () => + import( + /* webpackChunkName: "chunk-page-accessibility-statement" */ './accessibility-statement' + ), +); diff --git a/modules/settings/assets/js/pages/menu.js b/modules/settings/assets/js/pages/menu.js index 1f546fe2..1d63a389 100644 --- a/modules/settings/assets/js/pages/menu.js +++ b/modules/settings/assets/js/pages/menu.js @@ -1,6 +1,6 @@ import Box from '@elementor/ui/Box'; import { styled } from '@elementor/ui/styles'; -import { BottomBar } from '@ea11y/components'; +import { BottomBar, WidgetActivationSettings } from '@ea11y/components'; import SkipToContentSettings from '@ea11y/components/skip-to-content-settings'; import { MenuSettings, WidgetPreview } from '@ea11y/layouts'; import { @@ -32,6 +32,8 @@ const Menu = () => { + + diff --git a/modules/settings/assets/js/utils/move-notices.js b/modules/settings/assets/js/utils/move-notices.js new file mode 100644 index 00000000..dd92157b --- /dev/null +++ b/modules/settings/assets/js/utils/move-notices.js @@ -0,0 +1,120 @@ +const NOTICE_SELECTORS = '.wrap .notice, .wrap .updated, .wrap .error'; +const TARGET_SELECTOR = '#ea11y-app > div > div + div'; +const PLUGIN_SELECTOR = '#ea11y-app'; +const MAX_WAIT_ATTEMPTS = 10; +const RETRY_INTERVAL = 100; // ms + +/** + * Move notices from WordPress admin to the React app container. + * + * @return {boolean} True if target exists and notices were processed, false otherwise. + */ +function moveNotices() { + const targetContainer = document.querySelector(TARGET_SELECTOR); + + if (!targetContainer) { + return false; + } + + // Find or create a .wrap element inside the target container + let wrapElement = targetContainer.querySelector('.wrap'); + if (!wrapElement) { + wrapElement = document.createElement('div'); + wrapElement.className = 'wrap'; + targetContainer.prepend(wrapElement); + } + + // Find all notices in .wrap containers + const notices = document.querySelectorAll(NOTICE_SELECTORS); + + notices.forEach((notice) => { + // Only move notices that are NOT already inside the app + if (!notice.closest(PLUGIN_SELECTOR)) { + wrapElement.prepend(notice); + } + }); + + return true; +} + +/** + * Set up a MutationObserver to watch for dynamically-added notices. + */ +function setupNoticeObserver() { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + // Check if the added node is a notice/error/updated element + if ( + node.nodeType === Node.ELEMENT_NODE && + (node.classList.contains('notice') || + node.classList.contains('error') || + node.classList.contains('updated')) + ) { + // Check if it's in a .wrap but not in the app + const isInWrap = node.closest('.wrap'); + const isInApp = node.closest(PLUGIN_SELECTOR); + + if (isInWrap && !isInApp) { + const targetContainer = document.querySelector(TARGET_SELECTOR); + if (targetContainer) { + // Find or create a .wrap element inside the target container + let wrapElement = targetContainer.querySelector('.wrap'); + if (!wrapElement) { + wrapElement = document.createElement('div'); + wrapElement.className = 'wrap'; + targetContainer.prepend(wrapElement); + } + wrapElement.prepend(node); + } + } + } + }); + }); + }); + + // Observe the entire document body for added notices + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return observer; +} + +/** + * Wait for the React target element to exist, then move notices. + * + * This function polls for the target element with a retry mechanism, + * since React needs time to render the DOM structure. + */ +function waitAndMoveNotices() { + // Try moving immediately in case React has already rendered + if (moveNotices()) { + setupNoticeObserver(); + return; + } + + // Set up polling to wait for React to render + let attempts = 0; + const interval = setInterval(() => { + if (moveNotices() || attempts++ >= MAX_WAIT_ATTEMPTS) { + clearInterval(interval); + + // Only set up observer if we successfully found the target + if (attempts < MAX_WAIT_ATTEMPTS) { + setupNoticeObserver(); + } + } + }, RETRY_INTERVAL); +} + +/** + * Initialize the notice relocation system. + * + * Call this function after React has started rendering to begin + * moving WordPress admin notices into the React app container. + */ +export function initNoticeRelocation() { + waitAndMoveNotices(); +} diff --git a/modules/settings/banners/onboarding-banner.php b/modules/settings/banners/onboarding-banner.php index 2770d64f..f98a7503 100644 --- a/modules/settings/banners/onboarding-banner.php +++ b/modules/settings/banners/onboarding-banner.php @@ -91,11 +91,6 @@ public static function get_banner() {