diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index a95f7d2e..913a00fc 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -13,7 +13,7 @@ jobs: - name: Setup PHP version uses: shivammathur/setup-php@v2 with: - php-version: "7.2" + php-version: "7.4" extensions: simplexml - name: Checkout source code uses: actions/checkout@v4 @@ -36,7 +36,7 @@ jobs: - name: Setup PHP version uses: shivammathur/setup-php@v2 with: - php-version: "7.2" + php-version: "7.4" extensions: simplexml, mysql tools: phpunit-polyfills - name: Checkout source code diff --git a/assets/js/wizard-promo.js b/assets/js/wizard-promo.js new file mode 100644 index 00000000..b061eada --- /dev/null +++ b/assets/js/wizard-promo.js @@ -0,0 +1,320 @@ +/** + * Raft Pro upsell injection for the Otter onboarding wizard. + * + * Two surfaces: + * - Sidebar nudge: appended to `.o-sidebar__content` on every step. + * - Finish card: appended to `.o-finish__container` (before its actions) + * when the wizard reaches its done state. + * + * We don't import from Otter — we treat its rendered DOM + Redux store as + * the only interfaces, so changes to Otter's internals can't break us + * unless they rename the BEM classes we target. The script is loaded only + * when `?onboarding=true` is present AND Raft Pro is inactive, so paying + * users never see either nudge. + * + * Re-injection is necessary because React owns the wizard DOM and can + * remount sections on state changes — using marker IDs to avoid duplicates. + * + * @package + */ +( function () { + 'use strict'; + + if ( + 'undefined' === typeof window.wp || + ! window.wp.data || + ! window.raftWizardPromo + ) { + return; + } + + const data = window.wp.data; + const domReady = window.wp.domReady; + const config = window.raftWizardPromo; + const strings = config.strings || {}; + const upgradeUrl = config.upgradeUrl; + + const SIDEBAR_ID = 'raft-pro-wizard-sidebar-nudge'; + const FINISH_ID = 'raft-pro-wizard-finish-card'; + + /** + * Inject inline CSS for the two surfaces. Scoped via the marker IDs so + * we don't leak styles into Otter's own components. + */ + function injectStyles() { + if ( document.getElementById( 'raft-pro-wizard-promo-style' ) ) { + return; + } + const style = document.createElement( 'style' ); + style.id = 'raft-pro-wizard-promo-style'; + style.textContent = + '' + + '#' + + SIDEBAR_ID + + ' { margin-top: 24px; padding: 16px; border: 1px dashed #C26148; border-radius: 8px; background: rgba(194, 97, 72, 0.06); }' + + '#' + + SIDEBAR_ID + + ' .raft-eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 1.5px; text-transform: uppercase; color: #C26148; margin: 0 0 4px; }' + + '#' + + SIDEBAR_ID + + ' .raft-title { font-size: 15px; font-weight: 600; margin: 0 0 8px; color: #1e1e1e; }' + + '#' + + SIDEBAR_ID + + ' a { color: #C26148; font-weight: 600; text-decoration: none; font-size: 13px; }' + + '#' + + SIDEBAR_ID + + ' a:hover { text-decoration: underline; }' + + '#' + + FINISH_ID + + ' { margin: 24px 0; padding: 28px; border: 2px dashed #C26148; border-radius: 12px; background: rgba(194, 97, 72, 0.05); text-align: left; }' + + '#' + + FINISH_ID + + ' .raft-eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; color: #C26148; margin: 0 0 8px; }' + + '#' + + FINISH_ID + + ' h3 { font-size: 22px; font-weight: 600; margin: 0 0 12px; color: #1e1e1e; }' + + '#' + + FINISH_ID + + ' p { font-size: 15px; line-height: 1.5; margin: 0 0 20px; color: #50575e; }' + + '#' + + FINISH_ID + + ' a.raft-cta { display: inline-block; background: #C26148; color: #fff; padding: 10px 22px; border-radius: 999px; text-decoration: none; font-weight: 600; }' + + '#' + + FINISH_ID + + ' a.raft-cta:hover { background: #AC5039; color: #fff; }'; + document.head.appendChild( style ); + } + + /** + * Build a DOM node with the given tag, attributes, and child content. + * Tiny helper to avoid the verbosity of native createElement chains. + * + * @param {string} tag + * @param {Object} attrs + * @param {string|Node|Array} children + * @return {HTMLElement} HTML element + */ + function el( tag, attrs, children ) { + const node = document.createElement( tag ); + if ( attrs ) { + Object.keys( attrs ).forEach( function ( key ) { + if ( 'className' === key ) { + node.className = attrs[ key ]; + } else { + node.setAttribute( key, attrs[ key ] ); + } + } ); + } + if ( children ) { + if ( ! Array.isArray( children ) ) { + children = [ children ]; + } + children.forEach( function ( child ) { + if ( 'string' === typeof child ) { + node.appendChild( document.createTextNode( child ) ); + } else if ( child ) { + node.appendChild( child ); + } + } ); + } + return node; + } + + /** + * Build the sidebar nudge element. Separated from `injectSidebarNudge` + * so we can build-then-position without rebuilding on every re-render. + * + * @return {HTMLElement} HTML structure for the sidebar nudge + */ + function buildSidebarNudge() { + return el( 'div', { id: SIDEBAR_ID }, [ + el( + 'p', + { className: 'raft-eyebrow' }, + strings.sidebarEyebrow || 'Want more?' + ), + el( + 'p', + { className: 'raft-title' }, + strings.sidebarTitle || 'Try Raft Pro' + ), + el( + 'a', + { + href: upgradeUrl, + target: '_blank', + rel: 'noopener noreferrer', + }, + ( strings.sidebarLink || "See what's included" ) + ' →' + ), + ] ); + } + + /** + * Make sure the nudge exists AND is the last child of the sidebar + * content area. React owns this subtree and re-mounts step-specific + * controls between wizard steps — if our node ends up sandwiched + * between `.o-sidebar__info` and the freshly-mounted controls, the + * nudge appears in the middle of the sidebar instead of at the bottom. + * + * Idempotent: if the nudge is already last child, do nothing — so + * MutationObserver loops don't re-trigger themselves. + */ + function injectSidebarNudge() { + const container = document.querySelector( '.o-sidebar__content' ); + if ( ! container ) { + return; + } + + const existing = document.getElementById( SIDEBAR_ID ); + + if ( existing && existing === container.lastElementChild ) { + return; + } + + if ( existing && existing.parentNode ) { + existing.parentNode.removeChild( existing ); + } + + container.appendChild( buildSidebarNudge() ); + } + + /** + * Inject the larger Finish-step card before its action buttons. Returns + * early if the Finish container isn't in the DOM (we're not on Finish + * step yet) or if we already injected. + */ + function injectFinishCard() { + if ( document.getElementById( FINISH_ID ) ) { + return; + } + const container = document.querySelector( '.o-finish__container' ); + if ( ! container ) { + return; + } + + const card = el( 'div', { id: FINISH_ID }, [ + el( + 'p', + { className: 'raft-eyebrow' }, + strings.finishEyebrow || 'Get even more' + ), + el( + 'h3', + null, + strings.finishTitle || 'Take it further with Raft Pro' + ), + el( + 'p', + null, + strings.finishBody || + 'Unlock 17 extra patterns, 8 style variations, 7 ready-made page templates, and a fully designed WooCommerce storefront.' + ), + el( + 'a', + { + href: upgradeUrl, + target: '_blank', + rel: 'noopener noreferrer', + className: 'raft-cta', + }, + strings.finishCta || 'Upgrade to Pro' + ), + ] ); + + // Insert before the actions row so it sits naturally above the + // CTAs rather than after them (lower visual hierarchy). + const actions = container.querySelector( '.o-finish__actions' ); + if ( actions ) { + container.insertBefore( card, actions ); + } else { + container.appendChild( card ); + } + } + + let rafPending = false; + + function tick() { + injectStyles(); + injectSidebarNudge(); + injectFinishCard(); + } + + /** + * Debounce tick() to the next animation frame. wp.data.subscribe fires + * synchronously DURING React's render phase, before commit — running + * tick() at that point can land our nodes between info and a + * not-yet-mounted Controls child. Deferring to rAF runs us after the + * commit phase, when the DOM matches React's intent. + */ + function scheduleTick() { + if ( rafPending ) { + return; + } + rafPending = true; + ( + window.requestAnimationFrame || + function ( cb ) { + return setTimeout( cb, 16 ); + } + )( function () { + rafPending = false; + tick(); + } ); + } + + let observer = null; + + /** + * Watch the wizard root for child changes. Fires AFTER React's commit + * phase, so the DOM is settled when we re-inject. Idempotent: our + * inject functions short-circuit when the nudge is already in the + * right place, so the observer doesn't loop on its own mutations. + */ + function setupObserver() { + if ( observer ) { + return true; + } + const root = + document.getElementById( 'otter-onboarding' ) || document.body; + if ( ! root ) { + return false; + } + observer = new MutationObserver( scheduleTick ); + observer.observe( root, { childList: true, subtree: true } ); + return true; + } + + function start() { + // React commit happens after subscribe fires — defer to next frame. + try { + data.subscribe( scheduleTick ); + } catch ( e ) { + // Subscribe can throw before store registers; the poll below + // covers that window. + } + + setupObserver(); + + // Initial poll covers the brief window before Otter mounts the + // wizard root (the observer needs an existing node to attach to). + let attempts = 0; + const poll = setInterval( function () { + attempts++; + if ( setupObserver() ) { + scheduleTick(); + } + if ( attempts > 30 ) { + clearInterval( poll ); + } + }, 200 ); + } + + if ( domReady ) { + domReady( start ); + } else if ( 'loading' === document.readyState ) { + document.addEventListener( 'DOMContentLoaded', start ); + } else { + start(); + } +} )(); diff --git a/inc/Admin.php b/inc/Admin.php index cff945f4..bf751cb1 100644 --- a/inc/Admin.php +++ b/inc/Admin.php @@ -180,7 +180,7 @@ public function render_welcome_notice() { $notice_html .= ''; - $learn_more = '' . __( 'Learn More', 'raft' ) . ''; + $learn_more = '' . __( 'Learn More', 'raft' ) . ''; $notice_html .= '
' . __( 'Install our free builder plugin for more blocks, enhanced functionality, and seamless theme setup.', 'raft' ) . ' ' . $learn_more . '
'; diff --git a/inc/Core.php b/inc/Core.php index 4d635941..7aaff693 100644 --- a/inc/Core.php +++ b/inc/Core.php @@ -44,6 +44,9 @@ public function __construct() { new Admin(); new Block_Patterns(); new Block_Styles(); + new Dashboard(); + new Pro_Promotions(); + new Wizard_Promo(); } /** @@ -56,6 +59,37 @@ private function run_hooks() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'add_editor_styles' ) ); add_filter( 'raft_strings', array( $this, 'strings' ) ); + add_filter( 'home_template_hierarchy', array( $this, 'home_falls_back_to_archive' ) ); + } + + /** + * Let the blog/posts page render through `archive.html` when no `home.html` + * is present. WP's default hierarchy for the home/posts page is + * [home, index] — archive is not in the chain — so a single archive + * customization wouldn't otherwise show up on the page assigned as Posts + * page. Inserting `archive` ahead of `index` keeps `home.html` winning + * if a child theme ever provides one, while giving the archive template + * one source of truth for all post-listing contexts. + * + * @param array $hierarchy Candidate template slugs in lookup order. + * + * @return array + */ + public function home_falls_back_to_archive( $hierarchy ) { + if ( in_array( 'archive.php', $hierarchy, true ) ) { + return $hierarchy; + } + + $index = array_search( 'index.php', $hierarchy, true ); + + if ( false === $index ) { + $hierarchy[] = 'archive.php'; + return $hierarchy; + } + + array_splice( $hierarchy, $index, 0, 'archive.php' ); + + return $hierarchy; } /** @@ -102,7 +136,7 @@ public function setup() { 'title' => __( 'Archive Cards', 'raft' ), ), 'archive-row' => array( - 'file' => RAFT_DIR . 'library/archive/archive-row.php', + 'file' => RAFT_DIR . 'library/archive/archive-rows.php', 'title' => __( 'Archive Row', 'raft' ), ), ), diff --git a/inc/Dashboard.php b/inc/Dashboard.php new file mode 100644 index 00000000..bc6bfa3d --- /dev/null +++ b/inc/Dashboard.php @@ -0,0 +1,162 @@ + +{$eyebrow}
+ + +{$body}
+ + + + +' . esc_html( $raft_strings['short_text'] ) . '
- + +' . esc_html( $raft_strings['short_text'] ) . '
+ - - - + + - +