diff --git a/assets/src/css/admin/_components.admin-header.scss b/assets/src/css/admin/_components.admin-header.scss index 5e76cb7888..c129177cbe 100644 --- a/assets/src/css/admin/_components.admin-header.scss +++ b/assets/src/css/admin/_components.admin-header.scss @@ -5,6 +5,10 @@ .post-type-give_forms, .give_forms_page_give-subscriptions { + .page-title-action:not(.switch-new-view) { + display: none; + } + .wp-header-end + .notice { margin-top: 11px; } diff --git a/assets/src/js/admin/reports/components/no-data-notice/index.js b/assets/src/js/admin/reports/components/no-data-notice/index.js index 0e039f8e70..a0bbf60fbb 100644 --- a/assets/src/js/admin/reports/components/no-data-notice/index.js +++ b/assets/src/js/admin/reports/components/no-data-notice/index.js @@ -1,71 +1,82 @@ // Dependencies -import { useState, Fragment } from 'react'; -import { __ } from '@wordpress/i18n' -import { getWindowData } from '../../utils'; +import {Fragment, useState} from 'react'; +import {__} from '@wordpress/i18n'; +import {getWindowData} from '../../utils'; // Store-related dependencies -import { useStoreValue } from '../../store'; -import { disablePeriodSelector } from '../../store/actions'; +import {useStoreValue} from '../../store'; +import {disablePeriodSelector} from '../../store/actions'; // Styles import './style.scss'; -const NoDataNotice = ( { version } ) => { - const [ {}, dispatch ] = useStoreValue(); +const NoDataNotice = ({version}) => { + const [{}, dispatch] = useStoreValue(); - const [ showNotice, setShowNotice ] = useState( true ); + const [showNotice, setShowNotice] = useState(true); - const loadSampleData = () => { - setShowNotice( false ); - dispatch( disablePeriodSelector() ); - }; + const loadSampleData = () => { + setShowNotice(false); + dispatch(disablePeriodSelector()); + }; - const goToNewFormUrl = () => { - const url = getWindowData( 'newFormUrl' ); - window.location = url; - }; + const goToNewFormUrl = () => { + const url = getWindowData('newFormUrl'); + window.location = url; + }; - return ( - - { showNotice && ( -
-
- { version === 'dashboard' ? ( - -

{ __( 'Get a quick view of your', 'give' ) }
{ __( 'donation activity', 'give' ) }

-

- { __( 'It looks like there hasn\'t been any donations yet on your website.', 'give' ) }
- { __( 'Set up a donation form to begin collecting donations now.', 'give' ) }
-

- -
- ) : ( - -

{ __( 'Get a detailed view of your', 'give' ) }
{ __( 'donation activity', 'give' ) }

-

- { __( 'It looks like there hasn\'t been any donations yet on your website. ', 'give' ) }
- { __( 'Set up a donation form to begin collection donations or load some sample data to preview what the reports look like.', 'give' ) }
-

- -
- ) } -
-
- ) } -
- ); + return ( + + {showNotice && ( +
+
+ {version === 'dashboard' ? ( + +

+ {__('Get a quick view of your', 'give')} +
+ {__('donation activity', 'give')} +

+

+ {__("It looks like there hasn't been any donations yet on your website.", 'give')}{' '} +
+ {__('Set up a campaign form to begin collecting donations now.', 'give')} +
+

+ +
+ ) : ( + +

+ {__('Get a detailed view of your', 'give')} +
+ {__('donation activity', 'give')} +

+

+ {__("It looks like there hasn't been any donations yet on your website. ", 'give')}{' '} +
+ {__( + 'Set up a campaign form to begin collection donations or load some sample data to preview what the reports look like.', + 'give' + )}{' '} +
+

+ +
+ )} +
+
+ )} +
+ ); }; NoDataNotice.defaultProps = { - version: 'app', + version: 'app', }; export default NoDataNotice; diff --git a/blocks/components/no-form/index.js b/blocks/components/no-form/index.js index 7e29436b3a..b83143c900 100644 --- a/blocks/components/no-form/index.js +++ b/blocks/components/no-form/index.js @@ -1,33 +1,38 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n' -import { Button } from '@wordpress/components'; +import {__} from '@wordpress/i18n'; +import {Button} from '@wordpress/components'; /** * Internal dependencies */ -import { getSiteUrl } from '../../utils'; +import {getSiteUrl} from '../../utils'; import GiveBlankSlate from '../blank-slate'; /** * Render No forms Found UI -*/ + * + * @unreleased Replace "new form" with "new campaign form" link + */ const NoForms = () => { - return ( - - - - ); + return ( + + + + ); }; export default NoForms; diff --git a/blocks/donation-form-grid/class-give-donation-form-grid-block.php b/blocks/donation-form-grid/class-give-donation-form-grid-block.php index 5df98b54a3..1557db5ef2 100644 --- a/blocks/donation-form-grid/class-give-donation-form-grid-block.php +++ b/blocks/donation-form-grid/class-give-donation-form-grid-block.php @@ -270,6 +270,7 @@ private function getAsArray($value) { /** * Return formatted notice when shortcode return empty string * + * @unreleased Replace "new form" with "new campaign form" link * @since 2.4.0 * * @return string @@ -284,10 +285,11 @@ private function blank_slate() { $content = array( 'image_url' => GIVE_PLUGIN_URL . 'assets/dist/images/give-icon-full-circle.svg', 'image_alt' => __( 'Give Icon', 'give' ), - 'heading' => __( 'No donation forms found.', 'give' ), - 'message' => __( 'The first step towards accepting online donations is to create a form.', 'give' ), - 'cta_text' => __( 'Create Donation Form', 'give' ), - 'cta_link' => admin_url( 'post-new.php?post_type=give_forms' ), + 'heading' => __('No campaign forms found.', 'give'), + 'message' => __('The first step towards accepting online donations is to create a campaign.', + 'give'), + 'cta_text' => __('Create Campaign Form', 'give'), + 'cta_link' => admin_url('edit.php?post_type=give_forms&page=give-campaigns&new=campaign'), 'help' => sprintf( /* translators: 1: Opening anchor tag. 2: Closing anchor tag. */ __( 'Need help? Get started with %1$sGive 101%2$s.', 'give' ), diff --git a/composer.json b/composer.json index 7c96db9c58..4b5f6ffc7b 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "woocommerce/action-scheduler": "^3.6", "psr/container": "1.1.1", "ext-json": "*", - "stellarwp/admin-notices": "^2.0" + "stellarwp/admin-notices": "^2.0", + "stellarwp/arrays": "^1.3" }, "require-dev": { "fakerphp/faker": "^1.9", @@ -96,6 +97,7 @@ "classmap_prefix": "Give_Vendors_", "constant_prefix": "GIVE_VENDORS_", "packages": [ + "stellarwp/arrays", "stellarwp/admin-notices", "stellarwp/validation", "stellarwp/field-conditions", diff --git a/composer.lock b/composer.lock index 155d01c2d2..99aad72820 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2925ae35b3fa0513bedfb3876189d982", + "content-hash": "7434cdff752e188e2bb665489b6bd156", "packages": [ { "name": "composer/installers", @@ -474,6 +474,54 @@ }, "time": "2024-11-20T00:41:13+00:00" }, + { + "name": "stellarwp/arrays", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/stellarwp/arrays.git", + "reference": "315a9b2018ac6f2475a346c89b1d7120ae07c218" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stellarwp/arrays/zipball/315a9b2018ac6f2475a346c89b1d7120ae07c218", + "reference": "315a9b2018ac6f2475a346c89b1d7120ae07c218", + "shasum": "" + }, + "require-dev": { + "lucatume/wp-browser": "^3.5 || ^4.0", + "saggre/phpdocumentor-markdown": "^0.1.3", + "symfony/event-dispatcher-contracts": "^2.5.1", + "symfony/string": "^5.4", + "szepeviktor/phpstan-wordpress": "^1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "StellarWP\\Arrays\\": "src/Arrays/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0" + ], + "authors": [ + { + "name": "StellarWP", + "email": "dev@stellarwp.com" + }, + { + "name": "Matthew Batchelder", + "email": "matt.batchelder@stellarwp.com" + } + ], + "description": "A library for array manipulation.", + "support": { + "issues": "https://github.com/stellarwp/arrays/issues", + "source": "https://github.com/stellarwp/arrays/tree/1.3.1" + }, + "time": "2025-02-07T18:23:13+00:00" + }, { "name": "stellarwp/container-contract", "version": "1.0.4", diff --git a/give.php b/give.php index cd271810c4..e3704c8e85 100644 --- a/give.php +++ b/give.php @@ -42,6 +42,7 @@ * - The GiveWP Team */ +use Give\Campaigns\Repositories\CampaignRepository; use Give\Container\Container; use Give\DonationForms\ServiceProvider as DonationFormsServiceProvider; use Give\DonationForms\V2\Repositories\DonationFormsRepository; @@ -133,6 +134,7 @@ * @property-read DonorRepositoryProxy $donors * @property-read SubscriptionRepository $subscriptions * @property-read DonationFormsRepository $donationForms + * @property-read CampaignRepository $campaigns * @property-read Profile $donorDashboard * @property-read TabsRegister $donorDashboardTabs * @property-read Give_Recurring_DB_Subscription_Meta $subscription_meta @@ -245,6 +247,7 @@ final class Give Give\FormTaxonomies\ServiceProvider::class, Give\DonationSpam\ServiceProvider::class, Give\Settings\ServiceProvider::class, + Give\Campaigns\ServiceProvider::class, Give\FeatureFlags\OptionBasedFormEditor\ServiceProvider::class, Give\ThirdPartySupport\ServiceProvider::class, ]; diff --git a/includes/admin/class-blank-slate.php b/includes/admin/class-blank-slate.php index f6a26bfc09..4ef188b27d 100644 --- a/includes/admin/class-blank-slate.php +++ b/includes/admin/class-blank-slate.php @@ -249,20 +249,23 @@ private function donor_exists() { /** * Gets the content of a blank slate message based on provided context. * - * @since 1.8.13 + * @unreleased Replace "new form" with "new campaign form" link + * @since 1.8.13 * * @param string $context The key used to determine which content is returned. + * * @return array Blank slate content. */ private function get_content( $context ) { // Define default content. $defaults = array( 'image_url' => GIVE_PLUGIN_URL . 'assets/dist/images/give-icon-full-circle.svg', - 'image_alt' => __( 'GiveWP Icon', 'give' ), - 'heading' => __( 'No donation forms found.', 'give' ), - 'message' => __( 'The first step towards accepting online donations is to create a form.', 'give' ), - 'cta_text' => __( 'Create Donation Form', 'give' ), - 'cta_link' => admin_url( 'post-new.php?post_type=give_forms' ), + 'image_alt' => __( 'GiveWP Icon', 'give'), + 'heading' => __('No campaign forms found.', 'give'), + 'message' => __('The first step towards accepting online donations is to create a campaign.', + 'give'), + 'cta_text' => __('Create Campaign Form', 'give'), + 'cta_link' => admin_url('edit.php?post_type=give_forms&page=give-campaigns&new=campaign'), 'help' => sprintf( /* translators: 1: Opening anchor tag. 2: Closing anchor tag. */ __( 'Need help? Get started with %1$sGive 101%2$s.', 'give' ), diff --git a/includes/admin/shortcodes/shortcode-give-form.php b/includes/admin/shortcodes/shortcode-give-form.php index 97fc235329..922059bd90 100644 --- a/includes/admin/shortcodes/shortcode-give-form.php +++ b/includes/admin/shortcodes/shortcode-give-form.php @@ -33,14 +33,16 @@ public function __construct() { /** * Define the shortcode attribute fields * - * @return array + * @unreleased Replace "new form" with "new campaign form" link + * + * @return array */ public function define_fields() { $create_form_link = sprintf( /* translators: %s: create new form URL */ - __( 'Create a new Donation Form.', 'give' ), - admin_url( 'post-new.php?post_type=give_forms' ) + __('Create a new Campaign Form.', 'give'), + admin_url('edit.php?post_type=give_forms&page=give-campaigns&new=campaign') ); return array( @@ -51,10 +53,11 @@ public function define_fields() { ), 'name' => 'id', 'tooltip' => esc_attr__( 'Select a Donation Form', 'give' ), - 'placeholder' => '- ' . esc_attr__( 'Select a Donation Form', 'give' ) . ' -', + 'placeholder' => '- ' . esc_attr__('Select a Campaign Form', 'give') . ' -', 'required' => array( - 'alert' => esc_html__( 'You must first select a Form!', 'give' ), - 'error' => sprintf( '

%s

%s

', esc_html__( 'No forms found.', 'give' ), $create_form_link ), + 'alert' => esc_html__('You must first select a Campaign Form!', 'give'), + 'error' => sprintf('

%s

%s

', + esc_html__('No campaign forms found.', 'give'), $create_form_link), ), ), array( diff --git a/includes/admin/shortcodes/shortcode-give-goal.php b/includes/admin/shortcodes/shortcode-give-goal.php index 48c078102a..d81a7a964c 100644 --- a/includes/admin/shortcodes/shortcode-give-goal.php +++ b/includes/admin/shortcodes/shortcode-give-goal.php @@ -31,6 +31,8 @@ public function __construct() { /** * Define the shortcode attribute fields + * + * @unreleased Replace "new form" with "new campaign form" link * * @return array */ @@ -38,8 +40,8 @@ public function define_fields() { $create_form_link = sprintf( /* translators: %s: create new form URL */ - __( 'Create a new Donation Form.', 'give' ), - admin_url( 'post-new.php?post_type=give_forms' ) + __('Create a new Campaign Form.', 'give'), + admin_url('edit.php?post_type=give_forms&page=give-campaigns&new=campaign') ); return [ @@ -51,11 +53,12 @@ public function define_fields() { 'meta_value' => 'enabled', ], 'name' => 'id', - 'tooltip' => esc_attr__( 'Select a Donation Form', 'give' ), - 'placeholder' => '- ' . esc_attr__( 'Select a Donation Form', 'give' ) . ' -', + 'tooltip' => esc_attr__('Select a Campaign Form', 'give'), + 'placeholder' => '- ' . esc_attr__('Select a Campaign Form', 'give') . ' -', 'required' => [ - 'alert' => esc_html__( 'You must first select a Form!', 'give' ), - 'error' => sprintf( '

%s

%s

', esc_html__( 'No forms found.', 'give' ), $create_form_link ), + 'alert' => esc_html__('You must first select a Campaign Form!', 'give'), + 'error' => sprintf('

%s

%s

