/** * WordPress dependencies */ import { Icon } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { error, link as linkIcon, linkOff } from '@wordpress/icons'; import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ import { ContentHelperError, ContentHelperErrorCode } from '../../../../common/content-helper-error'; import { HydratedPost } from '../../../../common/providers/base-wordpress-provider'; import { TrafficBoostLink } from '../provider'; import { TrafficBoostSidebarTabs, TrafficBoostStore } from '../store'; import { PreviewFooter } from './components/preview-footer'; import { PreviewHeader } from './components/preview-header'; import { PreviewIframe } from './components/preview-iframe'; import './preview.scss'; /** * Structure of a text selection. * * @since 3.19.0 */ export interface TextSelection { text: string; offset: number; } /** * Props for the TrafficBoostPreview component. * * @since 3.19.0 */ interface TrafficBoostPreviewProps { activeLink: TrafficBoostLink; onAccept: ( link: TrafficBoostLink, selectedText: TextSelection | null ) => Promise; onDiscard: ( link: TrafficBoostLink ) => Promise; onRemoveInboundLink: ( link: TrafficBoostLink, restoreOriginal: boolean ) => Promise; onUpdateInboundLink: ( link: TrafficBoostLink, text: string, offset: number, restoreOriginal: boolean ) => Promise; } /** * Component that renders the Traffic Boost preview. * * @since 3.19.0 * * @param {TrafficBoostPreviewProps} props The component's props. */ export const TrafficBoostPreview = ( { activeLink: providedActiveLink, onAccept, onDiscard, onRemoveInboundLink, onUpdateInboundLink, }: TrafficBoostPreviewProps ): React.JSX.Element => { const [ isFrontendPreview, setIsFrontendPreview ] = useState( false ); const [ isInboundLink, setIsInboundLink ] = useState( false ); const [ isLoading, setIsLoading ] = useState( true ); const [ activeLink, setActiveLink ] = useState( providedActiveLink ); const [ activePost, setActivePost ] = useState( providedActiveLink.targetPost ); const [ selectedText, setSelectedText ] = useState( null ); const [ previewUrl, setPreviewUrl ] = useState( '' ); const [ totalItems, setTotalItems ] = useState( 0 ); const [ itemIndex, setItemIndex ] = useState( 0 ); const { createSuccessNotice, createErrorNotice, } = useDispatch( 'core/notices' ); const { post, suggestions, inboundLinks, } = useSelect( ( select ) => { return { post: select( TrafficBoostStore ).getCurrentPost(), suggestions: select( TrafficBoostStore ).getSuggestions(), inboundLinks: select( TrafficBoostStore ).getInboundLinks(), }; }, [ ] ); const { setSelectedLink, removeSuggestion, removeInboundLink, addInboundLink, setSelectedTab, setIsAccepting, setIsRemoving, setPreviewLinkType, } = useDispatch( TrafficBoostStore ); /** * Sets the active link to the provided active link. * * @since 3.19.0 */ useEffect( () => { setActiveLink( providedActiveLink ); }, [ providedActiveLink ] ); /** * Sets the active post to the target post of the active link, * and unsets the text selection when the active link changes. * * @since 3.19.0 */ useEffect( () => { setActivePost( activeLink.targetPost ); setIsInboundLink( ! activeLink.isSuggestion ); setSelectedText( null ); setPreviewLinkType( null ); }, [ activeLink, setPreviewLinkType ] ); /** * Sets the total items and item index based on the active link. * * @since 3.19.0 */ useEffect( () => { if ( activeLink.isSuggestion ) { setTotalItems( suggestions?.length ?? 0 ); setItemIndex( suggestions?.indexOf( activeLink ) + 1 ); } else { setTotalItems( inboundLinks?.length ?? 0 ); setItemIndex( inboundLinks?.indexOf( activeLink ) + 1 ); } }, [ activeLink, inboundLinks, suggestions ] ); /** * Sets the preview URL based on the active post and frontend preview setting. * * @since 3.19.0 */ useEffect( () => { if ( ! activePost ) { return; } const newUrl = ! isFrontendPreview ? addQueryArgs( `${ window.location.origin }/wp-admin/admin-ajax.php`, { action: 'parsely_post_preview', post_id: activePost.id, _wpnonce: window._parsely_traffic_boost_preview_nonce ?? '', smart_link_id: activeLink.smartLink?.smart_link_id, } ) : addQueryArgs( activePost.link, { parsely_preview: 'true', _wpnonce: window._parsely_traffic_boost_preview_nonce ?? '', smart_link_id: activeLink.smartLink?.smart_link_id, } ); // Only set loading state if URL actually changes. if ( newUrl !== previewUrl ) { setIsLoading( true ); setPreviewUrl( newUrl ); } }, [ activePost, isFrontendPreview, previewUrl ] ); // eslint-disable-line react-hooks/exhaustive-deps // Use useCallback for onRestoreOriginal to maintain reference stability const handleRestoreOriginal = useCallback( () => { setSelectedText( null ); }, [] ); /** * Opens the post in a new tab. * * @since 3.19.0 */ const openPostInNewTab = () => { if ( ! activePost?.link ) { return; } window.open( activePost.link, '_blank', 'noopener' ); }; /** * Opens the post editor in a new tab. * * @since 3.19.0 */ const openPostEditor = () => { if ( ! activePost?.id ) { return; } window.open( `${ window.location.origin }/wp-admin/post.php?post=${ activePost.id }&action=edit`, '_blank', 'noopener' ); }; /** * Opens the Parse.ly dashboard for this post in a new tab. * * @since 3.19.0 */ const openParselyDashboard = () => { if ( ! activePost?.link ) { return; } const parselyDashboardUrl = `https://dash.parsely.com/${ window.wpParselySiteId }/find?url=${ encodeURIComponent( activePost.link ) }`; window.open( parselyDashboardUrl, '_blank', 'noopener' ); }; /** * Handles the next item event. * * @since 3.19.0 */ const handleNext = () => { let nextItem: TrafficBoostLink | undefined; if ( isInboundLink ) { nextItem = inboundLinks?.[ itemIndex ]; } else { nextItem = suggestions?.[ itemIndex ]; } if ( nextItem ) { setItemIndex( itemIndex + 1 ); setSelectedLink( nextItem ); } }; /** * Handles the previous item event. * * @since 3.19.0 */ const handlePrevious = () => { let previousItem: TrafficBoostLink | undefined; if ( isInboundLink ) { previousItem = inboundLinks?.[ itemIndex - 2 ]; } else { previousItem = suggestions?.[ itemIndex - 2 ]; } if ( previousItem ) { setItemIndex( itemIndex - 1 ); setSelectedLink( previousItem ); } }; /** * Handles the accept event. * * @since 3.19.0 * * @param {TrafficBoostLink} link The link to accept. */ const handleAccept = async ( link: TrafficBoostLink ) => { setIsAccepting( link, true ); try { // Accept the suggestion. const accepted = await onAccept( link, selectedText ); if ( ! accepted ) { throw new ContentHelperError( __( 'Failed to accept suggestion.', 'wp-parsely' ), ContentHelperErrorCode.UnknownError, '' // No prefix for this error. ); } } catch ( err: unknown ) { let errorMessage: string = __( 'Failed to accept suggestion.', 'wp-parsely' ); if ( err instanceof ContentHelperError && err.message && err.code !== ContentHelperErrorCode.UnknownError ) { errorMessage += ` ${ err.message }`; } createErrorNotice( errorMessage, { type: 'snackbar', icon: , } ); setIsAccepting( link, false ); return; } // Remove suggestion from the list. removeSuggestion( link ); // Flag isSuggestion to false. link.isSuggestion = false; // Add the link to the inbound links list. addInboundLink( link ); setIsAccepting( link, false ); // Show a snackbar success message. createSuccessNotice( __( 'Link planted', 'wp-parsely' ), { type: 'snackbar', icon: , } ); // When accepting the only remaining suggestion, switch to inbound links tab. if ( itemIndex === totalItems && totalItems === 1 ) { setSelectedTab( TrafficBoostSidebarTabs.INBOUND_LINKS ); setSelectedLink( link ); // Refresh the iframe. setPreviewUrl( previewUrl + '?cache-bust=' + Date.now() ); } else if ( itemIndex === totalItems ) { // Navigate to previous suggestion when accepting the last one. handlePrevious(); } else { // Move to next suggestion after accepting current one. handleNext(); } }; /** * Discards a suggestion. * * @since 3.19.0 * * @param {TrafficBoostLink} link The link to discard. */ const handleDiscard = async ( link: TrafficBoostLink ) => { await onDiscard( link ); // Remove the suggestion from the list. removeSuggestion( link ); // When discarding the only remaining suggestion, switch to inbound links tab. if ( itemIndex === totalItems && totalItems === 1 ) { setSelectedLink( null ); } else if ( itemIndex === totalItems ) { // Navigate to previous suggestion when discarding the last one. handlePrevious(); } else { // Move to next suggestion after discarding current one. handleNext(); } }; /** * Removes an inbound link. * * @since 3.19.0 * * @param {TrafficBoostLink} link The link to remove. * @param {boolean} restoreOriginal Whether to restore the original link. */ const handleRemove = async ( link: TrafficBoostLink, restoreOriginal: boolean ) => { setIsRemoving( link, true ); try { const removed = await onRemoveInboundLink( link, restoreOriginal ); if ( ! removed ) { throw new ContentHelperError( __( 'Failed to remove link.', 'wp-parsely' ), ContentHelperErrorCode.UnknownError, '' // No prefix for this error. ); } } catch ( err: unknown ) { let errorMessage: string = __( 'Failed to remove link.', 'wp-parsely' ); if ( err instanceof ContentHelperError && err.message && err.code !== ContentHelperErrorCode.UnknownError ) { errorMessage += ` ${ err.message }`; } createErrorNotice( errorMessage, { type: 'snackbar', icon: , } ); setIsRemoving( link, false ); return; } removeInboundLink( link ); setIsRemoving( link, false ); // Show a snackbar success message. createSuccessNotice( __( 'Link removed', 'wp-parsely' ), { type: 'snackbar', icon: , } ); // When removing the only remaining inbound link, switch to inbound links tab. if ( itemIndex === totalItems && totalItems === 1 ) { setSelectedLink( null ); } else if ( itemIndex === totalItems ) { // Navigate to previous inbound link when removing the last one. handlePrevious(); } else { // Move to next inbound link after removing current one. handleNext(); } }; /** * Handles the update link event. * * @since 3.19.0 * * @param {TrafficBoostLink} link The link to update. * @param {boolean} restoreOriginal Whether to restore the original link. */ const handleUpdateLink = async ( link: TrafficBoostLink, restoreOriginal: boolean ) => { if ( ! selectedText ) { return; } setIsAccepting( link, true ); try { const updated = await onUpdateInboundLink( link, selectedText.text, selectedText.offset, restoreOriginal ); if ( ! updated ) { throw new ContentHelperError( __( 'Failed to update link.', 'wp-parsely' ), ContentHelperErrorCode.UnknownError, '' // No prefix for this error. ); } } catch ( err: unknown ) { let errorMessage: string = __( 'Failed to update link.', 'wp-parsely' ); if ( err instanceof ContentHelperError && err.message && err.code !== ContentHelperErrorCode.UnknownError ) { errorMessage += ` ${ err.message }`; } createErrorNotice( errorMessage, { type: 'snackbar', icon: , } ); setIsAccepting( link, false ); return; } setIsAccepting( link, false ); // Show a snackbar success message. createSuccessNotice( __( 'Link updated', 'wp-parsely' ), { type: 'snackbar', icon: , } ); // Refresh the iframe. setPreviewUrl( previewUrl + '?cache-bust=' + Date.now() ); // Clear the selected text. setSelectedText( null ); }; if ( ! activePost || ! post ) { return <>; } return (
{ setSelectedText( { text, offset } ); } } onRestoreOriginal={ handleRestoreOriginal } isFrontendPreview={ isFrontendPreview } onLoadingChange={ setIsLoading } onAccept={ handleAccept } onDiscard={ handleDiscard } onUpdateLink={ handleUpdateLink } onRemove={ handleRemove } />
); };