├── .circleci └── config.yml ├── .distignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── dependabot.yml ├── labeler.yml └── workflows │ ├── auto-merge.yml │ ├── changelog.yml │ └── main.yml ├── .gitignore ├── .hooks └── pre-push ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .stylelintrc.js ├── CHANGELOG.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── amp └── curated-list │ └── view.js ├── babel.config.js ├── bin └── install-wp-tests.sh ├── commitlint.config.js ├── composer.json ├── composer.lock ├── includes ├── class-api.php ├── class-block-patterns.php ├── class-blocks.php ├── class-core.php ├── class-featured.php ├── class-products.php ├── class-settings.php ├── importer │ ├── README.md │ ├── class-importer.php │ ├── config-sample.php │ └── importer-utils.php ├── migration │ └── class-migration.php ├── products │ ├── class-products-cron.php │ ├── class-products-purchase.php │ ├── class-products-ui.php │ └── class-products-user.php └── utils.php ├── languages └── newspack-listings.pot ├── newspack-listings.php ├── package-lock.json ├── package.json ├── phpcs.xml ├── phpunit.xml.dist ├── src ├── assets │ ├── front-end │ │ ├── archives.js │ │ ├── archives.scss │ │ ├── curated-list.js │ │ ├── curated-list.scss │ │ ├── event.js │ │ ├── event.scss │ │ ├── listing.scss │ │ ├── patterns.js │ │ ├── patterns.scss │ │ ├── related-listings.scss │ │ ├── self-serve-listings.js │ │ ├── self-serve-listings.scss │ │ └── view.scss │ └── shared │ │ ├── curated-list.scss │ │ ├── event.scss │ │ ├── listing.scss │ │ ├── patterns.scss │ │ ├── self-serve-listings.scss │ │ └── variables.scss ├── blocks │ ├── category.js │ ├── curated-list │ │ ├── block.json │ │ ├── edit.js │ │ ├── editor.scss │ │ ├── index.js │ │ └── view.php │ ├── event-dates │ │ ├── block.json │ │ ├── edit.js │ │ ├── index.js │ │ └── view.php │ ├── index.js │ ├── list-container │ │ ├── edit.js │ │ ├── editor.scss │ │ ├── index.js │ │ └── view.php │ ├── listing │ │ ├── block.json │ │ ├── edit.js │ │ ├── editor.scss │ │ ├── index.js │ │ ├── listing.js │ │ └── view.php │ ├── price │ │ ├── block.json │ │ ├── edit.js │ │ ├── index.js │ │ └── view.php │ └── self-serve-listings │ │ ├── block.json │ │ ├── edit.js │ │ ├── editor.scss │ │ ├── index.js │ │ └── view.php ├── components │ ├── autocomplete-tokenfield.scss │ ├── index.js │ └── sidebar-query-controls.js ├── editor │ ├── featured-listings │ │ └── index.js │ ├── index.js │ ├── sidebar │ │ ├── index.js │ │ └── style.scss │ ├── style.scss │ └── utils.js ├── svg │ ├── index.js │ ├── list.js │ └── newspack-logo.js └── templates │ ├── event-dates.php │ ├── listing.php │ ├── self-serve-form.php │ └── sort-ui.php ├── tests ├── bootstrap.php ├── test-blocks.php └── test-featured.php └── webpack.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | newspack: newspack/newspack@1.4.3 5 | 6 | workflows: 7 | version: 2 8 | all: 9 | jobs: 10 | - newspack/build 11 | - newspack/lint-js-scss: 12 | requires: 13 | - newspack/build 14 | - newspack/test-js: 15 | requires: 16 | - newspack/build 17 | - newspack/release: 18 | requires: 19 | - newspack/build 20 | filters: 21 | branches: 22 | only: 23 | - release 24 | - alpha 25 | - /^hotfix\/.*/ 26 | - /^epic\/.*/ 27 | - newspack/build-distributable: 28 | requires: 29 | - newspack/build 30 | # Running this after release ensure the version number in files will be correct. 31 | - newspack/release 32 | archive-name: 'newspack-listings' 33 | - newspack/post-release: 34 | requires: 35 | - newspack/release 36 | filters: 37 | branches: 38 | only: 39 | - release 40 | php: 41 | jobs: 42 | - newspack/lint-php 43 | - newspack/test-php 44 | -------------------------------------------------------------------------------- /.distignore: -------------------------------------------------------------------------------- 1 | # A set of files you probably don't want in your WordPress.org distribution 2 | 3 | .*ignore 4 | .*rc* 5 | *.config.js 6 | .git 7 | .github 8 | .circleci 9 | .DS_Store 10 | Thumbs.db 11 | composer.json 12 | composer.lock 13 | package.json 14 | package-lock.json 15 | yarn.lock 16 | phpunit.xml 17 | phpunit.xml.dist 18 | .phpcs.xml 19 | phpcs.xml 20 | .phpcs.xml.dist 21 | phpcs.xml.dist 22 | README.md 23 | PULL_REQUEST_TEMPLATE.md 24 | webpack.config.js 25 | *.sql 26 | *.tar.gz 27 | *.zip 28 | node_modules 29 | /tests 30 | /bin 31 | /src/**/*.js 32 | *.scss 33 | /release 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | require( '@rushstack/eslint-patch/modern-module-resolution' ); 2 | 3 | module.exports = { 4 | extends: [ './node_modules/newspack-scripts/config/eslintrc.js' ], 5 | }; 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Automattic/newspack-product 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot config. 2 | # Keep NPM and Composer packages up-to-date. 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for npm 7 | - package-ecosystem: 'npm' 8 | # Look for `package.json` and `lock` files in the `root` directory 9 | directory: '/' 10 | # Check the npm registry for updates every day (weekdays) 11 | schedule: 12 | interval: 'weekly' 13 | # Add reviewers 14 | reviewers: 15 | - 'Automattic/newspack-product' 16 | 17 | # Enable version updates for Composer 18 | - package-ecosystem: 'composer' 19 | # Look for a `composer.lock` in the `root` directory 20 | directory: '/' 21 | # Check for updates every day (weekdays) 22 | schedule: 23 | interval: 'weekly' 24 | # Add reviewers 25 | reviewers: 26 | - 'Automattic/newspack-product' 27 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | needs-changelog: 2 | - base-branch: ['trunk'] 3 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request_target: 3 | types: [labeled] 4 | 5 | name: Dependabot auto-merge 6 | jobs: 7 | auto-merge: 8 | name: Auto-merge dependabot PRs for minor and patch updates 9 | runs-on: ubuntu-latest 10 | if: | 11 | contains( github.event.pull_request.labels.*.name, 'dependencies' ) 12 | && ! contains( github.event.pull_request.labels.*.name, '[Status] Approved' ) 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 16 | with: 17 | target: minor # includes patch updates. 18 | github-token: ${{ secrets.DEPENDABOT_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | pull_request: 4 | types: [closed] 5 | 6 | jobs: 7 | labeler: 8 | if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'trunk' && github.event.pull_request.user.login != 'dependabot[bot]' 9 | permissions: 10 | contents: read 11 | pull-requests: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/labeler@v5 15 | 16 | comment_pr: 17 | if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'trunk' && github.event.pull_request.user.login != 'dependabot[bot]' 18 | permissions: 19 | contents: read 20 | pull-requests: write 21 | runs-on: ubuntu-latest 22 | name: Comment about the change log label 23 | steps: 24 | - name: Comment PR 25 | uses: thollander/actions-comment-pull-request@v3 26 | with: 27 | message: | 28 | Hey @${{ github.event.pull_request.user.login }}, good job getting this PR merged! :tada: 29 | 30 | Now, the `needs-changelog` label has been added to it. 31 | 32 | Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label. 33 | 34 | If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label. 35 | 36 | Thank you! :heart: 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: pull_request_review 2 | name: Label approved pull requests 3 | jobs: 4 | labelWhenApproved: 5 | name: Label when approved 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Label when approved 9 | uses: abinoda/label-when-approved-action@master 10 | env: 11 | APPROVALS: '1' 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | ADD_LABEL: '[Status] Approved' 14 | # Needs to be URL-encoded, see https://github.com/abinoda/label-when-approved-action/pull/3#discussion_r321882620 15 | REMOVE_LABEL: '%5BStatus%5D%20Needs%20Review' 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache 3 | /vendor/ 4 | /node_modules/ 5 | /dist/ 6 | .DS_Store 7 | release 8 | .cache 9 | /import/ 10 | -------------------------------------------------------------------------------- /.hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,') 4 | 5 | if [[ "$branch" = "trunk" ]]; then 6 | echo "Error: pushing directly to the trunk branch is prohibited" 7 | exit 1 8 | fi 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | release 4 | vendor -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require( './node_modules/newspack-scripts/config/prettier.config.js' ); 2 | 3 | module.exports = { 4 | ...baseConfig 5 | }; 6 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignoreFiles: [ 3 | 'dist/**', 4 | 'node_modules/**', 5 | 'release/**', 6 | 'scripts/**', 7 | ], 8 | extends: [ './node_modules/newspack-scripts/config/stylelint.config.js' ], 9 | }; 10 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### All Submissions: 2 | 3 | * [ ] Have you followed the [Newspack Contributing guideline](https://github.com/Automattic/newspack-plugin/blob/trunk/.github/CONTRIBUTING.md)? 4 | * [ ] Does your code follow the [WordPress' coding standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/) and [VIP Go coding standards](https://vip.wordpress.com/documentation/vip-go/code-review-blockers-warnings-notices/)? 5 | * [ ] Have you checked to ensure there aren't other open [Pull Requests](../../pulls) for the same update/change? 6 | 7 | 8 | 9 | 10 | 11 | ### Changes proposed in this Pull Request: 12 | 13 | 14 | 15 | Closes # . 16 | 17 | ### How to test the changes in this Pull Request: 18 | 19 | 1. 20 | 2. 21 | 3. 22 | 23 | ### Other information: 24 | 25 | * [ ] Have you added an explanation of what your changes do and why you'd like us to include them? 26 | * [ ] Have you written new tests for your changes, as applicable? 27 | * [ ] Have you successfully ran tests with your changes locally? 28 | 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # newspack-listings 2 | 3 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![newspack-listings](https://circleci.com/gh/Automattic/newspack-listings/tree/trunk.svg?style=shield)](https://circleci.com/gh/Automattic/newspack-listings) 4 | 5 | Create reusable content as listings and add them to lists wherever core blocks can be used. Create static, curated lists or dynamic, auto-updating lists with optional "load more" functionality. Edit display options to control how the list looks and behaves for readers. Compatible with [AMP](https://amp.dev/). 6 | 7 | ## Usage 8 | 9 | 1. Activate this plugin. 10 | 2. In the WP admin dashboard, look for Listings. 11 | 3. Create and publish listings of any type. Listings can contain any core blocks as content. 12 | 4. Optionally tag or categorize your listings to keep them organized, even across different listing types. 13 | 5. Once at least one listing is published, add a Curated List block to any post or page. 14 | 6. Choose Specific Listings mode to create a static list, or Query mode to create a dynamic list which will automatically update itself when new listings matching the query options are published. 15 | 7. Edit list options to control the list's display and behavior. 16 | 17 | For more detailed instructions, refer to the [public documentation for Newspack Listings](https://help.newspack.com/engagement/listings/). 18 | 19 | ## Development 20 | 21 | Run `composer update && npm install`. 22 | 23 | Run `npm run build`. 24 | 25 | Each listing type is a separate custom post type. Configuration is in `includes/newspack-listings-core.php`. 26 | 27 | Metadata for listing CPTs is synced from certain blocks in the post content. See configuration in `includes/newspack-listings-core.php` for details. 28 | -------------------------------------------------------------------------------- /amp/curated-list/view.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | let isFetching = false; 4 | buildLoadMoreHandler( document.querySelector( '.newspack-listings__curated-list' ) ); 5 | buildSortHandler( document.querySelector( '.newspack-listings__curated-list' ) ); 6 | function buildLoadMoreHandler( blockWrapperEl ) { 7 | const btnEl = blockWrapperEl.querySelector( '[data-next]' ); 8 | if ( ! btnEl ) return; 9 | const postsContainerEl = blockWrapperEl.querySelector( '.newspack-listings__list-container' ); 10 | const btnText = btnEl.textContent.trim(); 11 | const loadingText = blockWrapperEl.querySelector( '.loading' ).textContent; 12 | btnEl.addEventListener( 'click', function() { 13 | if ( isFetching ) return false; 14 | isFetching = true; 15 | blockWrapperEl.classList.remove( 'is-error' ); 16 | blockWrapperEl.classList.add( 'is-loading' ); 17 | if ( loadingText ) btnEl.textContent = loadingText; 18 | const requestURL = new URL( btnEl.getAttribute( 'data-next' ) ); 19 | apiFetchWithRetry( { url: requestURL.toString(), onSuccess, onError }, 3 ); 20 | function onSuccess( data, next ) { 21 | if ( isPostsDataValid( data ) ) { 22 | data.forEach( item => { 23 | const tempDIV = document.createElement( 'div' ); 24 | tempDIV.innerHTML = item.html.trim(); 25 | postsContainerEl.appendChild( tempDIV.childNodes[ 0 ] ); 26 | } ); 27 | if ( next ) btnEl.setAttribute( 'data-next', next ); 28 | if ( ! data.length || ! next ) { 29 | blockWrapperEl.classList.remove( 'has-more-button' ); 30 | } 31 | isFetching = false; 32 | blockWrapperEl.classList.remove( 'is-loading' ); 33 | btnEl.textContent = btnText; 34 | } 35 | } 36 | function onError() { 37 | isFetching = false; 38 | blockWrapperEl.classList.remove( 'is-loading' ); 39 | blockWrapperEl.classList.add( 'is-error' ); 40 | btnEl.textContent = btnText; 41 | } 42 | } ); 43 | } 44 | function buildSortHandler( blockWrapperEl ) { 45 | const sortUi = blockWrapperEl.querySelector( '.newspack-listings__sort-ui' ); 46 | const sortBy = blockWrapperEl.querySelector( '.newspack-listings__sort-select-control' ); 47 | const sortOrder = blockWrapperEl.querySelectorAll( 'input' ); 48 | const sortOrderContainer = blockWrapperEl.querySelector( 49 | '.newspack-listings__sort-order-container' 50 | ); 51 | const btnEl = blockWrapperEl.querySelector( '[data-next]' ); 52 | if ( ! sortBy || ! sortOrder.length || ! sortUi || ! sortOrderContainer ) return; 53 | const triggers = Array.prototype.concat.call( Array.prototype.slice.call( sortOrder ), [ 54 | sortBy, 55 | ] ); 56 | const postsContainerEl = blockWrapperEl.querySelector( '.newspack-listings__list-container' ); 57 | const restURL = sortUi.getAttribute( 'data-url' ); 58 | const hasMoreButton = blockWrapperEl.classList.contains( 'has-more-button' ); 59 | let isFetching = false; 60 | let _sortBy = sortUi.querySelector( '[selected]' ).value; 61 | let _order = sortUi.querySelector( '[checked]' ).value; 62 | const sortHandler = e => { 63 | if ( isFetching ) return false; 64 | isFetching = true; 65 | blockWrapperEl.classList.remove( 'is-error' ); 66 | blockWrapperEl.classList.add( 'is-loading' ); 67 | if ( e.target.tagName.toLowerCase() === 'select' ) { 68 | _sortBy = e.target.value; 69 | } else { 70 | _order = e.target.value; 71 | } 72 | if ( 'post__in' === e.target.value ) { 73 | sortOrderContainer.classList.add( 'is-hidden' ); 74 | } else { 75 | sortOrderContainer.classList.remove( 'is-hidden' ); 76 | } 77 | const requestURL = `${ restURL }&${ encodeURIComponent( 78 | 'query[sortBy]' 79 | ) }=${ _sortBy }&${ encodeURIComponent( 'query[order]' ) }=${ _order }`; 80 | if ( hasMoreButton && btnEl ) { 81 | blockWrapperEl.classList.add( 'has-more-button' ); 82 | btnEl.setAttribute( 'data-next', requestURL ); 83 | } 84 | apiFetchWithRetry( { url: requestURL, onSuccess, onError }, 3 ); 85 | function onSuccess( data, next ) { 86 | if ( ! isPostsDataValid( data ) ) return onError(); 87 | postsContainerEl.textContent = ''; 88 | data.forEach( item => { 89 | const tempDIV = document.createElement( 'div' ); 90 | tempDIV.innerHTML = item.html.trim(); 91 | postsContainerEl.appendChild( tempDIV.childNodes[ 0 ] ); 92 | } ); 93 | if ( next && btnEl ) btnEl.setAttribute( 'data-next', next ); 94 | isFetching = false; 95 | blockWrapperEl.classList.remove( 'is-loading' ); 96 | } 97 | function onError() { 98 | isFetching = false; 99 | blockWrapperEl.classList.remove( 'is-loading' ); 100 | blockWrapperEl.classList.add( 'is-error' ); 101 | } 102 | }; 103 | triggers.forEach( trigger => trigger.addEventListener( 'change', sortHandler ) ); 104 | } 105 | function apiFetchWithRetry( options, n ) { 106 | const xhr = new XMLHttpRequest(); 107 | xhr.onreadystatechange = () => { 108 | if ( xhr.readyState !== 4 || n === 0 ) return; 109 | if ( xhr.status >= 200 && xhr.status < 300 ) { 110 | const data = JSON.parse( xhr.responseText ); 111 | const next = xhr.getResponseHeader( 'next-url' ); 112 | options.onSuccess( data, next ); 113 | return; 114 | } 115 | options.onError(); 116 | apiFetchWithRetry( options, n - 1 ); 117 | }; 118 | xhr.open( 'GET', options.url ); 119 | xhr.send(); 120 | } 121 | function isPostsDataValid( data ) { 122 | if ( 123 | data && 124 | Array.isArray( data ) && 125 | data.length && 126 | hasOwnProp( data[ 0 ], 'html' ) && 127 | typeof data[ 0 ].html === 'string' 128 | ) { 129 | return true; 130 | } 131 | 132 | return false; 133 | } 134 | function hasOwnProp( obj, prop ) { 135 | return Object.prototype.hasOwnProperty.call( obj, prop ); 136 | } 137 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache( true ); 3 | return { 4 | extends: 'newspack-scripts/config/babel.config.js', 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | TMPDIR=${TMPDIR-/tmp} 16 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") 17 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} 18 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} 19 | 20 | download() { 21 | if [ `which curl` ]; then 22 | curl -s "$1" > "$2"; 23 | elif [ `which wget` ]; then 24 | wget -nv -O "$2" "$1" 25 | fi 26 | } 27 | 28 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then 29 | WP_BRANCH=${WP_VERSION%\-*} 30 | WP_TESTS_TAG="branches/$WP_BRANCH" 31 | 32 | elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then 33 | WP_TESTS_TAG="branches/$WP_VERSION" 34 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then 35 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 36 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 37 | WP_TESTS_TAG="tags/${WP_VERSION%??}" 38 | else 39 | WP_TESTS_TAG="tags/$WP_VERSION" 40 | fi 41 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 42 | WP_TESTS_TAG="trunk" 43 | else 44 | # http serves a single offer, whereas https serves multiple. we only want one 45 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 46 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 47 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 48 | if [[ -z "$LATEST_VERSION" ]]; then 49 | echo "Latest WordPress version could not be found" 50 | exit 1 51 | fi 52 | WP_TESTS_TAG="tags/$LATEST_VERSION" 53 | fi 54 | set -ex 55 | 56 | install_wp() { 57 | 58 | if [ -d $WP_CORE_DIR ]; then 59 | return; 60 | fi 61 | 62 | mkdir -p $WP_CORE_DIR 63 | 64 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 65 | mkdir -p $TMPDIR/wordpress-nightly 66 | download https://wordpress.org/nightly-builds/wordpress-latest.zip $TMPDIR/wordpress-nightly/wordpress-nightly.zip 67 | unzip -q $TMPDIR/wordpress-nightly/wordpress-nightly.zip -d $TMPDIR/wordpress-nightly/ 68 | mv $TMPDIR/wordpress-nightly/wordpress/* $WP_CORE_DIR 69 | else 70 | if [ $WP_VERSION == 'latest' ]; then 71 | local ARCHIVE_NAME='latest' 72 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then 73 | # https serves multiple offers, whereas http serves single. 74 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json 75 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 76 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 77 | LATEST_VERSION=${WP_VERSION%??} 78 | else 79 | # otherwise, scan the releases and get the most up to date minor version of the major release 80 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` 81 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) 82 | fi 83 | if [[ -z "$LATEST_VERSION" ]]; then 84 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 85 | else 86 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION" 87 | fi 88 | else 89 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 90 | fi 91 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz 92 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR 93 | fi 94 | 95 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 96 | } 97 | 98 | install_test_suite() { 99 | # portable in-place argument for both GNU sed and Mac OSX sed 100 | if [[ $(uname -s) == 'Darwin' ]]; then 101 | local ioption='-i.bak' 102 | else 103 | local ioption='-i' 104 | fi 105 | 106 | # set up testing suite if it doesn't yet exist 107 | if [ ! -d $WP_TESTS_DIR ]; then 108 | # set up testing suite 109 | mkdir -p $WP_TESTS_DIR 110 | svn co --ignore-externals --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 111 | svn co --ignore-externals --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 112 | fi 113 | 114 | if [ ! -f wp-tests-config.php ]; then 115 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 116 | # remove all forward slashes in the end 117 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 118 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 119 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 120 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 121 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 122 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 123 | fi 124 | 125 | } 126 | 127 | install_db() { 128 | 129 | if [ ${SKIP_DB_CREATE} = "true" ]; then 130 | return 0 131 | fi 132 | 133 | # parse DB_HOST for port or socket references 134 | local PARTS=(${DB_HOST//\:/ }) 135 | local DB_HOSTNAME=${PARTS[0]}; 136 | local DB_SOCK_OR_PORT=${PARTS[1]}; 137 | local EXTRA="" 138 | 139 | if ! [ -z $DB_HOSTNAME ] ; then 140 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 141 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 142 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 143 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 144 | elif ! [ -z $DB_HOSTNAME ] ; then 145 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 146 | fi 147 | fi 148 | 149 | # create database 150 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 151 | } 152 | 153 | install_wp 154 | install_test_suite 155 | install_db 156 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: [ '@commitlint/config-conventional' ] }; 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automattic/newspack-listings", 3 | "description": "A new thing.", 4 | "type": "wordpress-plugin", 5 | "require": {}, 6 | "require-dev": { 7 | "automattic/vipwpcs": "^3.0", 8 | "wp-coding-standards/wpcs": "^3.0", 9 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", 10 | "phpcompatibility/phpcompatibility-wp": "^2.1", 11 | "brainmaestro/composer-git-hooks": "^3.0", 12 | "yoast/phpunit-polyfills": "^3.0", 13 | "phpunit/phpunit": "^7.0 || ^9.5" 14 | }, 15 | "license": "GPL-2.0-or-later", 16 | "scripts": { 17 | "post-install-cmd": [ 18 | "vendor/bin/cghooks add --no-lock" 19 | ], 20 | "post-update-cmd": [ 21 | "vendor/bin/cghooks update" 22 | ] 23 | }, 24 | "extra": { 25 | "hooks": { 26 | "pre-commit": [ 27 | "./node_modules/.bin/lint-staged" 28 | ], 29 | "pre-push": "./.hooks/pre-push", 30 | "commit-msg": [ 31 | "cat $1 | ./node_modules/.bin/newspack-scripts commitlint" 32 | ] 33 | } 34 | }, 35 | "config": { 36 | "platform": { 37 | "php": "8.0" 38 | }, 39 | "allow-plugins": { 40 | "composer/installers": true, 41 | "dealerdirect/phpcodesniffer-composer-installer": true 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /includes/class-blocks.php: -------------------------------------------------------------------------------- 1 | labels->singular_name : 'Post'; 69 | 70 | foreach ( Core::NEWSPACK_LISTINGS_POST_TYPES as $slug => $name ) { 71 | $post_count = wp_count_posts( $name )->publish; 72 | $total_count = $total_count + $post_count; 73 | $post_types[ $slug ] = [ 74 | 'name' => $name, 75 | 'label' => get_post_type_object( Core::NEWSPACK_LISTINGS_POST_TYPES[ $slug ] )->labels->singular_name, 76 | 'show_in_inserter' => 0 < $post_count, 77 | ]; 78 | } 79 | 80 | $localized_data = [ 81 | 'post_type_label' => $post_type_label, 82 | 'post_type' => $post_type, 83 | 'post_type_slug' => array_search( $post_type, Core::NEWSPACK_LISTINGS_POST_TYPES ), 84 | 'post_types' => $post_types, 85 | 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : __( 'USD', 'newspack-listings' ), 86 | 'currencies' => function_exists( 'get_woocommerce_currencies' ) ? get_woocommerce_currencies() : [ 'USD' => __( 'United States (US) dollar', 'newspack-listings' ) ], 87 | 88 | // If we don't have ANY listings that can be added to a list yet, alert the editor so we can show messaging. 89 | 'no_listings' => 0 === $total_count, 90 | 'date_format' => get_option( 'date_format' ), 91 | 'time_format' => get_option( 'time_format' ), 92 | 93 | // Self-serve listings features are gated behind an environment variable. 94 | 'self_serve_enabled' => Products::is_active(), 95 | ]; 96 | 97 | if ( Products::is_active() ) { 98 | $localized_data['self_serve_listing_types'] = Products::get_listing_types(); 99 | $localized_data['self_serve_listing_expiration'] = Settings::get_settings( 'newspack_listings_single_purchase_expiration' ); 100 | 101 | if ( Products::is_listing_customer() ) { 102 | $localized_data['is_listing_customer'] = true; 103 | } 104 | } 105 | 106 | wp_localize_script( 107 | 'newspack-listings-editor', 108 | 'newspack_listings_data', 109 | $localized_data 110 | ); 111 | 112 | wp_register_style( 113 | 'newspack-listings-editor', 114 | plugins_url( '../dist/editor.css', __FILE__ ), 115 | [], 116 | NEWSPACK_LISTINGS_VERSION 117 | ); 118 | wp_style_add_data( 'newspack-listings-editor', 'rtl', 'replace' ); 119 | wp_enqueue_style( 'newspack-listings-editor' ); 120 | } 121 | 122 | /** 123 | * Enqueue front-end assets. 124 | */ 125 | public static function manage_view_assets() { 126 | // Do nothing in editor environment. 127 | if ( is_admin() ) { 128 | return; 129 | } 130 | 131 | $src_directory = NEWSPACK_LISTINGS_PLUGIN_FILE . 'src/blocks/'; 132 | $dist_directory = NEWSPACK_LISTINGS_PLUGIN_FILE . 'dist/'; 133 | $iterator = new \DirectoryIterator( $src_directory ); 134 | 135 | foreach ( $iterator as $block_directory ) { 136 | if ( ! $block_directory->isDir() || $block_directory->isDot() ) { 137 | continue; 138 | } 139 | $type = $block_directory->getFilename(); 140 | 141 | // If view.php is found, include it and use for block rendering. 142 | $view_php_path = $src_directory . $type . '/view.php'; 143 | if ( file_exists( $view_php_path ) ) { 144 | include_once $view_php_path; 145 | continue; 146 | } 147 | 148 | // If block.json exists, use it to register the block with default attributes. 149 | $block_config_file = $src_directory . $type . '/block.json'; 150 | $block_name = "newspack-listings/{$type}"; 151 | $block_default_attributes = null; 152 | if ( file_exists( $block_config_file ) ) { 153 | $block_config = json_decode( file_get_contents( $block_config_file ), true ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown 154 | $block_name = $block_config['name']; 155 | $block_default_attributes = $block_config['attributes']; 156 | } 157 | 158 | // If view.php is missing but view Javascript file is found, do generic view asset loading. 159 | $view_js_path = $dist_directory . $type . '/view.js'; 160 | if ( file_exists( $view_js_path ) ) { 161 | register_block_type( 162 | $block_name, 163 | [ 164 | 'render_callback' => function( $attributes, $content ) use ( $type ) { 165 | Newspack_Blocks::enqueue_view_assets( $type ); 166 | return $content; 167 | }, 168 | 'attributes' => $block_default_attributes, 169 | ] 170 | ); 171 | } 172 | } 173 | } 174 | 175 | /** 176 | * Enqueue custom scripts for Newspack Listings front-end components. 177 | */ 178 | public static function custom_scripts() { 179 | if ( ! Utils\is_amp() && has_block( 'newspack-listings/curated-list', get_the_ID() ) ) { 180 | wp_enqueue_script( 181 | 'newspack-listings-curated-list', 182 | NEWSPACK_LISTINGS_URL . 'dist/curated-list.js', 183 | [ 'mediaelement-core' ], 184 | NEWSPACK_LISTINGS_VERSION, 185 | true 186 | ); 187 | } 188 | } 189 | 190 | /** 191 | * Enqueue custom styles for Newspack Listings front-end components. 192 | */ 193 | public static function custom_styles() { 194 | if ( is_admin() ) { 195 | return; 196 | } 197 | 198 | $post_id = get_the_ID(); 199 | 200 | // Styles for listing archives. 201 | if ( Utils\archive_should_include_listings() ) { 202 | wp_enqueue_style( 203 | 'newspack-listings-archives', 204 | NEWSPACK_LISTINGS_URL . 'dist/archives.css', 205 | [], 206 | NEWSPACK_LISTINGS_VERSION 207 | ); 208 | } 209 | 210 | // Styles for Curated List block. 211 | wp_enqueue_style( 212 | 'newspack-listings-curated-list', 213 | NEWSPACK_LISTINGS_URL . 'dist/curated-list.css', 214 | [], 215 | NEWSPACK_LISTINGS_VERSION 216 | ); 217 | 218 | // Styles for any singular listing type. 219 | if ( is_singular( array_values( Core::NEWSPACK_LISTINGS_POST_TYPES ) ) ) { 220 | wp_enqueue_style( 221 | 'newspack-listings-patterns', 222 | NEWSPACK_LISTINGS_URL . 'dist/patterns.css', 223 | [], 224 | NEWSPACK_LISTINGS_VERSION 225 | ); 226 | } 227 | 228 | // Styles for singular event listings. 229 | if ( is_singular( Core::NEWSPACK_LISTINGS_POST_TYPES['event'] ) ) { 230 | wp_enqueue_style( 231 | 'newspack-listings-event', 232 | NEWSPACK_LISTINGS_URL . 'dist/event.css', 233 | [], 234 | NEWSPACK_LISTINGS_VERSION 235 | ); 236 | } 237 | 238 | // Styles for Self-Serve Listings admin UI. 239 | if ( 240 | ( is_singular() && has_block( 'newspack-listings/self-serve-listings', $post_id ) ) || 241 | ( function_exists( 'is_account_page' ) && is_account_page() ) 242 | ) { 243 | wp_enqueue_style( 244 | 'newspack-listings-self-serve-listings', 245 | NEWSPACK_LISTINGS_URL . 'dist/self-serve-listings.css', 246 | [], 247 | NEWSPACK_LISTINGS_VERSION 248 | ); 249 | } 250 | } 251 | } 252 | 253 | Blocks::instance(); 254 | -------------------------------------------------------------------------------- /includes/importer/README.md: -------------------------------------------------------------------------------- 1 | # Newspack Listings Importer 2 | 3 | This importer is executed via a WP_CLI command, and will import rows from a CSV file as Newspack Listings posts. 4 | 5 | ## Config 6 | 7 | The importer script requires a config file that describes the CSV data and how it maps to Newspack Listings post data. The config file can be named anything and located anywhere inside the Newspack Listings directory, but must be referenced as a parameter in the CLI command (see Usage below for details). The config should define the following constants: 8 | 9 | * `NEWSPACK_LISTINGS_IMPORT_MAPPING` - (Required) This should be an associative array with keys named as the WP post data to import to, and their values as the corresponding CSV header name that maps to that data. For example: `'post_title' => 'csv_header_title'`. 10 | * `NEWSPACK_LISTINGS_IMPORT_SEPARATOR` - (Required) This should be a string that defines what character (or set of characters) is used by the CSV file to separate multiple values that exist under a single column. For example, categories might be grouped under a single CSV column separated by a `;`. 11 | * `NEWSPACK_LISTINGS_IMPORT_DEFAULT_POST_TYPE` - (Optional) This lets you set the Newspack Listings post type the importer will default to if it can't determine what post type a CSV row should be imported as. If not defined, the importer will default to importing unknown data as generic listings. 12 | 13 | [A sample config file](https://github.com/Automattic/newspack-listings/tree/trunk/includes/importer/config-sample.php) is included in this repo for reference. For field mapping, only the keys present in this sample config will be used by the importer. 14 | 15 | ## Usage 16 | 17 | Once your config file has been created, drop a CSV file to import somewhere in the Newspack Listings plugin directory (it can be in a subdirectory of its own). Optionally, you can also include image files in an `/images` directory in the same location as the CSV file; if it exists, and the CSV file contains image filenames under a column mapped to `_thumbnail_id` in the config, the importer will look for those filenames in the `/images` directory and import them as media attachments, if they exist. 18 | 19 | Then, run the following WP_CLI command: 20 | 21 | `wp newpack-listings import --file= --config=` 22 | 23 | The `--file` and `--config` parameters are required, and both paths should be relative to the root Newspack Listings plugin directory. The following additional options are optional: 24 | 25 | * `--start=` - If set to a number, the importer will skip importing any rows before the specified start row. 26 | * `--max-rows=` - If set to a number, the importer will stop once this number of rows has been processed. 27 | * `--dry-run` - If this flag is present, the importer will parse the CSV file but will not persist any data to the WordPress database. 28 | -------------------------------------------------------------------------------- /includes/importer/config-sample.php: -------------------------------------------------------------------------------- 1 | 'directory_category', 23 | 'post_author' => 'post_author', 24 | 'post_content' => 'post_content', 25 | 'post_date' => 'post_published', 26 | 'post_excerpt' => 'field_business_label', 27 | 'post_title' => 'post_title', 28 | 'tags_input' => 'directory_tag', 29 | '_thumbnail_id' => 'directory_photos', 30 | 'post_type' => 'post_type', 31 | 32 | // Mappings for the values of the `post_type` field defined above. 33 | 'post_types' => [ 34 | 'event' => [ 'event', 'date' ], 35 | 'generic' => [ 'item', 'listing' ], 36 | 'marketplace' => [ 'classified', 'obituary', 'promo' ], 37 | 'place' => [ 'business', 'location', 'place' ], 38 | ], 39 | 40 | // Location fields. 41 | 'location_address' => 'location_address__address', // Full address of map marker. 42 | 43 | // Contact info fields. 44 | 'contact_email' => 'field_email', 45 | 'contact_phone' => 'field_phone', 46 | 'contact_street_1' => 'location_address__street', 47 | 'contact_street_2' => 'location_address__street_2', 48 | 'contact_city' => 'location_address__city', 49 | 'contact_region' => 'location_address__province', 50 | 'contact_postal' => 'location_address__zip', 51 | 52 | // Social media accounts. 53 | 'facebook' => 'field_social_accounts__facebook', 54 | 'twitter' => 'field_social_accounts__twitter', 55 | 'instagram' => 'field_social_accounts__instagram', 56 | 57 | // Additional fields which should be appended to post content. 58 | 'additional_content' => [ 'field_gen_hours' ], 59 | ] 60 | ); 61 | 62 | /** 63 | * The separator character used in the CSV file to separate multiple values in a single field. 64 | */ 65 | define( 'NEWSPACK_LISTINGS_IMPORT_SEPARATOR', ';' ); 66 | 67 | /** 68 | * Default listing post type to import as, if we can't determine the type from CSV data. 69 | */ 70 | define( 'NEWSPACK_LISTINGS_IMPORT_DEFAULT_POST_TYPE', 'newspack_lst_place' ); 71 | -------------------------------------------------------------------------------- /includes/importer/importer-utils.php: -------------------------------------------------------------------------------- 1 | features ) && ! empty( $response->features[0] ) ) { 43 | $location = reset( $response->features ); 44 | return build_map( $location ); 45 | } 46 | 47 | return false; 48 | } 49 | 50 | /** 51 | * Build a jetpack/map block using location data retrieved from the MapBox API. 52 | * 53 | * @param object $location Location data from Mapbox. 54 | * @return string|bool Block markup with attributes preset, or false. 55 | */ 56 | function build_map( $location = false ) { 57 | // Return false if missing required data. 58 | if ( 59 | ! $location || 60 | ! isset( $location->id ) || 61 | ! isset( $location->place_name ) || 62 | ! isset( $location->geometry ) || 63 | ! isset( $location->geometry->coordinates ) || 64 | ! isset( $location->text ) 65 | ) { 66 | return false; 67 | } 68 | 69 | // Map marker data. 70 | $points = [ 71 | [ 72 | 'placeTitle' => $location->text, 73 | 'title' => $location->text, 74 | 'caption' => $location->place_name, 75 | 'id' => $location->id, 76 | 'coordinates' => [ 77 | 'longitude' => $location->geometry->coordinates[0], 78 | 'latitude' => $location->geometry->coordinates[1], 79 | ], 80 | ], 81 | ]; 82 | 83 | // Map block attributes. 84 | $map_block_data = [ 85 | 'points' => $points, 86 | 'zoom' => 13, 87 | 'mapCenter' => [ 88 | 'lng' => $points[0]['coordinates']['longitude'], 89 | 'lat' => $points[0]['coordinates']['latitude'], 90 | ], 91 | ]; 92 | 93 | $list_items = array_map( 94 | function ( $point ) { 95 | $link = add_query_arg( 96 | array( 97 | 'api' => 1, 98 | 'query' => $point['coordinates']['latitude'] . ',' . $point['coordinates']['longitude'], 99 | ), 100 | 'https://www.google.com/maps/search/' 101 | ); 102 | return sprintf( '
  • %s
  • ', esc_url( $link ), $point['title'] ); 103 | }, 104 | $points 105 | ); 106 | 107 | // Build jetpack/map block with attributes. 108 | $map_block = '' . PHP_EOL; 109 | $map_block .= sprintf( 110 | '
    ', 111 | esc_html( wp_json_encode( $map_block_data['points'] ) ), 112 | (int) $map_block_data['zoom'], 113 | esc_html( wp_json_encode( $map_block_data['mapCenter'] ) ) 114 | ); 115 | $map_block .= '
      ' . implode( "\n", $list_items ) . '
    '; 116 | $map_block .= '
    ' . PHP_EOL; 117 | $map_block .= ''; 118 | 119 | return $map_block; 120 | } 121 | 122 | /** 123 | * Clean up a content string, stripping unwanted tags and shortcode-like strings. 124 | * Unlike WP core's strip_shortcodes, it will strip any string with shortcode-like syntax, 125 | * not just those that match registered shortcodes. 126 | * 127 | * @param string $content Content string to process. 128 | * @return string Filtered content string. 129 | */ 130 | function clean_content( $content ) { 131 | $allowed_elements = wp_kses_allowed_html( 'post' ); 132 | $unwanted_elements = [ 'div', 'section' ]; // Array of tag names we want to strip from the content. 133 | 134 | foreach ( $unwanted_elements as $unwanted_element ) { 135 | if ( isset( $allowed_elements[ $unwanted_element ] ) ) { 136 | unset( $allowed_elements[ $unwanted_element ] ); 137 | } 138 | } 139 | 140 | // Strip out style attributes. 141 | $content = preg_replace( '/(<[^>]+) style=".*?"/i', '$1', $content ); 142 | 143 | // Strip out shortcode-like strings. 144 | $content = preg_replace( '/\[[^\]]+\]/', '', $content ); 145 | 146 | // Add missing

    tags. 147 | $content = wpautop( $content ); 148 | 149 | // Strip out unwanted tags. 150 | return wp_kses( $content, $allowed_elements ); 151 | } 152 | 153 | /** 154 | * Get data from a remote URL via cURL. 155 | * 156 | * @param string $url The URL to request data from. 157 | * @return object|bool Data from the request, or false if we can't fetch. 158 | */ 159 | function rest_request( $url ) { 160 | $response = wp_remote_get( $url ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get 161 | 162 | // If the API request fails, print the error. 163 | if ( is_wp_error( $response ) ) { 164 | WP_CLI::error( 'Error! ' . $response->get_error_message() ); 165 | } 166 | 167 | // Retrieve the data from the REST response. 168 | $data = wp_remote_retrieve_body( $response ); 169 | 170 | if ( ! empty( $data ) ) { 171 | return json_decode( $data ); 172 | } 173 | 174 | return false; 175 | } 176 | -------------------------------------------------------------------------------- /includes/migration/class-migration.php: -------------------------------------------------------------------------------- 1 | 'Migrate legacy listing taxonomies to core post taxonomies.', 67 | 'synopsis' => [ 68 | [ 69 | 'type' => 'flag', 70 | 'name' => 'dry-run', 71 | 'description' => 'Whether to do a dry run.', 72 | 'optional' => true, 73 | 'repeating' => false, 74 | ], 75 | ], 76 | ] 77 | ); 78 | } 79 | 80 | /** 81 | * Run the 'newspack-listings taxonomy convert' WP CLI command. 82 | * 83 | * @param array $args Positional args. 84 | * @param array $assoc_args Associative args. 85 | */ 86 | public static function cli_taxonomy_convert( $args, $assoc_args ) { 87 | // If a dry run, we won't persist any data. 88 | self::$is_dry_run = isset( $assoc_args['dry-run'] ) ? true : false; 89 | 90 | if ( self::$is_dry_run ) { 91 | WP_CLI::log( "\n===================\n= Dry Run =\n===================\n" ); 92 | } 93 | 94 | WP_CLI::log( "Checking for legacy taxonomy terms...\n" ); 95 | 96 | $converted_taxonomies = self::convert_legacy_taxonomies(); 97 | 98 | if ( 0 === count( $converted_taxonomies['category'] ) && 0 === count( $converted_taxonomies['post_tag'] ) ) { 99 | WP_CLI::success( 'Completed! No legacy categories or tags found.' ); 100 | } else { 101 | WP_CLI::success( 102 | sprintf( 103 | 'Completed! Converted %1$s %2$s and %3$s %4$s.', 104 | count( $converted_taxonomies['category'] ), 105 | 1 < count( $converted_taxonomies['category'] ) ? 'categories' : 'category', 106 | count( $converted_taxonomies['post_tag'] ), 107 | 1 < count( $converted_taxonomies['post_tag'] ) ? 'tags' : 'tag' 108 | ) 109 | ); 110 | } 111 | } 112 | 113 | /** 114 | * Convert legacy custom taxonomies to regular post categories and tags. 115 | * Helpful for sites that have been using v1 of the Listings plugin. 116 | * 117 | * @return object Object containing converted term info. 118 | */ 119 | public static function convert_legacy_taxonomies() { 120 | $converted_taxonomies = [ 121 | 'category' => [], 122 | 'post_tag' => [], 123 | ]; 124 | $custom_category_slug = 'newspack_lst_cat'; 125 | $custom_tag_slug = 'newspack_lst_tag'; 126 | 127 | $category_args = [ 128 | 'hierarchical' => true, 129 | 'public' => false, 130 | 'rewrite' => false, 131 | 'show_in_menu' => false, 132 | 'show_in_rest' => false, 133 | 'show_tagcloud' => false, 134 | 'show_ui' => false, 135 | ]; 136 | $tag_args = [ 137 | 'hierarchical' => false, 138 | 'public' => false, 139 | 'rewrite' => false, 140 | 'show_in_menu' => false, 141 | 'show_in_rest' => false, 142 | 'show_tagcloud' => false, 143 | 'show_ui' => false, 144 | ]; 145 | 146 | // Temporarily register the taxonomies for all Listing CPTs. 147 | $post_types = array_values( Core::NEWSPACK_LISTINGS_POST_TYPES ); 148 | register_taxonomy( $custom_category_slug, $post_types, $category_args ); 149 | register_taxonomy( $custom_tag_slug, $post_types, $tag_args ); 150 | 151 | // Get a list of the custom terms. 152 | $custom_terms = get_terms( 153 | [ 154 | 'taxonomy' => [ $custom_category_slug, $custom_tag_slug ], 155 | 'hide_empty' => false, 156 | ] 157 | ); 158 | 159 | // If we don't have any terms from the legacy taxonomies, no need to proceed. 160 | if ( is_wp_error( $custom_terms ) || 0 === count( $custom_terms ) ) { 161 | unregister_taxonomy( $custom_category_slug ); 162 | unregister_taxonomy( $custom_tag_slug ); 163 | return $converted_taxonomies; 164 | } 165 | 166 | foreach ( $custom_terms as $term ) { 167 | // See if we have any corresponding terms already. 168 | $is_category = $custom_category_slug === $term->taxonomy; 169 | $corresponding_taxonomy = $is_category ? 'category' : 'post_tag'; 170 | $corresponding_term = get_term_by( 'name', $term->name, $corresponding_taxonomy, ARRAY_A ); 171 | 172 | // If not, create the term. 173 | if ( ! $corresponding_term && ! self::$is_dry_run ) { 174 | $corresponding_term = wp_insert_term( 175 | $term->name, 176 | $corresponding_taxonomy, 177 | [ 178 | 'description' => $term->description, 179 | 'slug' => $term->slug, 180 | ] 181 | ); 182 | } 183 | 184 | // Get any posts with the legacy term. 185 | $posts_with_custom_term = new \WP_Query( 186 | [ 187 | 'post_type' => $post_types, 188 | 'per_page' => 1000, 189 | 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query 190 | [ 191 | 'taxonomy' => $term->taxonomy, 192 | 'field' => 'term_id', 193 | 'terms' => $term->term_id, 194 | ], 195 | ], 196 | ] 197 | ); 198 | 199 | // Apply the new term to the post. 200 | if ( $posts_with_custom_term->have_posts() && ! self::$is_dry_run ) { 201 | while ( $posts_with_custom_term->have_posts() ) { 202 | $posts_with_custom_term->the_post(); 203 | wp_set_post_terms( 204 | get_the_ID(), // Post ID to apply the new term. 205 | [ $corresponding_term['term_id'] ], // Term ID of the new term. 206 | $corresponding_taxonomy, // Category or tag. 207 | true // Append the term without deleting existing terms. 208 | ); 209 | } 210 | } 211 | 212 | // Finally, delete the legacy term. 213 | if ( ! self::$is_dry_run ) { 214 | wp_delete_term( $term->term_id, $term->taxonomy ); 215 | } 216 | 217 | if ( $is_category ) { 218 | $converted_taxonomies['category'][] = $term->term_id; 219 | } else { 220 | $converted_taxonomies['post_tag'][] = $term->term_id; 221 | } 222 | 223 | WP_CLI::log( 224 | sprintf( 225 | 'Converted %1$s "%2$s".', 226 | $is_category ? 'category' : 'tag', 227 | $term->name 228 | ) 229 | ); 230 | } 231 | 232 | // Unregister the legacy taxonomies. 233 | unregister_taxonomy( $custom_category_slug ); 234 | unregister_taxonomy( $custom_tag_slug ); 235 | 236 | return $converted_taxonomies; 237 | } 238 | } 239 | 240 | Migration::instance(); 241 | -------------------------------------------------------------------------------- /includes/products/class-products-cron.php: -------------------------------------------------------------------------------- 1 | cron_hook = 'newspack_expire_listings'; 27 | 28 | // Handle expiration for single-purchase listings. 29 | add_action( 'init', [ $this, 'cron_init' ] ); 30 | add_action( $this->cron_hook, [ $this, 'expire_single_purchase_listings' ] ); 31 | } 32 | 33 | /** 34 | * Set up the cron job. Will run once daily and automatically unpublish single-purchase listings 35 | * whose publish dates are older than the expiration period defined in plugin settings. 36 | */ 37 | public function cron_init() { 38 | register_deactivation_hook( NEWSPACK_LISTINGS_FILE, [ $this, 'cron_deactivate' ] ); 39 | 40 | $single_expiration_period = Settings::get_settings( 'newspack_listings_single_purchase_expiration' ); 41 | 42 | // If WC Subscriptions is inactive, $single_expiration_period may be a WP error. 43 | // Let's not schedule the cron job in this case. 44 | if ( is_wp_error( $single_expiration_period ) ) { 45 | $single_expiration_period = 0; 46 | } 47 | 48 | if ( 0 < $single_expiration_period ) { 49 | if ( ! wp_next_scheduled( $this->cron_hook ) ) { 50 | wp_schedule_event( Utils\get_next_midnight(), 'daily', $this->cron_hook ); 51 | } 52 | } elseif ( wp_next_scheduled( $this->cron_hook ) ) { 53 | $this->cron_deactivate(); // If the option has been updated to 0, no need to run the cron job. 54 | 55 | } 56 | } 57 | 58 | /** 59 | * Clear the cron job when this plugin is deactivated. 60 | */ 61 | public function cron_deactivate() { 62 | wp_clear_scheduled_hook( $this->cron_hook ); 63 | } 64 | 65 | /** 66 | * Callback function to expire single-purchase listings whose publish date is older than the set expiration period. 67 | * Single-purchase listings can be distinguished because they should have an order ID meta value, but no subscription ID. 68 | * Subscription primary listings have both an order ID and a subscription ID. 69 | * Premium subscription "included" listings have a subscription ID, but no order ID. 70 | */ 71 | public function expire_single_purchase_listings() { 72 | $single_expiration_period = Settings::get_settings( 'newspack_listings_single_purchase_expiration' ); 73 | 74 | if ( 0 < $single_expiration_period ) { 75 | $args = [ 76 | 'post_status' => 'publish', 77 | 'post_type' => [ 78 | Core::NEWSPACK_LISTINGS_POST_TYPES['event'], 79 | Core::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], 80 | ], 81 | 'date_query' => [ 82 | 'before' => (string) $single_expiration_period . ' days ago', 83 | ], 84 | 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 85 | 'relation' => 'AND', 86 | // Only get listings that are associated with a WooCommerce order. 87 | [ 88 | 'key' => self::POST_META_KEYS['listing_order'], 89 | 'compare' => 'EXISTS', 90 | ], 91 | 92 | // Exclude listings that are associated with a subscription, which are active as long as the subscription is active. 93 | [ 94 | 'key' => self::POST_META_KEYS['listing_subscription'], 95 | 'compare' => 'NOT EXISTS', 96 | ], 97 | 98 | // Exclude listings with a set expiration date, as those are handled by the Core::expire_listings_with_expiration_date method. 99 | [ 100 | 'key' => 'newspack_listings_expiration_date', 101 | 'compare' => 'NOT EXISTS', 102 | ], 103 | ], 104 | ]; 105 | 106 | Utils\execute_callback_with_paged_query( $args, [ $this, 'expire_single_purchase_listing' ] ); 107 | } else { 108 | $this->cron_deactivate(); // If the option has been updated to 0, no need to run the cron job. 109 | } 110 | } 111 | 112 | /** 113 | * Given a post ID for a published post, unpublish it and flag it as expired. 114 | * 115 | * @param int $post_id ID for the post to expire. 116 | */ 117 | public function expire_single_purchase_listing( $post_id ) { 118 | if ( $post_id ) { 119 | $updated = wp_update_post( 120 | [ 121 | 'ID' => $post_id, 122 | 'post_status' => 'draft', 123 | ] 124 | ); 125 | 126 | if ( $updated ) { 127 | update_post_meta( $post_id, self::POST_META_KEYS['listing_has_expired'], 1 ); 128 | } else { 129 | error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 130 | sprintf( 131 | // Translators: error message logged when we're unable to expire a listing via cron job. 132 | __( 'Newspack Listings: Error expiring listing with ID %d.', 'newspack-listings' ), 133 | $post_id 134 | ) 135 | ); 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /includes/products/class-products-user.php: -------------------------------------------------------------------------------- 1 | get_nodes(); 212 | 213 | // Allow user-related nodes to get back to "My Account" pages or to log out. 214 | $allowed_nodes = [ 215 | 'wp-logo', 216 | 'site-name', 217 | 'edit-profile', 218 | 'logout', 219 | 'my-account', 220 | 'top-secondary', 221 | 'user-actions', 222 | 'user-info', 223 | ]; 224 | 225 | // Remove all the other nodes. 226 | foreach ( $nodes as $id => $node ) { 227 | if ( ! in_array( $id, $allowed_nodes ) ) { 228 | $wp_admin_bar->remove_node( $id ); 229 | } 230 | } 231 | } 232 | 233 | return $wp_admin_bar; 234 | } 235 | 236 | /** 237 | * Redirect customers to main admin screen if trying to access restricted admin pages. 238 | */ 239 | public function redirect_to_dashboard() { 240 | if ( self::is_listing_customer() ) { 241 | \wp_safe_redirect( \get_admin_url() ); 242 | exit; 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /newspack-listings.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Generally-applicable sniffs for WordPress plugins 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | . 31 | 32 | */dev-lib/* 33 | */node_modules/* 34 | */vendor/* 35 | */dist/* 36 | */release/* 37 | 38 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./includes 13 | ./api 14 | 15 | 16 | 17 | 18 | ./tests/ 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/assets/front-end/archives.js: -------------------------------------------------------------------------------- 1 | import './archives.scss'; 2 | -------------------------------------------------------------------------------- /src/assets/front-end/archives.scss: -------------------------------------------------------------------------------- 1 | /* Directory Archives */ 2 | .archive.newspack-listings-grid { 3 | #main { 4 | align-content: flex-start; 5 | display: flex; 6 | flex-wrap: wrap; 7 | 8 | @media ( min-width: 600px ) { 9 | margin-left: -1rem; 10 | margin-right: -1rem; 11 | } 12 | @media ( min-width: 782px ) { 13 | width: calc(65% + 40px); 14 | } 15 | } 16 | 17 | .navigation.pagination { 18 | width: 100%; 19 | } 20 | 21 | .site-main > article { 22 | display: block; 23 | margin: 0 0 3rem; 24 | position: relative; 25 | width: 100%; 26 | 27 | .listing-label { 28 | position: absolute; 29 | top: -0.5rem; 30 | } 31 | 32 | .entry-title { 33 | font-size: 0.75rem; 34 | text-transform: uppercase; 35 | } 36 | 37 | @media ( min-width: 600px ) { 38 | border: 1rem solid transparent; 39 | flex: 1 0 50%; 40 | max-width: 50%; 41 | } 42 | @media ( min-width: 1200px ) { 43 | flex: 1 0 33%; 44 | max-width: 33%; 45 | } 46 | } 47 | 48 | .has-post-thumbnail .post-thumbnail { 49 | margin-bottom: 0.5rem; 50 | max-width: 100%; 51 | 52 | img { 53 | object-position: 0 0; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/front-end/curated-list.js: -------------------------------------------------------------------------------- 1 | import './curated-list.scss'; 2 | import './listing.scss'; 3 | 4 | /** 5 | * VIEW 6 | * JavaScript used on front of site. 7 | */ 8 | 9 | const fetchRetryCount = 3; 10 | 11 | /** 12 | * Load More Button Handling 13 | * 14 | * Calls Array.prototype.forEach for IE11 compatibility. 15 | * 16 | * @see https://developer.mozilla.org/en-US/docs/Web/API/NodeList 17 | */ 18 | Array.prototype.forEach.call( 19 | document.querySelectorAll( '.newspack-listings__curated-list.has-more-button' ), 20 | buildLoadMoreHandler 21 | ); 22 | 23 | Array.prototype.forEach.call( 24 | document.querySelectorAll( '.newspack-listings__curated-list.show-sort-ui' ), 25 | buildSortHandler 26 | ); 27 | 28 | /** 29 | * Builds a function to handle clicks on the load more button. 30 | * Creates internal state via closure to ensure all state is 31 | * isolated to a single Block + button instance. 32 | * 33 | * @param {HTMLElement} blockWrapperEl the button that was clicked 34 | */ 35 | function buildLoadMoreHandler( blockWrapperEl ) { 36 | const btnEl = blockWrapperEl.querySelector( '[data-next]' ); 37 | if ( ! btnEl ) { 38 | return; 39 | } 40 | const postsContainerEl = blockWrapperEl.querySelector( '.newspack-listings__list-container' ); 41 | const btnText = btnEl.textContent.trim(); 42 | const loadingText = blockWrapperEl.querySelector( '.loading' ).textContent; 43 | 44 | // Set initial state flags. 45 | let isFetching = false; 46 | 47 | btnEl.addEventListener( 'click', () => { 48 | // Early return if still fetching or no more posts to render. 49 | if ( isFetching ) { 50 | return false; 51 | } 52 | 53 | isFetching = true; 54 | 55 | blockWrapperEl.classList.remove( 'is-error' ); 56 | blockWrapperEl.classList.add( 'is-loading' ); 57 | 58 | if ( loadingText ) { 59 | btnEl.textContent = loadingText; 60 | } 61 | 62 | const requestURL = btnEl.getAttribute( 'data-next' ); 63 | 64 | fetchWithRetry( { url: requestURL, onSuccess, onError }, fetchRetryCount ); 65 | 66 | /** 67 | * @param {Object} data Post data 68 | * @param {string} next URL to fetch next batch of posts 69 | */ 70 | function onSuccess( data, next ) { 71 | // Validate received data. 72 | if ( ! isPostsDataValid( data ) ) { 73 | return onError(); 74 | } 75 | 76 | if ( data.length ) { 77 | // Render posts' HTML from string. 78 | const postsHTML = data.map( item => item.html ).join( '' ); 79 | postsContainerEl.insertAdjacentHTML( 'beforeend', postsHTML ); 80 | } 81 | 82 | if ( next ) { 83 | // Save next URL as button's attribute. 84 | btnEl.setAttribute( 'data-next', next ); 85 | } 86 | 87 | // Remove next button if we're done. 88 | if ( ! data.length || ! next ) { 89 | blockWrapperEl.classList.remove( 'has-more-button' ); 90 | } 91 | 92 | isFetching = false; 93 | 94 | blockWrapperEl.classList.remove( 'is-loading' ); 95 | btnEl.textContent = btnText; 96 | } 97 | 98 | /** 99 | * Handle fetching error 100 | */ 101 | function onError() { 102 | isFetching = false; 103 | 104 | blockWrapperEl.classList.remove( 'is-loading' ); 105 | blockWrapperEl.classList.add( 'is-error' ); 106 | btnEl.textContent = btnText; 107 | } 108 | } ); 109 | } 110 | 111 | /** 112 | * Builds a function to handle sorting of listing items. 113 | * Creates internal state via closure to ensure all state is 114 | * isolated to a single Block + button instance. 115 | * 116 | * @param {HTMLElement} blockWrapperEl the button that was clicked 117 | */ 118 | function buildSortHandler( blockWrapperEl ) { 119 | const sortUi = blockWrapperEl.querySelector( '.newspack-listings__sort-ui' ); 120 | const sortBy = blockWrapperEl.querySelector( '.newspack-listings__sort-select-control' ); 121 | const sortOrder = blockWrapperEl.querySelectorAll( '[name="newspack-listings__sort-order"]' ); 122 | const sortOrderContainer = blockWrapperEl.querySelector( 123 | '.newspack-listings__sort-order-container' 124 | ); 125 | 126 | if ( ! sortUi || ! sortBy || ! sortOrder.length || ! sortOrderContainer ) { 127 | return; 128 | } 129 | 130 | const btnEl = blockWrapperEl.querySelector( '[data-next]' ); 131 | const triggers = Array.prototype.concat.call( Array.prototype.slice.call( sortOrder ), [ 132 | sortBy, 133 | ] ); 134 | 135 | const postsContainerEl = blockWrapperEl.querySelector( '.newspack-listings__list-container' ); 136 | const restURL = sortUi.getAttribute( 'data-url' ); 137 | const hasMoreButton = blockWrapperEl.classList.contains( 'has-more-button' ); 138 | 139 | // Set initial state flags and data. 140 | let isFetching = false; 141 | let _sortBy = sortUi.querySelector( '[selected]' ).value; 142 | let _order = sortUi.querySelector( '[checked]' ).value; 143 | 144 | const sortHandler = e => { 145 | // Early return if still fetching or no more posts to render. 146 | if ( isFetching ) { 147 | return false; 148 | } 149 | 150 | isFetching = true; 151 | 152 | blockWrapperEl.classList.remove( 'is-error' ); 153 | blockWrapperEl.classList.add( 'is-loading' ); 154 | 155 | if ( e.target.tagName.toLowerCase() === 'select' ) { 156 | _sortBy = e.target.value; 157 | } else { 158 | _order = e.target.value; 159 | } 160 | 161 | // Enable disabled sort order radio buttons. 162 | if ( 'post__in' === e.target.value ) { 163 | sortOrderContainer.classList.add( 'is-hidden' ); 164 | } else { 165 | sortOrderContainer.classList.remove( 'is-hidden' ); 166 | } 167 | 168 | const requestURL = `${ restURL }&${ encodeURIComponent( 169 | 'query[sortBy]' 170 | ) }=${ _sortBy }&${ encodeURIComponent( 'query[order]' ) }=${ _order }`; 171 | 172 | if ( hasMoreButton && btnEl ) { 173 | blockWrapperEl.classList.add( 'has-more-button' ); 174 | btnEl.setAttribute( 'data-next', requestURL ); 175 | } 176 | 177 | fetchWithRetry( { url: requestURL, onSuccess, onError }, fetchRetryCount ); 178 | 179 | /** 180 | * @param {Object} data Post data 181 | * @param {string} next URL to fetch next batch of posts 182 | */ 183 | function onSuccess( data, next ) { 184 | // Validate received data. 185 | if ( ! isPostsDataValid( data ) ) { 186 | return onError(); 187 | } 188 | 189 | if ( data.length ) { 190 | // Clear all existing list items. 191 | postsContainerEl.textContent = ''; 192 | 193 | // Render posts' HTML from string. 194 | const postsHTML = data.map( item => item.html ).join( '' ); 195 | postsContainerEl.insertAdjacentHTML( 'beforeend', postsHTML ); 196 | } 197 | 198 | if ( next && btnEl ) { 199 | // Save next URL as button's attribute. 200 | btnEl.setAttribute( 'data-next', next ); 201 | } 202 | 203 | isFetching = false; 204 | blockWrapperEl.classList.remove( 'is-loading' ); 205 | } 206 | 207 | /** 208 | * Handle fetching error 209 | */ 210 | function onError() { 211 | isFetching = false; 212 | 213 | blockWrapperEl.classList.remove( 'is-loading' ); 214 | blockWrapperEl.classList.add( 'is-error' ); 215 | } 216 | }; 217 | 218 | triggers.forEach( trigger => trigger.addEventListener( 'change', sortHandler ) ); 219 | } 220 | 221 | /** 222 | * Wrapper for XMLHttpRequest that performs given number of retries when error 223 | * occurs. 224 | * 225 | * @param {Object} options XMLHttpRequest options 226 | * @param {number} n retry count before throwing 227 | */ 228 | function fetchWithRetry( options, n ) { 229 | const xhr = new XMLHttpRequest(); 230 | 231 | xhr.onreadystatechange = () => { 232 | // Return if the request is completed. 233 | if ( xhr.readyState !== 4 ) { 234 | return; 235 | } 236 | 237 | // Call onSuccess with parsed JSON if the request is successful. 238 | if ( xhr.status >= 200 && xhr.status < 300 ) { 239 | const data = JSON.parse( xhr.responseText ); 240 | const next = xhr.getResponseHeader( 'next-url' ); 241 | 242 | return options.onSuccess( data, next ); 243 | } 244 | 245 | // Call onError if the request has failed n + 1 times (or if n is undefined). 246 | if ( ! n ) { 247 | return options.onError(); 248 | } 249 | 250 | // Retry fetching if request has failed and n > 0. 251 | return fetchWithRetry( options, n - 1 ); 252 | }; 253 | 254 | xhr.open( 'GET', options.url ); 255 | xhr.send(); 256 | } 257 | 258 | /** 259 | * Validates the "Load more" posts endpoint schema: 260 | * { 261 | * "type": "array", 262 | * "items": { 263 | * "type": "object", 264 | * "properties": { 265 | * "html": { 266 | * "type": "string" 267 | * } 268 | * }, 269 | * "required": ["html"] 270 | * }, 271 | * } 272 | * 273 | * @param {Object} data posts endpoint payload 274 | */ 275 | function isPostsDataValid( data ) { 276 | let isValid = false; 277 | 278 | if ( data && Array.isArray( data ) ) { 279 | isValid = true; 280 | 281 | if ( 282 | data.length && 283 | ! ( hasOwnProp( data[ 0 ], 'html' ) && typeof data[ 0 ].html === 'string' ) 284 | ) { 285 | isValid = false; 286 | } 287 | } 288 | 289 | return isValid; 290 | } 291 | 292 | /** 293 | * Checks if object has own property. 294 | * 295 | * @param {Object} obj Object 296 | * @param {string} prop Property to check 297 | */ 298 | function hasOwnProp( obj, prop ) { 299 | return Object.prototype.hasOwnProperty.call( obj, prop ); 300 | } 301 | -------------------------------------------------------------------------------- /src/assets/front-end/curated-list.scss: -------------------------------------------------------------------------------- 1 | @use "../shared/curated-list"; 2 | 3 | .newspack-listings { 4 | &__curated-list { 5 | position: relative; 6 | transition: opacity 0.25s ease-in-out; 7 | 8 | .error, 9 | .loading { 10 | display: none; 11 | } 12 | 13 | &.is-error .error { 14 | display: block; 15 | } 16 | 17 | &.is-loading { 18 | cursor: not-allowed; 19 | opacity: 0.5; 20 | pointer-events: none; 21 | 22 | /* stylelint-disable selector-type-no-unknown */ 23 | amp-script & { 24 | cursor: not-allowed; 25 | opacity: 0.5; 26 | pointer-events: none; 27 | } 28 | } 29 | } 30 | 31 | &__list-container { 32 | list-style: none; 33 | margin: 1rem 0; 34 | padding: 0; 35 | 36 | .newspack-listings__listing { 37 | padding: 1rem 0; 38 | } 39 | 40 | .newspack-listings__listing + .newspack-listings__listing { 41 | border-top: 1px solid var(--newspack-listings--border-dark); 42 | } 43 | 44 | .has-dark-background & { 45 | .newspack-listings__listing + .newspack-listings__listing { 46 | border-top-color: var(--newspack-listings--border-light); 47 | } 48 | } 49 | } 50 | 51 | &__load-more-button { 52 | display: none; 53 | 54 | .has-more-button & { 55 | display: block; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/assets/front-end/event.js: -------------------------------------------------------------------------------- 1 | import './event.scss'; 2 | -------------------------------------------------------------------------------- /src/assets/front-end/event.scss: -------------------------------------------------------------------------------- 1 | @use "../shared/event.scss"; 2 | -------------------------------------------------------------------------------- /src/assets/front-end/listing.scss: -------------------------------------------------------------------------------- 1 | @use "../shared/listing"; 2 | 3 | .entry-content { 4 | a.newspack-listings__listing-link, 5 | a:visited.newspack-listings__listing-link { 6 | color: currentcolor; 7 | text-decoration: none; 8 | } 9 | 10 | ul.newspack-listings__event-dates { 11 | font-weight: bold; 12 | list-style: none; 13 | padding: 0; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/front-end/patterns.js: -------------------------------------------------------------------------------- 1 | import './patterns.scss'; 2 | -------------------------------------------------------------------------------- /src/assets/front-end/patterns.scss: -------------------------------------------------------------------------------- 1 | @use "../shared/patterns.scss"; 2 | -------------------------------------------------------------------------------- /src/assets/front-end/related-listings.scss: -------------------------------------------------------------------------------- 1 | .entry .entry-content .newspack-listings { 2 | &__separator.wp-block-separator { 3 | margin-bottom: 0.25rem; 4 | } 5 | 6 | &__related-section-title.accent-header { 7 | border: none; 8 | margin-top: 0.25rem; 9 | } 10 | 11 | &__related-listing.author-bio { 12 | .avatar { 13 | border-radius: 0; 14 | width: auto; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/front-end/self-serve-listings.js: -------------------------------------------------------------------------------- 1 | import './self-serve-listings.scss'; 2 | -------------------------------------------------------------------------------- /src/assets/front-end/self-serve-listings.scss: -------------------------------------------------------------------------------- 1 | @use "../shared/self-serve-listings"; 2 | 3 | .woocommerce-info .showlogin { 4 | margin-left: 0.25rem; 5 | } 6 | 7 | .woocommerce-orders-table__cell-order-actions { 8 | .button { 9 | margin-bottom: 0.125rem; 10 | margin-top: 0.125rem; 11 | 12 | + .button { 13 | display: inline-block; 14 | margin-left: 0.25rem; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/front-end/view.scss: -------------------------------------------------------------------------------- 1 | @use "archives"; 2 | @use "curated-list"; 3 | @use "event"; 4 | @use "listing"; 5 | @use "patterns"; 6 | @use "related-listings"; 7 | @use "self-serve-listings"; 8 | -------------------------------------------------------------------------------- /src/assets/shared/curated-list.scss: -------------------------------------------------------------------------------- 1 | @use "variables.scss"; 2 | 3 | .newspack-listings { 4 | &__curated-list { 5 | counter-reset: list; 6 | 7 | &.show-numbers .newspack-listings__listing::before { 8 | color: #767676; 9 | color: currentcolor; 10 | content: counter(list) ". "; 11 | counter-increment: list; 12 | display: block; 13 | font-weight: bold; 14 | margin-bottom: 0.5rem; 15 | } 16 | 17 | &.has-background-color { 18 | padding: 1em; 19 | } 20 | } 21 | 22 | &__load-more { 23 | display: block; 24 | margin: 1rem auto; 25 | } 26 | 27 | &__sort-ui { 28 | font-size: 0.75rem; 29 | margin-top: 1.5rem; 30 | 31 | section, 32 | div { 33 | align-items: center; 34 | display: flex; 35 | 36 | &.full-width { 37 | width: 100%; 38 | } 39 | } 40 | 41 | section { 42 | margin-bottom: 0.5rem; 43 | } 44 | 45 | div + div { 46 | margin-left: 1rem; 47 | } 48 | 49 | &-label, 50 | &-label-inner { 51 | margin-bottom: 0; 52 | } 53 | 54 | &-label { 55 | font-weight: bold; 56 | margin-right: 0.5rem; 57 | } 58 | 59 | input[type="radio"] { 60 | margin-right: 0.25rem; 61 | } 62 | 63 | @media only screen and ( min-width: variables.$tablet_width ) { 64 | display: flex; 65 | flex-wrap: wrap; 66 | 67 | section + section:not(.full-width) { 68 | margin-left: 1.5rem; 69 | } 70 | } 71 | } 72 | 73 | &__sort-order-container { 74 | &.is-hidden { 75 | display: none; 76 | 77 | /* stylelint-disable selector-type-no-unknown */ 78 | amp-script & { 79 | display: none; 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/assets/shared/event.scss: -------------------------------------------------------------------------------- 1 | .newspack-listings { 2 | &__event-dates { 3 | font-weight: bold; 4 | 5 | span { 6 | font-weight: normal; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/shared/listing.scss: -------------------------------------------------------------------------------- 1 | @use "variables.scss"; 2 | 3 | .newspack-listings { 4 | &__listing-post { 5 | display: block; 6 | 7 | @media only screen and ( min-width: variables.$tablet_width ) { 8 | .media-position-left &, 9 | .media-position-right & { 10 | display: flex; 11 | } 12 | } 13 | 14 | + .is-link { 15 | padding-left: 0; 16 | padding-right: 0; 17 | } 18 | 19 | .editor-styles-wrapper & { 20 | p { 21 | font-size: 1em; 22 | } 23 | } 24 | 25 | .type-scale-1 & { 26 | font-size: 62.5%; 27 | } 28 | 29 | .type-scale-2 & { 30 | font-size: 75%; 31 | } 32 | 33 | .type-scale-3 & { 34 | font-size: 87.5%; 35 | } 36 | 37 | .type-scale-5 & { 38 | font-size: 112.5%; 39 | } 40 | 41 | .type-scale-6 & { 42 | font-size: 125%; 43 | } 44 | 45 | .type-scale-7 & { 46 | font-size: 125%; 47 | } 48 | 49 | .type-scale-8 & { 50 | font-size: 137.5%; 51 | } 52 | 53 | .type-scale-9 & { 54 | font-size: 150%; 55 | } 56 | 57 | .type-scale-10 & { 58 | font-size: 162.5%; 59 | } 60 | } 61 | 62 | &__listing-title { 63 | margin-top: 0.5rem; 64 | 65 | .media-position-left & { 66 | margin-top: 0; 67 | } 68 | 69 | .media-position-right & { 70 | margin-top: 0; 71 | } 72 | } 73 | 74 | &__listing-featured-media { 75 | flex-basis: 100%; 76 | margin: 0 0 1rem; 77 | max-width: 100%; 78 | padding: 0; 79 | 80 | img { 81 | display: inline-block; 82 | vertical-align: top; 83 | max-width: 100%; 84 | } 85 | 86 | .media-position-left & { 87 | margin-right: 1rem; 88 | } 89 | 90 | .media-position-right & { 91 | margin-left: 1rem; 92 | order: 2; 93 | } 94 | 95 | .media-size-1 & { 96 | flex-basis: 25%; 97 | } 98 | 99 | .media-size-2 & { 100 | flex-basis: 50%; 101 | } 102 | 103 | .media-size-3 & { 104 | flex-basis: 75%; 105 | } 106 | } 107 | 108 | &__listing-caption { 109 | padding-top: 0.5rem; 110 | } 111 | 112 | &__listing-meta { 113 | display: block; 114 | flex-basis: 100%; 115 | 116 | .cat-links { 117 | font-size: 0.75em; 118 | font-weight: 700; 119 | margin: 0 0 0.75rem; 120 | } 121 | } 122 | 123 | &__column-reverse { 124 | flex-direction: row-reverse; 125 | } 126 | 127 | &__sponsors { 128 | align-items: center; 129 | display: flex; 130 | 131 | .sponsor-logos { 132 | border-right: 1px solid var(--newspack-listings--grey-light); 133 | margin-right: 0.75rem; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/assets/shared/patterns.scss: -------------------------------------------------------------------------------- 1 | /* Styles and overrides for block patterns. */ 2 | .newspack-listings { 3 | /* Real Estate: Pattern 1 */ 4 | &__real-estate-pattern-1 { 5 | .wp-block-media-text { 6 | .wp-block-media-text__content { 7 | padding: 0 0 0 4px; 8 | } 9 | 10 | &.has-media-on-the-right { 11 | .wp-block-media-text__content { 12 | padding: 0 4px 0 0; 13 | } 14 | } 15 | 16 | .wp-block-jetpack-tiled-gallery { 17 | margin-bottom: 0; 18 | } 19 | } 20 | 21 | &__details p { 22 | margin-bottom: 0 !important; 23 | margin-top: 0 !important; 24 | } 25 | } 26 | 27 | /* Real Estate: Pattern 2 */ 28 | &__real-estate-pattern-2__gallery { 29 | .wp-block-image { 30 | margin-bottom: 4px; 31 | 32 | + .wp-block-jetpack-tiled-gallery { 33 | margin-top: 4px; 34 | } 35 | } 36 | } 37 | 38 | /* Classified Ads: Pattern 1 */ 39 | &__classified-ads-1__images { 40 | .wp-block-image { 41 | margin-bottom: 4px; 42 | } 43 | 44 | .wp-block-jetpack-tiled-gallery { 45 | margin-top: 4px; 46 | } 47 | } 48 | 49 | /* Classified Ads: Pattern 2 */ 50 | &__classified-ads-2 { 51 | &__gallery { 52 | margin-right: 2px; 53 | } 54 | 55 | &__image { 56 | margin-left: 2px; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/assets/shared/self-serve-listings.scss: -------------------------------------------------------------------------------- 1 | @use "variables.scss"; 2 | 3 | .newspack-listings__self-serve-form.wpbnbd { 4 | border: 1px solid var(--newspack-listings--border); 5 | 6 | .frequencies { 7 | font-size: 0.8em; 8 | padding-top: 7.65em; 9 | padding-top: calc(3 * ( 0.76rem + 1.6em + 1px )); 10 | position: relative; 11 | 12 | @media only screen and ( min-width: variables.$tablet_width ) { 13 | padding-top: 2.55em; 14 | padding-top: calc(0.76rem + 1.6em + 1px); 15 | } 16 | 17 | input[type="radio"] { 18 | display: none; 19 | } 20 | } 21 | 22 | .frequency { 23 | .freq-label { 24 | align-items: center; 25 | border: 0 solid var(--newspack-listings--border); 26 | border-width: 0 0 1px; 27 | color: var(--newspack-listings--text-light); 28 | cursor: pointer; 29 | display: flex; 30 | font-weight: bold; 31 | left: 0; 32 | padding: 0.38rem 0.76rem; 33 | position: absolute; 34 | text-overflow: ellipsis; 35 | text-transform: uppercase; 36 | top: 0; 37 | white-space: nowrap; 38 | width: 100%; 39 | z-index: 1; 40 | 41 | &:focus, 42 | &:hover { 43 | background: var(--newspack-listings--background-screen); 44 | color: var(--newspack-listings--text-main); 45 | } 46 | 47 | &::before { 48 | border: 2px solid currentcolor; 49 | border-radius: 100%; 50 | content: ""; 51 | display: block; 52 | height: 20px; 53 | padding: 3px; 54 | margin-right: 0.25rem; 55 | width: 20px; 56 | } 57 | 58 | &.listing-subscription { 59 | left: 50%; 60 | top: calc(0.76rem + 1.6em + 1px); 61 | 62 | @media only screen and ( min-width: variables.$tablet_width ) { 63 | border-left-width: 1px; 64 | top: 0; 65 | } 66 | } 67 | } 68 | 69 | @media only screen and ( min-width: variables.$tablet_width ) { 70 | .freq-label { 71 | justify-content: center; 72 | width: 50%; 73 | 74 | &::before { 75 | display: none; 76 | } 77 | } 78 | } 79 | } 80 | 81 | input[type="radio"]:checked { 82 | + .freq-label { 83 | color: inherit; 84 | 85 | &::before { 86 | background: var(--newspack-listings--text-main); 87 | background-clip: content-box; 88 | } 89 | 90 | @media only screen and ( min-width: variables.$tablet_width ) { 91 | border-bottom-color: transparent; 92 | } 93 | 94 | &:hover { 95 | background: var(--newspack-listings--background); 96 | } 97 | } 98 | } 99 | 100 | form { 101 | display: flex; 102 | flex-direction: column; 103 | } 104 | 105 | hr { 106 | background-color: var(--newspack-listings--border-light); 107 | max-width: none; 108 | } 109 | 110 | .input-container { 111 | display: none; 112 | margin: 0.76rem; 113 | 114 | @media only screen and ( min-width: variables.$tablet_width ) { 115 | margin: 1.5rem 1.5rem 0.76rem; 116 | } 117 | } 118 | 119 | input[type="radio"]:checked ~ .input-container { 120 | display: block; 121 | } 122 | 123 | .listing-details { 124 | label { 125 | display: inline-block; 126 | font-size: 20px; 127 | } 128 | 129 | input[type="text"], 130 | select { 131 | display: block; 132 | margin-bottom: 24px; 133 | padding: 6px; 134 | width: 100%; 135 | } 136 | 137 | input[type="checkbox"] { 138 | display: inline-block; 139 | margin-right: 8px; 140 | } 141 | 142 | input, 143 | select { 144 | font-size: 20px; 145 | } 146 | } 147 | 148 | p { 149 | font-size: 20px; 150 | 151 | &.newspack-listings__help { 152 | color: var(--newspack-listings--grey-medium); 153 | font-size: 16px; 154 | margin-top: 0; 155 | } 156 | } 157 | 158 | button { 159 | align-self: flex-end; 160 | background: var(--newspack-listings--secondary); 161 | border: none; 162 | border-radius: 5px; 163 | box-sizing: border-box; 164 | color: var(--newspack-listings--background); 165 | font-size: 16px; 166 | font-weight: bold; 167 | outline: none; 168 | margin: 24px 32px; 169 | padding-left: 20px; 170 | padding-right: 20px; 171 | 172 | span { 173 | line-height: 48px; 174 | } 175 | } 176 | 177 | &.single-only, 178 | &.subscription-only { 179 | .frequencies { 180 | padding-top: 0; 181 | 182 | .freq-label { 183 | display: none; 184 | } 185 | } 186 | 187 | .input-container { 188 | display: block; 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/assets/shared/variables.scss: -------------------------------------------------------------------------------- 1 | // Colors. 2 | :root { 3 | --newspack-listings--background: #fff; 4 | --newspack-listings--background-screen: #f1f1f1; 5 | --newspack-listings--primary: #003da5; 6 | --newspack-listings--secondary: #555; 7 | --newspack-listings--grey-dark: #1e1e1e; 8 | --newspack-listings--grey-medium: #757575; 9 | --newspack-listings--grey-light: #ddd; 10 | --newspack-listings--border: #ccc; 11 | --newspack-listings--border-dark: rgb(0 0 0/ 0.124); 12 | --newspack-listings--border-light: rgb(255 255 255/ 0.124); 13 | --newspack-listings--text-main: #111; 14 | --newspack-listings--text-light: #767676; 15 | } 16 | 17 | // Media queries. 18 | $mobile_width: 600px; 19 | $tablet_width: 782px; 20 | $desktop_width: 1168px; 21 | $wide_width: 1379px; 22 | -------------------------------------------------------------------------------- /src/blocks/category.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { getCategories, setCategories } from '@wordpress/blocks'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import { NewspackLogo } from '../svg'; 10 | 11 | /** 12 | * If the Newspack Blocks plugin is installed, use the existing Newspack block category. 13 | * Otherwise, create the category. This lets Newspack Listings remain usable without 14 | * depending on Newspack Blocks. 15 | */ 16 | export const setCustomCategory = () => { 17 | const categories = getCategories(); 18 | const hasNewspackCategory = !! categories.find( ( { slug } ) => slug === 'newspack' ); 19 | 20 | if ( ! hasNewspackCategory ) { 21 | setCategories( [ 22 | ...categories.filter( ( { slug } ) => slug !== 'newspack' ), 23 | { 24 | slug: 'newspack', 25 | title: 'Newspack', 26 | icon: , 27 | }, 28 | ] ); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/blocks/curated-list/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newspack-listings/curated-list", 3 | "category": "newspack", 4 | "attributes": { 5 | "className": { 6 | "type": "string", 7 | "default": "" 8 | }, 9 | "isSelected": { 10 | "type": "boolean", 11 | "default": false 12 | }, 13 | "showNumbers": { 14 | "type": "boolean", 15 | "default": true 16 | }, 17 | "showMap": { 18 | "type": "boolean", 19 | "default": false 20 | }, 21 | "showSortUi": { 22 | "type": "boolean", 23 | "default": false 24 | }, 25 | "showAuthor": { 26 | "type": "boolean", 27 | "default": false 28 | }, 29 | "showExcerpt": { 30 | "type": "boolean", 31 | "default": true 32 | }, 33 | "showImage": { 34 | "type": "boolean", 35 | "default": true 36 | }, 37 | "showCaption": { 38 | "type": "boolean", 39 | "default": false 40 | }, 41 | "imageShape": { 42 | "type": "string", 43 | "default": "landscape" 44 | }, 45 | "minHeight": { 46 | "type": "integer", 47 | "default": 0 48 | }, 49 | "showCategory": { 50 | "type": "boolean", 51 | "default": false 52 | }, 53 | "showTags": { 54 | "type": "boolean", 55 | "default": false 56 | }, 57 | "mediaPosition": { 58 | "type": "string", 59 | "default": "top" 60 | }, 61 | "categories": { 62 | "type": "array", 63 | "default": [], 64 | "items": { "type": "integer" } 65 | }, 66 | "tags": { 67 | "type": "array", 68 | "default": [], 69 | "items": { "type": "integer" } 70 | }, 71 | "tagExclusions": { 72 | "type": "array", 73 | "default": [], 74 | "items": { "type": "integer" } 75 | }, 76 | "typeScale": { 77 | "type": "integer", 78 | "default": 4 79 | }, 80 | "imageScale": { 81 | "type": "integer", 82 | "default": 3 83 | }, 84 | "textColor": { 85 | "type": "string", 86 | "default": "" 87 | }, 88 | "backgroundColor": { 89 | "type": "string", 90 | "default": "" 91 | }, 92 | "hasDarkBackground": { 93 | "type": "boolean", 94 | "default": false 95 | }, 96 | "startup": { 97 | "type": "boolean", 98 | "default": true 99 | }, 100 | "queryMode": { 101 | "type": "boolean", 102 | "default": false 103 | }, 104 | "queryOptions": { 105 | "type": "object", 106 | "default": { 107 | "type": null, 108 | "authors": [], 109 | "categories": [], 110 | "tags": [], 111 | "categoryExclusions": [], 112 | "tagExclusions": [], 113 | "maxItems": 10, 114 | "sortBy": "date", 115 | "order": "DESC" 116 | } 117 | }, 118 | "listingIds": { 119 | "type": "array", 120 | "default": [] 121 | }, 122 | "queriedListings": { 123 | "type": "array", 124 | "default": [] 125 | }, 126 | "showLoadMore": { 127 | "type": "boolean", 128 | "default": false 129 | }, 130 | "loadMoreText": { 131 | "type": "string", 132 | "default": "Load more listings" 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/blocks/curated-list/editor.scss: -------------------------------------------------------------------------------- 1 | @use "../../assets/shared/curated-list"; 2 | 3 | .newspack-listings { 4 | &__curated-list-editor { 5 | border-radius: 2px; 6 | clear: both; 7 | margin-left: -1rem; 8 | margin-right: -1rem; 9 | padding: 0 1rem; 10 | 11 | label, 12 | .newspack-listings__label { 13 | display: block; 14 | font-size: 13px; 15 | line-height: 1.5; 16 | margin-bottom: 8px; 17 | } 18 | 19 | .wp-block[data-type^="newspack-listings"] + .wp-block[data-type^="newspack-listings"] 20 | .newspack-listings__listing-editor, 21 | .newspack-listings__listing-editor + .newspack-listings__listing-editor { 22 | border-top: 1px solid var(--newspack-listings--border-dark); 23 | padding-top: 1rem; 24 | } 25 | 26 | .has-dark-background { 27 | .wp-block[data-type^="newspack-listings"] + .wp-block[data-type^="newspack-listings"] 28 | .newspack-listings__listing-editor, 29 | .newspack-listings__listing-editor + .newspack-listings__listing-editor { 30 | border-top-color: var(--newspack-listings--border-light); 31 | } 32 | } 33 | 34 | .query-mode .newspack-listings__list-container { 35 | display: none; 36 | } 37 | } 38 | 39 | &__placeholder { 40 | .components-button.has-icon { 41 | padding: 12px; 42 | } 43 | 44 | li { 45 | align-items: center; 46 | display: flex; 47 | flex-direction: column; 48 | justify-content: flex-start; 49 | margin-right: 0; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/blocks/curated-list/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { InnerBlocks } from '@wordpress/block-editor'; 6 | import { registerBlockType } from '@wordpress/blocks'; 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | import './editor.scss'; 12 | import { CuratedListEditor } from './edit'; 13 | import { List } from '../../svg'; 14 | import metadata from './block.json'; 15 | const { attributes, category, name } = metadata; 16 | 17 | export const registerCuratedListBlock = () => { 18 | registerBlockType( name, { 19 | title: __( 'Curated List', 'newspack-listings' ), 20 | icon: { 21 | src: , 22 | foreground: '#003da5', 23 | }, 24 | category, 25 | keywords: [ 26 | __( 'curated', 'newspack-listings' ), 27 | __( 'list', 'newspack-listings' ), 28 | __( 'lists', 'newspack-listings' ), 29 | __( 'listings', 'newspack-listings' ), 30 | __( 'latest', 'newspack-listings' ), 31 | ], 32 | 33 | attributes, 34 | 35 | edit: CuratedListEditor, 36 | save: () => , // also uses view.php 37 | } ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/blocks/curated-list/view.php: -------------------------------------------------------------------------------- 1 | $block_json['attributes'], 29 | 'render_callback' => __NAMESPACE__ . '\render_block', 30 | ] 31 | ); 32 | } 33 | 34 | /** 35 | * Block render callback. 36 | * 37 | * @param Array $attributes Block attributes. 38 | * @param String $inner_content InnerBlock content. 39 | */ 40 | function render_block( $attributes, $inner_content ) { 41 | // REST API URL for Listings. 42 | $rest_url = rest_url( '/newspack-listings/v1/listings' ); 43 | 44 | // Is current page an AMP page? 45 | $is_amp = Utils\is_amp(); 46 | 47 | // Conditional class names based on attributes. 48 | $classes = [ 'newspack-listings__curated-list', esc_attr( $attributes['className'] ) ]; 49 | 50 | if ( $attributes['showNumbers'] ) { 51 | $classes[] = 'show-numbers'; 52 | } 53 | if ( $attributes['showMap'] ) { 54 | $classes[] = 'show-map'; 55 | } 56 | if ( $attributes['showSortUi'] ) { 57 | $classes[] = 'show-sort-ui'; 58 | } 59 | if ( $attributes['showImage'] ) { 60 | $classes[] = 'show-image'; 61 | $classes[] = 'media-position-' . $attributes['mediaPosition']; 62 | $classes[] = 'media-size-' . $attributes['imageScale']; 63 | } 64 | if ( $attributes['backgroundColor'] ) { 65 | if ( $attributes['hasDarkBackground'] ) { 66 | $classes[] = 'has-dark-background'; 67 | } 68 | $classes[] = 'has-background-color'; 69 | } 70 | 71 | $classes[] = 'type-scale-' . $attributes['typeScale']; 72 | 73 | // Color styles for listings. 74 | $styles = []; 75 | 76 | if ( ! empty( $attributes['textColor'] ) ) { 77 | $styles[] = 'color:' . $attributes['textColor']; 78 | } 79 | if ( ! empty( $attributes['backgroundColor'] ) ) { 80 | $styles[] = 'background-color:' . $attributes['backgroundColor']; 81 | } 82 | 83 | // Extend wp_kses_post to allow jetpack/map required elements and attributes. 84 | $allowed_elements = wp_kses_allowed_html( 'post' ); 85 | 86 | // Allow amp-iframe with jetpack/map attributes. 87 | if ( empty( $allowed_elements['amp-iframe'] ) ) { 88 | $allowed_elements['amp-iframe'] = [ 89 | 'allowfullscreen' => true, 90 | 'frameborder' => true, 91 | 'height' => true, 92 | 'layout' => true, 93 | 'sandbox' => true, 94 | 'src' => true, 95 | 'width' => true, 96 | ]; 97 | } 98 | 99 | // Allow placeholder attribute on divs. 100 | if ( empty( $allowed_elements['div']['placeholder'] ) ) { 101 | $allowed_elements['div']['placeholder'] = true; 102 | } 103 | 104 | // Allow form, select and option elements. 105 | if ( empty( $allowed_elements['form'] ) ) { 106 | $allowed_elements['form'] = [ 107 | 'class' => true, 108 | 'data-url' => true, 109 | ]; 110 | } 111 | if ( empty( $allowed_elements['select'] ) ) { 112 | $allowed_elements['select'] = [ 113 | 'class' => true, 114 | 'id' => true, 115 | ]; 116 | } 117 | if ( empty( $allowed_elements['option'] ) ) { 118 | $allowed_elements['option'] = [ 119 | 'disabled' => true, 120 | 'selected' => true, 121 | 'value' => true, 122 | ]; 123 | } 124 | 125 | // Allow radio input elements. 126 | if ( empty( $allowed_elements['input'] ) ) { 127 | $allowed_elements['input'] = [ 128 | 'class' => true, 129 | 'checked' => true, 130 | 'disabled' => true, 131 | 'id' => true, 132 | 'name' => true, 133 | 'type' => true, 134 | 'value' => true, 135 | 'placeholder' => true, 136 | ]; 137 | } 138 | 139 | // Default: we can't have more pages unless a.) the block is in query mode, and b.) the number of queried posts exceeds max_num_pages. 140 | $has_more_pages = false; 141 | 142 | // If in query mode, dynamically build the list based on query terms. 143 | if ( $attributes['queryMode'] && ! empty( $attributes['queryOptions'] ) ) { 144 | $args = Api::build_listings_query( $attributes['queryOptions'] ); 145 | $listings = new \WP_Query( $args ); 146 | $page = $listings->paged ?? 1; 147 | $has_more_pages = $attributes['showLoadMore'] && ( ++$page ) <= $listings->max_num_pages; 148 | 149 | // Only include the attributes we care about for individual listings. 150 | $request_attributes = Utils\get_request_attributes( $attributes ); 151 | 152 | // REST API URL to fetch more listings. 153 | $next_url = add_query_arg( 154 | [ 155 | 'attributes' => $request_attributes, 156 | 'query' => $attributes['queryOptions'], 157 | 'page' => 2, 158 | 'amp' => $is_amp, 159 | '_fields' => 'html', 160 | ], 161 | $rest_url 162 | ); 163 | 164 | if ( $has_more_pages ) { 165 | $classes[] = 'has-more-button'; 166 | } 167 | 168 | if ( $listings->have_posts() ) { 169 | $inner_content .= '

      '; 170 | 171 | while ( $listings->have_posts() ) { 172 | $listings->the_post(); 173 | $listing_content = Utils\template_include( 174 | 'listing', 175 | [ 176 | 'attributes' => $attributes, 177 | 'post' => get_post(), 178 | ] 179 | ); 180 | 181 | $inner_content .= $listing_content; 182 | } 183 | 184 | $inner_content .= '
    '; 185 | } 186 | } 187 | 188 | // Load AMP script if a.) we're in an AMP page, and b.) we have either more pages or a sort UI. 189 | $amp_script = Utils\is_amp() && ( $attributes['showSortUi'] || $has_more_pages ); 190 | 191 | // Begin front-end output. 192 | ob_start(); 193 | 194 | ?> 195 | 196 | 197 | 198 |
    202 | 203 | 204 | 213 |

    214 | 215 |

    216 |

    217 | 218 |

    219 | 220 |
    221 | 222 |
    223 | 224 | { 18 | const { endDate, showEnd, showTime, startDate } = attributes; 19 | const { createNotice } = useDispatch( 'core/notices' ); 20 | const classes = [ 'newspack-listings__event-dates' ]; 21 | 22 | if ( ! showTime ) { 23 | classes.push( 'hide-time' ); 24 | } 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | setAttributes( { showTime: ! showTime } ) } 36 | /> 37 | 38 | 39 | { 48 | setAttributes( { showEnd: ! showEnd } ); 49 | setAttributes( { endDate: '' } ); 50 | } } 51 | /> 52 | 53 | 54 | 55 | 56 |
    57 |
    58 | 67 | { 71 | if ( 72 | ! value || // If clearing the value. 73 | ! endDate || // If there isn't an end date to compare with. 74 | ( endDate && 0 <= new Date( endDate ) - new Date( value ) ) // If there is an end date, and it's after the selected start date. 75 | ) { 76 | return setAttributes( { startDate: value } ); 77 | } 78 | 79 | createNotice( 80 | 'warning', 81 | __( 'Event end must be after event start.', 'newspack-listings' ), 82 | { 83 | id: 'newspack-listings__date-error', 84 | isDismissible: true, 85 | type: 'default', 86 | } 87 | ); 88 | } } 89 | /> 90 | { ! showTime && startDate && ( 91 | 94 | ) } 95 | 96 | { showEnd && ( 97 | 105 | { 109 | if ( 110 | ! value || 111 | ! startDate || 112 | ( startDate && 0 <= new Date( value ) - new Date( startDate ) ) 113 | ) { 114 | return setAttributes( { endDate: value } ); 115 | } 116 | 117 | createNotice( 118 | 'warning', 119 | __( 'Event end must be after event start.', 'newspack-listings' ), 120 | { 121 | id: 'newspack-listings__date-error', 122 | isDismissible: true, 123 | type: 'default', 124 | } 125 | ); 126 | } } 127 | /> 128 | { ! showTime && endDate && ( 129 | 132 | ) } 133 | 134 | ) } 135 |
    136 |
    137 |
    138 | ); 139 | }; 140 | -------------------------------------------------------------------------------- /src/blocks/event-dates/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { registerBlockType } from '@wordpress/blocks'; 6 | import { calendar } from '@wordpress/icons'; 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | import { EventDatesEditor } from './edit'; 12 | import metadata from './block.json'; 13 | const { attributes, category, name } = metadata; 14 | 15 | export const registerEventDatesBlock = () => { 16 | registerBlockType( name, { 17 | title: __( 'Event Dates', 'newspack-listings' ), 18 | icon: { 19 | src: calendar, 20 | foreground: '#003da5', 21 | }, 22 | category, 23 | keywords: [ 24 | __( 'curated', 'newspack-listings' ), 25 | __( 'list', 'newspack-listings' ), 26 | __( 'lists', 'newspack-listings' ), 27 | __( 'listings', 'newspack-listings' ), 28 | __( 'latest', 'newspack-listings' ), 29 | __( 'event', 'newspack-listings' ), 30 | __( 'events', 'newspack-listings' ), 31 | ], 32 | 33 | attributes, 34 | 35 | edit: EventDatesEditor, 36 | save: () => null, // uses view.php 37 | } ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/blocks/event-dates/view.php: -------------------------------------------------------------------------------- 1 | $block_json['attributes'], 28 | 'render_callback' => __NAMESPACE__ . '\render_block', 29 | ] 30 | ); 31 | } 32 | 33 | /** 34 | * Block render callback. 35 | * 36 | * @param array $attributes Block attributes. 37 | * @return string $content content. 38 | */ 39 | function render_block( $attributes ) { 40 | // Bail if there's no start date to display. 41 | if ( empty( $attributes['startDate'] ) ) { 42 | return ''; 43 | } 44 | 45 | // Begin front-end output. 46 | $content = Utils\template_include( 47 | 'event-dates', 48 | [ 'attributes' => $attributes ] 49 | ); 50 | 51 | return $content; 52 | } 53 | 54 | register_block(); 55 | -------------------------------------------------------------------------------- /src/blocks/index.js: -------------------------------------------------------------------------------- 1 | export * from './category'; 2 | export * from './curated-list'; 3 | export * from './event-dates'; 4 | export * from './list-container'; 5 | export * from './listing'; 6 | export * from './price'; 7 | export * from './self-serve-listings'; 8 | -------------------------------------------------------------------------------- /src/blocks/list-container/edit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { InnerBlocks, InspectorControls } from '@wordpress/block-editor'; 6 | import { Notice, PanelRow, Spinner } from '@wordpress/components'; 7 | import { compose } from '@wordpress/compose'; 8 | import { withDispatch, withSelect } from '@wordpress/data'; 9 | 10 | const ListContainerEditorComponent = ( { attributes, clientId, innerBlocks } ) => { 11 | const { queryMode, queryOptions, showSortUi } = attributes; 12 | const { order } = queryOptions; 13 | 14 | if ( queryMode && ! showSortUi ) { 15 | return null; 16 | } 17 | 18 | return ( 19 |
    20 | 21 | 22 | 23 | 24 | 25 | { ! queryMode && innerBlocks && 0 === innerBlocks.length && ( 26 | 27 | { __( 'This list is empty. Click the [+] button to add some listings.' ) } 28 | 29 | ) } 30 | { showSortUi && ( 31 |
    32 |
    33 | 39 | 48 |
    49 | 50 |
    51 | 57 | 58 |
    59 | 67 | 70 |
    71 | 72 |
    73 | 81 | 84 |
    85 |
    86 |
    87 | ) } 88 | ( queryMode ? null : ) } 96 | /> 97 |
    98 | ); 99 | }; 100 | 101 | const mapStateToProps = ( select, ownProps ) => { 102 | const { clientId } = ownProps; 103 | const { getBlock } = select( 'core/block-editor' ); 104 | const innerBlocks = getBlock( clientId ).innerBlocks || []; 105 | 106 | return { 107 | innerBlocks, 108 | }; 109 | }; 110 | 111 | const mapDispatchToProps = dispatch => { 112 | const { insertBlock, removeBlocks, updateBlockAttributes } = dispatch( 'core/block-editor' ); 113 | 114 | return { 115 | insertBlock, 116 | removeBlocks, 117 | updateBlockAttributes, 118 | }; 119 | }; 120 | 121 | export const ListContainerEditor = compose( [ 122 | withSelect( mapStateToProps ), 123 | withDispatch( mapDispatchToProps ), 124 | ] )( ListContainerEditorComponent ); 125 | -------------------------------------------------------------------------------- /src/blocks/list-container/editor.scss: -------------------------------------------------------------------------------- 1 | .newspack-listings { 2 | &__list-container { 3 | .newspack-listings__info { 4 | margin: 0; 5 | } 6 | } 7 | 8 | &__list-container-spinner .components-spinner { 9 | margin: 0 auto 1rem; 10 | } 11 | } 12 | 13 | [data-type="newspack-listings/list-container"]:focus::after { 14 | box-shadow: none !important; 15 | } 16 | -------------------------------------------------------------------------------- /src/blocks/list-container/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { InnerBlocks } from '@wordpress/block-editor'; 6 | import { registerBlockType } from '@wordpress/blocks'; 7 | import { group } from '@wordpress/icons'; 8 | 9 | /** 10 | * Internal dependencies 11 | */ 12 | import './editor.scss'; 13 | import { ListContainerEditor } from './edit'; 14 | import parentData from '../curated-list/block.json'; 15 | 16 | const parentAttributes = parentData.attributes; 17 | 18 | export const registerListContainerBlock = () => { 19 | registerBlockType( 'newspack-listings/list-container', { 20 | title: __( 'Container', 'newspack-listings' ), 21 | icon: { 22 | src: group, 23 | foreground: '#003da5', 24 | }, 25 | category: 'newspack', 26 | parent: [ 'newspack-listings/curated-list' ], 27 | keywords: [ 28 | __( 'curated', 'newspack-listings' ), 29 | __( 'list', 'newspack-listings' ), 30 | __( 'lists', 'newspack-listings' ), 31 | __( 'listings', 'newspack-listings' ), 32 | __( 'latest', 'newspack-listings' ), 33 | ], 34 | 35 | attributes: parentAttributes, 36 | 37 | // Hide from block inserter menus. 38 | supports: { 39 | inserter: false, 40 | }, 41 | 42 | edit: ListContainerEditor, 43 | save: () => , // also uses view.php 44 | } ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/blocks/list-container/view.php: -------------------------------------------------------------------------------- 1 | $parent_block_json['attributes'], 29 | 'render_callback' => __NAMESPACE__ . '\render_block', 30 | ] 31 | ); 32 | } 33 | 34 | /** 35 | * Block render callback. 36 | * 37 | * @param array $attributes Block attributes. 38 | * @param string $inner_content InnerBlock content. 39 | */ 40 | function render_block( $attributes, $inner_content ) { 41 | $content = ''; 42 | 43 | // If showing sort UI. 44 | if ( $attributes['showSortUi'] ) { 45 | $content .= Utils\template_include( 46 | 'sort-ui', 47 | [ 'attributes' => $attributes ] 48 | ); 49 | } 50 | 51 | // Bail if there's no InnerBlock content to display. 52 | if ( $attributes['queryMode'] || empty( trim( $inner_content ) ) ) { 53 | return $content; 54 | } 55 | 56 | // Begin front-end output. 57 | ob_start(); 58 | 59 | ?> 60 |
      61 | 62 |
    63 | { 27 | const [ post, setPost ] = useState( null ); 28 | const [ error, setError ] = useState( null ); 29 | const [ isEditingPost, setIsEditingPost ] = useState( false ); 30 | const { listing } = attributes; 31 | 32 | // Get the parent List Container block. 33 | const parents = getBlockParents( clientId ); 34 | const parent = parents.reduce( ( acc, outerBlock ) => { 35 | const blockInfo = getBlock( outerBlock ); 36 | 37 | if ( 'newspack-listings/list-container' === blockInfo.name ) { 38 | acc.listContainer = blockInfo; 39 | } 40 | 41 | return acc; 42 | }, {} ); 43 | 44 | // Build an array of just the listing post IDs that exist in the parent Curated List block. 45 | const listItems = parent.listContainer.innerBlocks.reduce( ( acc, innerBlock ) => { 46 | if ( innerBlock.attributes.listing ) { 47 | acc.push( innerBlock.attributes.listing ); 48 | } 49 | 50 | return acc; 51 | }, [] ); 52 | 53 | const { post_types } = window.newspack_listings_data; 54 | const listingTypeSlug = name.split( '/' ).slice( -1 )[ 0 ]; 55 | const listingType = post_types[ listingTypeSlug ]; 56 | 57 | // Fetch listing post data if we have a listing post ID. 58 | useEffect( () => { 59 | if ( ! post && listing ) { 60 | fetchPost( listing ); 61 | } 62 | }, [ listing ] ); 63 | 64 | // Fetch listing post by listingId. 65 | const fetchPost = async listingId => { 66 | try { 67 | setError( null ); 68 | const posts = await apiFetch( { 69 | path: addQueryArgs( '/newspack-listings/v1/listings', { 70 | per_page: 100, 71 | id: listingId, 72 | _fields: 'id,title,author,category,tags,excerpt,media,meta,type,sponsors,classes', 73 | } ), 74 | } ); 75 | 76 | if ( 0 === posts.length ) { 77 | throw `No posts found for ID ${ listingId }. Try refreshing or selecting a new post.`; 78 | } 79 | 80 | const foundPost = posts[ 0 ]; 81 | 82 | if ( foundPost.meta && foundPost.meta.newspack_listings_locations ) { 83 | setAttributes( { locations: foundPost.meta.newspack_listings_locations } ); 84 | } 85 | 86 | setPost( foundPost ); 87 | } catch ( e ) { 88 | setError( e ); 89 | } 90 | }; 91 | 92 | // Renders the autocomplete search field to select listings. Will only show listings of the type that matches the block. 93 | const renderSearch = () => { 94 | return ( 95 | 100 | { 107 | const posts = await apiFetch( { 108 | path: addQueryArgs( 'newspack-listings/v1/listings', { 109 | per_page: 100, 110 | include: postIDs.join( ',' ), 111 | _fields: 'id,title', 112 | } ), 113 | } ); 114 | 115 | return posts.map( _post => ( { 116 | value: _post.id, 117 | label: decodeEntities( _post.title ) || __( '(no title)', 'newspack-listings' ), 118 | } ) ); 119 | } } 120 | fetchSuggestions={ async search => { 121 | const posts = await apiFetch( { 122 | path: addQueryArgs( '/newspack-listings/v1/listings', { 123 | search, 124 | per_page: 10, 125 | _fields: 'id,title', 126 | type: listingType, 127 | } ), 128 | } ); 129 | 130 | // Only show suggestions if they aren't already in the list. 131 | const result = posts.reduce( ( acc, _post ) => { 132 | if ( 133 | listItems.indexOf( _post.id ) < 0 && 134 | listItems.indexOf( _post.id.toString() ) < 0 135 | ) { 136 | acc.push( { 137 | value: _post.id, 138 | label: decodeEntities( _post.title ) || __( '(no title)', 'newspack-listings' ), 139 | } ); 140 | } 141 | 142 | return acc; 143 | }, [] ); 144 | return result; 145 | } } 146 | postType={ listingType } 147 | postTypeSlug={ listingTypeSlug } 148 | maxLength={ 1 } 149 | onChange={ _listing => { 150 | if ( _listing.length ) { 151 | setIsEditingPost( false ); 152 | setPost( null ); 153 | setAttributes( { listing: _listing.shift().value.toString() } ); 154 | } 155 | } } 156 | selectedPost={ isEditingPost ? null : listing } 157 | listItems={ listItems } 158 | /> 159 | { listing && ( 160 | 163 | ) } 164 | 165 | ); 166 | }; 167 | 168 | // Renders selected listing post, or a placeholder if still fetching. 169 | const renderPost = () => { 170 | if ( ! post && ! error ) { 171 | return ( 172 | 177 | 178 | 179 | ); 180 | } 181 | 182 | return ( 183 |
    184 | 185 | { post && ( 186 | 193 | ) } 194 |
    195 | ); 196 | }; 197 | 198 | return ! listing || isEditingPost ? renderSearch() : renderPost(); 199 | }; 200 | 201 | const mapStateToProps = select => { 202 | const { getBlock, getBlockParents } = select( 'core/block-editor' ); 203 | 204 | return { 205 | getBlock, 206 | getBlockParents, 207 | }; 208 | }; 209 | 210 | export const ListingEditor = withSelect( mapStateToProps )( ListingEditorComponent ); 211 | -------------------------------------------------------------------------------- /src/blocks/listing/editor.scss: -------------------------------------------------------------------------------- 1 | @use "../../assets/shared/listing"; 2 | 3 | .newspack-listings { 4 | &__listing-editor { 5 | .components-spinner { 6 | display: block; 7 | float: none; 8 | margin: 1.75rem auto; 9 | } 10 | 11 | + .newspack-listings__listing-editor { 12 | margin-top: 1.75rem; 13 | } 14 | } 15 | 16 | &__listing-search { 17 | font-family: 18 | -apple-system, 19 | BlinkMacSystemFont, 20 | "Segoe UI", 21 | Roboto, 22 | Oxygen-Sans, 23 | Ubuntu, 24 | Cantarell, 25 | "Helvetica Neue", 26 | sans-serif; 27 | 28 | .autocomplete-tokenfield__help { 29 | margin-top: 4px; 30 | } 31 | 32 | .components-placeholder__fieldset { 33 | flex-direction: column; 34 | } 35 | 36 | .components-spinner { 37 | position: absolute; 38 | right: 0; 39 | top: 2rem; 40 | } 41 | 42 | &.is-loading .components-spinner { 43 | margin: auto; 44 | position: relative; 45 | } 46 | } 47 | 48 | &__error { 49 | margin: 1rem auto 2rem; 50 | } 51 | } 52 | 53 | .editor-styles-wrapper ul.newspack-listings__event-dates { 54 | font-weight: bold; 55 | list-style: none; 56 | margin: 1rem 0; 57 | padding: 0; 58 | } 59 | -------------------------------------------------------------------------------- /src/blocks/listing/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { registerBlockType } from '@wordpress/blocks'; 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | import './editor.scss'; 11 | import { ListingEditor } from './edit'; 12 | import metadata from './block.json'; 13 | import parentData from '../curated-list/block.json'; 14 | import { getIcon } from '../../editor/utils'; 15 | 16 | const parentAttributes = parentData.attributes; 17 | const { attributes, category } = metadata; 18 | const { post_types } = window.newspack_listings_data; 19 | 20 | export const registerListingBlock = () => { 21 | for ( const listingType in post_types ) { 22 | if ( post_types.hasOwnProperty( listingType ) ) { 23 | registerBlockType( `newspack-listings/${ listingType }`, { 24 | title: listingType.charAt( 0 ).toUpperCase() + listingType.slice( 1 ), 25 | icon: { 26 | src: getIcon( listingType ), 27 | foreground: '#003da5', 28 | }, 29 | category, 30 | parent: [ 'newspack-listings/list-container' ], 31 | keywords: [ 32 | __( 'lists', 'newspack-listings' ), 33 | __( 'listings', 'newspack-listings' ), 34 | __( 'latest', 'newspack-listings' ), 35 | ], 36 | 37 | // Combine attributes with parent attributes, so parent can pass data to InnerBlocks without relying on contexts. 38 | attributes: Object.assign( attributes, parentAttributes ), 39 | 40 | // Hide from Block Inserter if there are no published posts of this type. 41 | supports: { 42 | inserter: post_types[ listingType ].show_in_inserter || false, 43 | }, 44 | 45 | edit: ListingEditor, 46 | save: () => null, // uses view.php 47 | } ); 48 | } 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/blocks/listing/listing.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | 3 | /** 4 | * WordPress dependencies 5 | */ 6 | import { __, _x, sprintf } from '@wordpress/i18n'; 7 | import { Notice } from '@wordpress/components'; 8 | import { Fragment, RawHTML } from '@wordpress/element'; 9 | import { decodeEntities } from '@wordpress/html-entities'; 10 | 11 | /** 12 | * Internal dependencies. 13 | */ 14 | import { getTermClasses } from '../../editor/utils'; 15 | 16 | export const Listing = ( { attributes, error, post } ) => { 17 | // Parent Curated List block attributes. 18 | const { showAuthor, showCategory, showTags, showExcerpt, showImage, showCaption } = attributes; 19 | const { 20 | author = '', 21 | category = [], 22 | excerpt = '', 23 | media = {}, 24 | sponsors = false, 25 | tags = [], 26 | title = '', 27 | } = post; 28 | 29 | const classes = [ 'newspack-listings__listing-post', 'entry-wrapper' ]; 30 | const termClasses = getTermClasses( post ); 31 | 32 | return ( 33 |
    34 | { error && ( 35 | 36 | { error } 37 | 38 | ) } 39 | { showImage && post && media && media.image && ( 40 |
    41 | { 46 | { showCaption && media.caption && ( 47 |
    48 | { media.caption } 49 |
    50 | ) } 51 |
    52 | ) } 53 | { post && ( 54 |
    55 | { sponsors && 0 < sponsors.length && ( 56 | 57 | { sponsors[ 0 ].sponsor_flag } 58 | 59 | ) } 60 | { showCategory && 0 < category.length && ! sponsors && ( 61 |
    62 | { category.map( ( _category, index ) => ( 63 | 64 | { decodeEntities( _category.name ) } 65 | { index + 1 < category.length && ', ' } 66 | 67 | ) ) } 68 |
    69 | ) } 70 |

    { decodeEntities( title ) }

    71 | { sponsors && 0 < sponsors.length && ( 72 |
    73 | 74 | { sponsors.map( sponsor => { 75 | return ( 76 | { 83 | ); 84 | } ) } 85 | 86 | 87 | { sponsors.map( ( sponsor, index ) => 88 | sprintf( 89 | // Translators: Sponsorship attribution. 90 | '%1$s%2$s%3$s%4$s', 91 | 0 === index ? sponsor.sponsor_byline + ' ' : '', 92 | 1 < sponsors.length && index + 1 === sponsors.length 93 | ? __( ' and ', 'newspack-listings' ) 94 | : '', 95 | sponsor.sponsor_name, 96 | 2 < sponsors.length && index + 1 < sponsors.length 97 | ? _x( ', ', 'separator character', 'newspack-listings' ) 98 | : '' 99 | ) 100 | ) } 101 | 102 |
    103 | ) } 104 | { showAuthor && author && ! sponsors && ( 105 | { __( 'By', 'newpack-listings' ) + ' ' + decodeEntities( author ) } 106 | ) } 107 | 108 | { showExcerpt && excerpt && { excerpt } } 109 | 110 | { showTags && tags.length && ( 111 |

    112 | { __( 'Tagged: ', 'newspack-listings' ) } 113 | { tags.map( ( tag, index ) => ( 114 | 115 | { decodeEntities( tag.name ) } 116 | { index + 1 < tags.length && ', ' } 117 | 118 | ) ) } 119 |

    120 | ) } 121 |
    122 | ) } 123 |
    124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/blocks/listing/view.php: -------------------------------------------------------------------------------- 1 | $post_type ) { 34 | register_block_type( 35 | 'newspack-listings/' . $label, 36 | [ 37 | 'attributes' => $attributes, 38 | 'render_callback' => __NAMESPACE__ . '\render_block', 39 | ] 40 | ); 41 | } 42 | } 43 | 44 | /** 45 | * Block render callback. 46 | * 47 | * @param array $attributes Block attributes (including parent attributes inherited from Curated List container block). 48 | */ 49 | function render_block( $attributes ) { 50 | // Bail if there's no listing post ID for this block. 51 | if ( empty( $attributes['listing'] ) ) { 52 | return; 53 | } 54 | 55 | // Get the listing post by post ID. 56 | $post = get_post( intval( $attributes['listing'] ) ); 57 | 58 | // Bail if there's no published post with the saved ID. 59 | if ( empty( $post ) || 'publish' !== $post->post_status ) { 60 | return; 61 | } 62 | 63 | $content = Utils\template_include( 64 | 'listing', 65 | [ 66 | 'attributes' => $attributes, 67 | 'post' => $post, 68 | ] 69 | ); 70 | 71 | return $content; 72 | } 73 | 74 | register_blocks(); 75 | -------------------------------------------------------------------------------- /src/blocks/price/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newspack-listings/price", 3 | "category": "newspack", 4 | "attributes": { 5 | "price": { 6 | "type": "number", 7 | "default": 0 8 | }, 9 | "currency": { 10 | "type": "string", 11 | "default": "" 12 | }, 13 | "formattedPrice": { 14 | "type": "string", 15 | "default": "" 16 | }, 17 | "showDecimals": { 18 | "type": "boolean", 19 | "default": true 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/blocks/price/edit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __, sprintf } from '@wordpress/i18n'; 5 | import { InspectorControls } from '@wordpress/block-editor'; 6 | import { 7 | PanelBody, 8 | PanelRow, 9 | Placeholder, 10 | SelectControl, 11 | TextControl, 12 | ToggleControl, 13 | } from '@wordpress/components'; 14 | import { useEffect } from '@wordpress/element'; 15 | import { decodeEntities } from '@wordpress/html-entities'; 16 | import { currencyDollar } from '@wordpress/icons'; 17 | 18 | export const PriceEditor = ( { attributes, isSelected, setAttributes } ) => { 19 | const { currencies = {}, currency: defaultCurrency = 'USD' } = window.newspack_listings_data; 20 | const locale = window.navigator?.language || 'en-US'; 21 | const { currency, formattedPrice, price, showDecimals } = attributes; 22 | 23 | useEffect( () => { 24 | // Guard against setting invalid price attribute. 25 | if ( isNaN( price ) || '' === price || 0 > price ) { 26 | setAttributes( { price: 0 } ); 27 | } 28 | }, [ isSelected ] ); 29 | 30 | useEffect( () => { 31 | // Guard against rendering invalid price attribute. 32 | const priceToFormat = isNaN( price ) || '' === price || 0 > price ? 0 : price; 33 | 34 | // Format price according to editor's locale. 35 | setAttributes( { 36 | formattedPrice: new Intl.NumberFormat( locale, { 37 | style: 'currency', 38 | currency: currency || defaultCurrency, 39 | minimumFractionDigits: showDecimals ? 2 : 0, 40 | maximumFractionDigits: showDecimals ? 2 : 0, 41 | } ).format( priceToFormat ), 42 | } ); 43 | }, [ currency, showDecimals, price ] ); 44 | 45 | return ( 46 | <> 47 | 48 | 49 | { 0 < Object.keys( currencies ).length && ( 50 | setAttributes( { currency: value } ) } 54 | options={ Object.keys( currencies ) 55 | .map( _currency => { 56 | return { 57 | value: _currency, 58 | label: `${ decodeEntities( currencies[ _currency ] ) } (${ _currency })`, 59 | }; 60 | } ) 61 | .sort( ( a, b ) => a.label.toUpperCase().localeCompare( b.label.toUpperCase() ) ) } 62 | /> 63 | ) } 64 | 65 | setAttributes( { showDecimals: value } ) } 74 | /> 75 | 76 | 77 | 78 | 79 | { isSelected ? ( 80 | 85 | { 94 | setAttributes( { 95 | price: parseFloat( value < 0 ? 0 : value ), 96 | } ); 97 | } } 98 | /> 99 | 100 | ) : ( 101 |

    102 | { formattedPrice } 103 |

    104 | ) } 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/blocks/price/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { registerBlockType } from '@wordpress/blocks'; 6 | import { currencyDollar } from '@wordpress/icons'; 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | import { PriceEditor } from './edit'; 12 | import metadata from './block.json'; 13 | const { attributes, category, name } = metadata; 14 | 15 | export const registerPriceBlock = () => { 16 | registerBlockType( name, { 17 | title: __( 'Price', 'newspack-listings' ), 18 | icon: { 19 | src: currencyDollar, 20 | foreground: '#003da5', 21 | }, 22 | category, 23 | keywords: [ 24 | __( 'curated', 'newspack-listings' ), 25 | __( 'list', 'newspack-listings' ), 26 | __( 'lists', 'newspack-listings' ), 27 | __( 'listings', 'newspack-listings' ), 28 | __( 'latest', 'newspack-listings' ), 29 | __( 'price', 'newspack-listings' ), 30 | ], 31 | 32 | attributes, 33 | 34 | edit: PriceEditor, 35 | save: () => null, // uses view.php 36 | } ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/blocks/price/view.php: -------------------------------------------------------------------------------- 1 | $block_json['attributes'], 28 | 'render_callback' => __NAMESPACE__ . '\render_block', 29 | ] 30 | ); 31 | } 32 | 33 | /** 34 | * Block render callback. 35 | * 36 | * @param array $attributes Block attributes. 37 | * @return string $content content. 38 | */ 39 | function render_block( $attributes ) { 40 | return '

    ' . esc_html( $attributes['formattedPrice'] ) . '

    '; 41 | } 42 | 43 | register_block(); 44 | -------------------------------------------------------------------------------- /src/blocks/self-serve-listings/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newspack-listings/self-serve-listings", 3 | "category": "newspack", 4 | "attributes": { 5 | "allowedSingleListingTypes": { 6 | "type": "array", 7 | "default": [ 8 | { 9 | "slug": "blank", 10 | "name": "Blank listing (start from scratch)" 11 | }, 12 | { 13 | "slug": "event", 14 | "name": "Event" 15 | }, 16 | { 17 | "slug": "classified", 18 | "name": "Classified Ad" 19 | }, 20 | { 21 | "slug": "job", 22 | "name": "Job Listing" 23 | }, 24 | { 25 | "slug": "real-estate", 26 | "name": "Real Estate Listing" 27 | } 28 | ] 29 | }, 30 | "allowSubscription": { 31 | "type": "boolean", 32 | "default": true 33 | }, 34 | "allowedPurchases": { 35 | "type": "string", 36 | "default": "both" 37 | }, 38 | "buttonText": { 39 | "type": "string", 40 | "default": "Next" 41 | }, 42 | "clientId": { 43 | "type": "string", 44 | "default": "" 45 | }, 46 | "singleDescription": { 47 | "type": "string", 48 | "default": "Purchase a single Marketplace or Event listing." 49 | }, 50 | "subscriptionDescription": { 51 | "type": "string", 52 | "default": "Purchase a monthly subscription for a listing to represent your business or organization." 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/blocks/self-serve-listings/editor.scss: -------------------------------------------------------------------------------- 1 | @use "../../assets/shared/self-serve-listings"; 2 | -------------------------------------------------------------------------------- /src/blocks/self-serve-listings/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { registerBlockType } from '@wordpress/blocks'; 6 | import { edit } from '@wordpress/icons'; 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | import { SelfServeListingsEditor } from './edit'; 12 | import metadata from './block.json'; 13 | const { attributes, category, name } = metadata; 14 | 15 | export const registerSelfServeListingsBlock = () => { 16 | registerBlockType( name, { 17 | title: __( 'Listings: Self-Serve Form', 'newspack-listings' ), 18 | icon: { 19 | src: edit, 20 | foreground: '#003da5', 21 | }, 22 | category, 23 | keywords: [ 24 | __( 'list', 'newspack-listings' ), 25 | __( 'listings', 'newspack-listings' ), 26 | __( 'self', 'newspack-listings' ), 27 | __( 'serve', 'newspack-listings' ), 28 | ], 29 | 30 | attributes, 31 | 32 | edit: SelfServeListingsEditor, 33 | save: () => null, // uses view.php 34 | } ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/blocks/self-serve-listings/view.php: -------------------------------------------------------------------------------- 1 | $block_json['attributes'], 29 | 'render_callback' => __NAMESPACE__ . '\render_block', 30 | ] 31 | ); 32 | } 33 | 34 | /** 35 | * Block render callback. 36 | * 37 | * @param array $attributes Block attributes. 38 | * @return string $content content. 39 | */ 40 | function render_block( $attributes ) { 41 | // Only render if self-serve listings features are active. 42 | if ( ! Products::is_active() || ! Products::validate_products() ) { 43 | return ''; 44 | } 45 | 46 | $content = Utils\template_include( 47 | 'self-serve-form', 48 | [ 49 | 'attributes' => $attributes, 50 | ] 51 | ); 52 | 53 | return $content; 54 | } 55 | 56 | register_block(); 57 | -------------------------------------------------------------------------------- /src/components/autocomplete-tokenfield.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Style overrides for AutocompleteTokenfield component. 3 | */ 4 | .newspack-autocomplete-tokenfield { 5 | .components-spinner { 6 | position: absolute; 7 | top: 2em; 8 | right: 0; 9 | } 10 | 11 | .components-form-token-field__suggestions-list { 12 | margin: 4px -4px -4px; 13 | } 14 | 15 | .components-form-token-field__suggestion { 16 | padding: 8px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as SidebarQueryControls } from './sidebar-query-controls'; 2 | -------------------------------------------------------------------------------- /src/editor/featured-listings/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __, sprintf } from '@wordpress/i18n'; 5 | import apiFetch from '@wordpress/api-fetch'; 6 | import { 7 | BaseControl, 8 | Button, 9 | DatePicker, 10 | PanelRow, 11 | RangeControl, 12 | ToggleControl, 13 | } from '@wordpress/components'; 14 | import { compose } from '@wordpress/compose'; 15 | import { withDispatch, withSelect } from '@wordpress/data'; 16 | import { dateI18n } from '@wordpress/date'; 17 | import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; 18 | import { useEffect, useState } from '@wordpress/element'; 19 | import { addQueryArgs } from '@wordpress/url'; 20 | 21 | // Priority can be an integer between 0 and 9. 22 | const validateResponse = ( response = 0 ) => { 23 | if ( isNaN( response ) || 0 > response || 9 < response ) { 24 | return false; 25 | } 26 | 27 | return parseInt( response ); 28 | }; 29 | 30 | const FeaturedListingsComponent = ( { 31 | createNotice, 32 | isSavingPost, 33 | meta, 34 | postId, 35 | setIsDirty, 36 | updateMetaValue, 37 | } ) => { 38 | const [ error, setError ] = useState( null ); 39 | const [ priority, setPriority ] = useState( null ); 40 | const { newspack_listings_featured, newspack_listings_featured_expires } = meta; 41 | 42 | // Show error messages thrown by API requests. 43 | useEffect( () => { 44 | if ( error ) { 45 | createNotice( 'error', error, { 46 | id: 'newspack-listings__featured-error', 47 | isDismissible: true, 48 | } ); 49 | } 50 | }, [ error ] ); 51 | 52 | // On post save, also update the listing's priority level. 53 | useEffect( () => { 54 | if ( isSavingPost ) { 55 | const priorityToSet = newspack_listings_featured ? priority : 0; 56 | apiFetch( { 57 | path: addQueryArgs( '/newspack-listings/v1/priority', { 58 | post_id: postId, 59 | priority: priorityToSet, 60 | } ), 61 | method: 'POST', 62 | } ) 63 | .then( response => { 64 | if ( false === response ) { 65 | throw new Error( 66 | __( 67 | 'There was an error updating the feature priority for this post. Please try saving again.', 68 | 'newspack-listings' 69 | ) 70 | ); 71 | } 72 | } ) 73 | .catch( e => { 74 | setError( 75 | e?.message || 76 | __( 77 | 'There was an error updating the feature priority for this post. Please try saving again.', 78 | 'newspack-listings' 79 | ) 80 | ); 81 | } ); 82 | } 83 | }, [ isSavingPost ] ); 84 | 85 | // If the item is featured, get priority level. 86 | useEffect( () => { 87 | setError( null ); 88 | 89 | if ( newspack_listings_featured && ! priority ) { 90 | apiFetch( { 91 | path: addQueryArgs( '/newspack-listings/v1/priority', { 92 | post_id: postId, 93 | } ), 94 | } ) 95 | .then( response => { 96 | const validatedResponse = validateResponse( response ); 97 | if ( false !== validatedResponse ) { 98 | setPriority( response ); 99 | } 100 | } ) 101 | .catch( e => { 102 | setError( 103 | e?.message || 104 | __( 105 | 'There was an error fetching the priority for this post. Please refresh the editor.', 106 | 'newspack-listings' 107 | ) 108 | ); 109 | } ); 110 | } 111 | }, [ newspack_listings_featured ] ); 112 | 113 | return ( 114 | 119 | 120 | updateMetaValue( 'newspack_listings_featured', value ) } 132 | /> 133 | 134 | { newspack_listings_featured && ( 135 | <> 136 | 137 | { 146 | setIsDirty(); 147 | setPriority( value ); 148 | } } 149 | min={ 1 } 150 | max={ 9 } 151 | required 152 | /> 153 | 154 | 155 | 159 | {} } 166 | onChange={ value => { 167 | // Convert value to midnight in the local timezone. 168 | const date = new Date( value ); 169 | const midnight = new Date( date.getFullYear(), date.getMonth(), date.getDate() ); 170 | updateMetaValue( 171 | 'newspack_listings_featured_expires', 172 | dateI18n( 'Y-m-d\\TH:i:s', midnight ) 173 | ); 174 | } } 175 | /> 176 | { newspack_listings_featured_expires && ( 177 | 183 | ) } 184 | 185 | 186 | 187 | ) } 188 | 189 | ); 190 | }; 191 | 192 | const mapStateToProps = select => { 193 | const { getCurrentPostId, getEditedPostAttribute, isAutosavingPost, isSavingPost } = 194 | select( 'core/editor' ); 195 | 196 | return { 197 | isSavingPost: isSavingPost() && ! isAutosavingPost(), 198 | meta: getEditedPostAttribute( 'meta' ), 199 | postId: getCurrentPostId(), 200 | }; 201 | }; 202 | 203 | const mapDispatchToProps = dispatch => { 204 | const { editPost } = dispatch( 'core/editor' ); 205 | const { createNotice } = dispatch( 'core/notices' ); 206 | 207 | return { 208 | updateMetaValue: ( key, value ) => editPost( { meta: { [ key ]: value } } ), 209 | setIsDirty: () => editPost( { editorShouldAllowSave: true } ), 210 | createNotice, 211 | }; 212 | }; 213 | 214 | export const FeaturedListings = compose( [ 215 | withSelect( mapStateToProps ), 216 | withDispatch( mapDispatchToProps ), 217 | ] )( FeaturedListingsComponent ); 218 | -------------------------------------------------------------------------------- /src/editor/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @wordpress/no-unsafe-wp-apis */ 2 | 3 | /** 4 | * WordPress dependencies 5 | */ 6 | import { registerPlugin } from '@wordpress/plugins'; 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | import { Sidebar } from './sidebar'; 12 | import { 13 | registerCuratedListBlock, 14 | registerListContainerBlock, 15 | registerEventDatesBlock, 16 | registerListingBlock, 17 | registerPriceBlock, 18 | registerSelfServeListingsBlock, 19 | setCustomCategory, 20 | } from '../blocks'; 21 | import { FeaturedListings } from './featured-listings'; 22 | import { isListing } from './utils'; 23 | import './style.scss'; 24 | 25 | const { 26 | post_type: postType, 27 | post_types: postTypes, 28 | is_listing_customer: isListingCustomer = false, 29 | self_serve_enabled: selfServeEnabled, 30 | } = window?.newspack_listings_data; 31 | 32 | /** 33 | * Register Curated List blocks. Don't register if we're in a listing already 34 | * (to avoid possibly infinitely nesting lists within list items). 35 | */ 36 | if ( isListing() ) { 37 | // If we don't have a post type, we're probably not in a post editor, so we don't need to register the post editor sidebars. 38 | if ( postType ) { 39 | // Register plugin editor settings. 40 | registerPlugin( 'newspack-listings-editor', { 41 | render: Sidebar, 42 | icon: null, 43 | } ); 44 | 45 | // Register featured listing sidebar. 46 | // We don't want to show the Featured Listing UI to customers, otherwise anyone could just make their listings featured. 47 | if ( ! isListingCustomer ) { 48 | registerPlugin( 'newspack-listings-featured', { 49 | render: FeaturedListings, 50 | icon: null, 51 | } ); 52 | } 53 | } 54 | 55 | // Register Event Dates block if we're editing an Event. 56 | if ( isListing( postTypes.event.name ) ) { 57 | registerEventDatesBlock(); 58 | } 59 | 60 | // Register Price block if we're editing a Marketplace listing. 61 | if ( isListing( postTypes.marketplace.name ) ) { 62 | registerPriceBlock(); 63 | } 64 | } else { 65 | setCustomCategory(); 66 | registerCuratedListBlock(); 67 | registerListContainerBlock(); 68 | registerListingBlock(); 69 | 70 | if ( selfServeEnabled ) { 71 | registerSelfServeListingsBlock(); 72 | } 73 | } 74 | 75 | // Editor UI changes for listing customers. 76 | if ( isListingCustomer ) { 77 | /** 78 | * Remove the "Mapbox Access Token" sidebar panel from the jetpack/map block. 79 | * We can't really avoid exposing the API token (which is public anyway), but we can 80 | * at least try to prevent customers from changing or unsetting it, which affects all users. 81 | * 82 | * Note: I hate this, but WP provides no API For "properly" suppressing block sidebars. 83 | * See https: *github.com/WordPress/gutenberg/issues/33891 for more details. 84 | */ 85 | 86 | // eslint-disable-next-line no-unused-expressions 87 | window._wpLoadBlockEditor?.then( () => { 88 | // We need to wait until the editor UI elements we need to edit exist in the DOM. 89 | // Keep trying every second until we can query them. 90 | const intervalId = window.setInterval( () => { 91 | const editor = document.getElementById( 'editor' ); 92 | 93 | // Are the UI elements we need in the DOM yet? If so, we can stop the interval. 94 | if ( editor ) { 95 | window.clearInterval( intervalId ); 96 | 97 | // Since we're running this outside of React, we can use a MutationObserver 98 | // to run a callback whenever the child elements of the editor element mutate. 99 | const observer = new MutationObserver( mutationsList => { 100 | for ( const mutation of mutationsList ) { 101 | if ( 'childList' === mutation.type ) { 102 | if ( mutation.target.classList.contains( 'components-panel' ) ) { 103 | const sidebar = editor.querySelector( '.components-panel' ); 104 | if ( sidebar ) { 105 | const panels = Array.from( 106 | sidebar.querySelectorAll( '.components-panel__body' ) 107 | ); 108 | 109 | panels.forEach( panel => { 110 | const title = panel.querySelector( '.components-panel__body-title' ); 111 | 112 | // No other way to identify a particular sidebar panel, so this will only work for English-language sites. 113 | if ( 'Mapbox Access Token' === title.textContent ) { 114 | panel.style.display = 'none'; 115 | } 116 | } ); 117 | } 118 | } 119 | } 120 | } 121 | } ); 122 | 123 | observer.observe( editor, { childList: true, subtree: true } ); 124 | } 125 | }, 1000 ); 126 | } ); 127 | } 128 | -------------------------------------------------------------------------------- /src/editor/sidebar/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __, sprintf } from '@wordpress/i18n'; 5 | import { 6 | BaseControl, 7 | DateTimePicker, 8 | ExternalLink, 9 | PanelRow, 10 | ToggleControl, 11 | } from '@wordpress/components'; 12 | import { compose } from '@wordpress/compose'; 13 | import { withDispatch, withSelect } from '@wordpress/data'; 14 | import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; 15 | import { useState } from '@wordpress/element'; 16 | 17 | /** 18 | * Internal dependencies 19 | */ 20 | import { isListing } from '../utils'; 21 | import './style.scss'; 22 | 23 | const SidebarComponent = ( { createNotice, meta, publishDate, updateMetaValue } ) => { 24 | const { 25 | is_listing_customer: isListingCustomer = false, 26 | post_type_label: postTypeLabel, 27 | post_types: postTypes, 28 | self_serve_listing_expiration: expirationPeriod, 29 | } = window.newspack_listings_data; 30 | const { 31 | newspack_listings_hide_author: hideAuthor, 32 | newspack_listings_hide_publish_date: hidePublishDate, 33 | newspack_listings_expiration_date: expirationDate, 34 | } = meta; 35 | const [ initialExpirationDate ] = useState( expirationDate ); 36 | 37 | if ( ! postTypes ) { 38 | return null; 39 | } 40 | 41 | return ( 42 | 51 |

    52 | 53 | { __( 'Overrides ', 'newspack-listings' ) } 54 | 55 | { __( 'global settings', 'newspack-listings' ) } 56 | 57 | 58 |

    59 | 60 | updateMetaValue( 'newspack_listings_hide_author', value ) } 70 | /> 71 | 72 | 73 | updateMetaValue( 'newspack_listings_hide_publish_date', value ) } 83 | /> 84 | 85 | 86 |
    87 | 95 | { 98 | /** 99 | * If the current user is a listings customer, don't allow them to set the expiraiton date beyond the 100 | * last saved expiration date or `expirationPeriod` days from the publish date, whichever is later. 101 | */ 102 | if ( isListingCustomer ) { 103 | const fromExpirationDate = initialExpirationDate 104 | ? new Date( initialExpirationDate ) 105 | : null; 106 | const publishDateDate = new Date( publishDate ); 107 | const fromPublishDate = new Date( 108 | publishDateDate.setDate( 109 | publishDateDate.getDate() + parseInt( expirationPeriod ) 110 | ) 111 | ); 112 | const laterDate = fromExpirationDate 113 | ? new Date( Math.max( fromPublishDate, fromExpirationDate ) ) 114 | : fromPublishDate; 115 | 116 | if ( 0 < new Date( value ) - laterDate ) { 117 | return createNotice( 118 | 'warning', 119 | sprintf( 120 | // Translators: warning when listings customer tries to extend expiration beyond allowed range. 121 | __( 'Cannot set expiration date beyond %s.', 'newspack-listings' ), 122 | laterDate.toLocaleDateString( undefined, { 123 | weekday: 'long', 124 | year: 'numeric', 125 | month: 'long', 126 | day: 'numeric', 127 | } ) 128 | ), 129 | { 130 | id: 'newspack-listings__date-error', 131 | isDismissible: true, 132 | type: 'default', 133 | } 134 | ); 135 | } 136 | } 137 | 138 | if ( 139 | value && 140 | publishDate && 141 | 0 <= new Date( value ) - new Date( publishDate ) // Expiration date must come after publish date. 142 | ) { 143 | return updateMetaValue( 'newspack_listings_expiration_date', value ); 144 | } 145 | 146 | // If clearing the value. 147 | if ( ! value ) { 148 | return updateMetaValue( 'newspack_listings_expiration_date', '' ); 149 | } 150 | 151 | createNotice( 152 | 'warning', 153 | __( 'Expiration date must be after publish date.', 'newspack-listings' ), 154 | { 155 | id: 'newspack-listings__date-error', 156 | isDismissible: true, 157 | type: 'default', 158 | } 159 | ); 160 | } } 161 | /> 162 | 163 |
    164 |
    165 |
    166 | ); 167 | }; 168 | 169 | const mapStateToProps = select => { 170 | const { getEditedPostAttribute } = select( 'core/editor' ); 171 | 172 | return { 173 | meta: getEditedPostAttribute( 'meta' ), 174 | publishDate: getEditedPostAttribute( 'date' ), 175 | }; 176 | }; 177 | 178 | const mapDispatchToProps = dispatch => { 179 | const { editPost } = dispatch( 'core/editor' ); 180 | const { createNotice } = dispatch( 'core/notices' ); 181 | 182 | return { 183 | createNotice, 184 | updateMetaValue: ( key, value ) => editPost( { meta: { [ key ]: value } } ), 185 | }; 186 | }; 187 | 188 | export const Sidebar = compose( [ 189 | withSelect( mapStateToProps ), 190 | withDispatch( mapDispatchToProps ), 191 | ] )( SidebarComponent ); 192 | -------------------------------------------------------------------------------- /src/editor/sidebar/style.scss: -------------------------------------------------------------------------------- 1 | .newspack-listings { 2 | &__event-time-toggle { 3 | margin-bottom: 0.5rem; 4 | } 5 | 6 | &__toggle-control { 7 | .components-base-control__help { 8 | font-style: italic; 9 | margin-top: 0.5rem; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/editor/style.scss: -------------------------------------------------------------------------------- 1 | @use "../assets/shared/patterns"; 2 | 3 | .hide-time .components-datetime__time { 4 | display: none; 5 | } 6 | 7 | @media ( min-width: 782px ) { 8 | .editor-styles-wrapper .newspack-listings__column-reverse { 9 | .block-editor-block-list__block.wp-block-column:first-child { 10 | margin-left: 32px; 11 | } 12 | .block-editor-block-list__block.wp-block-column:last-child { 13 | margin-left: 0; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/editor/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Util functions for Newspack Listings. 3 | */ 4 | 5 | /** 6 | * WordPress dependencies 7 | */ 8 | import { useEffect, useRef } from '@wordpress/element'; 9 | import { Icon, calendar, mapMarker, postList, store } from '@wordpress/icons'; 10 | 11 | /** 12 | * Check if the current post in the editor is a listing CPT. 13 | * 14 | * @param {string|null} listingType (Optional) If given, check if the current post is this exact listing type 15 | * @return {boolean} Whether or not the current post is a listing CPT. 16 | */ 17 | export const isListing = ( listingType = null ) => { 18 | if ( ! window.newspack_listings_data ) { 19 | return false; 20 | } 21 | 22 | const { post_type, post_types } = window.newspack_listings_data; 23 | 24 | // If passed a listingType arg, just check whether it matches the current post type. 25 | if ( null !== listingType ) { 26 | return listingType === post_type; 27 | } 28 | 29 | // Otherwise, check whether the current post type is any listing type. 30 | for ( const slug in post_types ) { 31 | if ( post_types.hasOwnProperty( slug ) && post_type === post_types[ slug ].name ) { 32 | return true; 33 | } 34 | } 35 | 36 | return false; 37 | }; 38 | 39 | /** 40 | * Convert hex color to RGB. 41 | * From https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb 42 | * 43 | * @param {string} hex Color in HEX format 44 | * @return {Array} RGB values, e.g. [red, green, blue] 45 | */ 46 | const hexToRGB = hex => 47 | hex 48 | .replace( /^#?([a-f\d])([a-f\d])([a-f\d])$/i, ( m, r, g, b ) => '#' + r + r + g + g + b + b ) 49 | .substring( 1 ) 50 | .match( /.{2}/g ) 51 | .map( x => parseInt( x, 16 ) ); 52 | 53 | /** 54 | * Get contrast ratio of the given backgroundColor compared to black. 55 | * 56 | * @param {string} backgroundColor Color HEX value to compare with black. 57 | * @return {number} Contrast ratio vs. black. 58 | */ 59 | export const getContrastRatio = backgroundColor => { 60 | const blackColor = '#000'; 61 | const backgroundColorRGB = hexToRGB( backgroundColor ); 62 | const blackRGB = hexToRGB( blackColor ); 63 | 64 | const l1 = 65 | 0.2126 * Math.pow( backgroundColorRGB[ 0 ] / 255, 2.2 ) + 66 | 0.7152 * Math.pow( backgroundColorRGB[ 1 ] / 255, 2.2 ) + 67 | 0.0722 * Math.pow( backgroundColorRGB[ 2 ] / 255, 2.2 ); 68 | const l2 = 69 | 0.2126 * Math.pow( blackRGB[ 0 ] / 255, 2.2 ) + 70 | 0.7152 * Math.pow( blackRGB[ 1 ] / 255, 2.2 ) + 71 | 0.0722 * Math.pow( blackRGB[ 2 ] / 255, 2.2 ); 72 | 73 | return l1 > l2 74 | ? parseInt( ( l1 + 0.05 ) / ( l2 + 0.05 ) ) 75 | : parseInt( ( l2 + 0.05 ) / ( l1 + 0.05 ) ); 76 | }; 77 | 78 | /** 79 | * Get array of class names for Curated List, based on attributes. 80 | * 81 | * @param {string} className The base class name for the block. 82 | * @param {Object} attributes Block attributes. 83 | * @return {Array} Array of class names for the block. 84 | */ 85 | export const getCuratedListClasses = ( className, attributes ) => { 86 | const { 87 | backgroundColor, 88 | hasDarkBackground, 89 | queryMode, 90 | showNumbers, 91 | showMap, 92 | showSortUi, 93 | showImage, 94 | mediaPosition, 95 | typeScale, 96 | imageScale, 97 | } = attributes; 98 | 99 | const classes = [ className, 'newspack-listings__curated-list' ]; 100 | 101 | if ( showNumbers ) { 102 | classes.push( 'show-numbers' ); 103 | } 104 | if ( showMap ) { 105 | classes.push( 'show-map' ); 106 | } 107 | if ( showSortUi ) { 108 | classes.push( 'has-sort-ui' ); 109 | } 110 | if ( showImage ) { 111 | classes.push( 'show-image' ); 112 | classes.push( `media-position-${ mediaPosition }` ); 113 | classes.push( `media-size-${ imageScale }` ); 114 | } 115 | if ( backgroundColor ) { 116 | if ( hasDarkBackground ) { 117 | classes.push( 'has-dark-background' ); 118 | } 119 | classes.push( 'has-background-color' ); 120 | } 121 | if ( queryMode ) { 122 | classes.push( 'query-mode' ); 123 | } 124 | 125 | classes.push( `type-scale-${ typeScale }` ); 126 | 127 | return classes; 128 | }; 129 | 130 | /** 131 | * Hook to tell us whether the current render is the initial render 132 | * (immediately after mount, with default props) or a subsequent render. 133 | * Useful so we don't fire side effects before block attributes are ready. 134 | * 135 | * return {boolean} True if this is the initial render, false if subsequent. 136 | */ 137 | export const useDidMount = () => { 138 | const didMount = useRef( true ); 139 | 140 | useEffect( () => { 141 | didMount.current = false; 142 | }, [] ); 143 | 144 | return didMount.current; 145 | }; 146 | 147 | /** 148 | * Generic utility to capitalize a given string. 149 | * 150 | * @param {string} str String to capitalize. 151 | * @return {string} Same string, with first letter capitalized. 152 | */ 153 | export const capitalize = str => str[ 0 ].toUpperCase() + str.slice( 1 ); 154 | 155 | /** 156 | * Map listing type icons to listing type slugs. 157 | * 158 | * @param {string} listingTypeSlug Slug of the listing type to get an icon for. 159 | * One of: event, generic, marketplace, place 160 | * @return {Function} SVG component for the matching icon. 161 | */ 162 | export const getIcon = listingTypeSlug => { 163 | switch ( listingTypeSlug ) { 164 | case 'event': 165 | return ; 166 | case 'marketplace': 167 | return ; 168 | case 'place': 169 | return ; 170 | default: 171 | return ; 172 | } 173 | }; 174 | 175 | /** 176 | * Get an array of term-based class names for the given or current listing. 177 | * 178 | * @param {Object} post Post object for the post. 179 | * @return {Array} Array of term-based class names. 180 | */ 181 | export const getTermClasses = post => { 182 | const classes = []; 183 | 184 | if ( ! post.id || ! post.type ) { 185 | return classes; 186 | } 187 | 188 | // Post type class. 189 | classes.push( `type-${ post.type }` ); 190 | 191 | // Category and tag classes. 192 | ( post.category || [] ).forEach( category => classes.push( `category-${ category.slug }` ) ); 193 | ( post.tags || [] ).forEach( tag => classes.push( `tag-${ tag.slug }` ) ); 194 | 195 | // Add any extra classes. 196 | const extraClasses = post.classes && Array.isArray( post.classes ) ? post.classes : []; 197 | 198 | return classes.concat( extraClasses ); 199 | }; 200 | -------------------------------------------------------------------------------- /src/svg/index.js: -------------------------------------------------------------------------------- 1 | export { default as NewspackLogo } from './newspack-logo'; 2 | export { default as List } from './list'; 3 | -------------------------------------------------------------------------------- /src/svg/list.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { Path, SVG } from '@wordpress/components'; 5 | 6 | const List = ( { size = 24 } ) => ( 7 | 8 | 9 | 14 | 15 | 16 | ); 17 | 18 | export default List; 19 | -------------------------------------------------------------------------------- /src/svg/newspack-logo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { Path, SVG } from '@wordpress/components'; 5 | 6 | const NewspackLogo = ( { size = 24 } ) => ( 7 | 8 | 12 | 16 | 17 | ); 18 | 19 | export default NewspackLogo; 20 | -------------------------------------------------------------------------------- /src/templates/event-dates.php: -------------------------------------------------------------------------------- 1 | getTimestamp() ); 46 | $the_end_date = ''; 47 | 48 | if ( ! empty( $end_date ) ) { 49 | $the_end_date = date_i18n( $end_date_format, $end_date->getTimestamp() ); 50 | } 51 | ?> 52 |
    53 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 87 | 88 |
    89 | ID, 'native' ); 29 | ?> 30 |
  • 31 |
    32 | 33 | ID, 'large' ); 35 | if ( ! empty( $featured_image ) ) : 36 | ?> 37 | 47 | 48 | 49 | 50 |
    51 | 54 | 59 | ID ); 62 | 63 | if ( is_array( $categories ) && 0 < count( $categories ) ) : 64 | ?> 65 | 84 | 85 | 86 | 87 | 88 |

    post_title ); ?>

    89 |
    90 | 91 |
    92 | 106 | 107 | 129 | 130 | 152 |
    153 | ID, 'newspack_listings_hide_author', true ) ) ) : ?> 154 | 155 | post_author ) ); ?> 156 | 157 | 158 | 159 | 160 | 165 | 166 | ID, 'post_tag' ); 169 | 170 | if ( is_array( $tags ) && 0 < count( $tags ) ) : 171 | ?> 172 |

    173 | 174 | name ); 178 | 179 | if ( $tag_index + 1 < count( $tags ) ) { 180 | echo esc_html( ', ' ); 181 | } 182 | 183 | $tag_index++; 184 | } 185 | ?> 186 |

    187 | 188 | 189 |
    190 |
    191 |
    192 |
  • 193 | 39 |
    40 |
    41 |
    42 | 43 |
    44 | 52 | 58 |
    59 |

    60 | 61 |

    62 | 74 |

    75 | 76 |
    77 |

    78 | 81 | 88 | 91 | 108 | 113 | 116 |

    117 | 125 |

    126 |
    127 |
    128 | 129 | 130 |
    131 | 138 | checked 139 | 140 | /> 141 | 147 |
    148 |

    149 |

    150 | 151 |

    152 |
    153 |

    154 | 157 | 164 | 169 | 172 |

    173 | 181 |

    182 |
    183 |
    184 | 185 |
    186 | 189 |
    190 |
    191 | $request_attributes, 35 | 'query' => $query_options, 36 | 'page' => 1, 37 | 'amp' => $is_amp, 38 | '_fields' => 'html', 39 | ], 40 | $rest_url 41 | ) : 42 | add_query_arg( 43 | [ 44 | 'attributes' => $request_attributes, 45 | 'query' => [ 'post__in' => $attributes['listingIds'] ], 46 | 'page' => 1, 47 | 'per_page' => 100, 48 | 'amp' => $is_amp, 49 | '_fields' => 'html', 50 | ], 51 | $rest_url 52 | ); 53 | 54 | ?> 55 |
    59 | data-url="" 60 | 61 | > 62 |
    63 | 64 | 122 |
    123 | 124 | 157 |
    158 | $options ) { 30 | self::$default_attributes[ $attribute ] = $options['default']; 31 | } 32 | } 33 | 34 | /** 35 | * Create a listing. 36 | * 37 | * @param string $type Listing type. 38 | */ 39 | private function create_listing( $type = 'place' ) { 40 | $listing_id = self::factory()->post->create( 41 | [ 42 | 'post_type' => Core::NEWSPACK_LISTINGS_POST_TYPES[ $type ], 43 | 'post_title' => 'Listing Title: ' . $type, 44 | 'post_content' => 'Some ' . $type . ' listing content', 45 | ] 46 | ); 47 | 48 | self::$listings[] = $listing_id; 49 | return $listing_id; 50 | } 51 | 52 | /** 53 | * Basic Block rendering - Curated List block. 54 | */ 55 | public function test_curated_list_block_query_types() { 56 | $place = self::create_listing( 'place' ); 57 | $event = self::create_listing( 'event' ); 58 | 59 | // Curated List: query mode with all listing types. 60 | $query_all_block_content = Newspack_Listings\Curated_List_Block\render_block( 61 | wp_parse_args( 62 | [ 63 | 'queryMode' => true, 64 | 'queriedListings' => self::$listings, 65 | ], 66 | self::$default_attributes 67 | ), 68 | '' 69 | ); 70 | 71 | self::assertStringContainsString( 72 | get_the_title( $place ), 73 | $query_all_block_content, 74 | 'Query block with type set to all contains the place listing.' 75 | ); 76 | 77 | self::assertStringContainsString( 78 | get_the_title( $event ), 79 | $query_all_block_content, 80 | 'Query block with type set to all contains the event listing.' 81 | ); 82 | 83 | // Curated List: query mode with only Place listings. 84 | $query_place_block_content = Newspack_Listings\Curated_List_Block\render_block( 85 | wp_parse_args( 86 | [ 87 | 'queryMode' => true, 88 | 'queryOptions' => [ 89 | 'type' => Core::NEWSPACK_LISTINGS_POST_TYPES['place'], 90 | 'authors' => [], 91 | 'categories' => [], 92 | 'tags' => [], 93 | 'categoryExclusions' => [], 94 | 'tagExclusions' => [], 95 | 'maxItems' => 10, 96 | 'sortBy' => 'date', 97 | 'order' => 'DESC', 98 | ], 99 | 'queriedListings' => self::$listings, 100 | ], 101 | self::$default_attributes 102 | ), 103 | '' 104 | ); 105 | 106 | self::assertStringContainsString( 107 | get_the_title( $place ), 108 | $query_place_block_content, 109 | 'Query block with type set to "place" contains the place listing.' 110 | ); 111 | 112 | self::assertStringNotContainsString( 113 | get_the_title( $event ), 114 | $query_place_block_content, 115 | 'Query block with type set to "place" does not contain the event listing.' 116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/test-featured.php: -------------------------------------------------------------------------------- 1 | 'Listing Title', 47 | 'type' => 'place', 48 | 'date' => self::$publish_date, 49 | 'featured' => false, 50 | 'priority' => 5, 51 | 'expires' => '', 52 | ] 53 | ); 54 | 55 | $title = $args['title']; 56 | $type = $args['type']; 57 | $date = $args['date']; 58 | $listing_id = self::factory()->post->create( 59 | [ 60 | 'post_type' => Core::NEWSPACK_LISTINGS_POST_TYPES[ $type ], 61 | 'post_title' => $title, 62 | 'post_content' => 'Some ' . $type . ' listing content', 63 | 'post_date' => $date, 64 | ] 65 | ); 66 | 67 | // Add featured meta. 68 | if ( $args['featured'] ) { 69 | update_post_meta( $listing_id, Featured::META_KEYS['featured'], true ); 70 | update_post_meta( $listing_id, Featured::META_KEYS['expires'], $args['expires'] ); 71 | Featured::update_priority( $listing_id, $args['priority'] ); 72 | } 73 | 74 | self::$listings[] = $listing_id; 75 | return $listing_id; 76 | } 77 | 78 | /** 79 | * Create a term. 80 | * 81 | * @param string $term_name Name of the term to create. 82 | * @param string $taxonomy Type of term to create. 83 | * 84 | * @return array Array of term data. 85 | */ 86 | public function create_term( $term_name, $taxonomy ) { 87 | $term_id = self::factory()->term->create( 88 | [ 89 | 'name' => $term_name, 90 | 'taxonomy' => $taxonomy, 91 | ] 92 | ); 93 | 94 | self::$terms[] = [ 95 | 'taxonomy' => $taxonomy, 96 | 'term_id' => $term_id, 97 | ]; 98 | 99 | return $term_id; 100 | } 101 | 102 | /** 103 | * Control query with no featured listings. 104 | */ 105 | public function test_control_query() { 106 | // Generate 10 standard (non-featured) listings. 107 | $number_of_listings = 10; 108 | $current_listing = 0; 109 | $category_id = self::create_term( 'Featured Category', 'category' ); 110 | while ( $current_listing < $number_of_listings ) { 111 | $current_listing++; 112 | $listing_id = self::create_listing( 113 | [ 114 | 'date' => self::$publish_date->modify( '+' . $current_listing . 'minute' )->format( 'Y-m-d H:i:s' ), 115 | 'title' => 'Listing title ' . zeroise( $current_listing, 2 ), 116 | ] 117 | ); 118 | wp_set_object_terms( $listing_id, $category_id, 'category' ); 119 | } 120 | 121 | self::go_to( get_term_link( $category_id ) ); 122 | self::assertQueryTrue( 'is_archive', 'is_category' ); 123 | 124 | global $wp_query; 125 | $index = 10; 126 | foreach ( $wp_query->posts as $listing ) { 127 | $stringified_index = zeroise( $index, 2 ); 128 | self::assertEquals( 129 | $listing->post_title, 130 | 'Listing title ' . $stringified_index, 131 | $listing->post_title . ' is item number ' . $index . ' in the query results.' 132 | ); 133 | 134 | --$index; 135 | } 136 | } 137 | 138 | /** 139 | * Test featured listing sort order. 140 | */ 141 | public function test_featured_sort_order() { 142 | $tag_id = self::create_term( 'Featured Tag', 'post_tag' ); 143 | 144 | // Generate a featured listing with default priority. 145 | $featured_listing = self::create_listing( 146 | [ 147 | 'date' => self::$publish_date->format( 'Y-m-d H:i:s' ), 148 | 'title' => 'Featured Listing', 149 | 'featured' => true, 150 | ] 151 | ); 152 | wp_set_object_terms( $featured_listing, $tag_id, 'post_tag' ); 153 | 154 | // Generate 10 standard (non-featured) listings. 155 | $number_of_listings = 10; 156 | $current_listing = 0; 157 | while ( $current_listing < $number_of_listings ) { 158 | $current_listing++; 159 | $listing_id = self::create_listing( 160 | [ 161 | 'date' => self::$publish_date->modify( '+' . $current_listing . 'minute' )->format( 'Y-m-d H:i:s' ), 162 | 'title' => 'Listing title ' . zeroise( $current_listing, 2 ), 163 | ] 164 | ); 165 | wp_set_object_terms( $listing_id, $tag_id, 'post_tag' ); 166 | } 167 | 168 | self::go_to( get_term_link( $tag_id ) ); 169 | 170 | global $wp_query; 171 | self::assertQueryTrue( 'is_archive', 'is_tag' ); 172 | 173 | $index = 10; 174 | foreach ( $wp_query->posts as $listing ) { 175 | $stringified_index = zeroise( $index + 1, 2 ); // Index will be offset by one because the featured listing comes first. 176 | if ( 10 === $index ) { 177 | self::assertEquals( 178 | $listing->post_title, 179 | get_the_title( $featured_listing ), 180 | 'Featured listing appears first in results regardless of sort order.' 181 | ); 182 | } else { 183 | self::assertEquals( 184 | $listing->post_title, 185 | 'Listing title ' . $stringified_index, 186 | $listing->post_title . ' is item number ' . $index . ' in the query results.' 187 | ); 188 | } 189 | 190 | --$index; 191 | } 192 | } 193 | 194 | /** 195 | * Test featured listing sort order with priority. 196 | */ 197 | public function test_featured_priority_sort_order() { 198 | // Generate a featured listing with higher priority. 199 | $featured_listing_priority_high = self::create_listing( 200 | [ 201 | 'date' => self::$publish_date->format( 'Y-m-d H:i:s' ), 202 | 'title' => 'Featured Listing with High Priority', 203 | 'featured' => true, 204 | 'priority' => 9, 205 | ] 206 | ); 207 | 208 | // Generate a featured listing with default priority. 209 | $featured_listing_priority_default = self::create_listing( 210 | [ 211 | 'date' => self::$publish_date->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ), 212 | 'title' => 'Featured Listing with Default Priority', 213 | 'featured' => true, 214 | ] 215 | ); 216 | 217 | // Generate 10 standard (non-featured) listings. 218 | $number_of_listings = 10; 219 | $current_listing = 0; 220 | while ( $current_listing < $number_of_listings ) { 221 | $current_listing++; 222 | $listing_id = self::create_listing( 223 | [ 224 | 'date' => self::$publish_date->modify( '+' . $current_listing . 'minute' )->format( 'Y-m-d H:i:s' ), 225 | 'title' => 'Listing title ' . zeroise( $current_listing, 2 ), 226 | ] 227 | ); 228 | } 229 | 230 | self::go_to( get_post_type_archive_link( Core::NEWSPACK_LISTINGS_POST_TYPES['place'] ) ); 231 | 232 | global $wp_query; 233 | self::assertQueryTrue( 'is_archive', 'is_post_type_archive' ); 234 | 235 | $index = 10; 236 | foreach ( $wp_query->posts as $listing ) { 237 | $stringified_index = zeroise( $index + 2, 2 ); // Index will be offset by two because the featured listings come first. 238 | if ( 10 === $index ) { 239 | self::assertEquals( 240 | $listing->post_title, 241 | get_the_title( $featured_listing_priority_high ), 242 | 'Featured listings with higher priority appear first.' 243 | ); 244 | } elseif ( 9 === $index ) { 245 | self::assertEquals( 246 | $listing->post_title, 247 | get_the_title( $featured_listing_priority_default ), 248 | 'Featured listings with lower priority still appear before non-featured results.' 249 | ); 250 | } else { 251 | self::assertEquals( 252 | $listing->post_title, 253 | 'Listing title ' . $stringified_index, 254 | $listing->post_title . ' is item number ' . $index . ' in the query results.' 255 | ); 256 | } 257 | 258 | --$index; 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | **** WARNING: No ES6 modules here. Not transpiled! **** 3 | */ 4 | /* eslint-disable import/no-nodejs-modules */ 5 | /* eslint-disable @typescript-eslint/no-var-requires */ 6 | 7 | /** 8 | * External dependencies 9 | */ 10 | const fs = require( 'fs' ); 11 | const getBaseWebpackConfig = require( 'newspack-scripts/config/getWebpackConfig' ); 12 | const path = require( 'path' ); 13 | 14 | /** 15 | * Internal variables 16 | */ 17 | const editor = path.join( __dirname, 'src', 'editor' ); 18 | const assetsDir = path.join( __dirname, 'src', 'assets', 'front-end' ); 19 | const assets = fs 20 | .readdirSync( assetsDir ) 21 | .filter( asset => /.js?$/.test( asset ) ) 22 | .reduce( 23 | ( acc, fileName ) => ( { 24 | ...acc, 25 | [ fileName.replace( '.js', '' ) ]: path.join( 26 | __dirname, 27 | 'src', 28 | 'assets', 29 | 'front-end', 30 | fileName 31 | ), 32 | } ), 33 | {} 34 | ); 35 | 36 | const entry = { 37 | editor, 38 | ...assets, 39 | }; 40 | 41 | const webpackConfig = getBaseWebpackConfig( 42 | { 43 | entry, 44 | } 45 | ); 46 | 47 | module.exports = webpackConfig; 48 | --------------------------------------------------------------------------------