', + esc_html__('No campaign forms found.', 'give'), $create_form_link), ], ], [ diff --git a/includes/post-types.php b/includes/post-types.php index e0a0e3c313..44f556a905 100644 --- a/includes/post-types.php +++ b/includes/post-types.php @@ -56,8 +56,8 @@ function give_setup_post_types() { 'name' => __( 'Donation Forms', 'give' ), 'singular_name' => __( 'Form', 'give' ), 'add_new' => __( 'Add Form', 'give' ), - 'add_new_item' => __('Add New Donation Form', 'give'), - 'edit_item' => __('Edit Donation Form', 'give'), + 'add_new_item' => __( 'Add New Donation Form', 'give' ), + 'edit_item' => __( 'Edit Donation Form', 'give' ), 'new_item' => __( 'New Form', 'give' ), 'all_items' => __( 'All Forms', 'give' ), 'view_item' => __( 'View Form', 'give' ), diff --git a/package-lock.json b/package-lock.json index 0f4d564034..763e821714 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,8 @@ "@fortawesome/free-regular-svg-icons": "^5.15.1", "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/react-fontawesome": "^0.1.12", - "@givewp/design-system-foundation": "^1.1.0", - "@givewp/form-builder-library": "^1.7.0", + "@givewp/design-system-foundation": "^1.2.0", + "@givewp/form-builder-library": "^1.7.1", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "^2.9.10", "@paypal/paypal-js": "^5.1.4", @@ -23,6 +23,7 @@ "@stripe/react-stripe-js": "^2.1.0", "@stripe/stripe-js": "^1.52.0", "@svgr/webpack": "^6.5.1", + "@wordpress/api-fetch": "^7.8.0", "@wordpress/block-editor": "^11.2.0", "@wordpress/block-library": "^8.15.0", "@wordpress/components": "^23.2.0", @@ -53,10 +54,10 @@ "intl-tel-input": "^21.2.4", "joi": "^17.7.0", "jquery": "^3.6.0", - "jquery-chosen": "*", - "jquery.payment": "*", + "jquery-chosen": "latest", + "jquery.payment": "latest", "lodash": "^4.17.21", - "magnific-popup": "*", + "magnific-popup": "latest", "moment": "^2.29.4", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -86,7 +87,7 @@ "react-use-clipboard": "^1.0.9", "styled-components": "^5.2.1", "swr": "^2.0.1", - "uiblocker": "*", + "uiblocker": "latest", "use-immer": "^0.9.0", "uuid": "^9.0.1", "vhtml": "^2.2.0" @@ -2341,6 +2342,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2377,6 +2394,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2577,14 +2600,14 @@ } }, "node_modules/@givewp/design-system-foundation": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@givewp/design-system-foundation/-/design-system-foundation-1.1.0.tgz", - "integrity": "sha512-SOAS98QQOytIGsyDX55y4TCS0DeKijjmOPnNaG0YbClTL2u7HFNthqRHk246BXZ0s6U+CUzqZQ8mf/+3NY4Z1g==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@givewp/design-system-foundation/-/design-system-foundation-1.2.0.tgz", + "integrity": "sha512-MjWKkpaU5NfpOmatsRkx7NH++NQcuvhdPWbq/Xs3c3BF6OI1AxwU4kTrrglv+WymIanZt3nz2jTq0gNAbNHMRA==" }, "node_modules/@givewp/form-builder-library": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@givewp/form-builder-library/-/form-builder-library-1.7.0.tgz", - "integrity": "sha512-7u0wbs27xizgKQCodA3ZPyuaVfX5VFYv+mknRY5x5RO4GJ/FfUQhbAr6G6t0doCKii0NePABs0l4rrCU05Q5wQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@givewp/form-builder-library/-/form-builder-library-1.7.1.tgz", + "integrity": "sha512-lDMLaOicFd8wP/ip6slViHnfky26dI5u9cLbVd14eJ+BtX9ZeFnWdSgBCAW+XZKGscc2987B7xL3Q/2YUcFW2w==", "dependencies": { "@wordpress/components": "^25.10.0", "@wordpress/compose": "^6.21.0", @@ -3807,6 +3830,31 @@ } } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3823,6 +3871,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -7808,18 +7862,74 @@ } }, "node_modules/@wordpress/api-fetch": { - "version": "6.52.0", - "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.52.0.tgz", - "integrity": "sha512-zLgpRT6iKdfQupF7hGYbixjqgkeU2taclEHbbQqP6ClLfG709I3kX6Ft+2wh6FaG8MhdVZkl0/E0DTROJ5lbyA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.18.0.tgz", + "integrity": "sha512-mhzSIDRon8OWa1PLqo1gW35mfIkmjSXHteFJAmGHx7j3b/2+pa3THia8o2xYFXx+A0KWm0A3VOCuNUPp9q7aPg==", "dependencies": { - "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "^4.55.0", - "@wordpress/url": "^3.56.0" + "@babel/runtime": "7.25.7", + "@wordpress/i18n": "^5.18.0", + "@wordpress/url": "^4.18.0" }, "engines": { - "node": ">=12" + "node": ">=18.12.0", + "npm": ">=8.19.2" } }, + "node_modules/@wordpress/api-fetch/node_modules/@wordpress/hooks": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.18.0.tgz", + "integrity": "sha512-hJulGAT2ELS+UBCTPnTe2A2ep+sOF4PO/x41UmeSgiaIJV1G4xNCJnlcyuCsV3xI2CTnf+YqjyS3qbqhLq3YOA==", + "dependencies": { + "@babel/runtime": "7.25.7" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/api-fetch/node_modules/@wordpress/i18n": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.18.0.tgz", + "integrity": "sha512-XaA3qSyOnmx7FEQFwOXxqWLVjKw0TWR/S/sEmp6Rs88ttUDS0Y6z6xGwCwEK8acmUUWccaxk6aPirX8gV4Bdrw==", + "dependencies": { + "@babel/runtime": "7.25.7", + "@wordpress/hooks": "^4.18.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "sprintf-js": "^1.1.1", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/api-fetch/node_modules/@wordpress/url": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.18.0.tgz", + "integrity": "sha512-Jr1O9NjNKQuWRIBzc0G/aiHt54vXkCJ50JJiKAAFnz6yTE6XVoPvxDG3nidGAThNWQgZ1UykY6Zir+XrQPHBtw==", + "dependencies": { + "@babel/runtime": "7.25.7", + "remove-accents": "^0.5.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/api-fetch/node_modules/memize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/memize/-/memize-2.1.0.tgz", + "integrity": "sha512-yywVJy8ctVlN5lNPxsep5urnZ6TTclwPEyigM9M3Bi8vseJBOfqNrGWN/r8NzuIt3PovM323W04blJfGQfQSVg==" + }, + "node_modules/@wordpress/api-fetch/node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" + }, "node_modules/@wordpress/autop": { "version": "3.39.0", "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-3.39.0.tgz", @@ -7946,6 +8056,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/block-editor/node_modules/@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/block-editor/node_modules/@wordpress/compose": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-6.16.0.tgz", @@ -8065,6 +8188,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/block-library/node_modules/@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/block-library/node_modules/@wordpress/block-editor": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-12.7.0.tgz", @@ -8669,6 +8805,19 @@ "react": "^18.0.0" } }, + "node_modules/@wordpress/core-data/node_modules/@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/core-data/node_modules/@wordpress/block-editor": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-12.7.0.tgz", @@ -9254,9 +9403,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "3.57.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-3.57.0.tgz", - "integrity": "sha512-+RaPsTj80QNUw3RfiMhxIzaAuYPAvMByrpy97jmodrvhPM5wR9utj40DYIlAiBfMhwACh8NM+kY+UB08CKcmCQ==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-3.58.0.tgz", + "integrity": "sha512-9LB0ZHnZRQlORttux9t/xbAskF+dk2ujqzPGsVzc92mSKpQP3K2a5Wy74fUnInguB1vLUNHT6nrNdkVom5qX1Q==", "dependencies": { "@babel/runtime": "^7.16.0" }, @@ -9276,12 +9425,12 @@ } }, "node_modules/@wordpress/i18n": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-4.57.0.tgz", - "integrity": "sha512-VYWYHE+7NxnZvE9Swhhe4leQcn0jHNkzRAEV36TkfAL/MvrQYCRh71KLTvKhsilG96HUQdBwjH0VPLmYEmR3sg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-4.58.0.tgz", + "integrity": "sha512-VfvS3BWv/RDjRKD6PscIcvYfWKnGJcI/DEqyDgUMhxCM6NRwoL478CsUKTiGJIymeyRodNRfprdcF086DpGKYw==", "dependencies": { "@babel/runtime": "^7.16.0", - "@wordpress/hooks": "^3.57.0", + "@wordpress/hooks": "^3.58.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "sprintf-js": "^1.1.1", @@ -10250,6 +10399,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/reusable-blocks/node_modules/@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/reusable-blocks/node_modules/@wordpress/block-editor": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-12.7.0.tgz", @@ -11965,13 +12127,6 @@ } } }, - "node_modules/@wordpress/scripts/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@wordpress/scripts/node_modules/json2php": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.9.tgz", @@ -12897,6 +13052,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/server-side-render/node_modules/@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/server-side-render/node_modules/@wordpress/components": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-25.5.0.tgz", @@ -13154,9 +13322,9 @@ } }, "node_modules/@wordpress/url": { - "version": "3.56.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-3.56.0.tgz", - "integrity": "sha512-uW5cTftroxvYSoF2Wy/Rfc5eUuqANXSrqBu8axv1dmNLYbg+2Y8f/bzH1ZNLLtmkpD25QPOIstGjA8lsYRPuig==", + "version": "3.59.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-3.59.0.tgz", + "integrity": "sha512-GxvoMjYCav0w4CiX0i0h3qflrE/9rhLIZg5aPCQjbrBdwTxYR3Exfw0IJYcmVaTKXQOUU8fOxlDxULsbLmKe9w==", "dependencies": { "@babel/runtime": "^7.16.0", "remove-accents": "^0.5.0" @@ -13398,14 +13566,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.2.4.tgz", + "integrity": "sha512-nBeQgg/ZZA3u3SYxyaDvpvDtgZ/EZPF547ARgZBrG9Bhu1vKDwAIjtIf+sDtJUKa2zOcEbmRLBRSyMraS/Oy1A==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", "uri-js": "^4.2.2" }, "funding": { @@ -13456,21 +13625,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/alphanum-sort": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", @@ -16038,12 +16192,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/copy-webpack-plugin/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -18202,6 +18350,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -18364,6 +18528,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -23731,9 +23901,9 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { @@ -23963,6 +24133,31 @@ "integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug==", "dev": true }, + "node_modules/laravel-mix/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/laravel-mix/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/laravel-mix/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -24331,6 +24526,12 @@ "node": ">=8" } }, + "node_modules/laravel-mix/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/laravel-mix/node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -26089,13 +26290,6 @@ "ajv": "^8.8.2" } }, - "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", @@ -26576,6 +26770,22 @@ "npm": ">=6.0.0" } }, + "node_modules/npm-package-json-lint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/npm-package-json-lint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -26679,6 +26889,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/npm-package-json-lint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/npm-package-json-lint/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -32003,6 +32219,37 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -33962,13 +34209,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/tannin": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tannin/-/tannin-1.2.0.tgz", @@ -34141,13 +34381,6 @@ "node": ">= 10.13.0" } }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", @@ -35032,6 +35265,37 @@ } } }, + "node_modules/url-loader/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/url-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/url-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/url-loader/node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -35644,12 +35908,6 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -35756,12 +36014,6 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", @@ -35963,6 +36215,37 @@ "node": ">=0.10.0" } }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/webpack/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -37965,6 +38248,18 @@ "strip-json-comments": "^3.1.1" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -37989,6 +38284,12 @@ "argparse": "^2.0.1" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -38136,14 +38437,14 @@ } }, "@givewp/design-system-foundation": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@givewp/design-system-foundation/-/design-system-foundation-1.1.0.tgz", - "integrity": "sha512-SOAS98QQOytIGsyDX55y4TCS0DeKijjmOPnNaG0YbClTL2u7HFNthqRHk246BXZ0s6U+CUzqZQ8mf/+3NY4Z1g==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@givewp/design-system-foundation/-/design-system-foundation-1.2.0.tgz", + "integrity": "sha512-MjWKkpaU5NfpOmatsRkx7NH++NQcuvhdPWbq/Xs3c3BF6OI1AxwU4kTrrglv+WymIanZt3nz2jTq0gNAbNHMRA==" }, "@givewp/form-builder-library": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@givewp/form-builder-library/-/form-builder-library-1.7.0.tgz", - "integrity": "sha512-7u0wbs27xizgKQCodA3ZPyuaVfX5VFYv+mknRY5x5RO4GJ/FfUQhbAr6G6t0doCKii0NePABs0l4rrCU05Q5wQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@givewp/form-builder-library/-/form-builder-library-1.7.1.tgz", + "integrity": "sha512-lDMLaOicFd8wP/ip6slViHnfky26dI5u9cLbVd14eJ+BtX9ZeFnWdSgBCAW+XZKGscc2987B7xL3Q/2YUcFW2w==", "requires": { "@wordpress/components": "^25.10.0", "@wordpress/compose": "^6.21.0", @@ -39030,6 +39331,25 @@ "source-map": "^0.7.3" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -39040,6 +39360,12 @@ "path-exists": "^4.0.0" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -42177,13 +42503,55 @@ } }, "@wordpress/api-fetch": { - "version": "6.52.0", - "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.52.0.tgz", - "integrity": "sha512-zLgpRT6iKdfQupF7hGYbixjqgkeU2taclEHbbQqP6ClLfG709I3kX6Ft+2wh6FaG8MhdVZkl0/E0DTROJ5lbyA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.18.0.tgz", + "integrity": "sha512-mhzSIDRon8OWa1PLqo1gW35mfIkmjSXHteFJAmGHx7j3b/2+pa3THia8o2xYFXx+A0KWm0A3VOCuNUPp9q7aPg==", "requires": { - "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "^4.55.0", - "@wordpress/url": "^3.56.0" + "@babel/runtime": "7.25.7", + "@wordpress/i18n": "^5.18.0", + "@wordpress/url": "^4.18.0" + }, + "dependencies": { + "@wordpress/hooks": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.18.0.tgz", + "integrity": "sha512-hJulGAT2ELS+UBCTPnTe2A2ep+sOF4PO/x41UmeSgiaIJV1G4xNCJnlcyuCsV3xI2CTnf+YqjyS3qbqhLq3YOA==", + "requires": { + "@babel/runtime": "7.25.7" + } + }, + "@wordpress/i18n": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.18.0.tgz", + "integrity": "sha512-XaA3qSyOnmx7FEQFwOXxqWLVjKw0TWR/S/sEmp6Rs88ttUDS0Y6z6xGwCwEK8acmUUWccaxk6aPirX8gV4Bdrw==", + "requires": { + "@babel/runtime": "7.25.7", + "@wordpress/hooks": "^4.18.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "sprintf-js": "^1.1.1", + "tannin": "^1.2.0" + } + }, + "@wordpress/url": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.18.0.tgz", + "integrity": "sha512-Jr1O9NjNKQuWRIBzc0G/aiHt54vXkCJ50JJiKAAFnz6yTE6XVoPvxDG3nidGAThNWQgZ1UykY6Zir+XrQPHBtw==", + "requires": { + "@babel/runtime": "7.25.7", + "remove-accents": "^0.5.0" + } + }, + "memize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/memize/-/memize-2.1.0.tgz", + "integrity": "sha512-yywVJy8ctVlN5lNPxsep5urnZ6TTclwPEyigM9M3Bi8vseJBOfqNrGWN/r8NzuIt3PovM323W04blJfGQfQSVg==" + }, + "remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" + } } }, "@wordpress/autop": { @@ -42286,6 +42654,16 @@ "traverse": "^0.6.6" }, "dependencies": { + "@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + } + }, "@wordpress/compose": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-6.16.0.tgz", @@ -42387,6 +42765,16 @@ "uuid": "^8.3.0" }, "dependencies": { + "@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + } + }, "@wordpress/block-editor": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-12.7.0.tgz", @@ -42837,6 +43225,16 @@ "uuid": "^8.3.0" }, "dependencies": { + "@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + } + }, "@wordpress/block-editor": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-12.7.0.tgz", @@ -43278,9 +43676,9 @@ } }, "@wordpress/hooks": { - "version": "3.57.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-3.57.0.tgz", - "integrity": "sha512-+RaPsTj80QNUw3RfiMhxIzaAuYPAvMByrpy97jmodrvhPM5wR9utj40DYIlAiBfMhwACh8NM+kY+UB08CKcmCQ==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-3.58.0.tgz", + "integrity": "sha512-9LB0ZHnZRQlORttux9t/xbAskF+dk2ujqzPGsVzc92mSKpQP3K2a5Wy74fUnInguB1vLUNHT6nrNdkVom5qX1Q==", "requires": { "@babel/runtime": "^7.16.0" } @@ -43294,12 +43692,12 @@ } }, "@wordpress/i18n": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-4.57.0.tgz", - "integrity": "sha512-VYWYHE+7NxnZvE9Swhhe4leQcn0jHNkzRAEV36TkfAL/MvrQYCRh71KLTvKhsilG96HUQdBwjH0VPLmYEmR3sg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-4.58.0.tgz", + "integrity": "sha512-VfvS3BWv/RDjRKD6PscIcvYfWKnGJcI/DEqyDgUMhxCM6NRwoL478CsUKTiGJIymeyRodNRfprdcF086DpGKYw==", "requires": { "@babel/runtime": "^7.16.0", - "@wordpress/hooks": "^3.57.0", + "@wordpress/hooks": "^3.58.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "sprintf-js": "^1.1.1", @@ -43987,6 +44385,16 @@ "@wordpress/url": "^3.40.0" }, "dependencies": { + "@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + } + }, "@wordpress/block-editor": { "version": "12.7.0", "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-12.7.0.tgz", @@ -45158,12 +45566,6 @@ "xml-name-validator": "^4.0.0" } }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "json2php": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.9.tgz", @@ -45739,6 +46141,16 @@ "fast-deep-equal": "^3.1.3" }, "dependencies": { + "@wordpress/api-fetch": { + "version": "6.55.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", + "integrity": "sha512-1HrCUsJdeRY5Y0IjplotINwqMRO81e7O7VhBScuKk7iOuDm/E1ioKv2uLGnPNWziYu+Zf025byxOqVzXDyM2gw==", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.58.0", + "@wordpress/url": "^3.59.0" + } + }, "@wordpress/components": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-25.5.0.tgz", @@ -45924,9 +46336,9 @@ } }, "@wordpress/url": { - "version": "3.56.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-3.56.0.tgz", - "integrity": "sha512-uW5cTftroxvYSoF2Wy/Rfc5eUuqANXSrqBu8axv1dmNLYbg+2Y8f/bzH1ZNLLtmkpD25QPOIstGjA8lsYRPuig==", + "version": "3.59.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-3.59.0.tgz", + "integrity": "sha512-GxvoMjYCav0w4CiX0i0h3qflrE/9rhLIZg5aPCQjbrBdwTxYR3Exfw0IJYcmVaTKXQOUU8fOxlDxULsbLmKe9w==", "requires": { "@babel/runtime": "^7.16.0", "remove-accents": "^0.5.0" @@ -46114,14 +46526,15 @@ "dev": true }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.2.4.tgz", + "integrity": "sha512-nBeQgg/ZZA3u3SYxyaDvpvDtgZ/EZPF547ARgZBrG9Bhu1vKDwAIjtIf+sDtJUKa2zOcEbmRLBRSyMraS/Oy1A==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, @@ -46152,22 +46565,9 @@ "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true } } }, - "ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} - }, "alphanum-sort": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", @@ -48093,12 +48493,6 @@ "slash": "^4.0.0" } }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -49509,6 +49903,18 @@ "text-table": "^0.2.0" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -49608,6 +50014,12 @@ "argparse": "^2.0.1" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -53600,9 +54012,9 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, "json-stable-stringify-without-jsonify": { @@ -53788,6 +54200,25 @@ "integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug==", "dev": true }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -54048,6 +54479,12 @@ "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==", "dev": true }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -55306,12 +55743,6 @@ "fast-deep-equal": "^3.1.3" } }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "schema-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", @@ -55692,6 +56123,18 @@ "validate-npm-package-name": "^5.0.0" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -55753,6 +56196,12 @@ "argparse": "^2.0.1" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -59634,6 +60083,33 @@ "@types/json-schema": "^7.0.5", "ajv": "^6.12.4", "ajv-keywords": "^3.5.2" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + } } }, "select": { @@ -61103,12 +61579,6 @@ "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true } } }, @@ -61241,12 +61711,6 @@ "supports-color": "^8.0.0" } }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "schema-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", @@ -61890,6 +62354,31 @@ "schema-utils": "^3.0.0" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -62207,6 +62696,31 @@ "webpack-sources": "^3.2.3" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -62338,12 +62852,6 @@ "fast-deep-equal": "^3.1.3" } }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -62417,12 +62925,6 @@ "fast-deep-equal": "^3.1.3" } }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "schema-utils": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", diff --git a/package.json b/package.json index cd046737c6..2024aafc5f 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.1", "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/react-fontawesome": "^0.1.12", - "@givewp/design-system-foundation": "^1.1.0", + "@givewp/design-system-foundation": "^1.2.0", "@givewp/form-builder-library": "^1.7.1", "@hookform/error-message": "^2.0.1", "@hookform/resolvers": "^2.9.10", @@ -78,6 +78,7 @@ "@stripe/react-stripe-js": "^2.1.0", "@stripe/stripe-js": "^1.52.0", "@svgr/webpack": "^6.5.1", + "@wordpress/api-fetch": "^7.8.0", "@wordpress/block-editor": "^11.2.0", "@wordpress/block-library": "^8.15.0", "@wordpress/components": "^23.2.0", diff --git a/src/Campaigns/Actions/AddCampaignFormFromRequest.php b/src/Campaigns/Actions/AddCampaignFormFromRequest.php new file mode 100644 index 0000000000..9b0bccf0a1 --- /dev/null +++ b/src/Campaigns/Actions/AddCampaignFormFromRequest.php @@ -0,0 +1,39 @@ +addCampaignForm($campaign, $formId); + } + } + + /** + * @unreleased + * + * @throws Exception + */ + public function visualFormBuilder(DonationForm $donationForm) + { + if (isset($_GET['campaignId']) && $campaign = Campaign::find(absint($_GET['campaignId']))) { + give(CampaignRepository::class)->addCampaignForm($campaign, $donationForm->id); + } + } +} diff --git a/src/Campaigns/Actions/AssignDuplicatedFormToCampaign.php b/src/Campaigns/Actions/AssignDuplicatedFormToCampaign.php new file mode 100644 index 0000000000..0f412d05ba --- /dev/null +++ b/src/Campaigns/Actions/AssignDuplicatedFormToCampaign.php @@ -0,0 +1,54 @@ +campaignRepository = $campaignRepository; + } + + /** + * @unreleased + */ + public function __invoke($duplicatedFormID, $originalFormID) + { + $campaign = $this->campaignRepository->queryByFormId($originalFormID)->get(); + + if(!$campaign) { + Log::error('Campaign does not exist for duplicated form.', [ + 'duplicated_form_id' => $duplicatedFormID, + 'original_form_id' => $originalFormID, + ]); + return; + } + + try { + $this->campaignRepository->addCampaignForm($campaign, $duplicatedFormID, true); + } catch (\Exception $e) { + Log::error('Failed to assign duplicated form to campaign.', [ + 'campaign_id' => $campaign->id, + 'duplicated_form_id' => $duplicatedFormID, + 'original_form_id' => $originalFormID, + 'error' => $e->getMessage(), + ]); + return; + } + } +} diff --git a/src/Campaigns/Actions/AssociateCampaignPageWithCampaign.php b/src/Campaigns/Actions/AssociateCampaignPageWithCampaign.php new file mode 100644 index 0000000000..3f99574d30 --- /dev/null +++ b/src/Campaigns/Actions/AssociateCampaignPageWithCampaign.php @@ -0,0 +1,25 @@ +campaignId); + + if ($campaign) { + $campaign->pageId = $campaignPage->id; + $campaign->save(); + } + } +} diff --git a/src/Campaigns/Actions/ConvertQueryDataToCampaign.php b/src/Campaigns/Actions/ConvertQueryDataToCampaign.php new file mode 100644 index 0000000000..bcdd471dbf --- /dev/null +++ b/src/Campaigns/Actions/ConvertQueryDataToCampaign.php @@ -0,0 +1,42 @@ + (int)$queryObject->id, + 'pageId' => (int)$queryObject->pageId, + 'defaultFormId' => (int)$queryObject->defaultFormId, + 'type' => new CampaignType($queryObject->type), + 'enableCampaignPage' => (bool)$queryObject->enableCampaignPage, + 'title' => $queryObject->title, + 'shortDescription' => $queryObject->shortDescription, + 'longDescription' => $queryObject->longDescription, + 'logo' => $queryObject->logo, + 'image' => $queryObject->image, + 'primaryColor' => $queryObject->primaryColor, + 'secondaryColor' => $queryObject->secondaryColor, + 'goal' => (int)$queryObject->goal, + 'goalType' => new CampaignGoalType($queryObject->goalType), + 'startDate' => $queryObject->startDate ? Temporal::toDateTime($queryObject->startDate) : null, + 'endDate' => $queryObject->endDate ? Temporal::toDateTime($queryObject->endDate) : null, + 'status' => new CampaignStatus($queryObject->status), + 'createdAt' => Temporal::toDateTime($queryObject->createdAt), + ]); + } +} diff --git a/src/Campaigns/Actions/CreateDefaultCampaignForm.php b/src/Campaigns/Actions/CreateDefaultCampaignForm.php new file mode 100644 index 0000000000..238ad6d233 --- /dev/null +++ b/src/Campaigns/Actions/CreateDefaultCampaignForm.php @@ -0,0 +1,38 @@ + $campaign->title, + 'status' => DonationFormStatus::DRAFT(), + 'settings' => FormSettings::fromArray([ + 'enableDonationGoal' => true, + 'goalAmount' => $campaign->goal, + 'goalType' => $campaign->goalType->getValue(), + 'designId' => ClassicFormDesign::id(), + ]), + 'blocks' => (new GenerateDefaultDonationFormBlockCollection())(), + ]); + + give(CampaignRepository::class)->addCampaignForm($campaign, $defaultCampaignForm->id, true); + } +} diff --git a/src/Campaigns/Actions/CreateDefaultLayoutForCampaignPage.php b/src/Campaigns/Actions/CreateDefaultLayoutForCampaignPage.php new file mode 100644 index 0000000000..42680473fd --- /dev/null +++ b/src/Campaigns/Actions/CreateDefaultLayoutForCampaignPage.php @@ -0,0 +1,31 @@ +', $blockName, $campaignId); + }, $this->blocks); + + return implode('', $layout); + } +} diff --git a/src/Campaigns/Actions/DeleteCampaignPage.php b/src/Campaigns/Actions/DeleteCampaignPage.php new file mode 100644 index 0000000000..b807bdff63 --- /dev/null +++ b/src/Campaigns/Actions/DeleteCampaignPage.php @@ -0,0 +1,23 @@ +page() ?: CampaignPage::create([ + 'campaignId' => $campaign->id, + ]); + + wp_safe_redirect($page->getEditLinkUrl(), 303); + exit(); + } +} diff --git a/src/Campaigns/Actions/EnqueueCampaignPageEditorAssets.php b/src/Campaigns/Actions/EnqueueCampaignPageEditorAssets.php new file mode 100644 index 0000000000..db30546372 --- /dev/null +++ b/src/Campaigns/Actions/EnqueueCampaignPageEditorAssets.php @@ -0,0 +1,48 @@ +post_type !== 'give_campaign_page') { + return; + } + + $campaignPage = CampaignPage::find($currentPost->ID); + $scriptAsset = ScriptAsset::get(GIVE_PLUGIN_DIR . 'build/campaignPagePostTypeEditor.asset.php'); + $campaignDetailsURL = add_query_arg([ + 'post_type' => 'give_forms', + 'page' => 'give-campaigns', + 'id' => $campaignPage->campaignId, + ], admin_url('edit.php')); + + wp_enqueue_script( + 'givewp-campaign-page-post-type-editor', + GIVE_PLUGIN_URL . 'build/campaignPagePostTypeEditor.js', + $scriptAsset['dependencies'], + $scriptAsset['version'], + true + ); + + wp_localize_script( + 'givewp-campaign-page-post-type-editor', + 'giveCampaignPage', + [ + 'campaignDetailsURL' => $campaignDetailsURL, + ] + ); + } +} diff --git a/src/Campaigns/Actions/FormInheritsCampaignGoal.php b/src/Campaigns/Actions/FormInheritsCampaignGoal.php new file mode 100644 index 0000000000..ecb32750e5 --- /dev/null +++ b/src/Campaigns/Actions/FormInheritsCampaignGoal.php @@ -0,0 +1,33 @@ +settings->enableDonationGoal = true; + $donationForm->settings->goalAmount = $campaign->goal; + $donationForm->settings->goalType = new GoalType($campaign->goalType->getValue()); + } + } + } +} diff --git a/src/Campaigns/Actions/LoadCampaignDetailsAssets.php b/src/Campaigns/Actions/LoadCampaignDetailsAssets.php new file mode 100644 index 0000000000..8e0ec03ed0 --- /dev/null +++ b/src/Campaigns/Actions/LoadCampaignDetailsAssets.php @@ -0,0 +1,40 @@ + is_admin(), + 'adminUrl' => admin_url(), + 'campaignsAdminUrl' => admin_url('edit.php?post_type=give_forms&page=give-campaigns'), + 'currency' => give_get_currency(), + 'currencySymbol' => give_currency_symbol(), + 'isRecurringEnabled' => defined('GIVE_RECURRING_VERSION') + ? GIVE_RECURRING_VERSION + : null, + 'admin' => is_admin() + ? [ + 'showCampaignInteractionNotice' => !get_user_meta(get_current_user_id(), 'givewp_show_campaign_interaction_notice', true), + ] + : null, + ] + ); + + wp_enqueue_script('give-campaign-options'); + } +} diff --git a/src/Campaigns/Actions/LoadCampaignsListTableAssets.php b/src/Campaigns/Actions/LoadCampaignsListTableAssets.php new file mode 100644 index 0000000000..03f3345c2e --- /dev/null +++ b/src/Campaigns/Actions/LoadCampaignsListTableAssets.php @@ -0,0 +1,45 @@ + esc_url_raw(rest_url('give-api/v2/campaigns/list-table')), + 'apiNonce' => wp_create_nonce('wp_rest'), + 'table' => give(CampaignsListTable::class)->toArray(), + 'adminUrl' => admin_url(), + 'paymentMode' => give_is_test_mode(), + 'pluginUrl' => GIVE_PLUGIN_URL, + 'currency' => give_get_currency(), + 'isRecurringEnabled' => defined('GIVE_RECURRING_VERSION') ? GIVE_RECURRING_VERSION : null, + ] + ); + + wp_enqueue_script($handleName); + wp_enqueue_style('givewp-design-system-foundation'); + } +} diff --git a/src/Campaigns/Actions/RedirectDisabledCampaignPage.php b/src/Campaigns/Actions/RedirectDisabledCampaignPage.php new file mode 100644 index 0000000000..76d4a2eded --- /dev/null +++ b/src/Campaigns/Actions/RedirectDisabledCampaignPage.php @@ -0,0 +1,29 @@ +campaign()->enableCampaignPage) { + global $wp_query; + $wp_query->set_404(); + status_header(404); + } + } +} diff --git a/src/Campaigns/Actions/RedirectLegacyCreateFormToCreateCampaign.php b/src/Campaigns/Actions/RedirectLegacyCreateFormToCreateCampaign.php new file mode 100644 index 0000000000..9ba3074eb0 --- /dev/null +++ b/src/Campaigns/Actions/RedirectLegacyCreateFormToCreateCampaign.php @@ -0,0 +1,62 @@ +isAddingNewForm()) { + return; + } + + if ($this->isEditingCampaignForm()) { + return; + } + + if ( ! isset($_GET['campaignId']) || ! (Campaign::find(absint($_GET['campaignId'])))) { + wp_safe_redirect(admin_url('edit.php?post_type=give_forms&page=give-campaigns&new=campaign')); + exit; + } + } + + /** + * @unreleased + */ + private function isAddingNewForm(): bool + { + global $pagenow; + + $isOptionBasedFormEditorPage = $pagenow === 'post-new.php'; + $isVisualFormBuilderPage = $pagenow === 'edit.php' && isset($_GET['page']) && 'givewp-form-builder' === $_GET['page']; + $isGiveFormsCpt = isset($_GET['post_type']) && $_GET['post_type'] === 'give_forms'; + + return ($isOptionBasedFormEditorPage || $isVisualFormBuilderPage) && $isGiveFormsCpt; + } + + /** + * @unreleased + */ + private function isEditingCampaignForm(): bool + { + global $pagenow; + + $formId = $pagenow === 'post.php' && isset($_GET['post']) ? absint($_GET['post']) : 0; + $formId = $pagenow === 'edit.php' && isset($_GET['donationFormID'], $_GET['page']) && 'givewp-form-builder' === $_GET['page'] ? absint($_GET['donationFormID']) : $formId; + $isGiveFormsCpt = (isset($_GET['post_type']) && $_GET['post_type'] === 'give_forms') || (get_post_type($formId) === 'give_forms'); + + if ($formId && $isGiveFormsCpt) { + return (bool)Campaign::findByFormId($formId); + } + + return false; + } +} diff --git a/src/Campaigns/Actions/RegisterCampaignBlocks.php b/src/Campaigns/Actions/RegisterCampaignBlocks.php new file mode 100644 index 0000000000..2378b2e2cd --- /dev/null +++ b/src/Campaigns/Actions/RegisterCampaignBlocks.php @@ -0,0 +1,81 @@ +registerSharedStyles(); + } + + /** + * @unreleased + */ + public function loadBlockEditorAssets(): void + { + global $post; + + $scriptAsset = ScriptAsset::get(GIVE_PLUGIN_DIR . 'build/campaignBlocks.asset.php'); + + wp_register_script( + 'givewp-campaign-blocks', + GIVE_PLUGIN_URL . 'build/campaignBlocks.js', + $scriptAsset['dependencies'], + $scriptAsset['version'], + true + ); + + wp_enqueue_script('givewp-campaign-blocks'); + wp_enqueue_style( + 'givewp-campaign-blocks', + GIVE_PLUGIN_URL . 'build/campaignBlocks.css', + ['wp-components'], + $scriptAsset['version'] + ); + + if ($post && $post->post_type === 'give_campaign_page') { + $scriptAsset = ScriptAsset::get(GIVE_PLUGIN_DIR . 'build/campaignBlocksLandingPage.asset.php'); + + wp_register_script( + 'givewp-campaign-landing-page-blocks', + GIVE_PLUGIN_URL . 'build/campaignBlocksLandingPage.js', + $scriptAsset['dependencies'], + $scriptAsset['version'], + true + ); + + wp_enqueue_script('givewp-campaign-landing-page-blocks'); + wp_enqueue_style( + 'givewp-campaign-landing-page-blocks', + GIVE_PLUGIN_URL . 'build/campaignBlocksLandingPage.css', + ['wp-components'], + $scriptAsset['version'] + ); + } + } + + /** + * @unreleased + */ + private function registerSharedStyles(): void + { + wp_enqueue_style('givewp-design-system-foundation'); + wp_enqueue_style( + 'givewp-campaign-blocks-fonts', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap' + ); + } +} diff --git a/src/Campaigns/Actions/RegisterCampaignEntity.php b/src/Campaigns/Actions/RegisterCampaignEntity.php new file mode 100644 index 0000000000..67f8d98665 --- /dev/null +++ b/src/Campaigns/Actions/RegisterCampaignEntity.php @@ -0,0 +1,30 @@ + function ($object) { + return get_post_meta($object['id'], 'campaignId', true); + }, + 'update_callback' => function ($value, $object) { + return update_post_meta($object->ID, 'campaignId', (int) $value); + }, + 'schema' => [ + 'description' => 'Campaign ID', + 'type' => 'string', + 'context' => ['view', 'edit'], + ], + ] + ); + } +} diff --git a/src/Campaigns/Actions/RegisterCampaignPagePostType.php b/src/Campaigns/Actions/RegisterCampaignPagePostType.php new file mode 100644 index 0000000000..97cf4f32de --- /dev/null +++ b/src/Campaigns/Actions/RegisterCampaignPagePostType.php @@ -0,0 +1,33 @@ + __('Campaign Page', 'give'), + 'public' => true, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'supports' => [ + 'title', + 'editor' + ], + 'rewrite' => [ + 'slug' => 'campaign' + ], + 'template' => [ + // TODO: Add default blocks template. + ], + ] ); + } +} diff --git a/src/Campaigns/Actions/ReplaceGiveFormsCptLabels.php b/src/Campaigns/Actions/ReplaceGiveFormsCptLabels.php new file mode 100644 index 0000000000..5879836e9e --- /dev/null +++ b/src/Campaigns/Actions/ReplaceGiveFormsCptLabels.php @@ -0,0 +1,21 @@ + { + const {campaign, hasResolved} = useCampaign(attributes?.campaignId); + + if (!hasResolved) { + return null; + } + + return ( + + ); +} + +/** + * @unreleased + */ +const nodeList = document.querySelectorAll('[data-givewp-campaign-block]'); + +if (nodeList) { + const containers = Array.from(nodeList); + + containers.map((container: any) => { + const attributes: CampaignBlockType = JSON.parse(container.dataset?.attributes); + const root = createRoot(container); + return root.render() + }); +} diff --git a/src/Campaigns/Blocks/Campaign/block.json b/src/Campaigns/Blocks/Campaign/block.json new file mode 100644 index 0000000000..533fc7a8e9 --- /dev/null +++ b/src/Campaigns/Blocks/Campaign/block.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/block.json", + "apiVersion": 2, + "name": "givewp/campaign-block", + "version": "1.0.0", + "title": "Campaign Block", + "category": "give", + "description": "Insert an existing campaign into the page.", + "supports": { + "align": [ + "wide", + "full", + "left", + "center", + "right" + ] + }, + "attributes": { + "campaignId": { + "type": "number" + }, + "showImage": { + "type": "boolean", + "default": true + }, + "showDescription": { + "type": "boolean", + "default": true + }, + "showGoal": { + "type": "boolean", + "default": true + } + }, + "textdomain": "give", + "viewScript": "file:../../../../build/campaignBlockApp.js", + "viewStyle": "file:../../../../build/campaignBlockApp.css", + "render": "file:./render.php" +} diff --git a/src/Campaigns/Blocks/Campaign/edit.tsx b/src/Campaigns/Blocks/Campaign/edit.tsx new file mode 100644 index 0000000000..2e9e3c9a98 --- /dev/null +++ b/src/Campaigns/Blocks/Campaign/edit.tsx @@ -0,0 +1,148 @@ +import {__} from '@wordpress/i18n'; +import React, {CSSProperties, useState} from 'react'; +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import {PanelBody, ToggleControl} from '@wordpress/components'; +import {CampaignBlockType} from './types'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import CampaignCard from '../shared/components/CampaignCard'; +import {BlockNotice} from '@givewp/form-builder-library'; +import {getCampaignOptionsWindowData, useCampaignEntityRecord} from '@givewp/campaigns/utils'; + + +const styles = { + title: { + fontWeight: 600 + }, + notice: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 16, + borderRadius: 2, + color: '#0e0e0e', + background: '#f2f2f2', + fontSize: 12, + lineHeight: 1.33, + }, + close: { + position: 'absolute', + cursor: 'pointer', + right: 16, + top: 16, + }, + link: { + color: '#0e0e0e', + } +} as CSSProperties; + +const CloseIcon = () => ( + + + +) + +export default function Edit({attributes, setAttributes}: BlockEditProps) { + const blockProps = useBlockProps(); + const campaignWindowData = getCampaignOptionsWindowData(); + const [showNotification, setShowNotification] = useState(campaignWindowData.admin.showCampaignInteractionNotice); + const {campaign, hasResolved, edit, save} = useCampaignEntityRecord(attributes.campaignId); + + + const enableCampaignPage = (e: React.MouseEvent) => { + e.preventDefault() + edit({enableCampaignPage: true}); + save(); + } + + const Notices = () => { + if (!attributes.campaignId) { + return null; + } + + if (campaign.enableCampaignPage) { + if (!showNotification) { + return null; + } + + return ( +

+ { + fetch(campaignWindowData.adminUrl + '/admin-ajax.php?action=givewp_campaign_interaction_notice', {method: 'POST'}) + .then(() => setShowNotification(false)) + }}> + + + + {__('Campaign interaction', 'give ')} + + + {__('Users will be redirected to campaign page.', 'give')} + +

+ ) + } + + return ( + + enableCampaignPage(e)} + style={styles['link']} + > + {__('Enable campaign page.', 'give')} + + + ) + }; + + return ( +
+ {hasResolved && ( + <> + setAttributes({campaignId})} + showInspectorControl={true} + inspectorControls={} + > + + + + + + setAttributes({showImage})} + /> + setAttributes({showDescription})} + /> + setAttributes({showGoal})} + /> + + + + )} +
+ ) +} diff --git a/src/Campaigns/Blocks/Campaign/index.tsx b/src/Campaigns/Blocks/Campaign/index.tsx new file mode 100644 index 0000000000..b1e8520057 --- /dev/null +++ b/src/Campaigns/Blocks/Campaign/index.tsx @@ -0,0 +1,15 @@ +import edit from './edit'; +import GiveIcon from '@givewp/components/GiveIcon'; +import schema from './block.json'; + +/** + * @unreleased + */ +export default { + schema, + settings: { + icon: , + edit, + }, +}; + diff --git a/src/Campaigns/Blocks/Campaign/render.php b/src/Campaigns/Blocks/Campaign/render.php new file mode 100644 index 0000000000..bd351bbc78 --- /dev/null +++ b/src/Campaigns/Blocks/Campaign/render.php @@ -0,0 +1,18 @@ + +
>
diff --git a/src/Campaigns/Blocks/Campaign/types.ts b/src/Campaigns/Blocks/Campaign/types.ts new file mode 100644 index 0000000000..db984f729f --- /dev/null +++ b/src/Campaigns/Blocks/Campaign/types.ts @@ -0,0 +1,6 @@ +export type CampaignBlockType = { + campaignId: number; + showImage: boolean; + showDescription: boolean; + showGoal: boolean; +} diff --git a/src/Campaigns/Blocks/CampaignComments/Controller/BlockRenderController.php b/src/Campaigns/Blocks/CampaignComments/Controller/BlockRenderController.php new file mode 100644 index 0000000000..d3ec09cc98 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/Controller/BlockRenderController.php @@ -0,0 +1,25 @@ +toArray()); + + $blockId = $blockAttributes->blockId; + + return "
"; + } +} diff --git a/src/Campaigns/Blocks/CampaignComments/DataTransferObjects/BlockAttributes.php b/src/Campaigns/Blocks/CampaignComments/DataTransferObjects/BlockAttributes.php new file mode 100644 index 0000000000..4ba00cebc3 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/DataTransferObjects/BlockAttributes.php @@ -0,0 +1,90 @@ +blockId = !empty($array['blockId']) ? (string)$array['blockId'] : null; + $self->campaignId = !empty($array['campaignId']) ? (int)$array['campaignId'] : null; + $self->title = !empty($array['title']) ? (string)$array['title'] : ''; + $self->showAnonymous = !isset($array['showAnonymous']) || (bool)$array['showAnonymous']; + $self->showAvatar = !isset($array['showAvatar']) || (bool)$array['showAvatar']; + $self->showDate = !isset($array['showDate']) || (bool)$array['showDate']; + $self->showName = !isset($array['showName']) || (bool)$array['showName']; + $self->commentLength = isset($array['commentLength']) ? (int)$array['commentLength'] : 200; + $self->readMoreText = !empty($array['readMoreText']) ? (string)$array['readMoreText'] : ''; + $self->commentsPerPage = isset($array['commentsPerPage']) ? (int)$array['commentsPerPage'] : 3; + + return $self; + } + + /** + * @unreleased + */ + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/src/Campaigns/Blocks/CampaignComments/block.json b/src/Campaigns/Blocks/CampaignComments/block.json new file mode 100644 index 0000000000..6b995a365e --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/block.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "givewp/campaign-comments-block", + "version": "1.0.0", + "title": "Campaign Comments", + "category": "give", + "description": "Display all the donor comments associated with a campaign.", + "attributes": { + "blockId": { + "type": "string" + }, + "campaignId": { + "type": "number" + }, + "title": { + "type": "string" + }, + "showAnonymous": { + "type": "boolean", + "default": true + }, + "showAvatar": { + "type": "boolean", + "default": true + }, + "showDate": { + "type": "boolean", + "default": true + }, + "showName": { + "type": "boolean", + "default": true + }, + "commentLength": { + "type": "number", + "default": 200 + }, + "readMoreText": { + "type": "string" + }, + "commentsPerPage": { + "type": "number", + "default": 3 + } + }, + "supports": { + "anchor": true, + "className": true, + "splitting": true, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalLetterSpacing": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalWritingMode": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "textdomain": "give", + "render": "file:./render.php", + "viewScript": "file:../../../../build/campaignCommentsBlockApp.js", + "editorScript": "file:../../../../build/campaignBlocks.js", + "style": "file:../../../../build/campaignCommentsBlockApp.css" +} diff --git a/src/Campaigns/Blocks/CampaignComments/render.php b/src/Campaigns/Blocks/CampaignComments/render.php new file mode 100644 index 0000000000..67853a368c --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/render.php @@ -0,0 +1,18 @@ +getById($attributes['campaignId']) +) { + return; +} + +echo (new BlockRenderController())->render($attributes); diff --git a/src/Campaigns/Blocks/CampaignComments/resources/app.tsx b/src/Campaigns/Blocks/CampaignComments/resources/app.tsx new file mode 100644 index 0000000000..df16583dd0 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/app.tsx @@ -0,0 +1,12 @@ +import {createRoot} from '@wordpress/element'; +import CampaignComments from './shared/components/CampaignComments'; + +const roots = document.querySelectorAll('[data-givewp-campaign-comments]'); + +if (roots) { + roots.forEach((root) => { + const attributes = root.getAttribute('data-attributes'); + + return createRoot(root).render(); + }); +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/edit.tsx b/src/Campaigns/Blocks/CampaignComments/resources/edit.tsx new file mode 100644 index 0000000000..78acee61fa --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/edit.tsx @@ -0,0 +1,87 @@ +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import {PanelBody, TextControl, ToggleControl} from '@wordpress/components'; +import {__} from '@wordpress/i18n'; +import CampaignComments from './shared/components/CampaignComments'; +import useCampaign from '../../shared/hooks/useCampaign'; +import CampaignSelector from '../../shared/components/CampaignSelector'; +import {useEffect} from 'react'; +import {Attributes} from './types'; + +export default function Edit({attributes, setAttributes, clientId}: BlockEditProps) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes?.campaignId); + + useEffect(() => { + if (!attributes.blockId) { + setAttributes({blockId: clientId}); + } + }, []); + + const {title = __('Share your support', 'give'), readMoreText = __('Read More', 'give')} = attributes; + + return ( +
+ setAttributes({campaignId})} + > + + + + {hasResolved && campaign?.id && ( + + + setAttributes({title: value})} + /> + setAttributes({showAnonymous: value})} + /> + setAttributes({showAvatar: value})} + /> + setAttributes({showDate: value})} + /> + setAttributes({showName: value})} + /> + + + setAttributes({commentLength: Number(value)})} + /> + setAttributes({readMoreText: value})} + /> + setAttributes({commentsPerPage: Number(value)})} + /> + + + )} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/index.ts b/src/Campaigns/Blocks/CampaignComments/resources/index.ts new file mode 100644 index 0000000000..ac517aec2c --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/index.ts @@ -0,0 +1,13 @@ +import Edit from './edit'; +import schema from '../../CampaignComments/block.json'; +import {paragraph as icon} from '@wordpress/icons'; + +const settings = { + icon, + edit: Edit, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CampaignComments/index.tsx b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CampaignComments/index.tsx new file mode 100644 index 0000000000..7e4a30aec1 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CampaignComments/index.tsx @@ -0,0 +1,42 @@ +import apiFetch from '@wordpress/api-fetch'; +import {addQueryArgs} from '@wordpress/url'; +import {__} from '@wordpress/i18n'; +import useSWR from 'swr'; +import CampaignCommentCard from '../CommentCard'; +import EmptyState from '../EmptyState'; +import {Attributes, CommentData} from '../../../types'; + +import './styles.scss'; + +export default function CampaignComments({attributes}: {attributes: Attributes}) { + const {title = __('Share your support', 'give')} = attributes; + + const {data, isLoading} = useSWR( + addQueryArgs(`/give-api/v2/campaigns/${attributes?.campaignId}/comments`, { + id: attributes?.campaignId, + perPage: attributes?.commentsPerPage, + anonymous: attributes?.showAnonymous, + }), + (url) => apiFetch({path: url}) + ); + + if (isLoading) { + return null; + } + + if (data && data?.length === 0) { + return ; + } + + return ( +
+

{title}

+

+ {__('Leave a supportive message by donating to the campaign.', 'give')} +

+ {data?.map((comment: CommentData, index: number) => ( + + ))} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CampaignComments/styles.scss b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CampaignComments/styles.scss new file mode 100644 index 0000000000..b20fdc846c --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CampaignComments/styles.scss @@ -0,0 +1,32 @@ +.givewp-campaign-comment-block { + display: flex; + flex-direction: column; + gap: var(--givewp-spacing-2); + padding: var(--givewp-spacing-6) 0; + background-color: var(--givewp-shades-white); + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + line-height: 1.56; + text-align: left; + color: var(--givewp-neutral-900); + } + + &__cta { + display: flex; + align-items: center; + height: 36px; + gap: 8px; + margin: 0; + padding: var(--givewp-spacing-2) var(--givewp-spacing-4); + border-radius: 4px; + background-color: var(--givewp-neutral-50); + font-size: 14px; + font-weight: 600; + line-height: 1.43; + text-align: left; + color: var(--giewp-neutral-500); + } +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CommentCard/index.tsx b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CommentCard/index.tsx new file mode 100644 index 0000000000..905ac7ee25 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CommentCard/index.tsx @@ -0,0 +1,37 @@ +import {useState} from 'react'; +import {__} from '@wordpress/i18n'; +import {Attributes, CommentData} from '../../../types'; +import './styles.scss'; + +export default function CampaignCommentCard({attributes, data}: {attributes: Attributes; data: CommentData}) { + const [fullComment, setFullComment] = useState(false); + const {comment, date, donorName, avatar} = data; + const {commentLength, showAvatar, showDate, showName, readMoreText = __('Read More', 'give')} = attributes; + + const truncatedComment = comment.slice(0, commentLength) + (comment.length > commentLength ? '...' : ''); + + return ( +
+ {showAvatar && ( +
+ {__('Donor +
+ )} +
+ {showName &&

{donorName}

} + {showDate &&

{date}

} +

+ {fullComment ? comment : truncatedComment} +

+ {comment?.length > commentLength && !fullComment && ( + + )} +
+
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CommentCard/styles.scss b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CommentCard/styles.scss new file mode 100644 index 0000000000..63acbf8664 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/CommentCard/styles.scss @@ -0,0 +1,67 @@ +.givewp-campaign-comment-block-card { + display: flex; + gap: var(--givewp-spacing-3); + padding: var(--givewp-spacing-4) 0; + + &__avatar { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: .5rem; + border-radius: 50%; + + img { + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + width: 100%; + height: auto; + padding: .5rem; + border-radius: 50%; + } + } + + &__donor-name { + margin: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.5; + text-align: left; + color: var(--givewp-neutral-700); + } + + &__details { + display: flex; + gap: var(--givewp-spacing-2); + align-items: center; + height: auto; + margin: 2px 0 var(--givewp-spacing-3) 0; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.43; + text-align: left; + color: var(--givewp-neutral-400); + } + + &__comment { + margin: 0; + font-size: 1rem; + line-height: 1.5; + text-align: left; + color: var(--givewp-neutral-700); + } + + &__read-more { + font-size: 0.875rem; + padding: 0; + background: none; + color: var(--givewp-blue-500); + line-height: 1.43; + border: none; + outline: none; + cursor: pointer; + } +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/shared/components/EmptyState/index.tsx b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/EmptyState/index.tsx new file mode 100644 index 0000000000..704f5dcda9 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/EmptyState/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import {__} from '@wordpress/i18n'; +import './styles.scss'; + +export default function EmptyState() { + return ( +
+
+ + + +
+
+ + {__('Leave a supportive message by', 'give')} + +

+ {__('donating to the campaign.', 'give')} +

+
+
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/shared/components/EmptyState/styles.scss b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/EmptyState/styles.scss new file mode 100644 index 0000000000..89e322723b --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/shared/components/EmptyState/styles.scss @@ -0,0 +1,36 @@ +.givewp-campaign-comments-block-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--givewp-spacing-6); + background-color: var(--givewp-shades-white); + border-radius: 0.5rem; + border: 1px solid var(--givewp-neutral-50); + + &__details { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--givewp-spacing-1); + margin: .875rem 0; + } + + &__title { + margin: 0; + font-size: 1rem; + font-weight: 500; + color: var(--givewp-neutral-700); + } + + &__description { + margin: 0; + font-size: .875rem; + line-height: 1.43; + color: var(--givewp-neutral-700); + } + + &__icon { + } +} diff --git a/src/Campaigns/Blocks/CampaignComments/resources/types.ts b/src/Campaigns/Blocks/CampaignComments/resources/types.ts new file mode 100644 index 0000000000..797cf6d4da --- /dev/null +++ b/src/Campaigns/Blocks/CampaignComments/resources/types.ts @@ -0,0 +1,19 @@ +export type Attributes = { + blockId: string; + campaignId: number; + title: string; + commentLength: number; + commentsPerPage: number; + readMoreText: string; + showAvatar: boolean; + showDate: boolean; + showName: boolean; + showAnonymous: boolean; +}; + +export type CommentData = { + comment: string; + date: string; + donorName: string; + avatar: string; +}; diff --git a/src/Campaigns/Blocks/CampaignCover/Icon.tsx b/src/Campaigns/Blocks/CampaignCover/Icon.tsx new file mode 100644 index 0000000000..6c893fba1c --- /dev/null +++ b/src/Campaigns/Blocks/CampaignCover/Icon.tsx @@ -0,0 +1,16 @@ +import {Path, SVG} from "@wordpress/components"; + +export function GalleryIcon() { + return ( + + ); +} diff --git a/src/Campaigns/Blocks/CampaignCover/block.json b/src/Campaigns/Blocks/CampaignCover/block.json new file mode 100644 index 0000000000..98d48cfbd8 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignCover/block.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "givewp/campaign-cover-block", + "version": "1.0.0", + "title": "Campaign Cover", + "category": "give", + "description": "Displays the cover image of the campaign.", + "attributes": { + "campaignId": { + "type": "integer" + }, + "alt": { + "type": "string" + }, + "width": { + "type": "number", + "default": 645 + }, + "height": { + "type": "number", + "default": 865 + }, + "align": { + "type": "string", + "default": "" + } + }, + "supports": { + "align": [ + "wide", + "full", + "left", + "center", + "right" + ], + "filter": { + "duotone": true + }, + "selectors": { + "filter": { + "duotone": ".wp-block-givewp-campaign-cover-block img" + } + }, + "anchor": true, + "className": true, + "splitting": true, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalLetterSpacing": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalWritingMode": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "textdomain": "give", + "render": "file:./render.php", + "style": "file:../../../../build/campaignCoverBlock.css" +} diff --git a/src/Campaigns/Blocks/CampaignCover/edit.tsx b/src/Campaigns/Blocks/CampaignCover/edit.tsx new file mode 100644 index 0000000000..3de83a616a --- /dev/null +++ b/src/Campaigns/Blocks/CampaignCover/edit.tsx @@ -0,0 +1,144 @@ +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {__} from '@wordpress/i18n'; +import {useSelect} from '@wordpress/data'; +import {external} from '@wordpress/icons'; +import {BaseControl, Icon, PanelBody, Placeholder, ResizableBox, TextareaControl} from '@wordpress/components'; +import {BlockEditProps} from '@wordpress/blocks'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import useCampaign from '../shared/hooks/useCampaign'; +import {GalleryIcon} from "./Icon"; + +import './editor.scss'; + +interface EditProps extends BlockEditProps<{ + campaignId: number; + alt: string; + width: number; + height: number; + align: string; +}> { + toggleSelection: (isSelected: boolean) => void; +} + +export default function Edit({attributes, setAttributes, toggleSelection}: EditProps) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes.campaignId); + + const adminBaseUrl = useSelect( + // @ts-ignore + (select) => select('core').getSite()?.url + '/wp-admin/edit.php?post_type=give_forms&page=give-campaigns', + [] + ); + const editCampaignUrl = `${adminBaseUrl}&id=${attributes.campaignId}&tab=settings`; + + const handleResizeStop = (event: MouseEvent | TouchEvent, direction, refToElement: HTMLDivElement, delta: { + height: number, + width: number + }) => { + setAttributes({ + height: attributes.height + delta.height, + width: attributes.width + delta.width, + }); + toggleSelection(true); + }; + + const isSizeAligned = attributes.align === 'full' || attributes.align === 'wide'; + + return ( +
+ setAttributes({campaignId})} + > + {hasResolved && !campaign?.image && ( + } + label={__('Campaign Cover Image', 'give')} + instructions={__('Upload a cover image for your campaign.', 'give')} + /> + + )} + + {hasResolved && campaign?.image && + (!isSizeAligned ? ( + { + toggleSelection(false); + }} + onResizeStop={handleResizeStop} + enable={{ + bottom: true, + right: true, + bottomRight: false, + top: false, + left: false, + topLeft: false, + topRight: true, + bottomLeft: false, + }} + > + {attributes.alt + + ) : ( + {attributes.alt + ))} + + + {hasResolved && campaign && ( + + + + {campaign?.image && ( + {attributes.alt + )} +

+ {__('Shows the cover image of the campaign.', 'give')} +

+ + {__('Change campaign cover', 'give')} + + +
+ setAttributes({alt: value})} + /> +
+
+ )} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignCover/editor.scss b/src/Campaigns/Blocks/CampaignCover/editor.scss new file mode 100644 index 0000000000..223da9c73a --- /dev/null +++ b/src/Campaigns/Blocks/CampaignCover/editor.scss @@ -0,0 +1,61 @@ +.givewp-campaign-cover-block { + &__button { + display: flex; + align-items: center; + justify-content: center; + min-height: 32px; + width: 100%; + margin-bottom: .5rem; + color: #2271b1; + border: 1px solid #2271b1; + border-radius: 2px; + } + + &__image { + flex-grow: 1; + display: flex; + max-height: 4.44rem; + width: 100%; + object-fit: cover; + margin-bottom: .5rem; + border-radius: 2px; + } + + &__help-text { + font-size: 0.75rem; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1.4; + letter-spacing: normal; + text-align: left; + color: #4b5563; + } + + &__edit-campaign-link { + display: inline-flex; + align-items: center; + gap: 0.125rem; + font-size: 0.75rem; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1.4; + + svg { + fill: currentColor; + height: 1.25rem; + width: 1.25rem; + } + } +} + +.givewp-campaign-cover-block-preview { + &__image { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } +} diff --git a/src/Campaigns/Blocks/CampaignCover/index.tsx b/src/Campaigns/Blocks/CampaignCover/index.tsx new file mode 100644 index 0000000000..bf6773b5c5 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignCover/index.tsx @@ -0,0 +1,16 @@ +import schema from './block.json'; +import Edit from './edit'; +import {GalleryIcon} from './Icon'; + +/** + * @unreleased + */ +const settings = { + icon: , + edit: Edit, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/CampaignCover/render.php b/src/Campaigns/Blocks/CampaignCover/render.php new file mode 100644 index 0000000000..13a759a5d5 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignCover/render.php @@ -0,0 +1,42 @@ +getById($attributes['campaignId']); + +if (!$campaign || !$campaign->image) { + return; +} + +$campaignMediaSetting = $campaign->image; + +$altText = $attributes['alt'] ?? __('Campaign cover image', 'give'); +$alignment = isset($attributes['align']) ? 'align' . $attributes['align'] : ''; + +// Only assign width and height if the alignment is NOT "full" or "wide" +if ($attributes['align'] !== 'full' && $attributes['align'] !== 'wide') { + $widthStyle = isset($attributes['width']) ? "width: {$attributes['width']}px;" : ''; + $heightStyle = isset($attributes['height']) ? "max-height: {$attributes['height']}px;" : ''; +} else { + $widthStyle = 'width: auto;'; + $heightStyle = 'height: auto;'; +} +?> + + +
+ <?php echo esc_attr($altText); ?> +
diff --git a/src/Campaigns/Blocks/CampaignDonations/CampaignDonationsBlockViewModel.php b/src/Campaigns/Blocks/CampaignDonations/CampaignDonationsBlockViewModel.php new file mode 100644 index 0000000000..1650215eac --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/CampaignDonationsBlockViewModel.php @@ -0,0 +1,64 @@ +attributes = $attributes; + $this->campaign = $campaign; + $this->donations = $donations; + } + + /** + * @unreleased + */ + public function render(): void + { + View::render('Campaigns/Blocks/CampaignDonations.render', [ + 'campaign' => $this->campaign, + 'donations' => $this->formatDonationsData($this->donations), + 'attributes' => $this->attributes, + ]); + } + + + /** + * @unreleased + */ + private function formatDonationsData(array $donations): array + { + return array_map(static function ($entry) { + $entry->date = human_time_diff(strtotime($entry->date)); + $entry->amount = Money::fromDecimal($entry->amount, give_get_currency()); + + return $entry; + }, $donations); + } +} diff --git a/src/Campaigns/Blocks/CampaignDonations/app.tsx b/src/Campaigns/Blocks/CampaignDonations/app.tsx new file mode 100644 index 0000000000..e39aaa19eb --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/app.tsx @@ -0,0 +1 @@ +import './styles.scss'; diff --git a/src/Campaigns/Blocks/CampaignDonations/block.json b/src/Campaigns/Blocks/CampaignDonations/block.json new file mode 100644 index 0000000000..8173357cd2 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/block.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "givewp/campaign-donations", + "version": "1.0.0", + "title": "Campaign Donations", + "category": "give", + "description": "Display all the donations associated with a campaign.", + "attributes": { + "campaignId": { + "type": "integer" + }, + "showAnonymous": { + "type": "boolean", + "default": true + }, + "showIcon": { + "type": "boolean", + "default": true + }, + "showButton": { + "type": "boolean", + "default": true + }, + "donateButtonText": { + "type": "string", + "default": "Donate" + }, + "sortBy": { + "type": "string", + "default": "top-donations" + }, + "donationsPerPage": { + "type": "number", + "default": 5 + }, + "loadMoreButtonText": { + "type": "string", + "default": "Load more" + } + }, + "supports": { + "className": true + }, + "textdomain": "give", + "render": "file:./render.php", + "style": "file:../../../../build/campaignDonationsBlockApp.css" +} diff --git a/src/Campaigns/Blocks/CampaignDonations/edit.tsx b/src/Campaigns/Blocks/CampaignDonations/edit.tsx new file mode 100644 index 0000000000..a255c88b37 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/edit.tsx @@ -0,0 +1,101 @@ +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import { + __experimentalNumberControl as NumberControl, + PanelBody, + SelectControl, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import {__} from '@wordpress/i18n'; +import ServerSideRender from '@wordpress/server-side-render'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import useCampaign from '../shared/hooks/useCampaign'; + +export default function Edit({ + attributes, + setAttributes, + }: BlockEditProps<{ + campaignId: number; + showAnonymous: boolean; + showIcon: boolean; + showButton: boolean; + donateButtonText: string; + sortBy: string; + donationsPerPage: number; + loadMoreButtonText: string; +}>) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes.campaignId); + + const {showAnonymous, showIcon, showButton, donateButtonText, sortBy, donationsPerPage, loadMoreButtonText} = + attributes; + + return ( +
+ setAttributes({campaignId})} + > + + + + {hasResolved && campaign?.id && ( + + + setAttributes({showAnonymous: value})} + /> + setAttributes({showIcon: value})} + /> + setAttributes({showButton: value})} + /> + setAttributes({donateButtonText: value})} + help={__('This shows on the header', 'give')} + /> + + + + setAttributes({sortBy: value})} + help={__('The order donations are displayed in.', 'give')} + /> + {/* TODO: Revert the label and help text back to what are in the designs once the backend for pagination is ready */} + setAttributes({donationsPerPage: parseInt(value)})} + help={__('The maximum number of donations to display.', 'give')} + /> + {/* TODO: Revert the field back once the backend for pagination is ready + setAttributes({loadMoreButtonText: value})} + /> + */} + + + )} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignDonations/index.ts b/src/Campaigns/Blocks/CampaignDonations/index.ts new file mode 100644 index 0000000000..c8aa8a4ea5 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/index.ts @@ -0,0 +1,16 @@ +import {paragraph as icon} from '@wordpress/icons'; +import schema from './block.json'; +import Edit from './edit'; + +/** + * @unreleased + */ +const settings = { + icon, + edit: Edit, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/CampaignDonations/render.php b/src/Campaigns/Blocks/CampaignDonations/render.php new file mode 100644 index 0000000000..ae40989775 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/render.php @@ -0,0 +1,47 @@ +getById($attributes['campaignId']); + +if ( ! $campaign) { + return; +} + +$sortBy = $attributes['sortBy'] ?? 'top-donations'; +$query = (new CampaignDonationQuery($campaign)) + ->select( + 'donation.ID as id', + 'donorIdMeta.meta_value as donorId', + 'amountMeta.meta_value as amount', + 'donation.post_date as date', + 'donors.name as donorName' + ) + ->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorIdMeta') + ->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amountMeta') + ->leftJoin('give_donors', 'donorIdMeta.meta_value', 'donors.id', 'donors') + ->orderBy($sortBy === 'top-donations' ? 'amount' : 'donation.ID', 'DESC') + ->limit($attributes['donationsPerPage'] ?? 5); + +if ( ! $attributes['showAnonymous']) { + $query->joinDonationMeta(DonationMetaKeys::ANONYMOUS, 'anonymousMeta') + ->where('anonymousMeta.meta_value', '0'); +} + +(new CampaignDonationsBlockViewModel($campaign, $query->getAll(), $attributes))->render(); diff --git a/src/Campaigns/Blocks/CampaignDonations/resources/icons/empty-state.svg b/src/Campaigns/Blocks/CampaignDonations/resources/icons/empty-state.svg new file mode 100644 index 0000000000..97c9d126fe --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/resources/icons/empty-state.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/Campaigns/Blocks/CampaignDonations/resources/icons/ribbon.svg b/src/Campaigns/Blocks/CampaignDonations/resources/icons/ribbon.svg new file mode 100644 index 0000000000..5b8803f0be --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/resources/icons/ribbon.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/Campaigns/Blocks/CampaignDonations/resources/views/render.php b/src/Campaigns/Blocks/CampaignDonations/resources/views/render.php new file mode 100644 index 0000000000..9f9740a47e --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/resources/views/render.php @@ -0,0 +1,128 @@ +primaryColor ?? '#0b72d9'), + esc_attr($campaign->secondaryColor ?? '#27ae60') +); +?> +
'givewp-campaign-donations-block'])); ?> + style=""> +
+

+ +
+ render([ + 'formId' => $campaign->defaultFormId, + 'openFormButton' => $attributes['donateButtonText'], + 'formFormat' => 'modal', + ]); ?> +
+ +
+ + +
+

+ +

+

+ +

+ +
+ +
+ + +
+ render([ + 'formId' => $campaign->defaultFormId, + 'openFormButton' => __('Be the first', 'give'), + 'formFormat' => 'modal', + ]); + ?> +
+ +
+ +
    + $donation) : ?> +
  • + +
    + <?php
+                                _e('Donation icon', 'give'); ?> +
    + + +
    +
    + ' . esc_html($donation->donorName) . '', + '' . esc_html($donation->amount->formatToLocale()) . '' + ); + ?> +
    + + date + ) + ); ?> +
    + + +
    + +
    + +
  • + +
+ +
diff --git a/src/Campaigns/Blocks/CampaignDonations/styles.scss b/src/Campaigns/Blocks/CampaignDonations/styles.scss new file mode 100644 index 0000000000..9b938f52d0 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonations/styles.scss @@ -0,0 +1,179 @@ +.givewp-campaign-donations-block { + padding: 1.5rem 0; + + * { + font-family: 'Inter', sans-serif; + } + + &__header { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + &__title { + color: var(--givewp-neutral-900); + font-size: 1.125rem; + font-weight: 600; + line-height: 1.56; + margin: 0; + } + + &__donations { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + } + + &__donation, + &__empty-state { + background-color: var(--givewp-shades-white); + border-radius: 0.5rem; + border: 1px solid var(--givewp-neutral-50); + display: flex; + padding: 1rem; + } + + &__donation { + align-items: center; + gap: 0.75rem; + } + + &__donation-icon { + align-items: center; + display: flex; + + img { + border-radius: 100%; + height: 2.5rem; + object-fit: cover; + width: 2.5rem; + } + } + + &__donation-info { + display: flex; + flex-direction: column; + justify-content: center; + row-gap: 0.25rem; + } + + &__donation-description { + color: var(--givewp-neutral-500); + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + margin: 0; + + strong { + color: var(--givewp-neutral-700); + font-weight: 600; + } + } + + &__donation-date { + align-items: center; + color: var(--givewp-neutral-400); + display: flex; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.43; + } + + &__donation-ribbon { + align-items: center; + border-radius: 100%; + color: #1F2937; + display: flex; + height: 1.25rem; + justify-content: center; + margin-left: auto; + width: 1.25rem; + + &[data-position="1"] { + background-color: #ffd700; + } + + &[data-position="2"] { + background-color: #c0c0c0; + } + + &[data-position="3"] { + background-color: #cd7f32; + color: #fffaf2; + } + } + + &__donation-amount { + color: var(--givewp-neutral-700); + font-size: 1.125rem; + font-weight: 600; + line-height: 1.56; + margin-left: auto; + } + + &__footer { + display: flex; + justify-content: center; + margin-top: 0.5rem; + } + + &__load-more-button, + &__donate-button button.givewp-donation-form-modal__open, + &__empty-button button.givewp-donation-form-modal__open { + background: none; + border-radius: 0.5rem; + border: 1px solid var(--givewp-primary-color); + color: var(--givewp-primary-color) !important; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.43; + padding: 0.25rem 1rem !important; + + &:hover { + background: var(--givewp-primary-color); + color: var(--givewp-shades-white) !important; + } + } + + &__empty-state { + align-items: center; + flex-direction: column; + padding: 1.5rem; + } + + &__empty-title, + &__empty-description { + color: var(--givewp-neutral-700); + margin: 0; + } + + &__empty-title { + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + } + + &__empty-description { + font-size: 0.875rem; + line-height: 1.43; + margin-top: 0.25rem; + } + + &__empty-icon { + color: var(--givewp-secondary-color); + margin-bottom: 0.875rem; + order: -1; + } + + &__empty-button { + margin-top: 0.875rem; + + button.givewp-donation-form-modal__open { + border-radius: 0.25rem; + padding: 0.5rem 1rem !important; + } + } +} diff --git a/src/Campaigns/Blocks/CampaignDonors/CampaignDonorsBlockViewModel.php b/src/Campaigns/Blocks/CampaignDonors/CampaignDonorsBlockViewModel.php new file mode 100644 index 0000000000..d00f95ddbc --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/CampaignDonorsBlockViewModel.php @@ -0,0 +1,66 @@ +attributes = $attributes; + $this->campaign = $campaign; + $this->donors = $donors; + } + + /** + * @unreleased + */ + public function render(): void + { + View::render('Campaigns/Blocks/CampaignDonors.render', [ + 'campaign' => $this->campaign, + 'donors' => $this->formatDonorsData($this->donors), + 'attributes' => $this->attributes, + ]); + } + + + /** + * @unreleased + */ + private function formatDonorsData(array $donors): array + { + return array_map(static function ($entry) { + if (isset($entry->date)) { + $entry->date = human_time_diff(strtotime($entry->date)); + } + $entry->amount = Money::fromDecimal($entry->amount, give_get_currency()); + + return $entry; + }, $donors); + } +} diff --git a/src/Campaigns/Blocks/CampaignDonors/app.tsx b/src/Campaigns/Blocks/CampaignDonors/app.tsx new file mode 100644 index 0000000000..e39aaa19eb --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/app.tsx @@ -0,0 +1 @@ +import './styles.scss'; diff --git a/src/Campaigns/Blocks/CampaignDonors/block.json b/src/Campaigns/Blocks/CampaignDonors/block.json new file mode 100644 index 0000000000..3b53e3453e --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/block.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "givewp/campaign-donors", + "version": "1.0.0", + "title": "Campaign Donors", + "category": "give", + "description": "Display all the donors associated with a campaign.", + "attributes": { + "campaignId": { + "type": "integer" + }, + "showAnonymous": { + "type": "boolean", + "default": true + }, + "showCompanyName": { + "type": "boolean", + "default": true + }, + "showAvatar": { + "type": "boolean", + "default": true + }, + "showButton": { + "type": "boolean", + "default": true + }, + "donateButtonText": { + "type": "string", + "default": "Join the list" + }, + "sortBy": { + "type": "string", + "default": "top-donors" + }, + "donorsPerPage": { + "type": "number", + "default": 5 + }, + "loadMoreButtonText": { + "type": "string", + "default": "Load more" + } + }, + "supports": { + "className": true + }, + "textdomain": "give", + "render": "file:./render.php", + "style": "file:../../../../build/campaignDonorsBlockApp.css" +} diff --git a/src/Campaigns/Blocks/CampaignDonors/edit.tsx b/src/Campaigns/Blocks/CampaignDonors/edit.tsx new file mode 100644 index 0000000000..cc67208d25 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/edit.tsx @@ -0,0 +1,115 @@ +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import { + __experimentalNumberControl as NumberControl, + PanelBody, + SelectControl, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import {__} from '@wordpress/i18n'; +import ServerSideRender from '@wordpress/server-side-render'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import useCampaign from '../shared/hooks/useCampaign'; + +export default function Edit({ + attributes, + setAttributes, +}: BlockEditProps<{ + campaignId: number; + showAnonymous: boolean; + showCompanyName: boolean; + showAvatar: boolean; + showButton: boolean; + donateButtonText: string; + sortBy: string; + donorsPerPage: number; + loadMoreButtonText: string; +}>) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes.campaignId); + + const { + showAnonymous, + showCompanyName, + showAvatar, + showButton, + donateButtonText, + sortBy, + donorsPerPage, + loadMoreButtonText, + } = attributes; + + return ( +
+ setAttributes({campaignId})} + > + + + + {hasResolved && campaign?.id && ( + + + setAttributes({showAnonymous: value})} + /> + setAttributes({showCompanyName: value})} + /> + setAttributes({showAvatar: value})} + /> + setAttributes({showButton: value})} + /> + setAttributes({donateButtonText: value})} + help={__('This shows on the header', 'give')} + /> + + + + setAttributes({sortBy: value})} + help={__('The order donors are displayed in.', 'give')} + /> + {/* TODO: Revert the label and help text back to what are in the designs once the backend for pagination is ready */} + setAttributes({donorsPerPage: parseInt(value)})} + help={__('The maximum number of donors to display.', 'give')} + /> + {/* TODO: Revert the field back once the backend for pagination is ready + setAttributes({loadMoreButtonText: value})} + /> + */} + + + )} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignDonors/index.ts b/src/Campaigns/Blocks/CampaignDonors/index.ts new file mode 100644 index 0000000000..c8aa8a4ea5 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/index.ts @@ -0,0 +1,16 @@ +import {paragraph as icon} from '@wordpress/icons'; +import schema from './block.json'; +import Edit from './edit'; + +/** + * @unreleased + */ +const settings = { + icon, + edit: Edit, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/CampaignDonors/render.php b/src/Campaigns/Blocks/CampaignDonors/render.php new file mode 100644 index 0000000000..23dda1d0a0 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/render.php @@ -0,0 +1,61 @@ +getById($attributes['campaignId']); + +if ( ! $campaign) { + return; +} + +$sortBy = $attributes['sortBy'] ?? 'top-donors'; +$query = (new CampaignDonationQuery($campaign)) + ->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorIdMeta') + ->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amountMeta') + ->leftJoin('give_donors', 'donorIdMeta.meta_value', 'donors.id', 'donors') + ->limit($attributes['donorsPerPage'] ?? 5); + +if ($sortBy === 'top-donors') { + $query->select( + 'donorIdMeta.meta_value as id', + 'SUM(amountMeta.meta_value) as amount', + 'donors.name as name' + ) + ->groupBy('donorIdMeta.meta_value') + ->orderBy('amount', 'DESC'); +} else { + $query->joinDonationMeta(DonationMetaKeys::COMPANY, 'companyMeta') + ->select( + 'donation.ID as donationID', + 'donorIdMeta.meta_value as id', + 'companyMeta.meta_value as company', + 'donation.post_date as date', + 'amountMeta.meta_value as amount', + 'donors.name as name' + ) + ->orderBy('donation.ID', 'DESC'); +} + +if ( ! $attributes['showAnonymous']) { + $query->joinDonationMeta(DonationMetaKeys::ANONYMOUS, 'anonymousMeta') + ->where('anonymousMeta.meta_value', '0'); +} + +(new CampaignDonorsBlockViewModel($campaign, $query->getAll(), $attributes))->render(); diff --git a/src/Campaigns/Blocks/CampaignDonors/resources/icons/empty-state.svg b/src/Campaigns/Blocks/CampaignDonors/resources/icons/empty-state.svg new file mode 100644 index 0000000000..0e8992b4a0 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/resources/icons/empty-state.svg @@ -0,0 +1,7 @@ + + + diff --git a/src/Campaigns/Blocks/CampaignDonors/resources/icons/ribbon.svg b/src/Campaigns/Blocks/CampaignDonors/resources/icons/ribbon.svg new file mode 100644 index 0000000000..5b8803f0be --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/resources/icons/ribbon.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/Campaigns/Blocks/CampaignDonors/resources/views/render.php b/src/Campaigns/Blocks/CampaignDonors/resources/views/render.php new file mode 100644 index 0000000000..82496362a8 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/resources/views/render.php @@ -0,0 +1,145 @@ +primaryColor ?? '#0b72d9'), + esc_attr($campaign->secondaryColor ?? '#27ae60') +); +?> +
'givewp-campaign-donors-block'])); ?> + style=""> +
+

+ +
+ $campaign->defaultFormId, + 'openFormButton' => $donateButtonText, + 'formFormat' => 'modal', + ]; + + echo (new BlockRenderController())->render($params); + ?> +
+ +
+ + +
+

+ +

+

+ +

+ +
+ +
+ + +
+ $campaign->defaultFormId, + 'openFormButton' => __('Be the first donor', 'give'), + 'formFormat' => 'modal', + ]; + + echo (new BlockRenderController())->render($params); + ?> +
+ +
+ +
    + $donor) : ?> +
  • + +
    + <?php
+                                _e('Donor avatar', 'give'); ?> +
    + + +
    + name); ?> + + + + + + + + date)) : ?> + date + ) + ); ?> + + + company) && $donor->company) : ?> + company); ?> + +
    +
    amount->formatToLocale()); ?>
    +
  • + +
+ +
diff --git a/src/Campaigns/Blocks/CampaignDonors/styles.scss b/src/Campaigns/Blocks/CampaignDonors/styles.scss new file mode 100644 index 0000000000..3e8d04ab04 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignDonors/styles.scss @@ -0,0 +1,193 @@ +.givewp-campaign-donors-block { + padding: 1.5rem 0; + + * { + font-family: 'Inter', sans-serif; + } + + &__header { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + &__title { + color: var(--givewp-neutral-900); + font-size: 1.125rem; + font-weight: 600; + line-height: 1.56; + margin: 0; + } + + &__donors { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + } + + &__donor, + &__empty-state { + background-color: var(--givewp-shades-white); + border-radius: 0.5rem; + border: 1px solid var(--givewp-neutral-50); + display: flex; + padding: 1rem; + } + + &__donor { + align-items: center; + gap: 0.75rem; + } + + &__donor-avatar { + align-items: center; + display: flex; + + img { + border-radius: 100%; + height: 2.5rem; + object-fit: cover; + width: 2.5rem; + } + } + + &__donor-info { + align-items: center; + display: flex; + flex-wrap: wrap; + row-gap: 0.25rem; + } + + &__donor-name { + color: var(--givewp-neutral-900); + font-size: 1rem; + font-weight: 600; + line-height: 1.5; + margin: 0; + } + + &__donor-date { + align-items: center; + color: var(--givewp-neutral-400); + display: flex; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.43; + + &::before { + background: var(--givewp-neutral-100); + border-radius: 100%; + content: ""; + display: block; + height: 0.25rem; + margin: 0 0.5rem; + width: 0.25rem; + } + } + + &__donor-ribbon { + align-items: center; + border-radius: 100%; + color: #1F2937; + display: flex; + height: 1.25rem; + justify-content: center; + margin-left: 0.5rem; + width: 1.25rem; + + &[data-position="1"] { + background-color: #ffd700; + } + + &[data-position="2"] { + background-color: #c0c0c0; + } + + &[data-position="3"] { + background-color: #cd7f32; + color: #fffaf2; + } + } + + &__donor-company { + color: var(--givewp-neutral-400); + display: flex; + flex: 0 0 100%; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.43; + } + + &__donor-amount { + color: var(--givewp-neutral-700); + font-size: 1.125rem; + font-weight: 600; + line-height: 1.56; + margin-left: auto; + } + + &__footer { + display: flex; + justify-content: center; + margin-top: 0.5rem; + } + + &__load-more-button, + &__donate-button button.givewp-donation-form-modal__open, + &__empty-button button.givewp-donation-form-modal__open { + background: none; + border-radius: 0.5rem; + border: 1px solid var(--givewp-primary-color); + color: var(--givewp-primary-color) !important; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.43; + padding: 0.25rem 1rem !important; + + &:hover { + background: var(--givewp-primary-color); + color: var(--givewp-shades-white) !important; + } + } + + &__empty-state { + align-items: center; + flex-direction: column; + padding: 1.5rem; + } + + &__empty-title, + &__empty-description { + color: var(--givewp-neutral-700); + margin: 0; + } + + &__empty-title { + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + } + + &__empty-description { + font-size: 0.875rem; + line-height: 1.43; + margin-top: 0.25rem; + } + + &__empty-icon { + color: var(--givewp-secondary-color); + margin-bottom: 0.875rem; + order: -1; + } + + &__empty-button { + margin-top: 0.875rem; + + button.givewp-donation-form-modal__open { + border-radius: 0.25rem; + padding: 0.5rem 1rem !important; + } + } +} diff --git a/src/Campaigns/Blocks/CampaignGoal/app.tsx b/src/Campaigns/Blocks/CampaignGoal/app.tsx new file mode 100644 index 0000000000..072983484a --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/app.tsx @@ -0,0 +1,27 @@ +import {createRoot} from '@wordpress/element'; +import useCampaign from '../shared/hooks/useCampaign'; +import App from './app/index'; + +const BlockApp = ({campaignId}: { campaignId: number }) => { + const {campaign, hasResolved} = useCampaign(campaignId); + + if (!hasResolved || !campaignId) { + return null; + } + + return ; +} + +/** + * @unreleased + */ +const nodeList = document.querySelectorAll('[data-givewp-campaign-goal]'); + +if (nodeList) { + const containers = Array.from(nodeList); + + containers.map((container: any) => { + const root = createRoot(container); + return root.render(); + }); +} diff --git a/src/Campaigns/Blocks/CampaignGoal/app/index.tsx b/src/Campaigns/Blocks/CampaignGoal/app/index.tsx new file mode 100644 index 0000000000..37fa61bdcd --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/app/index.tsx @@ -0,0 +1,34 @@ +import {__} from '@wordpress/i18n'; +import {Campaign} from '@givewp/campaigns/admin/components/types'; +import {getGoalDescription, getGoalFormattedValue} from '../utils'; + +import './styles.scss'; + +export default ({campaign}: { campaign: Campaign }) => { + return ( +
+
+
+ {getGoalDescription(campaign.goalType)} + + {getGoalFormattedValue(campaign.goalType, campaign.goalStats.actual)} + +
+
+ {__('Our goal', 'give')} + + {getGoalFormattedValue(campaign.goalType, campaign.goal)} + +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignGoal/app/styles.scss b/src/Campaigns/Blocks/CampaignGoal/app/styles.scss new file mode 100644 index 0000000000..b98f4b8b2f --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/app/styles.scss @@ -0,0 +1,51 @@ +.givewp-campaign-goal { + display: flex; + flex-direction: column; + gap: 0.5rem; + + &__container { + display: flex; + flex-direction: row; + justify-content: space-between; + + &-item { + display: flex; + flex-direction: column; + gap: 0.2rem; + + span { + text-transform: uppercase; + font-size: 12px; + line-height: 18px; + color: #4b5563; + } + + strong { + font-size: 20px; + line-height: 32px; + color: #060c1a; + } + } + } + + &__progress-bar { + display: flex; + + &-container { + display: flex; + height: 8px; + flex-grow: 1; + border-radius: 14px; + box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.09); + background-color: #f2f2f2; + } + + &-progress { + display: flex; + height: 8px; + border-radius: 14px; + box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.09); + background-color: #2d802f; + } + } +} diff --git a/src/Campaigns/Blocks/CampaignGoal/block.json b/src/Campaigns/Blocks/CampaignGoal/block.json new file mode 100644 index 0000000000..0145bccb61 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/block.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/block.json", + "apiVersion": 2, + "name": "givewp/campaign-goal", + "version": "1.0.0", + "title": "Campaign Goal", + "category": "give", + "description": "Displays the goal of the campaign.", + "supports": { + "align": [ + "wide", + "full" + ] + }, + "attributes": { + "campaignId": { + "type": "number" + } + }, + "textdomain": "give", + "viewScript": "file:../../../../build/campaignGoalBlockApp.js", + "style": "file:../../../../build/campaignGoalBlockApp.css", + "render": "file:./render.php" +} diff --git a/src/Campaigns/Blocks/CampaignGoal/edit.tsx b/src/Campaigns/Blocks/CampaignGoal/edit.tsx new file mode 100644 index 0000000000..d62837de59 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/edit.tsx @@ -0,0 +1,56 @@ +import {__} from '@wordpress/i18n'; +import {useSelect} from '@wordpress/data'; +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import {ExternalLink, PanelBody, TextControl} from '@wordpress/components'; +import useCampaign from '../shared/hooks/useCampaign'; +import CampaignGoalApp from './app/index'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import {getGoalDescription} from './utils'; + +/** + * @unreleased + */ +export default function Edit({attributes, setAttributes}: BlockEditProps<{ + campaignId: number; + goalType: string; +}>) { + const {campaign, hasResolved} = useCampaign(attributes.campaignId); + + const blockProps = useBlockProps(); + + const adminBaseUrl = useSelect( + // @ts-ignore + (select) => select('core').getSite()?.url + '/wp-admin/edit.php?post_type=give_forms&page=give-campaigns', + [] + ); + + if (!hasResolved) { + return null; + } + + return ( +
+ setAttributes({campaignId})} + > + + + + {campaign?.id && ( + + + + + {__('Edit campaign goal settings', 'give')} + + + + )} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignGoal/icon.jsx b/src/Campaigns/Blocks/CampaignGoal/icon.jsx new file mode 100644 index 0000000000..fd153a0135 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/icon.jsx @@ -0,0 +1,11 @@ +export default () => ( + + + + + +) diff --git a/src/Campaigns/Blocks/CampaignGoal/index.tsx b/src/Campaigns/Blocks/CampaignGoal/index.tsx new file mode 100644 index 0000000000..a79fbc4283 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/index.tsx @@ -0,0 +1,15 @@ +import edit from './edit'; +import icon from './icon'; +import schema from './block.json'; + +/** + * @unreleased + */ +export default { + schema, + settings: { + icon, + edit, + }, +}; + diff --git a/src/Campaigns/Blocks/CampaignGoal/render.php b/src/Campaigns/Blocks/CampaignGoal/render.php new file mode 100644 index 0000000000..7b507bdf08 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/render.php @@ -0,0 +1,20 @@ +getById($attributes['campaignId']) +) { + return; +} + +?> + +
diff --git a/src/Campaigns/Blocks/CampaignGoal/utils.ts b/src/Campaigns/Blocks/CampaignGoal/utils.ts new file mode 100644 index 0000000000..8a81a38016 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGoal/utils.ts @@ -0,0 +1,35 @@ +import {__} from '@wordpress/i18n'; +import {getCampaignOptionsWindowData, amountFormatter} from '@givewp/campaigns/utils'; + + +export const getGoalDescription = (goalType: string) => { + switch (goalType) { + case 'amount': + return __('Amount raised', 'give'); + case 'donations': + return __('Number of donations', 'give'); + case 'donors': + return __('Number of donors', 'give'); + case 'amountFromSubscriptions': + return __('Recurring amount raised', 'give'); + case 'subscriptions': + return __('Number of recurring donations', 'give'); + case 'donorsFromSubscriptions': + return __('Number of recurring donors', 'give'); + } +} + + +export const getGoalFormattedValue = (goalType: string, value: number) => { + switch (goalType) { + case 'amount': + case 'amountFromSubscriptions': + const {currency} = getCampaignOptionsWindowData() + const currencyFormatter = amountFormatter(currency); + + return currencyFormatter.format(value); + + default: + return value; + } +} diff --git a/src/Campaigns/Blocks/CampaignGrid/app.tsx b/src/Campaigns/Blocks/CampaignGrid/app.tsx new file mode 100644 index 0000000000..c6a6de366e --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/app.tsx @@ -0,0 +1,18 @@ +import {createRoot} from '@wordpress/element'; +import {CampaignGridType} from './types'; +import App from './app/index'; + +/** + * @unreleased + */ +const nodeList = document.querySelectorAll('[data-givewp-campaign-grid]'); + +if (nodeList) { + const containers = Array.from(nodeList); + + containers.map((container: any) => { + const attributes: CampaignGridType = JSON.parse(container.dataset?.attributes); + const root = createRoot(container); + return root.render(); + }); +} diff --git a/src/Campaigns/Blocks/CampaignGrid/app/index.tsx b/src/Campaigns/Blocks/CampaignGrid/app/index.tsx new file mode 100644 index 0000000000..ac5ee538e3 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/app/index.tsx @@ -0,0 +1,59 @@ +import {useState} from '@wordpress/element'; +import useCampaigns from '../../shared/hooks/useCampaigns'; +import Pagination from '../../shared/components/Pagination'; +import CampaignCard from '../../shared/components/CampaignCard'; +import {CampaignGridType} from '../types'; + +import './styles.scss'; + +const getGridSettings = (layout: string) => { + switch (layout) { + case 'double': + return 2; + case 'triple': + return 3; + default: + return 1; + } +} + +export default ({attributes}: { attributes: CampaignGridType }) => { + const [page, setPage] = useState(1); + + const {campaigns, hasResolved, totalPages} = useCampaigns({ + ids: attributes?.filterBy?.map((item: { value: string }) => Number(item.value)), + per_page: attributes?.perPage, + sortBy: attributes?.sortBy, + orderBy: attributes?.orderBy, + page, + }); + + if (!hasResolved) { + return null; + } + + return ( + <> +
+ {campaigns?.map((campaign) => ( + + ))} +
+ + {attributes.showPagination && totalPages >= page && ( +
+ setPage(number)} /> +
+ )} + + ) +} diff --git a/src/Campaigns/Blocks/CampaignGrid/app/styles.scss b/src/Campaigns/Blocks/CampaignGrid/app/styles.scss new file mode 100644 index 0000000000..a73426857b --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/app/styles.scss @@ -0,0 +1,11 @@ +.givewp-campaign-grid { + display: grid; + column-gap: 16px; + row-gap: 16px; + + &__pagination { + display: flex; + justify-content: center; + margin-top: 16px; + } +} diff --git a/src/Campaigns/Blocks/CampaignGrid/block.json b/src/Campaigns/Blocks/CampaignGrid/block.json new file mode 100644 index 0000000000..9802d87600 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/block.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json.schemastore.org/block.json", + "apiVersion": 2, + "name": "givewp/campaign-grid", + "version": "1.0.0", + "title": "Campaign Grid", + "category": "give", + "description": "Insert an existing campaign into the page.", + "supports": { + "align": [ + "wide", + "full", + "left", + "center", + "right" + ] + }, + "attributes": { + "layout": { + "type": "string", + "default": "full", + "enum": [ + "full", + "double", + "triple" + ] + }, + "showImage": { + "type": "boolean", + "default": true + }, + "showDescription": { + "type": "boolean", + "default": true + }, + "showGoal": { + "type": "boolean", + "default": true + }, + "sortBy": { + "type": "string", + "default": "date", + "enum": [ + "date" + ] + }, + "orderBy": { + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ] + }, + "filterBy": { + "type": "array", + "default": [] + }, + "perPage": { + "type": "number", + "default": "6" + }, + "showPagination": { + "type": "boolean", + "default": true + } + }, + "textdomain": "give", + "viewScript": "file:../../../../build/campaignGridApp.js", + "viewStyle": "file:../../../../build/campaignGridApp.css", + "render": "file:./render.php" +} diff --git a/src/Campaigns/Blocks/CampaignGrid/edit.tsx b/src/Campaigns/Blocks/CampaignGrid/edit.tsx new file mode 100644 index 0000000000..8535a21d10 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/edit.tsx @@ -0,0 +1,140 @@ +import {__} from '@wordpress/i18n'; +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import {FormTokenField, PanelBody, SelectControl, TextControl, ToggleControl} from '@wordpress/components'; +import {TokenItem} from '@wordpress/components/build-types/form-token-field/types' +import GridControl from '../shared/components/GridControl'; +import useCampaigns from '../shared/hooks/useCampaigns'; +import {CampaignGridType} from './types'; +import CampaignGridApp from './app/index' + +export default function Edit({attributes, setAttributes}: BlockEditProps) { + const blockProps = useBlockProps(); + const {campaigns, hasResolved} = useCampaigns(); + const suggestions = campaigns?.map((campaign) => campaign.title); + + return ( +
+ {hasResolved && ( + <> + + + + setAttributes({layout})} + options={[ + { + value: 'full', + label: __('Full Width', 'give'), + }, + { + value: 'double', + label: __('Double', 'give'), + }, + { + value: 'triple', + label: __('Triple', 'give'), + } + ]} + /> + + + + setAttributes({showImage})} + /> + setAttributes({showDescription})} + /> + setAttributes({showGoal})} + /> + + + + setAttributes({sortBy})} + value={attributes.sortBy} + help={__('The order campaigns are displayed in.', 'give')} + options={[ + { + value: 'date', + label: __('Date Created', 'give'), + }, + { + value: 'amount', + label: __('Total Amount Raised', 'give'), + }, + { + value: 'donations', + label: __('Number of Donations', 'give'), + }, + { + value: 'donors', + label: __('Number of Donors', 'give'), + } + ]} + /> + setAttributes({orderBy})} + value={attributes.orderBy} + help={__('Choose whether the campaign order ascends or descends.', 'give')} + options={[ + { + value: 'desc', + label: __('Descending', 'give'), + }, + { + value: 'asc', + label: __('Ascending', 'give'), + } + ]} + /> + item.title)} + label={__('Filter by Campaign', 'give')} + onChange={(values) => { + const filterBy = campaigns + .filter((campaign) => values.includes(campaign.title)) + .map((campaign) => { + return { + value: String(campaign.id), + title: campaign.title + } + }); + + setAttributes({filterBy}) + }} + suggestions={suggestions} + /> + setAttributes({perPage: Number(perPage)})} + help={__('Set the number of campaigns to be displayed on the first page load.', 'give')} + /> + setAttributes({showPagination})} + help={__('All campaigns will be spread across multiple pages.', 'give')} + /> + + + + )} +
+ ) +} diff --git a/src/Campaigns/Blocks/CampaignGrid/index.tsx b/src/Campaigns/Blocks/CampaignGrid/index.tsx new file mode 100644 index 0000000000..b1e8520057 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/index.tsx @@ -0,0 +1,15 @@ +import edit from './edit'; +import GiveIcon from '@givewp/components/GiveIcon'; +import schema from './block.json'; + +/** + * @unreleased + */ +export default { + schema, + settings: { + icon: , + edit, + }, +}; + diff --git a/src/Campaigns/Blocks/CampaignGrid/render.php b/src/Campaigns/Blocks/CampaignGrid/render.php new file mode 100644 index 0000000000..05a2055fd2 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/render.php @@ -0,0 +1,6 @@ + +
>
diff --git a/src/Campaigns/Blocks/CampaignGrid/types.ts b/src/Campaigns/Blocks/CampaignGrid/types.ts new file mode 100644 index 0000000000..8ae939b0da --- /dev/null +++ b/src/Campaigns/Blocks/CampaignGrid/types.ts @@ -0,0 +1,13 @@ +import {TokenItem} from '@wordpress/components/build-types/form-token-field/types'; + +export type CampaignGridType = { + layout: string; + showImage: boolean; + showDescription: boolean; + showGoal: boolean; + showPagination: boolean; + sortBy: string; + orderBy: string; + filterBy: (string | TokenItem)[]; + perPage: number; +} diff --git a/src/Campaigns/Blocks/CampaignStats/Icon.tsx b/src/Campaigns/Blocks/CampaignStats/Icon.tsx new file mode 100644 index 0000000000..b8e68daa9f --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/Icon.tsx @@ -0,0 +1,20 @@ +import {Path, SVG} from '@wordpress/components'; + +export function StatsIcon() { + return ( + + + + + ); +} diff --git a/src/Campaigns/Blocks/CampaignStats/app.tsx b/src/Campaigns/Blocks/CampaignStats/app.tsx new file mode 100644 index 0000000000..e39aaa19eb --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/app.tsx @@ -0,0 +1 @@ +import './styles.scss'; diff --git a/src/Campaigns/Blocks/CampaignStats/block.json b/src/Campaigns/Blocks/CampaignStats/block.json new file mode 100644 index 0000000000..ee52e1e2bd --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/block.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "givewp/campaign-stats-block", + "version": "1.0.0", + "title": "Campaign Statistics", + "category": "give", + "description": "Displays the campaign’s statistics.", + "attributes": { + "campaignId": { + "type": "integer" + }, + "statistic": { + "type": "string", + "enum": [ + "top-donation", + "average-donation" + ], + "default": "top-donation" + } + }, + "supports": { + "align": [ + "wide", + "full" + ], + "anchor": true, + "className": true, + "splitting": true, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalLetterSpacing": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalWritingMode": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "textdomain": "give", + "render": "file:./render.php", + "viewScript": "file:../../../../build/campaignStatsApp.js", + "style": "file:../../../../build/campaignStatsBlockApp.css" +} diff --git a/src/Campaigns/Blocks/CampaignStats/edit.tsx b/src/Campaigns/Blocks/CampaignStats/edit.tsx new file mode 100644 index 0000000000..3b676a6a20 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/edit.tsx @@ -0,0 +1,54 @@ +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {__} from '@wordpress/i18n'; +import {BlockEditProps} from '@wordpress/blocks'; +import {PanelBody, SelectControl} from '@wordpress/components'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import ServerSideRender from '@wordpress/server-side-render'; +import useCampaign from '../shared/hooks/useCampaign'; + +import './styles.scss'; + +type statisticType = 'top-donation' | 'average-donation'; + +export default function Edit({ + attributes, + setAttributes, +}: BlockEditProps<{ + campaignId: number; + statistic: statisticType; +}>) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes?.campaignId); + + const statsHelpText = attributes.statistic === 'top-donation' + ? __('Displays the top donation of the selected campaign.', 'give') + : __('Displays the average donation of the selected campaign.', 'give'); + + return ( +
+ setAttributes({campaignId})} + > + + + + {hasResolved && campaign?.id && ( + + + setAttributes({statistic: value})} + /> + + + )} +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignStats/index.tsx b/src/Campaigns/Blocks/CampaignStats/index.tsx new file mode 100644 index 0000000000..1e035d8a69 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/index.tsx @@ -0,0 +1,16 @@ +import schema from './block.json'; +import Edit from './edit'; +import {StatsIcon} from './Icon'; + +/** + * @unreleased + */ +const settings = { + icon: , + edit: Edit, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/CampaignStats/render.php b/src/Campaigns/Blocks/CampaignStats/render.php new file mode 100644 index 0000000000..82cd23a08a --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/render.php @@ -0,0 +1,43 @@ +getById($attributes['campaignId']) +) { + return; +} + +$query = (new CampaignDonationQuery($campaign)) + ->select( + $attributes['statistic'] === 'top-donation' + ? 'MAX(amountMeta.meta_value) as amount' + : 'AVG(amountMeta.meta_value) as amount' + ) + ->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amountMeta'); + +$donationStat = $query->get(); + +$amount = $donationStat && $donationStat->amount + ? Money::fromDecimal($donationStat->amount, give_get_currency()) + : Money::fromDecimal(0, give_get_currency()); + +$title = $attributes['statistic'] === 'top-donation' ? __('Top Donation', 'give') : __('Average Donation', 'give'); +?> + +
+ + formatToLocale()) ?> +
diff --git a/src/Campaigns/Blocks/CampaignStats/styles.scss b/src/Campaigns/Blocks/CampaignStats/styles.scss new file mode 100644 index 0000000000..575b3afbda --- /dev/null +++ b/src/Campaigns/Blocks/CampaignStats/styles.scss @@ -0,0 +1,23 @@ +.givewp-campaign-stats-block { + + span { + display: block; + margin-bottom: 2px; + height: 18px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + letter-spacing: 0.48px; + text-align: left; + color: var(--givewp-neutral-500); + } + + strong { + height: 32px; + font-size: 20px; + font-weight: 600; + line-height: 1.6; + letter-spacing: normal; + color: var(--givewp-neutral-900); + } +} diff --git a/src/Campaigns/Blocks/CampaignTitle/block.json b/src/Campaigns/Blocks/CampaignTitle/block.json new file mode 100644 index 0000000000..7d21c84eb0 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignTitle/block.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "givewp/campaign-title", + "version": "1.0.0", + "title": "Campaign Title", + "category": "give", + "icon": "heading", + "description": "Displays the title of the campaign.", + "attributes": { + "campaignId": { + "type": "integer" + }, + "headingLevel": { + "type": "number", + "default": 1 + }, + "textAlign": { + "type": "string" + } + }, + "supports": { + "align": [ + "wide", + "full" + ], + "className": true, + "splitting": true, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "color": { + "gradients": true, + "link": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalLetterSpacing": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalWritingMode": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "textdomain": "give", + "editorStyle": "file:../../../../build/campaignTitleBlock.css", + "render": "file:./render.php" +} diff --git a/src/Campaigns/Blocks/CampaignTitle/edit.tsx b/src/Campaigns/Blocks/CampaignTitle/edit.tsx new file mode 100644 index 0000000000..efb6ab33d5 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignTitle/edit.tsx @@ -0,0 +1,80 @@ +import { + AlignmentControl, + BlockControls, + HeadingLevelDropdown, + InspectorControls, + useBlockProps, +} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import {BaseControl, Icon, PanelBody, TextareaControl} from '@wordpress/components'; +import ServerSideRender from '@wordpress/server-side-render'; +import CampaignSelector from '../shared/components/CampaignSelector'; +import useCampaign from '../shared/hooks/useCampaign'; +import {__} from '@wordpress/i18n'; +import {getCampaignOptionsWindowData} from '@givewp/campaigns/utils'; +import {external} from '@wordpress/icons'; + +import './editor.scss'; + +export default function Edit({ + attributes, + setAttributes, + }: BlockEditProps<{ + campaignId: number; + headingLevel: string; + textAlign: string; +}>) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes.campaignId); + const campaignWindowData = getCampaignOptionsWindowData(); + + const editCampaignUrl = `${campaignWindowData.campaignsAdminUrl}&id=${attributes.campaignId}&tab=settings`; + + return ( +
+ setAttributes({campaignId})} + > + + + + {hasResolved && campaign && ( + + + + null} + help={ + + {__('Edit campaign title', 'give')} + + + } + /> + + + + )} + + + setAttributes({headingLevel: newLevel})} + /> + setAttributes({textAlign: nextAlign})} + /> + +
+ ); +} diff --git a/src/Campaigns/Blocks/CampaignTitle/editor.scss b/src/Campaigns/Blocks/CampaignTitle/editor.scss new file mode 100644 index 0000000000..031ddc43f7 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignTitle/editor.scss @@ -0,0 +1,13 @@ +.givewp-campaign-title-block { + &__edit-campaign-link { + display: inline-flex; + align-items: center; + gap: 0.125rem; + + svg { + fill: currentColor; + height: 1.25rem; + width: 1.25rem; + } + } +} diff --git a/src/Campaigns/Blocks/CampaignTitle/index.ts b/src/Campaigns/Blocks/CampaignTitle/index.ts new file mode 100644 index 0000000000..2d86c84f64 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignTitle/index.ts @@ -0,0 +1,16 @@ +import {heading as icon} from '@wordpress/icons'; +import schema from './block.json'; +import Edit from './edit'; + +/** + * @unreleased + */ +const settings = { + icon, + edit: Edit, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/CampaignTitle/index.tsx b/src/Campaigns/Blocks/CampaignTitle/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Campaigns/Blocks/CampaignTitle/render.php b/src/Campaigns/Blocks/CampaignTitle/render.php new file mode 100644 index 0000000000..6a9a5b9833 --- /dev/null +++ b/src/Campaigns/Blocks/CampaignTitle/render.php @@ -0,0 +1,27 @@ +getById($attributes['campaignId']); + +if ( ! $campaign) { + return; +} + +$headingLevel = isset($attributes['headingLevel']) ? (int) $attributes['headingLevel'] : 1; +$headingTag = 'h' . min(6, max(1, $headingLevel)); + +$textAlignClass = isset($attributes['textAlign']) ? 'has-text-align-' . $attributes['textAlign'] : ''; +?> + +< $textAlignClass])); ?>> +title); ?> +> diff --git a/src/Campaigns/Blocks/DonateButton/block.json b/src/Campaigns/Blocks/DonateButton/block.json new file mode 100644 index 0000000000..1fefcae93e --- /dev/null +++ b/src/Campaigns/Blocks/DonateButton/block.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/block.json", + "apiVersion": 2, + "name": "givewp/campaign-donate-button", + "version": "1.0.0", + "title": "Donate Button", + "category": "give", + "description": "The GiveWP Donate Button inserts an donate button into the page.", + "supports": { + "align": [ + "wide", + "full" + ] + }, + "attributes": { + "campaignId": { + "type": "number" + }, + "useDefaultForm": { + "type": "boolean", + "default": true + }, + "selectedForm": { + "type": "string" + }, + "buttonText": { + "type": "string", + "default": "Donate now" + } + }, + "textdomain": "give", + "render": "file:./render.php" +} diff --git a/src/Campaigns/Blocks/DonateButton/edit.tsx b/src/Campaigns/Blocks/DonateButton/edit.tsx new file mode 100644 index 0000000000..417d80ed1f --- /dev/null +++ b/src/Campaigns/Blocks/DonateButton/edit.tsx @@ -0,0 +1,105 @@ +import {__} from '@wordpress/i18n'; +import {useSelect} from '@wordpress/data'; +import {addQueryArgs} from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import useSWR from 'swr'; +import {InspectorControls, useBlockProps} from '@wordpress/block-editor'; +import {BlockEditProps} from '@wordpress/blocks'; +import {PanelBody, SelectControl, TextControl, ToggleControl} from '@wordpress/components'; +import {Button} from 'react-aria-components'; +import useCampaign from '../shared/hooks/useCampaign'; +import CampaignSelector from '../shared/components/CampaignSelector'; + + +/** + * @unreleased + */ +export default function Edit({attributes, setAttributes}: BlockEditProps<{ + campaignId: number; + buttonText: string; + useDefaultForm: boolean; + selectedForm: string; +}>) { + const blockProps = useBlockProps(); + const {campaign, hasResolved} = useCampaign(attributes.campaignId); + + const adminBaseUrl = useSelect( + // @ts-ignore + (select) => select('core').getSite()?.url + '/wp-admin/edit.php?post_type=give_forms&page=give-campaigns', + [] + ); + + const campaignForms = (() => { + const {data, isLoading}: { data: { items: [] }, isLoading: boolean } = useSWR( + addQueryArgs('/give-api/v2/admin/forms', {campaignId: attributes.campaignId, status: 'publish'}), + path => apiFetch({path}) + ) + + if (isLoading) { + return [{label: __('Loading...', 'give'), value: ''}] + } + + const options = data?.items.map((form: { name: string, id: string }) => ({ + label: form.name, + value: form.id + })) + + return [ + {label: __('Select form', 'give'), value: ''}, + ...options + ]; + })(); + + return ( +
+ setAttributes({campaignId})} + > + + + + {hasResolved && campaign?.id && ( + + + setAttributes({buttonText})} + /> + setAttributes({useDefaultForm})} + help={ + <> + {__('Uses the campaign’s default form.', 'give')} + {` `} + + {__('Change default form', 'give')} + + + } + /> + {!attributes.useDefaultForm && ( + setAttributes({selectedForm})} + options={campaignForms} + value={attributes.selectedForm} + help={__('Donations are collected through this form.', 'give')} + /> + )} + + + )} +
+ ); +} diff --git a/src/Campaigns/Blocks/DonateButton/index.tsx b/src/Campaigns/Blocks/DonateButton/index.tsx new file mode 100644 index 0000000000..c6cf79da0b --- /dev/null +++ b/src/Campaigns/Blocks/DonateButton/index.tsx @@ -0,0 +1,17 @@ +import schema from './block.json'; +import Edit from './edit'; +import GiveIcon from '@givewp/components/GiveIcon'; + +/** + * @unreleased + */ +const settings = { + icon: , + edit: Edit, + save: () => null, +}; + +export default { + schema, + settings, +}; diff --git a/src/Campaigns/Blocks/DonateButton/render.php b/src/Campaigns/Blocks/DonateButton/render.php new file mode 100644 index 0000000000..31b7064c84 --- /dev/null +++ b/src/Campaigns/Blocks/DonateButton/render.php @@ -0,0 +1,40 @@ +getById($attributes['campaignId']) +) { + return; +} + +$blockInlineStyles = sprintf( + '--givewp-primary-color: %s;', + esc_attr($campaign->primaryColor ?? '#0b72d9') +); + +$params = [ + 'formId' => ($attributes['useDefaultForm'] || ! isset($attributes['selectedForm'])) + ? $campaign->defaultFormId + : $attributes['selectedForm'], + 'openFormButton' => $attributes['buttonText'], + 'formFormat' => 'modal', +]; +?> + +
'givewp-campaign-donate-button-block'])); ?> + style=""> + render($params); ?> +
diff --git a/src/Campaigns/Blocks/blocks.ts b/src/Campaigns/Blocks/blocks.ts new file mode 100644 index 0000000000..32356f1fec --- /dev/null +++ b/src/Campaigns/Blocks/blocks.ts @@ -0,0 +1,23 @@ +/** + * WordPress dependencies + */ +import {BlockConfiguration, getBlockType, registerBlockType} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import campaignGrid from './CampaignGrid'; +import campaignBlock from './Campaign'; + +export const getAllBlocks = () => { + return [ + campaignGrid, + campaignBlock, + ]; +}; + +getAllBlocks().forEach((block) => { + if (!getBlockType(block.schema.name)) { + registerBlockType(block.schema as BlockConfiguration, block.settings); + } +}); diff --git a/src/Campaigns/Blocks/landingPage.ts b/src/Campaigns/Blocks/landingPage.ts new file mode 100644 index 0000000000..463e99ca13 --- /dev/null +++ b/src/Campaigns/Blocks/landingPage.ts @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import {BlockConfiguration, getBlockType, registerBlockType} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import campaignCover from './CampaignCover'; +import campaignDonateButton from './DonateButton'; +import campaignDonations from './CampaignDonations'; +import campaignDonors from './CampaignDonors'; +import campaignTitle from './CampaignTitle'; +import campaignGoal from './CampaignGoal'; +import campaignStats from './CampaignStats'; + +export const getAllBlocks = () => { + return [ + campaignCover, + campaignDonateButton, + campaignDonations, + campaignDonors, + campaignTitle, + campaignGoal, + campaignStats + ]; +}; + +getAllBlocks().forEach((block) => { + if (!getBlockType(block.schema.name)) { + registerBlockType(block.schema as BlockConfiguration, block.settings); + } +}); diff --git a/src/Campaigns/Blocks/shared/components/CampaignCard/index.tsx b/src/Campaigns/Blocks/shared/components/CampaignCard/index.tsx new file mode 100644 index 0000000000..d059d82fb5 --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignCard/index.tsx @@ -0,0 +1,71 @@ +import {__} from '@wordpress/i18n'; +import {getGoalDescription, getGoalFormattedValue} from '../../../CampaignGoal/utils'; +import {Campaign} from '@givewp/campaigns/admin/components/types'; +import {getCampaignOptionsWindowData} from '@givewp/campaigns/utils'; + +import './styles.scss'; + +export default ({showImage, showGoal, showDescription, campaign}: { + showImage: boolean, + showDescription: boolean, + showGoal: boolean, + campaign: Campaign +}) => { + + const campaignWindowData = getCampaignOptionsWindowData(); + + return ( +
window.location = campaign.pagePermalink + })} + > + {showImage && campaign.image && ( +
+
+ )} +
+ {campaign.title} +
+ {showDescription && ( +
+ {campaign.shortDescription} +
+ )} + + {showGoal && ( +
+
+
+
+
+
+
+
+
+ {getGoalDescription(campaign.goalType)} + + {getGoalFormattedValue(campaign.goalType, campaign.goalStats.actual)} + +
+
+ {__('Our goal', 'give')} + + {getGoalFormattedValue(campaign.goalType, campaign.goal)} + +
+
+
+ )} +
+ ) +} diff --git a/src/Campaigns/Blocks/shared/components/CampaignCard/styles.scss b/src/Campaigns/Blocks/shared/components/CampaignCard/styles.scss new file mode 100644 index 0000000000..08617f5c4c --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignCard/styles.scss @@ -0,0 +1,97 @@ +.give-campaigns-component-campaign { + + padding: 0 0 24px; + border-radius: 8px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05); + border: solid 1px var(--neutral-100); + background-color: #fff; + + + &-image { + background-size: cover; + height: 180px; + border-radius: 8px 8px 0 0; + } + + &-title { + padding: 8px; + font-size: 16px; + font-weight: bold; + line-height: 1.5; + color: #060c1a; + } + + &-description { + padding: 0 8px; + font-size: 14px; + font-weight: normal; + line-height: 1.43; + color: #363636; + } + + &__goal { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 8px; + margin-top: 8px; + + &-container { + display: flex; + flex-direction: row; + justify-content: space-between; + + &-item { + display: flex; + flex-direction: column; + + span { + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + letter-spacing: 0.48px; + color: #4b5563; + } + + strong { + font-size: 16px; + font-weight: 600; + line-height: 1.5; + color: #060c1a; + } + } + + &-item:first-child { + margin-bottom: 8px; + } + } + + &-progress { + display: flex; + + &-container { + display: flex; + height: 8px; + flex-grow: 1; + border-radius: 14px; + box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.09); + background-color: #f2f2f2; + } + + &-bar { + display: flex; + height: 8px; + border-radius: 14px; + box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.09); + background-color: #2d802f; + } + } + } + + &__pagination { + display: flex; + justify-content: center; + margin-top: 16px; + } +} diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector/Inspector.tsx b/src/Campaigns/Blocks/shared/components/CampaignSelector/Inspector.tsx new file mode 100644 index 0000000000..9a95c8e3ec --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignSelector/Inspector.tsx @@ -0,0 +1,63 @@ +import {__} from '@wordpress/i18n'; +import {PanelBody, SelectControl} from '@wordpress/components'; +import {InspectorControls} from '@wordpress/block-editor'; +import {Campaign} from '@givewp/campaigns/admin/components/types'; +import {getCampaignOptionsWindowData} from '@givewp/campaigns/utils'; + +type CampaignDropdownProps = { + campaignId: number; + campaigns: Campaign[], + hasResolved: boolean; + handleSelect: (id: number) => void; + inspectorControls?: JSX.Element | JSX.Element[]; +} + +export default function Inspector({campaignId, campaigns, hasResolved, handleSelect, inspectorControls = null}: CampaignDropdownProps) { + const campaignWindowData = getCampaignOptionsWindowData(); + const options = (() => { + if (!hasResolved) { + return [{label: __('Loading...', 'give'), value: ''}]; + } + + if (campaigns.length) { + const campaignOptions = campaigns.map((campaign) => ({ + label: campaign.title, + value: campaign.id.toString(), + })); + + return [{label: __('Select...', 'give'), value: ''}, ...campaignOptions]; + } + + return [{label: __('No campaigns found.', 'give'), value: ''}]; + })(); + + return ( + + + handleSelect(parseInt(newValue))} + help={ + <> + {__('Select a campaign to display.', 'give') + ` `} + {campaignId && ( + + {__('Edit campaign', 'give')} + + )} + + } + /> + {inspectorControls} + + + ); +} diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector/Selector.tsx b/src/Campaigns/Blocks/shared/components/CampaignSelector/Selector.tsx new file mode 100644 index 0000000000..82ce71073b --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignSelector/Selector.tsx @@ -0,0 +1,77 @@ +import {useState} from 'react'; +import {__} from '@wordpress/i18n'; +import {Campaign} from '@givewp/campaigns/admin/components/types'; +import ReactSelect from 'react-select'; +import {reactSelectStyles, reactSelectThemeStyles} from './reactSelectStyles'; +import logo from './images/givewp-logo.svg'; + +import './styles.scss'; + +type CampaignSelectorProps = { + hasResolved: boolean; + campaigns: Campaign[]; + handleSelect: (id: number) => void; +} + +/** + * @unreleased + */ +export default ({campaigns, hasResolved, handleSelect}: CampaignSelectorProps) => { + const [selectedCampaign, setSelectedCampaign] = useState(null); + + const campaignOptions = (() => { + if (!hasResolved) { + return [{label: __('Loading...', 'give'), value: ''}]; + } + + if (campaigns.length) { + const campaignOptions = campaigns.map((campaign) => ({ + label: campaign.title, + value: campaign.id, + })); + + return [{label: __('Select a campaign', 'give'), value: ''}, ...campaignOptions]; + } + + return [{label: __('No campaigns found.', 'give'), value: ''}]; + })(); + + const campaign = campaignOptions.find(option => option.value === selectedCampaign); + + return ( +
+ givewp-logo +
+ + + setSelectedCampaign(option?.value)} + noOptionsMessage={() =>

{__('No campaigns were found.', 'give')}

} + //@ts-ignore + options={campaignOptions} + loadingMessage={() => <>{__('Loading Campaigns...', 'give')}} + isLoading={!hasResolved} + theme={reactSelectThemeStyles} + styles={reactSelectStyles} + /> +
+ + +
+ ); +} diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector/images/givewp-logo.svg b/src/Campaigns/Blocks/shared/components/CampaignSelector/images/givewp-logo.svg new file mode 100644 index 0000000000..166821b8ac --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignSelector/images/givewp-logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector/index.tsx b/src/Campaigns/Blocks/shared/components/CampaignSelector/index.tsx new file mode 100644 index 0000000000..b812a5f2f4 --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignSelector/index.tsx @@ -0,0 +1,55 @@ +import {useEffect} from 'react'; +import {select} from '@wordpress/data'; +import Inspector from './Inspector'; +import useCampaigns from '../../hooks/useCampaigns'; +import Selector from './Selector'; + +type CampaignSelectorProps = { + campaignId: number; + children: JSX.Element | JSX.Element[], + handleSelect: (id: number) => void; + inspectorControls?: JSX.Element | JSX.Element[]; + showInspectorControl?: boolean; +} + +export default ({campaignId, handleSelect, children, inspectorControls = null, showInspectorControl = false}: CampaignSelectorProps) => { + + // set campaign id from context + useEffect(() => { + if (campaignId) { + return; + } + // @ts-ignore + const id = select('core/editor').getEditedPostAttribute('campaignId'); + + if (id) { + handleSelect(id); + } + }, []); + + const {campaigns, hasResolved} = useCampaigns(); + + return ( + <> + {!campaignId && ( + handleSelect(id)} + campaigns={campaigns} + hasResolved={hasResolved} + /> + )} + + {showInspectorControl && ( + handleSelect(id)} + inspectorControls={inspectorControls} + /> + )} + + {campaignId && children} + + ); +} diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector/reactSelectStyles.ts b/src/Campaigns/Blocks/shared/components/CampaignSelector/reactSelectStyles.ts new file mode 100644 index 0000000000..2f1ad6b82a --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignSelector/reactSelectStyles.ts @@ -0,0 +1,24 @@ +export const reactSelectStyles = { + input: (provided, state) => ({ + ...provided, + height: '3rem', + }), + option: (provided, state) => ({ + ...provided, + paddingTop: '0.8rem', + paddingBottom: '0.8rem', + fontSize: '1rem', + }), + control: (provided, state) => ({ + ...provided, + fontSize: '1rem', + }), +}; + +export const reactSelectThemeStyles = (theme) => ({ + ...theme, + colors: { + ...theme.colors, + primary: '#27ae60', + }, +}); diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector/styles.scss b/src/Campaigns/Blocks/shared/components/CampaignSelector/styles.scss new file mode 100644 index 0000000000..976ea41fcb --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/CampaignSelector/styles.scss @@ -0,0 +1,58 @@ + +.givewp-campaign-selector { + display: flex; + flex-direction: column; + gap: 20px; + padding: 40px 24px; + border: 1px solid #e5e7eb; + border-radius: 5px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05); + + &__label { + padding-bottom: 16px; + } + + &__select { + input[type='text']:focus { + border-color: transparent; + box-shadow: 0 0 0 1px transparent; + outline: 2px solid transparent; + } + } + + &__logo { + align-self: center; + } + + &__open { + color: #fff; + background: #2271b1; + padding: 0.5rem 1rem; + cursor: pointer; + border: none; + border-radius: 5px; + } + + &__submit { + width: 100%; + background-color: #27ae60; + color: #fff; + padding: 1rem; + border-radius: 5px; + font-weight: bold; + text-align: center; + outline: none; + border: 0; + transition: 0.2s; + + &:disabled { + background-color: #f3f4f6; + color: #9ca0af; + } + + &:hover:not(:disabled) { + cursor: pointer; + filter: brightness(1.2); + } + } +} diff --git a/src/Campaigns/Blocks/shared/components/GridControl/index.tsx b/src/Campaigns/Blocks/shared/components/GridControl/index.tsx new file mode 100644 index 0000000000..f8cdce10ce --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/GridControl/index.tsx @@ -0,0 +1,38 @@ +import {SelectControl} from '@wordpress/components'; + +import './styles.scss'; + +/** + * @unreleased + */ +export default ({label, options, onChange, value}: GridLayoutProps) => { + + const index = options.findIndex((option) => value === option.value); + + return ( + <> +
+
+ {Array(index + 1).fill(
)} +
+
+ + onChange(selected)} + options={options} + /> + + ) +} + +interface GridLayoutProps { + label: string; + value: string; + options: { + value: string, + label: string + }[], + onChange: (value: string) => void, +} diff --git a/src/Campaigns/Blocks/shared/components/GridControl/styles.scss b/src/Campaigns/Blocks/shared/components/GridControl/styles.scss new file mode 100644 index 0000000000..93350b5f8d --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/GridControl/styles.scss @@ -0,0 +1,22 @@ +.give-campaign-components-gridLayout { + background-color: #d9d9d9; + padding: 16px; + margin-bottom: 16px; + + &__columns { + padding: 8px; + gap: 8px; + border-radius: 2px; + border: 1px dashed #000000; + display: flex; + justify-content: space-between; + flex-direction: row; + + &-item { + flex: 1; + height: 42px; + border-radius: 2px; + background-color: #2271b1; + } + } +} diff --git a/src/Campaigns/Blocks/shared/components/Pagination/icons.tsx b/src/Campaigns/Blocks/shared/components/Pagination/icons.tsx new file mode 100644 index 0000000000..85d4b13f26 --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/Pagination/icons.tsx @@ -0,0 +1,14 @@ +export const ChevronRight = () => ( + + + +) + +export const ChevronLeft = () => ( + + + +) diff --git a/src/Campaigns/Blocks/shared/components/Pagination/index.tsx b/src/Campaigns/Blocks/shared/components/Pagination/index.tsx new file mode 100644 index 0000000000..6a74c4a6ce --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/Pagination/index.tsx @@ -0,0 +1,78 @@ +import {__, sprintf} from '@wordpress/i18n'; +import cx from 'classnames'; +import {ChevronLeft, ChevronRight} from './icons'; + +import './styles.scss'; + +export default ({currentPage, totalPages, setPage}: PaginationProps) => { + if (1 >= totalPages) { + return null; + } + + const nextPage = currentPage + 1; + const previousPage = currentPage - 1; + + return ( +
+
+
+ {previousPage > 0 ? ( + + ) : ( + + )} + + {[...Array(totalPages)].map((e, i) => { + const page = i + 1; + return ( + + ) + })} + + {nextPage <= totalPages ? ( + + ) : ( + + )} +
+
+
+ ); +} + +interface PaginationProps { + currentPage: number, + totalPages: number, + setPage: (page: number) => void +} diff --git a/src/Campaigns/Blocks/shared/components/Pagination/styles.scss b/src/Campaigns/Blocks/shared/components/Pagination/styles.scss new file mode 100644 index 0000000000..bc895867f0 --- /dev/null +++ b/src/Campaigns/Blocks/shared/components/Pagination/styles.scss @@ -0,0 +1,61 @@ +.give-campaign-components-pagination { + display: flex; + padding: 8px 0; + flex-direction: row; + + &__pages { + display: flex; + + &-links { + display: flex; + flex-direction: row; + gap: 4px; + + button { + flex-grow: 0; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 8px; + text-decoration: none !important; + border-radius: 4px; + border: solid 1px #e5e7eb; + font-weight: normal; + font-size: 14px; + line-height: 1.43; + } + + &-arrow { + padding: 8px; + + &-disabled { + padding: 8px; + opacity: 0.7; + cursor: not-allowed; + + svg { + opacity: 0.7; + } + } + } + + &-page { + padding: 8px 14px; + color: #060c1a !important; + } + + button:not(.give-campaign-components-pagination__pages-links-current, .give-campaign-components-pagination__pages-links-arrow-disabled):hover { + border: solid 1px #459948; + background-color: #f2fff3; + } + + &-current { + border: solid 1px #459948; + background-color: #459948; + color: #fff !important; + } + + } + } +} diff --git a/src/Campaigns/Blocks/shared/hooks/useCampaign.ts b/src/Campaigns/Blocks/shared/hooks/useCampaign.ts new file mode 100644 index 0000000000..3d0ed8f3ad --- /dev/null +++ b/src/Campaigns/Blocks/shared/hooks/useCampaign.ts @@ -0,0 +1,13 @@ +import {useEntityRecord} from '@wordpress/core-data'; +import {Campaign} from '@givewp/campaigns/admin/components/types'; + +export default function useCampaign(campaignId: number) { + const campaignData = useEntityRecord('givewp', 'campaign', campaignId); + + return { + campaign: { + ...campaignData?.record as Campaign + }, + hasResolved: campaignData?.hasResolved, + }; +} diff --git a/src/Campaigns/Blocks/shared/hooks/useCampaigns.ts b/src/Campaigns/Blocks/shared/hooks/useCampaigns.ts new file mode 100644 index 0000000000..42da3a9cf7 --- /dev/null +++ b/src/Campaigns/Blocks/shared/hooks/useCampaigns.ts @@ -0,0 +1,38 @@ +import {useEntityRecords} from '@wordpress/core-data'; +import {Campaign} from '@givewp/campaigns/admin/components/types'; + +type useCampaignsParams = { + ids?: number[], + page?: number, + per_page?: number; + status?: 'active' | 'draft' | 'archived'; + sortBy?: string; + orderBy?: string; +} + +export default function useCampaigns({ + ids = [], + page = 1, + per_page = 30, + status = 'active', + sortBy = 'date', + orderBy = 'desc', + }: useCampaignsParams = {}) { + const data = useEntityRecords('givewp', 'campaign', { + ids, + page, + per_page, + status, + sortBy, + orderBy + }); + + return { + campaigns: data?.records as Campaign[], + //@ts-ignore + totalItems: data.totalItems, + //@ts-ignore + totalPages: data.totalPages, + hasResolved: data?.hasResolved, + }; +} diff --git a/src/Campaigns/CampaignDonationQuery.php b/src/Campaigns/CampaignDonationQuery.php new file mode 100644 index 0000000000..5eb66afaab --- /dev/null +++ b/src/Campaigns/CampaignDonationQuery.php @@ -0,0 +1,127 @@ +from('posts', 'donation'); + $this->where('post_type', 'give_payment'); + + // Include only valid statuses + $this->whereIn('donation.post_status', ['publish', 'give_subscription']); + + // Include only current payment "mode" + $this->joinDonationMeta(DonationMetaKeys::MODE, 'paymentMode'); + $this->where('paymentMode.meta_value', give_is_test_mode() ? 'test' : 'live'); + + // Include only forms associated with the Campaign. + $this->joinDonationMeta(DonationMetaKeys::CAMPAIGN_ID, 'campaignId'); + $this->where('campaignId.meta_value', $campaign->id); + } + + /** + * @unreleased + */ + public function between(DateTimeInterface $startDate, DateTimeInterface $endDate): self + { + $query = clone $this; + $query->joinDonationMeta('_give_completed_date', 'completed'); + $query->whereBetween( + 'completed.meta_value', + $startDate->format('Y-m-d H:i:s'), + $endDate->format('Y-m-d H:i:s') + ); + return $query; + } + + /** + * Returns a calculated sum of the intended amounts (without recovered fees) for the donations. + * + * @unreleased + * + * @return int|float + */ + public function sumIntendedAmount() + { + $query = clone $this; + $query->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amount'); + $query->joinDonationMeta('_give_fee_donation_amount', 'intendedAmount'); + return $query->sum( + /** + * The intended amount meta and the amount meta could either be 0 or NULL. + * So we need to use the NULLIF function to treat the 0 values as NULL. + * Then we coalesce the values to select the first non-NULL value. + * @link https://github.com/impress-org/givewp/pull/7411 + */ + 'COALESCE(NULLIF(intendedAmount.meta_value,0), NULLIF(amount.meta_value,0), 0)' + ); + } + + /** + * @unreleased + */ + public function countDonations(): int + { + $query = clone $this; + return $query->count('donation.ID'); + } + + /** + * @unreleased + */ + public function countDonors(): int + { + $query = clone $this; + $query->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorId'); + return $query->count('DISTINCT donorId.meta_value'); + } + + /** + * @unreleased + */ + public function getDonationsByDay(): array + { + $query = clone $this; + + $query->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amount'); + $query->joinDonationMeta('_give_fee_donation_amount', 'intendedAmount'); + $query->select( + 'SUM(COALESCE(NULLIF(intendedAmount.meta_value,0), NULLIF(amount.meta_value,0), 0)) as amount' + ); + + $query->joinDonationMeta('_give_completed_date', 'completed'); + $query->select('DATE(completed.meta_value) as date'); + $query->groupBy('date'); + + return $query->getAll(); + } + + /** + * An opinionated join method for the donation meta table. + * @unreleased + */ + public function joinDonationMeta($key, $alias): self + { + $this->join(function (JoinQueryBuilder $builder) use ($key, $alias) { + $builder + ->leftJoin('give_donationmeta', $alias) + ->on('donation.ID', $alias . '.donation_id') + ->andOn($alias . '.meta_key', $key, true); + }); + return $this; + } +} diff --git a/src/Campaigns/CampaignsAdminPage.php b/src/Campaigns/CampaignsAdminPage.php new file mode 100644 index 0000000000..cf6be15b93 --- /dev/null +++ b/src/Campaigns/CampaignsAdminPage.php @@ -0,0 +1,57 @@ +'; + } + + /** + * @unreleased + */ + public static function isShowingDetailsPage(): bool + { + return isset($_GET['id'], $_GET['page']) && 'give-campaigns' === $_GET['page']; + } +} diff --git a/src/Campaigns/Controllers/CampaignRequestController.php b/src/Campaigns/Controllers/CampaignRequestController.php new file mode 100644 index 0000000000..2fea7b2c47 --- /dev/null +++ b/src/Campaigns/Controllers/CampaignRequestController.php @@ -0,0 +1,257 @@ +get_param('id')); + + if ( ! $campaign) { + return new WP_Error('campaign_not_found', __('Campaign not found', 'give'), ['status' => 404]); + } + + return new WP_REST_Response((new CampaignViewModel($campaign))->exports()); + } + + /** + * @unreleased + */ + public function getCampaigns(WP_REST_Request $request): WP_REST_Response + { + $ids = $request->get_param('ids'); + $page = $request->get_param('page'); + $perPage = $request->get_param('per_page'); + $status = $request->get_param('status'); + $sortBy = $request->get_param('sortBy'); + $orderBy = $request->get_param('orderBy'); + + $query = Campaign::query(); + + $query->where('status', $status); + + if ( ! empty($ids)) { + $query->whereIn('id', $ids); + } + + $totalQuery = clone $query; + + $query + ->limit($perPage) + ->offset(($page - 1) * $perPage); + + $this->orderCampaigns($query, $sortBy, $orderBy); + + $campaigns = $query->getAll() ?? []; + $totalCampaigns = empty($campaigns) ? 0 : $totalQuery->count(); + $totalPages = (int)ceil($totalCampaigns / $perPage); + + $campaigns = array_map(function ($campaign) { + return (new CampaignViewModel($campaign))->exports(); + }, $campaigns); + + $response = rest_ensure_response($campaigns); + $response->header('X-WP-Total', $totalCampaigns); + $response->header('X-WP-TotalPages', $totalPages); + + $base = add_query_arg( + map_deep($request->get_query_params(), function ($value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + return urlencode($value); + }), + rest_url(CampaignRoute::CAMPAIGNS) + ); + + if ($page > 1) { + $prevPage = $page - 1; + + if ($prevPage > $totalPages) { + $prevPage = $totalPages; + } + + $response->link_header('prev', add_query_arg('page', $prevPage, $base)); + } + + if ($totalPages > $page) { + $nextPage = $page + 1; + $response->link_header('next', add_query_arg('page', $nextPage, $base)); + } + + return $response; + } + + /** + * @unreleased + * + * @return WP_Error | WP_REST_Response + * + * @throws Exception + */ + public function updateCampaign(WP_REST_Request $request) + { + $campaign = Campaign::find($request->get_param('id')); + + if ( ! $campaign) { + return new WP_Error('campaign_not_found', __('Campaign not found', 'give'), ['status' => 404]); + } + + $statusMap = [ + 'archived' => CampaignStatus::ARCHIVED(), + 'draft' => CampaignStatus::DRAFT(), + 'active' => CampaignStatus::ACTIVE(), + ]; + + foreach ($request->get_params() as $key => $value) { + switch ($key) { + case 'id': + break; + case 'status': + $status = array_key_exists($value, $statusMap) + ? $statusMap[$value] + : CampaignStatus::DRAFT(); + + $campaign->status = $status; + + break; + case 'goal': + $campaign->goal = (int)$value; + break; + case 'goalType': + $campaign->goalType = new CampaignGoalType($value); + break; + case 'defaultFormId': + give(CampaignRepository::class)->updateDefaultCampaignForm($campaign, + $request->get_param('defaultFormId')); + break; + default: + if ($campaign->hasProperty($key)) { + $campaign->$key = $value; + } + } + } + + if ($campaign->isDirty()) { + $campaign->save(); + } + + return new WP_REST_Response((new CampaignViewModel($campaign))->exports()); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function mergeCampaigns(WP_REST_Request $request): WP_REST_Response + { + $destinationCampaign = Campaign::find($request->get_param('id')); + $campaignsToMerge = Campaign::query()->whereIn('id', $request->get_param('campaignsToMergeIds'))->getAll(); + + $campaignsMerged = $destinationCampaign->merge(...$campaignsToMerge); + + return new WP_REST_Response($campaignsMerged); + } + + + /** + * @unreleased + * + * @throws Exception + */ + public function createCampaign(WP_REST_Request $request): WP_REST_Response + { + $campaign = Campaign::create([ + 'type' => CampaignType::CORE(), + 'title' => $request->get_param('title'), + 'shortDescription' => $request->get_param('shortDescription') ?? '', + 'longDescription' => '', + 'logo' => '', + 'image' => $request->get_param('image') ?? '', + 'primaryColor' => '#0b72d9', + 'secondaryColor' => '#27ae60', + 'goal' => (int)$request->get_param('goal'), + 'goalType' => new CampaignGoalType($request->get_param('goalType')), + 'status' => CampaignStatus::DRAFT(), + 'startDate' => $request->get_param('startDateTime'), + 'endDate' => $request->get_param('endDateTime'), + ]); + + return new WP_REST_Response((new CampaignViewModel($campaign))->exports(), 201); + } + + /** + * @unreleased + */ + private function orderCampaigns(ModelQueryBuilder $query, $sortBy, $orderBy) + { + switch ($sortBy) { + case 'date': + $query->orderBy('date_created', $orderBy); + + break; + case 'amount': + $query + ->selectRaw('(SELECT SUM(amount) FROM %1s WHERE campaign_id = campaigns.id) AS amount', + DB::prefix('give_revenue')) + ->orderBy('amount', $orderBy); + + break; + case 'donations': + $query + ->selectRaw('(SELECT COUNT(donation_id) FROM %1s WHERE campaign_id = campaigns.id) AS donationsCount', + DB::prefix('give_revenue')) + ->orderBy('donationsCount', $orderBy); + + break; + case 'donors': + + $postsTable = DB::prefix('posts'); + $metaTable = DB::prefix('give_donationmeta'); + $campaignIdKey = DonationMetaKeys::CAMPAIGN_ID; + $donorIdKey = DonationMetaKeys::DONOR_ID; + + $query + ->selectRaw( + "( + SELECT COUNT(DISTINCT donorId.meta_value) + FROM {$postsTable} AS donation + LEFT JOIN {$metaTable} campaignId ON donation.ID = campaignId.donation_id AND campaignId.meta_key = '{$campaignIdKey}' + LEFT JOIN {$metaTable} donorId ON donation.ID = donorId.donation_id AND donorId.meta_key = '{$donorIdKey}' + WHERE post_type = 'give_payment' + AND donation.post_status IN ('publish', 'give_subscription') + AND campaignId.meta_value = campaigns.id + ) AS donorsCount" + ) + ->orderBy('donorsCount', $orderBy); + + break; + } + } +} diff --git a/src/Campaigns/DataTransferObjects/CampaignGoalData.php b/src/Campaigns/DataTransferObjects/CampaignGoalData.php new file mode 100644 index 0000000000..780de8c704 --- /dev/null +++ b/src/Campaigns/DataTransferObjects/CampaignGoalData.php @@ -0,0 +1,126 @@ +campaign = $campaign; + $this->actual = $this->getActual(); + $this->actualFormatted = $this->getActualFormatted(); + $this->percentage = $this->getPercentage(); + $this->goal = $campaign->goal; + $this->goalFormatted = $this->getGoalFormatted(); + } + + /** + * @unreleased + */ + private function getActual(): int + { + $query = new CampaignDonationQuery($this->campaign); + + switch ($this->campaign->goalType->getValue()) { + case GoalType::DONATIONS(): + return $query->countDonations(); + + case GoalType::DONORS(): + return $query->countDonors(); + + case GoalType::AMOUNT(): + default: + return $query->sumIntendedAmount(); + } + } + + /** + * @unreleased + */ + private function getPercentage(): float + { + $percentage = $this->campaign->goal + ? $this->actual / $this->campaign->goal + : 0; + return round($percentage * 100, 2); + } + + /** + * @unreleased + */ + private function getActualFormatted(): string + { + if ($this->campaign->goalType == GoalType::AMOUNT) { + return give_currency_filter(give_format_amount($this->actual)); + } + + return $this->actual; + } + + /** + * @unreleased + */ + private function getGoalFormatted(): string + { + if ($this->campaign->goalType == GoalType::AMOUNT) { + return give_currency_filter(give_format_amount($this->goal)); + } + + return $this->goal; + } + + /** + * @unreleased + */ + public function toArray(): array + { + return [ + 'actual' => $this->actual, + 'actualFormatted' => $this->actualFormatted, + 'percentage' => $this->percentage, + 'goal' => $this->goal, + 'goalFormatted' => $this->goalFormatted, + ]; + } +} diff --git a/src/Campaigns/Factories/CampaignFactory.php b/src/Campaigns/Factories/CampaignFactory.php new file mode 100644 index 0000000000..1cc64f9510 --- /dev/null +++ b/src/Campaigns/Factories/CampaignFactory.php @@ -0,0 +1,42 @@ + CampaignType::CORE(), + 'enableCampaignPage' => true, + 'defaultFormId' => 1, + 'title' => __('GiveWP Campaign', 'give'), + 'shortDescription' => __('Campaign short description', 'give'), + 'longDescription' => __('Campaign long description', 'give'), + 'goal' => 10000000, + 'goalType' => CampaignGoalType::AMOUNT(), + 'status' => CampaignStatus::ACTIVE(), + 'logo' => '', + 'image' => '', + 'primaryColor' => '#28C77B', + 'secondaryColor' => '#FFA200', + 'createdAt' => Temporal::withoutMicroseconds($currentDate), + 'startDate' => Temporal::withoutMicroseconds($currentDate), + 'endDate' => Temporal::withoutMicroseconds($currentDate->modify('+1 day')), + ]; + } +} diff --git a/src/Campaigns/ListTable/CampaignsListTable.php b/src/Campaigns/ListTable/CampaignsListTable.php new file mode 100644 index 0000000000..27ef0ea570 --- /dev/null +++ b/src/Campaigns/ListTable/CampaignsListTable.php @@ -0,0 +1,69 @@ +createdAt->format($format); + } +} diff --git a/src/Campaigns/ListTable/Columns/DescriptionColumn.php b/src/Campaigns/ListTable/Columns/DescriptionColumn.php new file mode 100644 index 0000000000..77bb6dcd91 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/DescriptionColumn.php @@ -0,0 +1,38 @@ +shortDescription, true); + } +} diff --git a/src/Campaigns/ListTable/Columns/DonationsCountColumn.php b/src/Campaigns/ListTable/Columns/DonationsCountColumn.php new file mode 100644 index 0000000000..a06e0461bf --- /dev/null +++ b/src/Campaigns/ListTable/Columns/DonationsCountColumn.php @@ -0,0 +1,59 @@ +countDonations(); + + $label = $totalDonations > 0 + ? sprintf( + _n( + '%1$s donation', + '%1$s donations', + $totalDonations, + 'give' + ), + $totalDonations + ) : __('No donations', 'give'); + + + return sprintf( + '%s', + admin_url("edit.php?post_type=give_forms&page=give-payment-history&form_id=$model->id"), + __('Visit donations page', 'give'), + apply_filters("givewp_list_table_cell_value_{$this::getId()}_content", $label, $model, $this) + ); + } +} diff --git a/src/Campaigns/ListTable/Columns/EndDateColumn.php b/src/Campaigns/ListTable/Columns/EndDateColumn.php new file mode 100644 index 0000000000..eeaba73645 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/EndDateColumn.php @@ -0,0 +1,42 @@ +endDate->format($format); + } +} diff --git a/src/Campaigns/ListTable/Columns/GoalColumn.php b/src/Campaigns/ListTable/Columns/GoalColumn.php new file mode 100644 index 0000000000..2fc68dd58d --- /dev/null +++ b/src/Campaigns/ListTable/Columns/GoalColumn.php @@ -0,0 +1,76 @@ + + + +
+ %3$s%4$s %5$s +
+ '; + + return sprintf( + $template, + $model->id, + $goalData->percentage, + $goalData->actualFormatted, + sprintf( + ' %s %s', + __('of', 'give'), + $goalData->goalFormatted + ), + sprintf( + '%3$s%4$s', + apply_filters('givewp_list_table_goal_progress_achieved_opacity', $goalData->percentage >= 100 ? 1 : 0), + GIVE_PLUGIN_URL . 'assets/dist/images/list-table/star-icon.svg', + __('Goal achieved icon', 'give'), + __('Goal achieved!', 'give') + ) + ); + } +} diff --git a/src/Campaigns/ListTable/Columns/IdColumn.php b/src/Campaigns/ListTable/Columns/IdColumn.php new file mode 100644 index 0000000000..1a5a616cc5 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/IdColumn.php @@ -0,0 +1,40 @@ +id; + } +} diff --git a/src/Campaigns/ListTable/Columns/RevenueColumn.php b/src/Campaigns/ListTable/Columns/RevenueColumn.php new file mode 100644 index 0000000000..66476c1523 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/RevenueColumn.php @@ -0,0 +1,50 @@ +sumIntendedAmount())); + + return sprintf( + '%s', + admin_url("edit.php?post_type=give_forms&page=give-reports&tab=forms&legacy=true&form-id=$model->id"), + __('Visit form reports page', 'give'), + apply_filters("givewp_list_table_cell_value_{$this::getId()}_content", + $revenue, $model, $this) + ); + } +} diff --git a/src/Campaigns/ListTable/Columns/StartDateColumn.php b/src/Campaigns/ListTable/Columns/StartDateColumn.php new file mode 100644 index 0000000000..3a420f5191 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/StartDateColumn.php @@ -0,0 +1,42 @@ +startDate->format($format); + } +} diff --git a/src/Campaigns/ListTable/Columns/StatusColumn.php b/src/Campaigns/ListTable/Columns/StatusColumn.php new file mode 100644 index 0000000000..4430d9a9a6 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/StatusColumn.php @@ -0,0 +1,68 @@ +status->getValue()) { + case 'active': + $statusLabel = __('Active', 'give'); + break; + case 'inactive': + $statusLabel = __('Inactive', 'give'); + break; + case 'archived': + $statusLabel = __('Archived', 'give'); + break; + case 'draft': + $statusLabel = __('Draft', 'give'); + break; + case 'pending': + $statusLabel = __('Pending', 'give'); + break; + case 'processing': + $statusLabel = __('Processing', 'give'); + break; + case 'failed': + $statusLabel = __('Failed', 'give'); + break; + default: + $statusLabel = __('Draft', 'give'); + } + + return sprintf( + '

%2$s

', + $model->status->getValue(), + $statusLabel + ); + } +} diff --git a/src/Campaigns/ListTable/Columns/TitleColumn.php b/src/Campaigns/ListTable/Columns/TitleColumn.php new file mode 100644 index 0000000000..09b513841e --- /dev/null +++ b/src/Campaigns/ListTable/Columns/TitleColumn.php @@ -0,0 +1,45 @@ +%s', + admin_url("edit.php?post_type=give_forms&page=give-campaigns&id=$model->id"), + __('Visit campaign page', 'give'), + $model->title + ); + } +} diff --git a/src/Campaigns/Migrations/Donations/AddCampaignId.php b/src/Campaigns/Migrations/Donations/AddCampaignId.php new file mode 100644 index 0000000000..26aa60a658 --- /dev/null +++ b/src/Campaigns/Migrations/Donations/AddCampaignId.php @@ -0,0 +1,92 @@ +select('campaign_id', 'form_id') + ->getAll(); + + foreach ($data as $relationship) { + $relationships[$relationship->campaign_id][] = $relationship->form_id; + } + + $donations = DB::table('posts') + ->select('ID') + ->attachMeta( + 'give_donationmeta', + 'ID', + 'donation_id', + [DonationMetaKeys::FORM_ID(), 'formId'] + ) + ->where('post_type', 'give_payment') + ->getAll(); + + $donationMeta = []; + + foreach ($donations as $donation) { + foreach ($relationships as $campaignId => $formIds) { + if (in_array($donation->formId, $formIds)) { + $donationMeta[] = [ + 'donation_id' => $donation->ID, + 'meta_key' => DonationMetaKeys::CAMPAIGN_ID, + 'meta_value' => $campaignId, + ]; + + break; + } + } + } + + if ( ! empty($donationMeta)) { + DB::table('give_donationmeta') + ->insert($donationMeta, ['%d', '%s', '%d']); + } + } catch (DatabaseQueryException $exception) { + throw new DatabaseMigrationException("An error occurred while adding campaign ID to the donation meta table", 0, $exception); + } + } +} diff --git a/src/Campaigns/Migrations/MigrateFormsToCampaignForms.php b/src/Campaigns/Migrations/MigrateFormsToCampaignForms.php new file mode 100644 index 0000000000..e7a4ed8b24 --- /dev/null +++ b/src/Campaigns/Migrations/MigrateFormsToCampaignForms.php @@ -0,0 +1,312 @@ +getAllFormsData()); + array_map([$this, 'addUpgradedV2FormToCampaign'], $this->getUpgradedV2FormsData()); + } catch (DatabaseQueryException $exception) { + DB::rollback(); + throw new DatabaseMigrationException('An error occurred while creating initial campaigns', 0, $exception); + } + }); + } + + /** + * @unreleased + */ + protected function getAllFormsData(): array + { + $query = DB::table('posts', 'forms')->distinct() + ->select( + ['forms.ID', 'id'], + ['forms.post_title', 'title'], + ['forms.post_status', 'status'], + ['forms.post_date', 'createdAt'] + ) + ->where('forms.post_type', 'give_forms'); + + $query->select(['formmeta.meta_value', 'settings']) + ->join(function (JoinQueryBuilder $builder) { + $builder + ->leftJoin('give_formmeta', 'formmeta') + ->on('formmeta.form_id', 'forms.ID')->joinRaw("AND formmeta.meta_key = 'formBuilderSettings'"); + }); + + // Exclude forms already associated with a campaign (ie Peer-to-peer). + $query->join(function (JoinQueryBuilder $builder) { + $builder + ->leftJoin('give_campaigns', 'campaigns') + ->on('campaigns.form_id', 'forms.ID'); + }) + ->whereIsNull('campaigns.id'); + + /** + * Exclude forms with an "auto-draft" status, which are WP revisions. + * + * @see https://wordpress.org/documentation/article/post-status/#auto-draft + */ + $query->where('forms.post_status', 'auto-draft', '!='); + + /** + * Excluded upgraded V2 forms as their corresponding V3 version will be used to create the campaign - later the V2 form will be added to the proper campaign as a non-default form through the addUpgradedV2FormToCampaign() method. + */ + $query->whereNotIn('forms.ID', function (QueryBuilder $builder) { + $builder + ->select('meta_value') + ->from('give_formmeta') + ->where('meta_key', 'migratedFormId'); + }); + + // Ensure campaigns will be displayed in the same order on the list table + $query->orderBy('forms.ID'); + + return $query->getAll(); + } + + /** + * @unreleased + * @return array [{formId, campaignId, migratedFormId}] + */ + protected function getUpgradedV2FormsData(): array + { + return DB::table('posts', 'forms') + ->select(['forms.ID', 'formId'], ['campaign_forms.campaign_id', 'campaignId']) + ->attachMeta('give_formmeta', 'ID', 'form_id', 'migratedFormId') + ->join(function (JoinQueryBuilder $builder) { + $builder + ->rightJoin('give_campaign_forms', 'campaign_forms') + ->on('campaign_forms.form_id', 'forms.ID'); + }) + ->where('forms.post_type', 'give_forms') + ->whereIsNotNull('give_formmeta_attach_meta_migratedFormId.meta_value') + ->getAll(); + } + + /** + * @unreleased + */ + public function createCampaignForForm($formData): void + { + $formId = $formData->id; + $formStatus = $formData->status; + $formTitle = $formData->title; + $formCreatedAt = $formData->createdAt; + $isV3Form = ! is_null($formData->settings); + $formSettings = $isV3Form ? json_decode($formData->settings) : $this->getV2FormSettings($formId); + + DB::table('give_campaigns') + ->insert([ + 'form_id' => $formId, + 'campaign_type' => 'core', + 'campaign_title' => $formTitle, + 'status' => $this->mapFormToCampaignStatus($formStatus), + 'short_desc' => $formSettings->formExcerpt, + 'long_desc' => $formSettings->description, + 'campaign_logo' => $formSettings->designSettingsLogoUrl, + 'campaign_image' => $formSettings->designSettingsImageUrl, + 'primary_color' => $formSettings->primaryColor, + 'secondary_color' => $formSettings->secondaryColor, + 'campaign_goal' => $formSettings->goalAmount, + 'goal_type' => $formSettings->goalType, + 'start_date' => $formSettings->goalStartDate, + 'end_date' => $formSettings->goalEndDate, + 'date_created' => $formCreatedAt, + ]); + + $campaignId = DB::last_insert_id(); + + $this->addCampaignFormRelationship($formId, $campaignId); + } + + /** + * @param $data + */ + protected function addUpgradedV2FormToCampaign($data): void + { + $this->addCampaignFormRelationship($data->migratedFormId, $data->campaignId); + } + + /** + * @unreleased + */ + protected function addCampaignFormRelationship($formId, $campaignId) + { + DB::table('give_campaign_forms') + ->insert([ + 'form_id' => $formId, + 'campaign_id' => $campaignId, + ]); + } + + /** + * @unreleased + */ + protected function mapFormToCampaignStatus(string $status): string + { + switch ($status) { + + case 'pending': + return 'pending'; + + case 'draft': + case 'upgraded': // Some V3 forms can have the 'upgraded' status after being migrated from a V2 form + return 'draft'; + + case 'trash': + return 'archived'; + + case 'publish': + case 'private': + return 'active'; + + default: // TODO: How do we handle an unknown form status? + return 'inactive'; + } + } + + /** + * @unreleased + */ + protected function getV2FormSettings(int $formId): stdClass + { + $template = give_get_meta($formId, '_give_form_template', true); + $templateSettings = give_get_meta($formId, "_give_{$template}_form_template_settings", true); + $templateSettings = is_array($templateSettings) ? $templateSettings : []; + + return (object)[ + 'formExcerpt' => get_the_excerpt($formId), + 'description' => $this->getV2FormDescription($templateSettings), + 'designSettingsLogoUrl' => '', + 'designSettingsImageUrl' => $this->getV2FormFeaturedImage($templateSettings, $formId), + 'primaryColor' => $this->getV2FormPrimaryColor($templateSettings), + 'secondaryColor' => '', + 'goalAmount' => $this->getV2FormGoalAmount($formId), + 'goalType' => $this->getV2FormGoalType($formId), + 'goalStartDate' => '', + 'goalEndDate' => '', + ]; + } + + /** + * @unreleased + */ + protected function getV2FormFeaturedImage(array $templateSettings, int $formId): string + { + if ( ! empty($templateSettings['introduction']['image'])) { + // Sequoia Template (Multi-Step) + $featuredImage = $templateSettings['introduction']['image']; + } elseif ( ! empty($templateSettings['visual_appearance']['header_background_image'])) { + // Classic Template - it doesn't use the featured image from the WP default setting as a fallback + $featuredImage = $templateSettings['visual_appearance']['header_background_image']; + } elseif ( ! isset($templateSettings['visual_appearance']['header_background_image'])) { + // Legacy Template or Sequoia Template without the ['introduction']['image'] setting + $featuredImage = get_the_post_thumbnail_url($formId, 'full'); + } else { + $featuredImage = ''; + } + + return $featuredImage; + } + + /** + * @unreleased + */ + protected function getV2FormDescription(array $templateSettings): string + { + if ( ! empty($templateSettings['introduction']['description'])) { + // Sequoia Template (Multi-Step) + $description = $templateSettings['introduction']['description']; + } elseif ( ! empty($templateSettings['visual_appearance']['description'])) { + // Classic Template + $description = $templateSettings['visual_appearance']['description']; + } else { + $description = ''; + } + + return $description; + } + + /** + * @unreleased + */ + protected function getV2FormPrimaryColor(array $templateSettings): string + { + if ( ! empty($templateSettings['introduction']['primary_color'])) { + // Sequoia Template (Multi-Step) + $primaryColor = $templateSettings['introduction']['primary_color']; + } elseif ( ! empty($templateSettings['visual_appearance']['primary_color'])) { + // Classic Template + $primaryColor = $templateSettings['visual_appearance']['primary_color']; + } else { + $primaryColor = ''; + } + + return $primaryColor; + } + + /** + * @unreleased + */ + protected function getV2FormGoalAmount(int $formId) + { + return give_get_form_goal($formId); + } + + /** + * @unreleased + */ + protected function getV2FormGoalType(int $formId): string + { + $onlyRecurringEnabled = filter_var(give_get_meta($formId, '_give_recurring_goal_format', true), + FILTER_VALIDATE_BOOLEAN); + + switch (give_get_form_goal_format($formId)) { + case 'donors': + return $onlyRecurringEnabled ? 'donorsFromSubscriptions' : 'donors'; + case 'donation': + return $onlyRecurringEnabled ? 'subscriptions' : 'donations'; + case 'amount': + case 'percentage': + default: + return $onlyRecurringEnabled ? 'amountFromSubscriptions' : 'amount'; + } + } +} diff --git a/src/Campaigns/Migrations/P2P/SetCampaignType.php b/src/Campaigns/Migrations/P2P/SetCampaignType.php new file mode 100644 index 0000000000..66f9d3d292 --- /dev/null +++ b/src/Campaigns/Migrations/P2P/SetCampaignType.php @@ -0,0 +1,58 @@ +where('campaign_type', '') + ->update([ + 'campaign_type' => CampaignType::PEER_TO_PEER + ]); + } catch (DatabaseQueryException $exception) { + throw new DatabaseMigrationException('An error occurred while updating the campaign type', 0, $exception); + } + } +} diff --git a/src/Campaigns/Migrations/RevenueTable/AddCampaignID.php b/src/Campaigns/Migrations/RevenueTable/AddCampaignID.php new file mode 100644 index 0000000000..a53d7c5586 --- /dev/null +++ b/src/Campaigns/Migrations/RevenueTable/AddCampaignID.php @@ -0,0 +1,51 @@ +give_revenue} ADD INDEX (form_id), ADD INDEX (campaign_id)"); + } catch (DatabaseQueryException $exception) { + throw new DatabaseMigrationException("An error occurred while updating the {$wpdb->give_revenue} table", 0, + $exception); + } + } +} diff --git a/src/Campaigns/Migrations/RevenueTable/AssociateDonationsToCampaign.php b/src/Campaigns/Migrations/RevenueTable/AssociateDonationsToCampaign.php new file mode 100644 index 0000000000..3c1bf2d7cc --- /dev/null +++ b/src/Campaigns/Migrations/RevenueTable/AssociateDonationsToCampaign.php @@ -0,0 +1,54 @@ +give_revenue} AS revenue JOIN {$wpdb->give_campaign_forms} forms ON revenue.form_id = forms.form_id SET revenue.campaign_id = forms.campaign_id"); + } catch (DatabaseQueryException $exception) { + throw new DatabaseMigrationException("An error occurred while updating the {$wpdb->give_revenue} table", 0, + $exception); + } + } +} diff --git a/src/Campaigns/Migrations/Tables/CreateCampaignFormsTable.php b/src/Campaigns/Migrations/Tables/CreateCampaignFormsTable.php new file mode 100644 index 0000000000..49c32034c2 --- /dev/null +++ b/src/Campaigns/Migrations/Tables/CreateCampaignFormsTable.php @@ -0,0 +1,65 @@ +give_campaign_forms; + $charset = DB::get_charset_collate(); + + $sql = "CREATE TABLE $table ( + campaign_id INT UNSIGNED NOT NULL, + form_id INT UNSIGNED NOT NULL, + KEY form_id (form_id), + KEY campaign_id (campaign_id), + PRIMARY KEY (campaign_id, form_id) + ) $charset"; + + try { + DB::delta($sql); + } catch (DatabaseQueryException $exception) { + throw new DatabaseMigrationException("An error occurred while creating the $table table", 0, $exception); + } + } +} diff --git a/src/Campaigns/Migrations/Tables/CreateCampaignsTable.php b/src/Campaigns/Migrations/Tables/CreateCampaignsTable.php new file mode 100644 index 0000000000..f837078d5a --- /dev/null +++ b/src/Campaigns/Migrations/Tables/CreateCampaignsTable.php @@ -0,0 +1,80 @@ +give_campaigns; + $charset = DB::get_charset_collate(); + + $sql = "CREATE TABLE $table ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + campaign_page_id INT UNSIGNED NULL, + form_id INT NOT NULL, + campaign_type VARCHAR(12) NOT NULL DEFAULT '', + enable_campaign_page BOOLEAN NOT NULL DEFAULT 1, + campaign_title TEXT NOT NULL, + campaign_url TEXT NOT NULL, + short_desc TEXT NOT NULL, + long_desc TEXT NOT NULL, + campaign_logo TEXT NOT NULL, + campaign_image TEXT NOT NULL, + primary_color VARCHAR(7) NOT NULL, + secondary_color VARCHAR(7) NOT NULL, + campaign_goal INT UNSIGNED NOT NULL, + goal_type VARCHAR(24) NOT NULL DEFAULT 'amount', + status VARCHAR(12) NOT NULL, + start_date DATETIME NULL, + end_date DATETIME NULL, + date_created DATETIME NOT NULL, + PRIMARY KEY (id) + ) $charset"; + + try { + DB::delta($sql); + } catch (DatabaseQueryException $exception) { + throw new DatabaseMigrationException("An error occurred while creating the $table table", 0, $exception); + } + } +} diff --git a/src/Campaigns/Models/Campaign.php b/src/Campaigns/Models/Campaign.php new file mode 100644 index 0000000000..f0f8e5ef5c --- /dev/null +++ b/src/Campaigns/Models/Campaign.php @@ -0,0 +1,203 @@ + 'int', + 'pageId' => 'int', + 'defaultFormId' => 'int', + 'type' => CampaignType::class, + 'enableCampaignPage' => ['bool', true], + 'title' => 'string', + 'shortDescription' => 'string', + 'longDescription' => 'string', + 'logo' => 'string', + 'image' => 'string', + 'primaryColor' => 'string', + 'secondaryColor' => 'string', + 'goal' => 'int', + 'goalType' => CampaignGoalType::class, + 'status' => CampaignStatus::class, + 'startDate' => DateTime::class, + 'endDate' => DateTime::class, + 'createdAt' => DateTime::class, + ]; + + /** + * @unreleased + */ + public function defaultForm(): ?DonationForm + { + return give(DonationFormsRepository::class)->getById($this->defaultFormId); + } + + /** + * @unreleased + */ + public function forms(): ModelQueryBuilder + { + return DonationForm::query() + ->join(function (JoinQueryBuilder $builder) { + $builder + ->leftJoin('give_campaign_forms', 'campaign_forms') + ->on('campaign_forms.form_id', 'id'); + }) + ->where('campaign_forms.campaign_id', $this->id); + } + + /** + * @unreleased + */ + public function page() + { + return give(CampaignPageRepository::class)->findByCampaignId($this->id); + } + + /** + * @unreleased + */ + public static function factory(): CampaignFactory + { + return new CampaignFactory(static::class); + } + + /** + * Find campaign by ID + * + * @unreleased + */ + public static function find($id): ?Campaign + { + return give(CampaignRepository::class)->getById($id); + } + + /** + * Find campaign by Form ID + * + * @unreleased + */ + public static function findByFormId(int $formId): ?Campaign + { + return give(CampaignRepository::class)->getByFormId($formId); + } + + /** + * @unreleased + * + * @throws Exception + */ + public static function create(array $attributes): Campaign + { + $campaign = new static($attributes); + + give(CampaignRepository::class)->insert($campaign); + + return $campaign; + } + + /** + * @unreleased + * + * @throws Exception|InvalidArgumentException + */ + public function save(): void + { + if ( ! $this->id) { + give(CampaignRepository::class)->insert($this); + } else { + give(CampaignRepository::class)->update($this); + } + } + + /** + * @unreleased + * + * @throws Exception + */ + public function delete(): bool + { + return give(CampaignRepository::class)->delete($this); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function merge(Campaign ...$campaignsToMerge): bool + { + return give(CampaignRepository::class)->mergeCampaigns($this, ...$campaignsToMerge); + } + + public function getGoalStats(): array + { + return (new CampaignGoalData($this))->toArray(); + } + + /** + * @unreleased + * + * @return ModelQueryBuilder + */ + public static function query(): ModelQueryBuilder + { + return give(CampaignRepository::class)->prepareQuery(); + } + + /** + * @unreleased + * + * @param object $object + */ + public static function fromQueryBuilderObject($object): Campaign + { + return (new ConvertQueryDataToCampaign())($object); + } +} diff --git a/src/Campaigns/Models/CampaignPage.php b/src/Campaigns/Models/CampaignPage.php new file mode 100644 index 0000000000..0f4f0ef9c5 --- /dev/null +++ b/src/Campaigns/Models/CampaignPage.php @@ -0,0 +1,117 @@ + 'int', + 'campaignId' => 'int', + 'createdAt' => DateTime::class, + 'updatedAt' => DateTime::class, + ]; + + public $relationships = [ + 'campaign' => Relationship::BELONGS_TO, + ]; + + /** + * @unreleased + */ + public function getEditLinkUrl(): string + { + // By default, the URL is encoded for display purposes. + // Setting any other value prevents encoding the URL. + return get_edit_post_link($this->id, 'redirect'); + } + + /** + * @unreleased + */ + public function campaign() + { + return Campaign::find($this->campaignId); + } + + /** + * @unreleased + */ + public static function find($id) + { + return give(CampaignPageRepository::class) + ->prepareQuery() + ->where('ID', $id) + ->get(); + } + + /** + * @unreleased + */ + public static function create(array $attributes): CampaignPage + { + $campaignPage = new static($attributes); + + give(CampaignPageRepository::class)->insert($campaignPage); + + return $campaignPage; + } + + /** + * @unreleased + */ + public function save(): void + { + if (!$this->id) { + give(CampaignPageRepository::class)->insert($this); + } else { + give(CampaignPageRepository::class)->update($this); + } + } + + /** + * @unreleased + */ + public function delete(): bool + { + return give(CampaignPageRepository::class)->delete($this); + } + + /** + * @unreleased + * + * @return ModelQueryBuilder + */ + public static function query(): ModelQueryBuilder + { + return give(CampaignPageRepository::class)->prepareQuery(); + } + + /** + * @unreleased + */ + public static function fromQueryBuilderObject($object): CampaignPage + { + return new CampaignPage([ + 'id' => (int) $object->id, + 'campaignId' => (int) $object->campaignId, + 'createdAt' => Temporal::toDateTime($object->createdAt), + 'updatedAt' => Temporal::toDateTime($object->updatedAt), + ]); + } +} diff --git a/src/Campaigns/Repositories/CampaignPageRepository.php b/src/Campaigns/Repositories/CampaignPageRepository.php new file mode 100644 index 0000000000..cbc75323ec --- /dev/null +++ b/src/Campaigns/Repositories/CampaignPageRepository.php @@ -0,0 +1,214 @@ +prepareQuery() + ->where('id', $id) + ->get(); + } + + /** + * @unreleased + */ + public function findByCampaignId(int $campaignId): ?CampaignPage + { + return $this->prepareQuery() + ->where('postmeta_attach_meta_campaignId.meta_value', $campaignId) + ->get(); + } + + /** + * @unreleased + */ + public function insert(CampaignPage $campaignPage): void + { + $this->validate($campaignPage); + + Hooks::doAction('givewp_campaign_page_creating', $campaignPage); + + $dateCreated = Temporal::withoutMicroseconds($campaignPage->createdAt ?: Temporal::getCurrentDateTime()); + $dateCreatedFormatted = Temporal::getFormattedDateTime($dateCreated); + $dateUpdated = $campaignPage->updatedAt ?? $dateCreated; + $dateUpdatedFormatted = Temporal::getFormattedDateTime($dateUpdated); + + DB::query('START TRANSACTION'); + + try { + DB::table('posts') + ->insert([ + 'post_title' => $campaignPage->campaign()->title, + 'post_date' => $dateCreatedFormatted, + 'post_date_gmt' => get_gmt_from_date($dateCreatedFormatted), + 'post_modified' => $dateUpdatedFormatted, + 'post_modified_gmt' => get_gmt_from_date($dateUpdatedFormatted), + 'post_status' => 'publish', // TODO: Update to value object + 'post_type' => 'give_campaign_page', + 'post_content' => give(CreateDefaultLayoutForCampaignPage::class)($campaignPage->campaignId), + ]); + + $campaignPage->id = DB::last_insert_id(); + $campaignPage->createdAt = $dateCreated; + $campaignPage->updatedAt = $dateUpdated; + + DB::table('postmeta') + ->insert([ + 'post_id' => $campaignPage->id, + 'meta_key' => 'campaignId', + 'meta_value' => $campaignPage->campaignId, + ]); + + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed creating a campaign page', [$campaignPage]); + + throw new $exception('Failed creating a campaign page'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_page_created', $campaignPage); + } + + /** + * @unreleased + */ + public function update(CampaignPage $campaignPage): void + { + $this->validate($campaignPage); + + Hooks::doAction('givewp_campaign_page_updating', $campaignPage); + + $now = Temporal::withoutMicroseconds(Temporal::getCurrentDateTime()); + $nowFormatted = Temporal::getFormattedDateTime($now); + + DB::query('START TRANSACTION'); + + try { + DB::table('posts') + ->where('ID', $campaignPage->id) + ->update([ + 'post_modified' => $nowFormatted, + 'post_modified_gmt' => get_gmt_from_date($nowFormatted), + 'post_status' => 'publish', // TODO: Update to value object + 'post_type' => 'give_campaign_page', + ]); + + $campaignPage->updatedAt = $now; + + DB::table('postmeta') + ->where('post_id', $campaignPage->id) + ->where('meta_key', 'campaignId') + ->update([ + 'meta_value' => $campaignPage->campaignId, + ]); + + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed updating a campaign page', [$campaignPage]); + + throw new $exception('Failed updating a campaign page'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_page_updated', $campaignPage); + } + + /** + * @unreleased + */ + public function delete(CampaignPage $campaignPage): bool + { + DB::query('START TRANSACTION'); + + Hooks::doAction('givewp_campaign_page_deleting', $campaignPage); + + try { + DB::table('posts') + ->where('id', $campaignPage->id) + ->delete(); + + DB::table('postmeta') + ->where('post_id', $campaignPage->id) + ->delete(); + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed deleting a campaign page', [$campaignPage]); + + throw new $exception('Failed deleting a campaign page'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_page_deleted', $campaignPage); + + return true; + } + + /** + * @unreleased + * + * @return ModelQueryBuilder + */ + public function prepareQuery(): ModelQueryBuilder + { + $builder = new ModelQueryBuilder(CampaignPage::class); + + return $builder->from('posts') + ->select( + ['ID', 'id'], + ['post_date', 'createdAt'], + ['post_modified', 'updatedAt'], + ['post_status', 'status'] + ) + ->attachMeta( + 'postmeta', + 'ID', + 'post_id', + 'campaignId' + ) + ->where('post_type', 'give_campaign_page'); + } + + /** + * @unreleased + */ + public function validate(CampaignPage $campaignPage) + { + foreach ($this->requiredProperties as $key) { + if (!isset($campaignPage->$key)) { + throw new InvalidArgumentException("'$key' is required."); + } + } + } +} diff --git a/src/Campaigns/Repositories/CampaignRepository.php b/src/Campaigns/Repositories/CampaignRepository.php new file mode 100644 index 0000000000..6752720238 --- /dev/null +++ b/src/Campaigns/Repositories/CampaignRepository.php @@ -0,0 +1,381 @@ +prepareQuery() + ->where('id', $id) + ->get(); + } + + /** + * @unreleased + * + * Get Campaign by Form ID using a lookup table + */ + public function getByFormId(int $formId) + { + return $this->queryByFormId($formId)->get(); + } + + /** + * @unreleased + * + * @param int $formId + * + * @return ModelQueryBuilder + */ + public function queryByFormId(int $formId): ModelQueryBuilder + { + return $this->prepareQuery() + ->leftJoin('give_campaign_forms', 'campaigns.id', 'forms.campaign_id', 'forms') + ->where('forms.form_id', $formId); + } + + /** + * @unreleased + * + * @throws Exception|InvalidArgumentException + */ + public function insert(Campaign $campaign): void + { + $this->validateProperties($campaign); + + Hooks::doAction('givewp_campaign_creating', $campaign); + + $currentDate = Temporal::getCurrentDateTime(); + + $dateCreated = Temporal::withoutMicroseconds($campaign->createdAt ?: $currentDate); + $dateCreatedFormatted = Temporal::getFormattedDateTime($dateCreated); + + $startDateFormatted = Temporal::getFormattedDateTime($campaign->startDate ?: $currentDate); + $endDateFormatted = $campaign->endDate ? Temporal::getFormattedDateTime($campaign->endDate) : $campaign->endDate; + + DB::query('START TRANSACTION'); + + try { + DB::table('give_campaigns') + ->insert([ + 'campaign_type' => $campaign->type->getValue(), + 'enable_campaign_page' => $campaign->enableCampaignPage, + 'campaign_title' => wp_strip_all_tags($campaign->title, true), + 'short_desc' => wp_strip_all_tags($campaign->shortDescription), + 'long_desc' => wp_strip_all_tags($campaign->longDescription), + 'campaign_logo' => $campaign->logo, + 'campaign_image' => $campaign->image, + 'primary_color' => $campaign->primaryColor, + 'secondary_color' => $campaign->secondaryColor, + 'campaign_goal' => $campaign->goal, + 'goal_type' => $campaign->goalType->getValue(), + 'status' => $campaign->status->getValue(), + 'start_date' => $startDateFormatted, + 'end_date' => $endDateFormatted, + 'date_created' => $dateCreatedFormatted, + ]); + + $campaignId = DB::last_insert_id(); + + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed creating a campaign', compact('campaign')); + + throw new $exception('Failed creating a campaign'); + } + + DB::query('COMMIT'); + + $campaign->id = $campaignId; + $campaign->createdAt = $dateCreated; + + Hooks::doAction('givewp_campaign_created', $campaign); + } + + /** + * @unreleased + * + * @throws Exception|InvalidArgumentException + */ + public function update(Campaign $campaign): void + { + $this->validateProperties($campaign); + + $startDateFormatted = Temporal::getFormattedDateTime($campaign->startDate); + $endDateFormatted = $campaign->endDate ? Temporal::getFormattedDateTime($campaign->endDate) : $campaign->endDate; + + Hooks::doAction('givewp_campaign_updating', $campaign); + + DB::query('START TRANSACTION'); + + try { + DB::table('give_campaigns') + ->where('id', $campaign->id) + ->update([ + 'campaign_type' => $campaign->type->getValue(), + 'enable_campaign_page' => $campaign->enableCampaignPage, + 'campaign_page_id' => $campaign->pageId, + 'campaign_title' => wp_strip_all_tags($campaign->title, true), + 'short_desc' => wp_strip_all_tags($campaign->shortDescription), + 'long_desc' => wp_strip_all_tags($campaign->longDescription), + 'campaign_logo' => $campaign->logo, + 'campaign_image' => $campaign->image, + 'primary_color' => $campaign->primaryColor, + 'secondary_color' => $campaign->secondaryColor, + 'campaign_goal' => $campaign->goal, + 'goal_type' => $campaign->goalType->getValue(), + 'status' => $campaign->status->getValue(), + 'start_date' => $startDateFormatted, + 'end_date' => $endDateFormatted, + ]); + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed updating a campaign', compact('campaign')); + + throw new $exception('Failed updating a campaign'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_updated', $campaign); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function addCampaignForm(Campaign $campaign, int $donationFormId, bool $isDefault = false) + { + Hooks::doAction('givewp_campaign_form_relationship_creating', $campaign, $donationFormId, $isDefault); + + DB::query('START TRANSACTION'); + + try { + if ($isDefault) { + DB::table('give_campaigns') + ->where('id', $campaign->id) + ->update([ + 'form_id' => $donationFormId, + ]); + + $campaign->defaultFormId = $donationFormId; + } + + DB::table('give_campaign_forms') + ->insert([ + 'form_id' => $donationFormId, + 'campaign_id' => $campaign->id, + ]); + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed creating a campaign form relationship', compact('campaign')); + + throw new $exception('Failed creating a campaign form relationship'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_form_relationship_created', $campaign, $donationFormId, $isDefault); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function updateDefaultCampaignForm(Campaign $campaign, int $donationFormId) + { + Hooks::doAction('givewp_campaign_default_form_updating', $campaign, $donationFormId); + + DB::query('START TRANSACTION'); + + try { + DB::table('give_campaigns') + ->where('id', $campaign->id) + ->update([ + 'form_id' => $donationFormId + ]); + + $campaign->defaultFormId = $donationFormId; + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed updating the campaign default form', compact('campaign')); + + throw new $exception('Failed updating the campaign default form'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_default_form_updated', $campaign, $donationFormId); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function delete(Campaign $campaign): bool + { + DB::query('START TRANSACTION'); + + Hooks::doAction('givewp_campaign_deleting', $campaign); + + try { + DB::table('give_campaigns') + ->where('id', $campaign->id) + ->delete(); + + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed deleting a campaign', compact('campaign')); + + throw new $exception('Failed deleting a campaign'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaign_deleted', $campaign); + + return true; + } + + /** + * @unreleased + * + * @throws Exception + */ + public function mergeCampaigns(Campaign $destinationCampaign, Campaign ...$campaignsToMerge): bool + { + // Make sure the destination campaign ID will not be included into $campaignsToMergeIds + $campaignsToMergeIds = array_column($campaignsToMerge, 'id'); + if ($key = array_search($destinationCampaign->id, $campaignsToMergeIds)) { + unset($campaignsToMergeIds[$key]); + } + + Hooks::doAction('givewp_campaigns_merging', $destinationCampaign, $campaignsToMergeIds); + + DB::query('START TRANSACTION'); + + try { + // Convert $campaignsToMergeIds to string to use it in the queries + $campaignsToMergeIdsString = implode(', ', $campaignsToMergeIds); + + // Migrate revenue entries from campaigns to merge to the destination campaign + DB::query( + DB::prepare("UPDATE " . DB::prefix('give_revenue') . " SET campaign_id = %d WHERE campaign_id IN ($campaignsToMergeIdsString)", + [ + $destinationCampaign->id, + ]) + ); + + // Migrate forms from campaigns to merge to the destination campaign + DB::query( + DB::prepare("UPDATE " . DB::prefix('give_campaign_forms') . " SET campaign_id = %d WHERE campaign_id IN ($campaignsToMergeIdsString)", + [ + $destinationCampaign->id, + ]) + ); + + // Delete campaigns to merge now that we already migrated the necessary data to the destination campaign + DB::query("DELETE FROM " . DB::prefix('give_campaigns') . " WHERE id IN ($campaignsToMergeIdsString)"); + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed merging campaigns into destination campaign', [ + 'campaignsToMergeIds' => $campaignsToMergeIds, + 'destinationCampaign' => compact('destinationCampaign'), + ]); + + throw new $exception('Failed merging campaigns into destination campaign'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaigns_merged', $destinationCampaign, $campaignsToMergeIds); + + return true; + } + + /** + * @unreleased + */ + private function validateProperties(Campaign $campaign): void + { + foreach ($this->requiredProperties as $key) { + if ( ! isset($campaign->$key)) { + throw new InvalidArgumentException("'$key' is required."); + } + } + } + + /** + * @unreleased + * + * @return ModelQueryBuilder + */ + public function prepareQuery(): ModelQueryBuilder + { + $builder = new ModelQueryBuilder(Campaign::class); + + return $builder->from('give_campaigns', 'campaigns') + ->select( + ['campaigns.id', 'id'], + ['campaigns.form_id', 'defaultFormId'], // Prefix the `form_id` column to avoid conflicts with the `give_campaign_forms` table. + ['campaign_type', 'type'], + ['campaign_page_id', 'pageId'], + ['enable_campaign_page', 'enableCampaignPage'], + ['campaign_title', 'title'], + ['short_desc', 'shortDescription'], + ['long_desc', 'longDescription'], + ['campaign_logo', 'logo'], + ['campaign_image', 'image'], + ['primary_color', 'primaryColor'], + ['secondary_color', 'secondaryColor'], + ['campaign_goal', 'goal'], + ['goal_type', 'goalType'], + 'status', + ['start_date', 'startDate'], + ['end_date', 'endDate'], + ['date_created', 'createdAt'] + ) + // Exclude Peer to Peer campaign type until it is fully supported. + ->where('campaigns.campaign_type', CampaignType::PEER_TO_PEER, '!='); + } +} diff --git a/src/Campaigns/Routes/DeleteCampaignListTable.php b/src/Campaigns/Routes/DeleteCampaignListTable.php new file mode 100644 index 0000000000..616f095d80 --- /dev/null +++ b/src/Campaigns/Routes/DeleteCampaignListTable.php @@ -0,0 +1,116 @@ +endpoint, + [ + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => [$this, 'permissionsCheck'], + ], + 'args' => [ + 'ids' => [ + 'type' => 'string', + 'required' => true, + 'validate_callback' => function ($ids) { + foreach ($this->splitString($ids) as $id) { + if ( ! filter_var($id, FILTER_VALIDATE_INT)) { + return false; + } + } + + return true; + }, + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest(WP_REST_Request $request): WP_Rest_Response + { + $ids = $this->splitString($request->get_param('ids')); + $errors = []; + $successes = []; + + foreach ($ids as $id) { + $campaignDeleted = give(CampaignRepository::class)->getById($id)->delete(); + $campaignDeleted ? $successes[] = $id : $errors[] = $id; + } + + return new WP_REST_Response(['errors' => $errors, 'successes' => $successes]); + } + + + /** + * Split string + * + * @unreleased + * + * @return string[] + */ + protected function splitString(string $ids): array + { + if (strpos($ids, ',')) { + return array_map('trim', explode(',', $ids)); + } + + return [trim($ids)]; + } + + /** + * @unreleased + * + * @return bool|WP_Error + */ + public function permissionsCheck() + { + return current_user_can('delete_posts') ?: new WP_Error( + 'rest_forbidden', + esc_html__("You don't have permission to delete Campaigns", 'give'), + ['status' => is_user_logged_in() ? 403 : 401] + ); + } +} diff --git a/src/Campaigns/Routes/GetCampaignComments.php b/src/Campaigns/Routes/GetCampaignComments.php new file mode 100644 index 0000000000..254836de1a --- /dev/null +++ b/src/Campaigns/Routes/GetCampaignComments.php @@ -0,0 +1,107 @@ + WP_REST_Server::READABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => '__return_true', + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ], + 'perPage' => [ + 'type' => 'integer', + 'required' => false, + 'sanitize_callback' => 'absint', + ], + 'anonymous' => [ + 'type' => 'boolean', + 'required' => false, + 'default' => true, + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest($request): WP_REST_Response + { + $campaignId = $request->get_param('id'); + $perPage = $request->get_param('perPage'); + $anonymous = $request->get_param('anonymous'); + + $campaign = Campaign::find($campaignId); + + if (!$campaign) { + return new WP_REST_Response('Campaign not found', 404); + } + + $query = (new CampaignDonationQuery($campaign)) + ->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorIdMeta') + ->joinDonationMeta(DonationMetaKeys::COMMENT, 'commentMeta') + ->joinDonationMeta(DonationMetaKeys::ANONYMOUS, 'anonymousMeta') + ->joinDonationMeta('_give_completed_date', 'dateMeta') + ->leftJoin('give_donors', 'donorIdMeta.meta_value', 'donors.id', 'donors'); + + + if (!$anonymous) { + $query->where('anonymousMeta.meta_value', '0'); + } + + $query->where('commentMeta.meta_value', '', '!='); + $query->whereIsNotNull('commentMeta.meta_value'); + + $query->select( + 'donorIdMeta.meta_value as donorId', + 'commentMeta.meta_value as comment', + 'anonymousMeta.meta_value as anonymous', + 'dateMeta.meta_value as date', + 'donors.name as donorName' + ); + + $donations = $query->limit($perPage)->getAll(); + + $formattedComments = array_map(function ($donation) { + $donorName = $donation->anonymous === '1' ? __('Anonymous') : $donation->donorName; + + return [ + 'donorName' => $donorName, + 'comment' => $donation->comment, + 'anonymous' => $donation->anonymous === '1', + 'date' => human_time_diff(strtotime($donation->date)), + 'avatar' => get_avatar_url($donation->email), + ]; + }, $donations); + + return new WP_REST_Response($formattedComments); + } +} diff --git a/src/Campaigns/Routes/GetCampaignRevenue.php b/src/Campaigns/Routes/GetCampaignRevenue.php new file mode 100644 index 0000000000..d21e5c9b9c --- /dev/null +++ b/src/Campaigns/Routes/GetCampaignRevenue.php @@ -0,0 +1,96 @@ + WP_REST_Server::READABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest($request): WP_REST_Response + { + + $campaign = Campaign::find($request->get_param('id')); + + $dates = $this->getDatesFromRange(new DateTime('-7 days'), new DateTime()); + + $query = new CampaignDonationQuery($campaign); + $query->between(new DateTime('-7 days'), new DateTime()); + $results = $query->getDonationsByDay(); + + foreach($results as $result) { + $dates[$result->date] = $result->amount; + } + + $data = []; + foreach($dates as $date => $amount) { + $data[] = [ + 'date' => $date, + 'amount' => $amount, + ]; + } + + return new WP_REST_Response($data, 200); + } + + public function getDatesFromRange(DateTimeInterface $startDate, DateTimeInterface $endDate): array + { + $period = new DatePeriod( + $startDate, + new DateInterval('P1D'), + $endDate + ); + + $dates = array_map(function($date) { + return $date->format('Y-m-d'); + }, iterator_to_array($period)); + + return array_fill_keys($dates, 0); + } +} diff --git a/src/Campaigns/Routes/GetCampaignStatistics.php b/src/Campaigns/Routes/GetCampaignStatistics.php new file mode 100644 index 0000000000..d94d1a069d --- /dev/null +++ b/src/Campaigns/Routes/GetCampaignStatistics.php @@ -0,0 +1,93 @@ + WP_REST_Server::READABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ], + 'rangeInDays' => [ + 'type' => 'integer', + 'required' => false, + 'sanitize_callback' => 'absint', + 'default' => 0, // Zero to mean "all time". + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest($request): WP_REST_Response + { + $campaign = Campaign::find($request->get_param('id')); + + $query = new CampaignDonationQuery($campaign); + + if(!$request->get_param('rangeInDays')) { + return new WP_REST_Response([[ + 'amountRaised' => $query->sumIntendedAmount(), + 'donationCount' => $query->countDonations(), + 'donorCount' => $query->countDonors(), + ]]); + } + + $days = $request->get_param('rangeInDays'); + $date = new DateTimeImmutable('now', wp_timezone()); + $interval = DateInterval::createFromDateString("-$days days"); + $period = new DatePeriod($date, $interval, 1); + + return new WP_REST_Response(array_map(function($targetDate) use ($query, $interval) { + + $query = $query->between( + Temporal::withStartOfDay($targetDate->add($interval)), + Temporal::withEndOfDay($targetDate) + ); + + return [ + 'amountRaised' => $query->sumIntendedAmount(), + 'donationCount' => $query->countDonations(), + 'donorCount' => $query->countDonors(), + ]; + }, iterator_to_array($period) )); + } +} diff --git a/src/Campaigns/Routes/GetCampaignsListTable.php b/src/Campaigns/Routes/GetCampaignsListTable.php new file mode 100644 index 0000000000..e179ab3450 --- /dev/null +++ b/src/Campaigns/Routes/GetCampaignsListTable.php @@ -0,0 +1,192 @@ +endpoint, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => [$this, 'permissionsCheck'], + ], + 'args' => [ + 'page' => [ + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ], + 'perPage' => [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 1, + ], + 'search' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'status' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'sortColumn' => [ + 'type' => 'string', + 'default' => 'id', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'sortDirection' => [ + 'type' => 'string', + 'default' => 'asc', + 'enum' => ['asc', 'desc'], + ], + 'locale' => [ + 'type' => 'string', + 'required' => false, + 'default' => get_locale(), + ], + ], + ] + ); + } + + /** + * @unreleased + */ + public function handleRequest(WP_REST_Request $request): WP_REST_Response + { + $this->request = $request; + $this->listTable = give(CampaignsListTable::class); + + $campaigns = $this->getCampaigns(); + $campaignsCount = $this->getTotalCampaignsCount(); + $pageCount = (int)ceil($campaignsCount / $request->get_param('perPage')); + + $this->listTable->items($campaigns, $this->request->get_param('locale') ?? ''); + $items = $this->listTable->getItems(); + + + return new WP_REST_Response( + [ + 'items' => $items, + 'totalItems' => $campaignsCount, + 'totalPages' => $pageCount, + ] + ); + } + + /** + * @unreleased + */ + public function getCampaigns(): array + { + $page = $this->request->get_param('page'); + $perPage = $this->request->get_param('perPage'); + $sortColumns = $this->listTable->getSortColumnById($this->request->get_param('sortColumn') ?: 'id'); + $sortDirection = $this->request->get_param('sortDirection') ?: 'desc'; + + $query = give(CampaignRepository::class)->prepareQuery(); + $query = $this->getWhereConditions($query); + + foreach ($sortColumns as $sortColumn) { + $query->orderBy($sortColumn, $sortDirection); + } + + $query->limit($perPage) + ->offset(($page - 1) * $perPage); + + $campaigns = $query->getAll(); + + if ( ! $campaigns) { + return []; + } + + return $campaigns; + } + + /** + * @unreleased + */ + public function getTotalCampaignsCount(): int + { + $query = Campaign::query(); + $query = $this->getWhereConditions($query); + + return $query->count(); + } + + /** + * @unreleased + */ + private function getWhereConditions(QueryBuilder $query): QueryBuilder + { + $search = $this->request->get_param('search'); + $status = $this->request->get_param('status'); + + if ($search) { + if (ctype_digit($search)) { + $query->where('id', $search); + } else { + $query->whereLike('campaign_title', $search); + $query->orWhereLike('short_desc', $search); + } + } + + if ($status && 'any' !== $status) { + $query->where('status', $status); + } + + return $query; + } + + /** + * @unreleased + * + * @return bool|WP_Error + */ + public function permissionsCheck() + { + return current_user_can('edit_posts') ?: new WP_Error( + 'rest_forbidden', + esc_html__("You don't have permission to view Campaigns", 'give'), + ['status' => is_user_logged_in() ? 403 : 401] + ); + } +} diff --git a/src/Campaigns/Routes/RegisterCampaignRoutes.php b/src/Campaigns/Routes/RegisterCampaignRoutes.php new file mode 100644 index 0000000000..9ea878c0b0 --- /dev/null +++ b/src/Campaigns/Routes/RegisterCampaignRoutes.php @@ -0,0 +1,463 @@ +campaignRequestController = $campaignRequestController; + } + + /** + * @unreleased + */ + public function __invoke() + { + $this->registerGetCampaign(); + $this->registerUpdateCampaign(); + $this->registerGetCampaigns(); + $this->registerMergeCampaigns(); + $this->registerCreateCampaign(); + } + + /** + * Get Campaign route + * + * @unreleased + */ + public function registerGetCampaign() + { + register_rest_route( + CampaignRoute::NAMESPACE, + CampaignRoute::CAMPAIGN, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->campaignRequestController->getCampaign($request); + }, + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + ], + ], + ] + ); + } + + /** + * Get Campaigns route + * + * @unreleased + */ + public function registerGetCampaigns() + { + register_rest_route( + CampaignRoute::NAMESPACE, + CampaignRoute::CAMPAIGNS, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->campaignRequestController->getCampaigns($request); + }, + 'permission_callback' => '__return_true', + ], + 'args' => [ + 'status' => [ + 'type' => 'enum', + 'enum' => [ + 'active', + 'draft', + 'archived', + ], + 'default' => 'active', + ], + 'ids' => [ + 'type' => 'array', + 'default' => [], + ], + 'page' => [ + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ], + 'per_page' => [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 1, + 'maximum' => 100, + ], + 'sortBy' => [ + 'type' => 'enum', + 'enum' => [ + 'date', + 'amount', + 'donors', + 'donations', + ], + 'default' => 'date', + ], + 'orderBy' => [ + 'type' => 'enum', + 'enum' => [ + 'asc', + 'desc' + ], + 'default' => 'desc', + ], + ], + ] + ); + } + + /** + * Update Campaign route + * + * @unreleased + */ + public function registerUpdateCampaign() + { + register_rest_route( + CampaignRoute::NAMESPACE, + CampaignRoute::CAMPAIGN, + [ + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->campaignRequestController->updateCampaign($request); + }, + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => rest_get_endpoint_args_for_schema($this->getSchema(), WP_REST_Server::EDITABLE), + 'schema' => [$this, 'getSchema'], + ] + ); + } + + + /** + * Update Campaign route + * + * @unreleased + */ + public function registerMergeCampaigns() + { + register_rest_route( + CampaignRoute::NAMESPACE, + CampaignRoute::CAMPAIGN . '/merge', + [ + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->campaignRequestController->mergeCampaigns($request); + }, + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + ], + 'campaignsToMergeIds' => [ + 'type' => 'array', + 'required' => true, + 'items' => [ + 'type' => 'integer', + ], + ], + ], + ] + ); + } + + + /** + * Create Campaign route + * + * @unreleased + */ + public function registerCreateCampaign() + { + register_rest_route( + CampaignRoute::NAMESPACE, + CampaignRoute::CAMPAIGNS, + [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->campaignRequestController->createCampaign($request); + }, + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'title' => [ + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'shortDescription' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'startDateTime' => [ + 'type' => 'string', + 'format' => 'date-time', // @link https://datatracker.ietf.org/doc/html/rfc3339#section-5.8 + 'required' => false, + 'validate_callback' => 'rest_parse_date', + 'sanitize_callback' => function ($value) { + return new DateTime($value); + }, + ], + 'endDateTime' => [ + 'type' => 'string', + 'format' => 'date-time', // @link https://datatracker.ietf.org/doc/html/rfc3339#section-5.8 + 'required' => false, + 'validate_callback' => 'rest_parse_date', + 'sanitize_callback' => function ($value) { + return new DateTime($value); + }, + ], + ], + ] + ); + } + + + /** + * @unreleased + */ + public function getSchema(): array + { + return [ + 'title' => 'campaign', + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + 'description' => esc_html__('Campaign ID', 'give'), + ], + 'title' => [ + 'type' => 'string', + 'description' => esc_html__('Campaign title', 'give'), + 'minLength' => 3, + 'maxLength' => 128, + 'errorMessage' => esc_html__('Campaign title is required', 'give'), + ], + 'status' => [ + 'enum' => ['active', 'inactive', 'draft', 'pending', 'processing', 'failed', 'archived'], + 'description' => esc_html__('Campaign status', 'give'), + ], + 'shortDescription' => [ + 'type' => 'string', + 'description' => esc_html__('Campaign short description', 'give'), + 'maxLength' => 120, + ], + 'primaryColor' => [ + 'type' => 'string', + 'description' => esc_html__('Primary color for the campaign', 'give'), + ], + 'secondaryColor' => [ + 'type' => 'string', + 'description' => esc_html__('Secondary color for the campaign', 'give'), + ], + 'goal' => [ + 'type' => 'number', + 'minimum' => 1, + 'description' => esc_html__('Campaign goal', 'give'), + 'errorMessage' => esc_html__('Must be a number', 'give'), + ], + 'goalProgress' => [ + 'type' => 'number', + 'description' => esc_html__('Campaign goal progress', 'give'), + ], + 'goalType' => [ + 'enum' => [ + 'amount', + 'donations', + 'donors', + 'amountFromSubscriptions', + 'subscriptions', + 'donorsFromSubscriptions', + ], + 'description' => esc_html__('Campaign goal type', 'give'), + ], + 'enableCampaignPage' => [ + 'type' => 'boolean', + 'default' => true, + 'description' => esc_html__('Enable campaign page for your campaign.', 'give'), + ], + 'defaultFormId' => [ + 'type' => 'integer', + 'description' => esc_html__('Default campaign form ID', 'give'), + ], + ], + 'required' => ['id', 'title', 'goal', 'goalType'], + 'allOf' => [ + [ + 'if' => [ + 'properties' => [ + 'goalType' => [ + 'const' => 'amount', + ], + ], + ], + 'then' => [ + 'properties' => [ + 'goal' => [ + 'minimum' => 1, + 'type' => 'number', + ], + ], + 'errorMessage' => [ + 'properties' => [ + 'goal' => esc_html__('Goal amount must be greater than 0', 'give'), + ], + ], + ], + ], + [ + 'if' => [ + 'properties' => [ + 'goalType' => [ + 'const' => 'donations', + ], + ], + ], + 'then' => [ + 'properties' => [ + 'goal' => [ + 'minimum' => 1, + 'type' => 'number', + ], + ], + 'errorMessage' => [ + 'properties' => [ + 'goal' => esc_html__('Number of donations must be greater than 0', 'give'), + ], + ], + ], + ], + [ + 'if' => [ + 'properties' => [ + 'goalType' => [ + 'const' => 'donors', + ], + ], + ], + 'then' => [ + 'properties' => [ + 'goal' => [ + 'minimum' => 1, + 'type' => 'number', + ], + ], + 'errorMessage' => [ + 'properties' => [ + 'goal' => esc_html__('Number of donors must be greater than 0', 'give'), + ], + ], + ], + ], + [ + 'if' => [ + 'properties' => [ + 'goalType' => [ + 'const' => 'amountFromSubscriptions', + ], + ], + ], + 'then' => [ + 'properties' => [ + 'goal' => [ + 'minimum' => 1, + 'type' => 'number', + ], + ], + 'errorMessage' => [ + 'properties' => [ + 'goal' => esc_html__('Goal recurring amount must be greater than 0', 'give'), + ], + ], + ], + ], + [ + 'if' => [ + 'properties' => [ + 'goalType' => [ + 'const' => 'subscriptions', + ], + ], + ], + 'then' => [ + 'properties' => [ + 'goal' => [ + 'minimum' => 1, + 'type' => 'number', + ], + ], + 'errorMessage' => [ + 'properties' => [ + 'goal' => esc_html__('Number of recurring donations must be greater than 0', 'give'), + ], + ], + ], + ], + [ + 'if' => [ + 'properties' => [ + 'goalType' => [ + 'const' => 'donorsFromSubscriptions', + ], + ], + ], + 'then' => [ + 'properties' => [ + 'goal' => [ + 'minimum' => 1, + 'type' => 'number', + ], + ], + 'errorMessage' => [ + 'properties' => [ + 'goal' => esc_html__('Number of recurring donors must be greater than 0', 'give'), + ], + ], + ], + ], + ], + ]; + } +} diff --git a/src/Campaigns/ServiceProvider.php b/src/Campaigns/ServiceProvider.php new file mode 100644 index 0000000000..6517655908 --- /dev/null +++ b/src/Campaigns/ServiceProvider.php @@ -0,0 +1,198 @@ +singleton('campaigns', CampaignRepository::class); + $this->registerTableNames(); + } + + /** + * @unreleased + * @inheritDoc + */ + public function boot(): void + { + $this->registerMenus(); + $this->replaceGiveFormsCptLabels(); + $this->registerActions(); + $this->setupCampaignPages(); + $this->registerMigrations(); + $this->registerRoutes(); + $this->registerCampaignEntity(); + $this->registerCampaignBlocks(); + $this->setupCampaignForms(); + $this->loadCampaignOptions(); + } + + /** + * @unreleased + */ + private function registerRoutes() + { + Hooks::addAction('rest_api_init', Routes\RegisterCampaignRoutes::class); + Hooks::addAction('rest_api_init', Routes\GetCampaignsListTable::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\DeleteCampaignListTable::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\GetCampaignStatistics::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\GetCampaignRevenue::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\GetCampaignComments::class, 'registerRoute'); + } + + /** + * @unreleased + */ + private function registerMigrations(): void + { + give(MigrationsRegister::class)->addMigrations( + [ + CreateCampaignsTable::class, + SetCampaignType::class, + CreateCampaignFormsTable::class, + MigrateFormsToCampaignForms::class, + RevenueTableAddCampaignID::class, + AssociateDonationsToCampaign::class, + AddIndexes::class, + DonationsAddCampaignId::class, + ] + ); + } + + /** + * @unreleased + */ + private function registerTableNames(): void + { + global $wpdb; + + $wpdb->give_campaigns = $wpdb->prefix . 'give_campaigns'; + $wpdb->give_campaign_forms = $wpdb->prefix . 'give_campaign_forms'; + } + + /** + * @unreleased + */ + private function registerActions(): void + { + Hooks::addAction('givewp_campaign_deleted', DeleteCampaignPage::class); + Hooks::addAction('givewp_donation_form_creating', FormInheritsCampaignGoal::class); + Hooks::addAction('givewp_campaign_page_created', AssociateCampaignPageWithCampaign::class); + Hooks::addAction('give_form_duplicated', Actions\AssignDuplicatedFormToCampaign::class, '__invoke', 10, 2); + + // notices + add_action('wp_ajax_givewp_campaign_interaction_notice', static function () { + add_user_meta(get_current_user_id(), 'givewp_show_campaign_interaction_notice', time(), true); + }); + } + + /** + * @unreleased + */ + private function registerMenus() + { + Hooks::addAction('admin_menu', CampaignsAdminPage::class, 'addCampaignsSubmenuPage', 999); + } + + /** + * @unreleased + */ + private function replaceGiveFormsCptLabels() + { + Hooks::addFilter('give_forms_labels', ReplaceGiveFormsCptLabels::class); + } + + private function setupCampaignPages() + { + Hooks::addAction('init', Actions\RegisterCampaignPagePostType::class); + Hooks::addAction('template_redirect', Actions\RedirectDisabledCampaignPage::class); + Hooks::addAction('enqueue_block_editor_assets', Actions\EnqueueCampaignPageEditorAssets::class); + Hooks::addAction('admin_action_edit_campaign_page', Actions\EditCampaignPageRedirect::class); + } + + /** + * @unreleased + */ + private function registerCampaignEntity() + { + Hooks::addAction('init', Actions\RegisterCampaignEntity::class); + } + + /** + * @unreleased + */ + private function setupCampaignForms() + { + if (CampaignsAdminPage::isShowingDetailsPage()) { + Hooks::addAction('admin_enqueue_scripts', DonationFormsAdminPage::class, 'loadScripts'); + } + + /** + * We implemented a feature to load the stats columns ("Goal", "Donations" and "Revenue") using an async approach, + * so we could prevent a long page load on websites with lots of forms. However, the campaign details page's current + * "Forms" tab still doesn't support it. Still, it's using the same Form List Table that active the async approach by + * default, so the line below is necessary to disable it while we still don't have support for async loading on this screen. + * + * @see https://github.com/impress-org/givewp/pull/7483 + */ + if (!defined('GIVE_IS_ALL_STATS_COLUMNS_ASYNC_ON_ADMIN_FORM_LIST_VIEWS')) { + define('GIVE_IS_ALL_STATS_COLUMNS_ASYNC_ON_ADMIN_FORM_LIST_VIEWS', false); + } + + Hooks::addAction('admin_init', RedirectLegacyCreateFormToCreateCampaign::class); + + Hooks::addAction('save_post_give_forms', AddCampaignFormFromRequest::class, 'optionBasedFormEditor', 10, 3); + Hooks::addAction('givewp_donation_form_created', AddCampaignFormFromRequest::class, 'visualFormBuilder'); + Hooks::addAction('givewp_campaign_created', CreateDefaultCampaignForm::class); + } + + /** + * @unreleased + */ + private function registerCampaignBlocks() + { + Hooks::addAction('rest_api_init', Actions\RegisterCampaignIdRestField::class); + Hooks::addAction('init', Actions\RegisterCampaignBlocks::class); + Hooks::addAction('enqueue_block_editor_assets', Actions\RegisterCampaignBlocks::class, 'loadBlockEditorAssets'); + } + + /** + * @unreleased + */ + private function loadCampaignOptions() + { + Hooks::addAction('init', LoadCampaignOptions::class); + } +} diff --git a/src/Campaigns/ValueObjects/CampaignGoalType.php b/src/Campaigns/ValueObjects/CampaignGoalType.php new file mode 100644 index 0000000000..469647b5ab --- /dev/null +++ b/src/Campaigns/ValueObjects/CampaignGoalType.php @@ -0,0 +1,31 @@ +[0-9]+)'; + const CAMPAIGNS = 'campaigns'; +} diff --git a/src/Campaigns/ValueObjects/CampaignStatus.php b/src/Campaigns/ValueObjects/CampaignStatus.php new file mode 100644 index 0000000000..6ffa5516b1 --- /dev/null +++ b/src/Campaigns/ValueObjects/CampaignStatus.php @@ -0,0 +1,36 @@ +campaign = $campaign; + } + + /** + * @unreleased + */ + public function exports(): array + { + return [ + 'id' => $this->campaign->id, + 'pageId' => (int)$this->campaign->pageId, + 'pagePermalink' => $this->campaign->pageId + ? get_permalink($this->campaign->pageId) + : null, + 'enableCampaignPage' => $this->campaign->enableCampaignPage, + 'defaultFormId' => $this->campaign->defaultFormId, + 'defaultFormTitle' => $this->campaign->defaultForm()->title, + 'type' => $this->campaign->type->getValue(), + 'title' => $this->campaign->title, + 'shortDescription' => $this->campaign->shortDescription, + 'longDescription' => $this->campaign->longDescription, + 'logo' => $this->campaign->logo, + 'image' => $this->campaign->image, + 'primaryColor' => $this->campaign->primaryColor, + 'secondaryColor' => $this->campaign->secondaryColor, + 'goal' => $this->campaign->goal, + 'goalType' => $this->campaign->goalType->getValue(), + 'goalStats' => $this->campaign->getGoalStats(), + 'status' => $this->campaign->status->getValue(), + 'startDate' => Temporal::getFormattedDateTime($this->campaign->startDate), + 'endDate' => $this->campaign->endDate + ? Temporal::getFormattedDateTime($this->campaign->endDate) + : null, + 'createdAt' => Temporal::getFormattedDateTime($this->campaign->createdAt), + ]; + } +} diff --git a/src/Campaigns/resources/admin/campaign-details.tsx b/src/Campaigns/resources/admin/campaign-details.tsx new file mode 100644 index 0000000000..f1f7f2cc12 --- /dev/null +++ b/src/Campaigns/resources/admin/campaign-details.tsx @@ -0,0 +1,10 @@ +import {createRoot} from '@wordpress/element'; +import CampaignsDetailsPage from './components/CampaignDetailsPage'; + +const container = document.getElementById('give-admin-campaigns-root'); +const urlParams = new URLSearchParams(window.location.search); + +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/src/Campaigns/resources/admin/campaigns-list-table.tsx b/src/Campaigns/resources/admin/campaigns-list-table.tsx new file mode 100644 index 0000000000..d99f8b1c98 --- /dev/null +++ b/src/Campaigns/resources/admin/campaigns-list-table.tsx @@ -0,0 +1,6 @@ +import {createRoot} from 'react-dom/client'; +import CampaignsListTable from './components/CampaignsListTable'; + +const container = document.getElementById('give-admin-campaigns-root'); +const root = createRoot(container!); +root.render(); diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/CampaignDetailsPage.module.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/CampaignDetailsPage.module.scss new file mode 100644 index 0000000000..9f749ebffb --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/CampaignDetailsPage.module.scss @@ -0,0 +1,586 @@ +.container { + margin-top: 3rem; + padding: 3rem; +} + +:global { + :root { + --give-primary-color: #69b868; + } + + .post-type-give_forms #wpbody { + box-sizing: border-box; + background-color: #f9fafb; + min-height: calc(100vh - 32px); + + & > a { + text-decoration: underline; + } + } + + .post-type-give_forms #wpbody-content { + box-sizing: border-box; + } + + .post-type-give_forms #wpbody::after { + all: revert; + } + + .give-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + #wpcontent { + padding: 0; + } +} + +.page { + box-sizing: border-box; + color: #333; + font-family: Inter, system-ui, sans-serif; + font-size: 1rem; + + *, + ::before, + ::after { + box-sizing: inherit; + } +} + +.pageHeader { + background-color: var(--givewp-shades-white); + padding-block: 1rem; + padding-inline: 1.5rem; +} + +.flexContainer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: var(--givewp-spacing-2); + + & > * { + flex-shrink: 0; + } +} + +.flexRow { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + column-gap: var(--givewp-spacing-2); + margin-top: auto; +} + +.flexRow:not(:first-child) { + flex: 1; + justify-content: flex-end; +} + +.justifyContentEnd { + flex: 1; + justify-content: flex-end; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 0.25rem; + line-height: 1.125rem; + font-size: 0.75rem; + + & > span { + font-weight: bold; + color: var(--givewp-neutral-900); + } + + & > a { + text-decoration: none; + color: var(--givewp-neutral-500); + font-weight: 400; + } + + & > a:hover { + text-decoration: underline; + } +} + +.pageTitle { + color: var(--givewp-neutral-900); + margin: 0; + font-size: 1.5rem; + font-weight: 700; + line-height: 2.25rem; +} + +select[name="campaignId"] { + display: none; +} + +.tabs { + display: flex; + gap: 0.25rem; + background-color: #fff; + border-bottom: 0.0625rem solid #dbdbdb; + + &:not(.fullWidth) { + padding: 0 var(--givewp-spacing-6); + } + + &.fullWidth { + padding-left: var(--givewp-spacing-6); + } +} + +.tabs [data-reach-tab], +.tabs [role='tab'] { + position: relative; + appearance: none; + padding: var(--givewp-spacing-2) var(--givewp-spacing-4); + border: 0; + background-color: transparent; + color: var(--givewp-neutral-700); + font-size: 0.8rem; + line-height: 1.5rem; + text-align: center; + cursor: pointer; + box-sizing: border-box +} + +@media screen and (min-width: 48rem) { + .tabs [data-reach-tab], + .tabs [role='tab'] { + font-size: 1rem; + } +} + +.tabs [data-reach-tab]::after, +.tabs [role='tab']:after { + content: ''; + display: block; + position: absolute; + top: calc(100% - 0.1875em); + left: 0; + right: 0; + height: 0.1875rem; + background-color: transparent; + transition: background-color 100ms ease-in-out; +} + +.tabs [role='tab']:hover, +.tabs [data-reach-tab]:hover { + color: var(--givewp-neutral-900); + background-color: var(--givewp-neutral-50); +} + +.tabs [data-reach-tab][aria-selected='true'], +.tabs [role='tab'][aria-selected='true'] { + font-weight: 600; + color: var(--givewp-neutral-900); +} + +// set width for each tab to maintain size on hover. +.tabs [data-reach-tab]:first-child, +.tabs [role='tab']:first-child { + width: 106px; +} + +.tabs [data-reach-tab]:nth-child(2), +.tabs [role='tab']:nth-child(2) { + width: 95px; +} + +.tabs [data-reach-tab]:last-child, +.tabs [role='tab']:last-child { + width: 79px; +} + +.tabs [data-reach-tab][aria-selected='true']::after, +.tabs [role='tab'][aria-selected='true']::after { + background-color: #66bb6a; +} + +.archiveDialogContent { + font-size: 16px; + font-weight: 500; + line-height: 1.5; + color: var(--givewp-neutral-700); +} + +.archiveDialogButtons { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: var(--givewp-spacing-2); + margin-top: var(--givewp-spacing-6); + + button { + cursor: pointer; + display: flex; + flex: 1; + justify-content: center; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + padding: var(--givewp-spacing-3) var(--givewp-spacing-6); + border: none; + } + + .cancelButton { + border: 1px solid var(--givewp-neutral-300); + background-color: var(--givewp-shades-white); + color: var(--givewp-neutral-900); + + &:hover { + background-color: var(--givewp-neutral-50); + } + } + + .confirmButton { + border: 1px solid var(--givewp-red-500); + background-color: var(--givewp-red-500); + color: var(--givewp-shades-white); + + &:hover { + border: 1px solid var(--givewp-red-400); + background-color: var(--givewp-red-400); + } + } +} + +.pageContent { + /*padding: 0 var(--givewp-spacing-6) var(--givewp-spacing-6);*/ + + &:not(.fullWidth) { + padding: var(--givewp-spacing-4) var(--givewp-spacing-6); + } + + section { + margin-bottom: var(--givewp-spacing-14); + position: relative; + + h2 { + font-size: 1.125rem; + line-height: 1.56; + margin: 0 0 var(--givewp-spacing-2); + } + } + + .sections { + display: flex; + flex-direction: column; + gap: var(--givewp-spacing-6); + + .section { + display: flex; + align-content: flex-start; + flex-direction: row; + flex-wrap: wrap; + background-color: var(--givewp-shades-white); + padding: var(--givewp-spacing-9) var(--givewp-spacing-6); + border-radius: var(--givewp-spacing-2); + + @media (max-width: 1100px) { + flex-direction: column; + } + + .leftColumn { + flex: 1; + padding-right: var(--givewp-spacing-6); + + @media (max-width: 1100px) { + width: 100%; + margin-bottom: var(--givewp-spacing-4); + } + } + + .rightColumn { + flex: 2; + } + + .sectionTitle { + font-size: 18px; + font-weight: 600; + line-height: 1.56; + color: var(--givewp-neutral-900); + padding: 0; + margin-bottom: var(--givewp-spacing-1); + } + + .sectionDescription { + font-size: 16px; + line-height: 1.5; + color: var(--givewp-neutral-500); + } + + .sectionSubtitle { + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + color: var(--givewp-neutral-700); + } + + .sectionField { + position: relative; + + display: flex; + flex-direction: column; + gap: var(--givewp-spacing-1); + margin-bottom: var(--givewp-spacing-10); + + input, + textarea, + select, + .upload { + margin-top: var(--givewp-spacing-1); + color: var(--givewp-neutral-900); + font-weight: 500; + } + + input, select { + padding: var(--givewp-spacing-2) var(--givewp-spacing-4); + } + + input:focus, select:focus { + border-color: rgb(34, 113, 177); + outline: none; + box-shadow: rgb(34, 113, 177) 0px 0px 0px 1px; + } + } + + .sectionField:last-child { + margin: 0; + } + + .sectionFieldDescription { + font-size: 14px; + line-height: 1.43; + color: var(--givewp-neutral-500); + } + + .errorMsg { + font-size: 14px; + padding: var(--givewp-spacing-2) 0; + color: #ff0000; + } + + select { + max-width: 100%; + } + + input, + select { + font-size: 1rem; + line-height: 2; + display: block; + width: 100%; + border: 1px solid #9ca0af; + border-radius: var(--givewp-spacing-1); + padding: var(--givewp-spacing-2); + } + } + } + + .toggle { + margin-top: var(--givewp-spacing-3); + + span:is(:global(.components-form-toggle)) { + height: 24px; + } + + & > div { + margin-bottom: 0; + } + + span:is(:global(.components-form-toggle .components-form-toggle__track)) { + width: 48px; + height: 24px; + border-radius: 133.3px; + } + + span:is(:global(.components-form-toggle .components-form-toggle__thumb)) { + width: 1rem; + height: 1rem; + top: 4px; + left: 4px; + } + + span:is(:global(.components-form-toggle.is-checked .components-form-toggle__thumb)) { + left: 12px; + } + + span:is(:global(.components-form-toggle.is-checked .components-form-toggle__track)) { + background-color: #007cba; + } + + span:is(:global(.components-form-toggle .components-form-toggle__input:focus+.components-form-toggle__track)) { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) #fff, 0 0 0 calc(var(--wp-admin-border-width-focus) * 2) #007cba; + } + + label { + font-family: Inter, system-ui, sans-serif; + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + color: #1f2937; + padding: var(--givewp-spacing-2) 0; + margin-left: 0.5rem; + } + + p { + font-family: Inter, system-ui, sans-serif; + font-size: .875rem; + color: #4b5563; + margin-top: -0.1rem; + margin-left: 1.5rem; + } + } + + .warningNotice { + display: flex; + gap: 0.3rem; + margin-top: var(--givewp-spacing-2); + padding: 0 0.5rem 0 0.5rem; + background-color: #fffaf2; + border-radius: 4px; + border: 1px solid var(--givewp-orange-400); + border-left-width: 4px; + font-size: 0.875rem; + font-weight: 500; + color: #1a0f00; + + svg { + margin: 0.8rem 0.3rem; + height: 1.25rem; + width: 1.25rem; + } + } + + .colorControl { + margin-top: var(--givewp-spacing-3); + } +} + +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + + .loadingContainerContent { + display: flex; + background-color: #fff; + padding: var(--givewp-spacing-6); + border-radius: var(--givewp-spacing-2); + margin-bottom: var(--givewp-spacing-4); + align-items: center; + flex-direction: column; + } + + .loadingContainerContentText { + padding: var(--givewp-spacing-4); + font-size: 1rem; + } +} + +:global(#give-admin-campaigns-root) { + + + .editCampaignPageButton, + .updateCampaignButton { + display: flex; + align-content: center; + border-radius: var(--givewp-rounded-4); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5; + text-align: center; + padding: var(--givewp-spacing-2) var(--givewp-spacing-4); + + svg { + margin: 3px 0 0 8px !important; + } + } + + .editCampaignPageButton { + color: #060c1a; + background-color: var(--givewp-neutral-100); + border-color: var(--givewp-neutral-100); + + &:hover { + background-color: var(--givewp-neutral-200); + border-color: var(--givewp-neutral-200); + } + } + + .campaignButtonDots { + background-color: var(--givewp-neutral-100); + border-color: var(--givewp-neutral-100); + border-radius: var(--givewp-rounded-4); + line-height: 0; + padding: var(--givewp-spacing-2); + + &:hover, &:active, &:focus { + background-color: var(--givewp-neutral-200); + border-color: var(--givewp-neutral-200); + } + } + + .campaignButtonDotsActive { + background-color: var(--givewp-neutral-200); + border-color: var(--givewp-neutral-200); + } + + .contextMenu { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--givewp-spacing-1); + z-index: 9999; + padding: var(--givewp-spacing-1); + top: 50px; + width: 203px; + border-radius: 4px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + border: solid 1px var(--givewp-neutral-50); + background-color: var(--givewp-shades-white); + + .contextMenuItem { + text-decoration: none; + gap: 4px; + display: flex; + align-items: center; + padding: var(--givewp-spacing-2); + font-size: .875rem; + font-weight: 500; + line-height: 1.43; + color: var(--givewp-neutral-700); + + + &:hover { + background-color: #f3f4f6; + } + } + + .archive { + color: var(--givewp-red-500); + } + + .draft { + font-weight: bold; + } + } +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ArchiveCampaignDialog.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ArchiveCampaignDialog.tsx new file mode 100644 index 0000000000..492b023fcb --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ArchiveCampaignDialog.tsx @@ -0,0 +1,53 @@ +import {__} from '@wordpress/i18n' +import ModalDialog from '@givewp/components/AdminUI/ModalDialog'; +import {ErrorIcon} from '../../Icons'; +import styles from '../CampaignDetailsPage.module.scss' + +/** + * @unreleased + */ +export default ({ + isOpen, + title, + handleClose, + handleConfirm, + className, +}: { + isOpen: boolean; + handleClose: () => void; + handleConfirm: () => void; + title: string; + className?: string; +}) => { + return ( + } + isOpen={isOpen} + showHeader={true} + handleClose={handleClose} + title={title} + wrapperClassName={className} + > + <> +
+ {__('Are you sure you want to archive your campaign? All forms associated with this campaign will be inaccessible to donors.', 'give')} +
+
+ + +
+ +
+ ); +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx new file mode 100644 index 0000000000..101915be04 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx @@ -0,0 +1,174 @@ +import {__} from '@wordpress/i18n'; +import {useEffect, useState} from "react"; +import RevenueChart from "../RevenueChart"; +import GoalProgressChart from "../GoalProgressChart"; +import apiFetch from '@wordpress/api-fetch'; +import {addQueryArgs} from '@wordpress/url'; +import HeaderText from '../HeaderText'; +import HeaderSubText from '../HeaderSubText'; +import DefaultFormWidget from "../DefaultForm"; +import {useCampaignEntityRecord, amountFormatter, getCampaignOptionsWindowData} from '@givewp/campaigns/utils'; + +import styles from "./styles.module.scss" + +const campaignId = new URLSearchParams(window.location.search).get('id'); + +const {currency} = getCampaignOptionsWindowData(); +const currencyFormatter = amountFormatter(currency); + +const pluck = (array: any[], property: string) => array.map(element => element[property]) + +const filterOptions = [ + {label: __('Today', 'give'), value: 1, description: __('from today', 'give')}, + {label: __('Last 7 days', 'give'), value: 7, description: __('from the last 7 days', 'give')}, + {label: __('Last 30 days', 'give'), value: 30, description: __('from the last 30 days', 'give')}, + {label: __('Last 90 days', 'give'), value: 90, description: __('from the last 90 days', 'give')}, + {label: __('All-time', 'give'), value: 0, description: __('total for all-time', 'give')}, +] + +const CampaignStats = () => { + + const [dayRange, setDayRange] = useState(null); + const [stats, setStats] = useState([]); + const {campaign} = useCampaignEntityRecord(); + + useEffect(() => { + onDayRangeChange(0) + }, []) + + const onDayRangeChange = async (days: number) => { + setDayRange(days) + + apiFetch({path: addQueryArgs('/give-api/v2/campaigns/' + campaignId + '/statistics', {rangeInDays: days})}) + .then(setStats); + } + + const widgetDescription = filterOptions.find(option => option.value === dayRange)?.description + + return ( + <> + +
+ + + + +
+ + +
+
+ + ) +} + +const FooterText = ({children}) => { + return ( +
+ {children} +
+ ) +} + +const DisplayText = ({children}) => { + return ( +
+ {children} +
+ ) +} + +const StatWidget = ({label, values, description, formatter = null}) => { + return ( +
+
+ {label} +
+
+ + {'undefined' !== typeof values[0] + ? formatter?.format(values[0]) ?? values[0] + :   + } + + {!!values[1] && ( + + )} +
+
+ {description} +
+
+ ) +} + +const PercentChangePill = ({value, comparison}) => { + + const change = Math.round(100 * ((value - comparison) / comparison)) ?? 0 + + const [color, backgroundColor, symbol] = change == 0 + ? ['#060c1a', '#f2f2f2', '⯈'] + : change > 0 + ? ['#2d802f', '#f2fff3', '⯅'] + : ['#e35f45', '#fff4f2', '⯆'] + + return ( + + {symbol} {Math.abs(change)}% + + ) + +} + + +const RevenueWidget = () => { + return ( +
+
+ {__('Revenue', 'give')} + {__('Show your revenue over time', 'give')} +
+ +
+ ); +} + +const GoalProgressWidget = () => { + + const {campaign} = useCampaignEntityRecord(); + + return ( +
+
+ {__('Goal progress', 'give')} + {__('Show your campaign performance', 'give')} +
+ +
+ ) +} + +const DateRangeFilters = ({options, onSelect, selected}) => { + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ) +} + + +export default CampaignStats; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/styles.module.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/styles.module.scss new file mode 100644 index 0000000000..623b30cd74 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/styles.module.scss @@ -0,0 +1,137 @@ +.dateRangeFilter { + padding: var(--givewp-spacing-2); + display: flex; + flex-direction: row; + justify-content: flex-end; + margin-bottom: var(--givewp-spacing-2); + background-color: var(--givewp-neutral-50); + border-radius: var(--givewp-rounded-8); +} + +.dateRangeFilter button { + padding: 0.5rem 1rem; + background-color: transparent; + color: var(--givewp-neutral-900); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + border: 0; + cursor: pointer; + + &:hover { + background-color: var(--givewp-neutral-100); + } + + &:first-child { + border-radius: var(--givewp-rounded-4) 0 0 var(--givewp-rounded-4); + } + + &:last-child { + border-radius: 0 var(--givewp-rounded-4) var(--givewp-rounded-4) 0; + } +} + +.dateRangeFilter .selectedDateRange { + background-color: var(--givewp-shades-white); + font-weight: 600; + + &:hover { + background-color: var(--givewp-shades-white); + } +} + +.statWidget { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: var(--givewp-spacing-2); + padding: var(--givewp-spacing-6); + background-color: var(--givewp-shades-white); + color: var(--givewp-neutral-700); + font-weight: 600; + border-radius: var(--givewp-rounded-8); + grid-column: span 1; +} + +.statWidget header, .statWidget footer { + flex: 1; +} + +.statWidget footer{ + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--givewp-neutral-700); +} + +.statWidgetAmount { + flex: 1; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; +} + +.statWidgetDisplay { + font-size: 2.25rem; + font-weight: 600; + line-height: 44px; + color: var(--givewp-neutral-900); +} + +.revenueWidget { + flex: 2; + background-color: var(--givewp-shades-white); + padding: 20px; + border-radius: var(--givewp-rounded-8); + grid-column: span 2; +} + +.percentChangePill { + padding: 0.5rem; + font-size: .8rem; + border-radius: var(--givewp-rounded-16); +} + +.progressWidget { + flex: 1; + background-color: var(--givewp-shades-white); + padding: 20px; + border-radius: var(--givewp-rounded-8); +} + +.headerSpacing { + display: flex; + flex-direction: column; + gap: .25rem; +} + +.mainGrid{ + display: grid; + grid-template-columns: repeat(3, 1fr); + row-gap: var(--givewp-spacing-6); + column-gap: var(--givewp-spacing-4); +} + +.nestedGrid { + grid-column: span 1; + display: grid; + gap: var(--givewp-spacing-6); +} + +@media (max-width: 768px) { + .mainGrid { + grid-template-columns: 1fr; + gap: var(--givewp-spacing-3); + } + + .revenueWidget { + grid-column: span 1; + } + + .nestGrid { + grid-template-columns: 1fr; + gap: var(--givewp-spacing-3); + } +} \ No newline at end of file diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/Icons/CheckIcon.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/Icons/CheckIcon.tsx new file mode 100644 index 0000000000..c4a28928c1 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/Icons/CheckIcon.tsx @@ -0,0 +1,24 @@ +export default function CheckIcon({refColor}) { + return ( + + + + ); +} + +function getContrastColor(hexColor){ + hexColor = hexColor.replace(/^#/, ''); + + let r = parseInt(hexColor.substring(0, 2), 16); + let g = parseInt(hexColor.substring(2, 4), 16); + let b = parseInt(hexColor.substring(4, 6), 16); + + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + return luminance > 0.5 ? '#000000' : '#FFFFFF'; +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/Icons/EditIcon.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/Icons/EditIcon.tsx new file mode 100644 index 0000000000..a8598b4d80 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/Icons/EditIcon.tsx @@ -0,0 +1,12 @@ +export default function EditIcon() { + return ( + + + + ); +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/index.tsx new file mode 100644 index 0000000000..4020522af3 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/index.tsx @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import {useCallback, useState} from 'react'; +import {Controller, useFormContext} from 'react-hook-form'; + +/** + * WordPress dependencies + */ +import {ColorIndicator, ColorPalette, Popover} from '@wordpress/components'; +import {__} from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import EditIcon from './Icons/EditIcon'; +import CheckIcon from './Icons/CheckIcon'; +import './styles.scss'; + +interface ColorOption { + name: string; + slug: string; + color: string; +} + +const defaultColors: ColorOption[] = [ + {name: 'Blue', slug: 'blue', color: '#0b72d9'}, + {name: 'Green', slug: 'green', color: '#27ae60'}, + {name: 'Purple', slug: 'purple', color: '#19078c'}, + {name: 'Orange', slug: 'orange', color: '#f29718'}, + {name: 'Lavender', slug: 'lavender', color: '#9b51e0'}, + {name: 'Terracotta', slug: 'terracotta', color: '#e26f56'}, + {name: 'Red', slug: 'red', color: '#cc1818'}, +]; + +/** + * @unreleased + */ +function ColorControl({name, disabled = false, className}: { name: string; disabled?: boolean; className?: string }) { + const [popoverIsVisible, setPopoverIsVisible] = useState(false); + const {control} = useFormContext(); + + const toggleVisible = useCallback(() => { + setPopoverIsVisible((prev) => !prev); + }, []); + + return ( + ( +
+
+ + {field.value && } +
+ + {!disabled && ( +
+ + {popoverIsVisible && ( + + + + )} +
+ )} +
+ )} + /> + ); +}; + +export default ColorControl; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/styles.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/styles.scss new file mode 100644 index 0000000000..d9f49d43f2 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/ColorControl/styles.scss @@ -0,0 +1,63 @@ +.givewp-color-control { + align-items: center; + display: flex; + gap: var(--givewp-spacing-4); + + &__indicator { + align-items: center; + display: flex; + position: relative; + + span { + border-radius: 0.5rem; + border: 0; + box-shadow: none; + height: 3rem; + width: 3rem; + } + + svg { + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + } + } + + &__edit-button { + align-items: center; + background: none; + border-radius: 0.5rem; + border: solid 1px var(--givewp-neutral-300); + color: var(--givewp-neutral-900); + display: flex; + font-size: 1rem; + font-weight: 500; + gap: var(--givewp-spacing-2); + height: 3rem; + line-height: 1.5; + padding: var(--givewp-spacing-3) var(--givewp-spacing-6) var(--givewp-spacing-3) var(--givewp-spacing-4); + + &:hover { + background: var(--givewp-neutral-50); + cursor: pointer; + } + + &--active { + background: var(--givewp-neutral-700); + border: solid 1px var(--givewp-neutral-700); + color: var(--givewp-neutral-25); + + &:hover { + background: var(--givewp-neutral-900); + } + } + } + + &__popover-content { + .components-popover__content { + padding: var(--givewp-spacing-4); + width: auto; + } + } +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/DefaultForm/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/DefaultForm/index.tsx new file mode 100644 index 0000000000..47325399af --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/DefaultForm/index.tsx @@ -0,0 +1,26 @@ +import {__} from '@wordpress/i18n'; +import HeaderText from '../HeaderText'; +import HeaderSubText from '../HeaderSubText'; + +import styles from './styles.module.scss'; + +/** + * @unreleased + */ +const DefaultFormWidget = ({defaultForm}: {defaultForm: string}) => { + return ( +
+
+
+ {__('Default campaign form', 'give')} + {__('Your campaign page and blocks will collect donations through this form by default.', 'give')} +
+
+
+ {defaultForm} +
+
+ ) +} + +export default DefaultFormWidget; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/DefaultForm/styles.module.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/DefaultForm/styles.module.scss new file mode 100644 index 0000000000..bcd283f09f --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/DefaultForm/styles.module.scss @@ -0,0 +1,46 @@ +.defaultForm { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; + background-color: var(--givewp-shades-white); + padding: 1rem 1.5rem 1.5rem 1.5rem; + border-radius: var(--givewp-rounded-8); +} + +.description { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; +} + +.headerSpacing { + display: flex; + flex-direction: column; + gap: .25rem; +} + +.edit { + font-size: 0.875rem; + color: var(--givewp-neutral-900); + font-weight: 500; + background-color: var(--givewp-neutral-100); + padding: .5rem 1rem; + border-radius: var(--givewp-rounded-4); + text-decoration: none; + + &:hover { + background-color: var(--givewp-neutral-200); + color: var(--givewp-neutral-900); + } +} + +.formName { + font-weight: 500; + background-color: var(--givewp-neutral-25); + padding: 0.75rem 1rem; + border-radius: var(--givewp-rounded-4); + border: solid 1px var(--givewp-neutral-100); + cursor: default; +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/GoalProgressChart/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/GoalProgressChart/index.tsx new file mode 100644 index 0000000000..eec49a17fd --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/GoalProgressChart/index.tsx @@ -0,0 +1,73 @@ +import {__} from '@wordpress/i18n'; +import Chart from "react-apexcharts"; +import React from "react"; + +import styles from "./styles.module.scss" +import {getCampaignOptionsWindowData, amountFormatter} from '@givewp/campaigns/utils'; + +const {currency} = getCampaignOptionsWindowData(); +const currencyFormatter = amountFormatter(currency); + +type GoalProgressChartProps = { + value: number; + goal: number; +} + +const GoalProgressChart = ({ value, goal }: GoalProgressChartProps) => { + const progress = Math.ceil((value / goal) * 100); + const percentage = Math.min(progress, 100); + + return ( +
+
+ +
+
+
{__('Goal', 'give')}
+
{currencyFormatter.format(goal)}
+
{__('Amount raised', 'give')}
+
+
+ ) +} + +export default GoalProgressChart; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/GoalProgressChart/styles.module.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/GoalProgressChart/styles.module.scss new file mode 100644 index 0000000000..f6555196c6 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/GoalProgressChart/styles.module.scss @@ -0,0 +1,43 @@ +.goalProgressChart { + display: flex; + gap: 20px; + align-items: center; +} + +/** + * The size of the chart is relative to the container. + * To get close to the design, + * the size is balanced at flex 3/2 + * and the margins use negative values to control padding. + */ +.chartContainer { + flex: 3; + margin: 0 -50px; +} + +.goalDetails { + flex: 2; +} + +.goal { + font-size: 14px; + font-weight: 400; + line-height: 20px; + margin-bottom: 4px; + color: #1F2937; +} + +.amount { + color: #2D802F; + font-size: 18px; + font-weight: 600; + margin-bottom: 2px; + line-height: 28px; +} + +.goalType { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: #4b5563; +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/HeaderSubText.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/HeaderSubText.tsx new file mode 100644 index 0000000000..112b63a917 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/HeaderSubText.tsx @@ -0,0 +1,17 @@ +/** + * @unreleased + */ +const HeaderSubText = ({children}) => { + return ( +
+ {children} +
+ ) +} + +export default HeaderSubText; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/HeaderText.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/HeaderText.tsx new file mode 100644 index 0000000000..fe969ed252 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/HeaderText.tsx @@ -0,0 +1,17 @@ +/** + * @unreleased + */ +const HeaderText = ({children}) => { + return ( +
+ {children} +
+ ) +} + +export default HeaderText; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/ArchivedCampaignNotice.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/ArchivedCampaignNotice.tsx new file mode 100644 index 0000000000..a4064b9e9f --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/ArchivedCampaignNotice.tsx @@ -0,0 +1,16 @@ +import {__} from '@wordpress/i18n'; +import {TriangleIcon} from '@givewp/campaigns/admin/components/Icons'; + +export default ({handleClick}) => ( + <> + + + {__("Your campaign is currently archived. You can view the campaign details but won't be able to make any changes until it's moved out of archive.", 'give')} + + + handleClick()}> + {__('Move to draft', 'give')} + + + +) diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/DefaultFormNotice.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/DefaultFormNotice.tsx new file mode 100644 index 0000000000..51652e664f --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/DefaultFormNotice.tsx @@ -0,0 +1,19 @@ +import {__} from '@wordpress/i18n'; +import {CloseIcon} from "@givewp/campaigns/admin/components/Icons"; + +import styles from './styles.module.scss' + +export default ({handleClick}) => ( +
+
+ +
+

+ {__('Default campaign form', 'give')} +

+
+ {__('The default form will always appear at the top of this list. Your campaign page and blocks will collect donations through this form by default. You can change it at any time.', 'give')} +
+
+) + diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/styles.module.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/styles.module.scss new file mode 100644 index 0000000000..a002b25593 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/Notices/styles.module.scss @@ -0,0 +1,37 @@ +.tooltip { + position: absolute; + top: 420px; // hacky but I can't think of any other way as the entire list table is wrapped in an element that has the overflow property set + left: 100px; + z-index: 9; + width: 377px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 16px; + padding: 16px 24px 24px; + border-radius: 8px; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.15); + border: solid 2px #6b7280; + background-color: #fff; + + .close { + cursor: pointer; + position: absolute; + right: 13px; + top: 13px; + } + + h3 { + padding: 0; + margin: 0; + font-size: 16px; + color: #060c1a; + } + + .content { + font-size: 14px; + color: #1f2937; + font-weight: normal; + } +} diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx new file mode 100644 index 0000000000..56f19fd070 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx @@ -0,0 +1,92 @@ +import React, {useEffect, useState} from "react"; +import Chart from "react-apexcharts"; +import apiFetch from "@wordpress/api-fetch"; +import {addQueryArgs} from "@wordpress/url"; + +const campaignId = new URLSearchParams(window.location.search).get('id'); + +const RevenueChart = () => { + + const [max, setMax] = useState(0); + const [categories, setCategories] = useState([]); + const [series, setSeries] = useState([{name: "Revenue", data: []}]); + + useEffect(() => { + apiFetch({path: addQueryArgs( '/give-api/v2/campaigns/' + campaignId +'/revenue' ) } ) + .then((data: {date: string, amount: number}[]) => { + + setMax(Math.max(...data.map(item => item.amount)) * 1.1) + + setCategories(data.map(item => item.date)) + + setSeries([{ + name: "Revenue", + data: data.map(item => item.amount) + }]) + }); + }, []) + + const options = { + chart: { + id: "campaign-revenue", + zoom: { + enabled: false + }, + }, + xaxis: { + categories, + type: 'datetime' as "datetime" | "category" | "numeric", + }, + yaxis: { + max, + min: 0, + }, + stroke: { + color: ['#60a1e2'], + width: 1.5, + curve: 'smooth' as "straight" | "smooth" | "monotoneCubic" | "stepline" | "linestep" | ("straight" | "smooth" | "monotoneCubic" | "stepline" | "linestep")[], + lineCap: 'butt' as "butt" | "square" | "round", + }, + dataLabels: { + enabled: false, + }, + fill: { + type: 'gradient', + gradient: { + colorStops: [ + [ + { + offset: 0, + color: '#eee', + opacity: 1 + }, + { + offset: 0.6, + color: '#b7d4f2', + opacity: 50 + }, + { + offset: 100, + color: '#f0f7ff', + opacity: 1 + } + ], + ], + } + } + }; + + return ( + <> + + + ) +} + +export default RevenueChart diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/TextareaControl/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/TextareaControl/index.tsx new file mode 100644 index 0000000000..f3ccd24406 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/TextareaControl/index.tsx @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import {TextareaHTMLAttributes} from 'react'; +import {Controller, useFormContext} from 'react-hook-form'; + +/** + * Internal dependencies + */ +import './styles.scss'; + +type TextareaControlProps = TextareaHTMLAttributes & { + name: string; + help?: string; + className?: string; +}; + +/** + * @unreleased + */ +function TextareaControl({name, help, maxLength, className, ...rest}: TextareaControlProps) { + const {control} = useFormContext(); + + return ( + ( +
+