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' ) }
-
- goToNewFormUrl() }
- className="givewp-not-found-notice-button">
- { __( 'Create a Donation Form', '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' ) }
-
- loadSampleData() }
- className="givewp-not-found-notice-button">
- { __( 'Explore Sample Reports', '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')}
+
+
+ goToNewFormUrl()} className="givewp-not-found-notice-button">
+ {__('Create a Campaign Form', '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'
+ )}{' '}
+
+
+ loadSampleData()} className="givewp-not-found-notice-button">
+ {__('Explore Sample Reports', '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 (
-
-
- { __( 'Create Donation Form', 'give' ) }
-
-
- );
+ return (
+
+
+ {__('Create Campaign Form', 'give')}
+
+
+ );
};
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 && (
+
+
+
+ )}
+
+ {showName &&
{donorName}
}
+ {showDate &&
{date}
}
+
+ {fullComment ? comment : truncatedComment}
+
+ {comment?.length > commentLength && !fullComment && (
+
setFullComment(!fullComment)}
+ >
+ {readMoreText}
+
+ )}
+
+
+ );
+}
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,
+ }}
+ >
+
+
+ ) : (
+
+ ))}
+
+
+ {hasResolved && campaign && (
+
+
+
+ {campaign?.image && (
+
+ )}
+
+ {__('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;';
+}
+?>
+
+
+
+
+
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' => __('Be the first', 'give'),
+ 'formFormat' => 'modal',
+ ]);
+ ?>
+
+
+
+
+
+ $donation) : ?>
+
+
+
+
+
+
+
+
+
+ ' . 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' => __('Be the first donor', 'give'),
+ 'formFormat' => 'modal',
+ ];
+
+ echo (new BlockRenderController())->render($params);
+ ?>
+
+
+
+
+
+
+
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 && (
+
+ )}
+ >
+ )
+}
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})}
+ >
+
+ {attributes.buttonText}
+
+
+
+ {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 (
+
+
+
+
+ {__('Choose a campaign', 'give')}
+
+
+
setSelectedCampaign(option?.value)}
+ noOptionsMessage={() => {__('No campaigns were found.', 'give')}
}
+ //@ts-ignore
+ options={campaignOptions}
+ loadingMessage={() => <>{__('Loading Campaigns...', 'give')}>}
+ isLoading={!hasResolved}
+ theme={reactSelectThemeStyles}
+ styles={reactSelectStyles}
+ />
+
+
+
{
+ handleSelect(selectedCampaign);
+ }}
+ >
+ {__('Confirm', 'give')}
+
+
+ );
+}
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 ? (
+ {
+ e.preventDefault();
+ setPage(previousPage);
+ }}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+ {[...Array(totalPages)].map((e, i) => {
+ const page = i + 1;
+ return (
+ {
+ e.preventDefault();
+ setPage(page);
+ }}
+ >
+ {page}
+
+ )
+ })}
+
+ {nextPage <= totalPages ? (
+ {
+ e.preventDefault();
+ setPage(nextPage);
+ }}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
+
+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(
+ ' %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(
+ '',
+ $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')}
+
+
+
+ {__('Cancel', 'give')}
+
+
+ {__('Archive campaign', '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 (
+
+
+
+
+ {'undefined' !== typeof values[0]
+ ? formatter?.format(values[0]) ?? values[0]
+ :
+ }
+
+ {!!values[1] && (
+
+ )}
+
+
+
+ )
+}
+
+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) => (
+ onSelect(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+ )
+}
+
+
+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 && (
+
+
+
+ {__('Edit', 'give')}
+
+ {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 (
+ (
+
+ )}
+ />
+ );
+}
+
+export default TextareaControl;
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/TextareaControl/styles.scss b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/TextareaControl/styles.scss
new file mode 100644
index 0000000000..7480064081
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/TextareaControl/styles.scss
@@ -0,0 +1,34 @@
+.givewp-textarea-control {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ justify-content: space-between;
+
+ &__textarea {
+ border-radius: 0.25rem;
+ color: var(--givewp-neutral-900);
+ display: block;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5;
+ padding: var(--givewp-spacing-3) var(--givewp-spacing-4);
+ width: 100%;
+ }
+
+ &__help,
+ &__counter {
+ font-size: 0.875rem;
+ line-height: 1.43;
+ margin: var(--givewp-spacing-1) 0 0;
+ }
+
+ &__help {
+ color: var(--givewp-neutral-500);
+ }
+
+ &__counter {
+ color: var(--givewp-neutral-700);
+ margin-left: auto;
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Forms.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Forms.tsx
new file mode 100644
index 0000000000..0c40d707d2
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Forms.tsx
@@ -0,0 +1,10 @@
+import DonationFormsListTable from '../../../../../../DonationForms/V2/resources/components/DonationFormsListTable';
+import {useCampaignEntityRecord} from '@givewp/campaigns/utils';
+
+/**
+ * @unreleased
+ */
+export default () => {
+ const entity = useCampaignEntityRecord();
+ return ;
+};
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Overview.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Overview.tsx
new file mode 100644
index 0000000000..70dfbe159c
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Overview.tsx
@@ -0,0 +1,16 @@
+import CampaignStats from "../Components/CampaignStats";
+
+/**
+ * @unreleased
+ */
+export default () => {
+
+
+ return (
+
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Settings.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Settings.tsx
new file mode 100644
index 0000000000..3bd3738100
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/Settings.tsx
@@ -0,0 +1,266 @@
+import {__, sprintf} from '@wordpress/i18n';
+import {useFormContext} from 'react-hook-form';
+import {Currency, Upload} from '../../Inputs';
+import styles from '../CampaignDetailsPage.module.scss';
+import {ToggleControl} from '@wordpress/components';
+import campaignPageImage from './images/campaign-page.svg';
+import {WarningIcon} from '@givewp/campaigns/admin/components/Icons';
+import {amountFormatter, getCampaignOptionsWindowData} from '@givewp/campaigns/utils';
+import ColorControl from '@givewp/campaigns/admin/components/CampaignDetailsPage/Components/ColorControl';
+import TextareaControl from '@givewp/campaigns/admin/components/CampaignDetailsPage/Components/TextareaControl';
+
+const {currency, isRecurringEnabled} = getCampaignOptionsWindowData();
+const currencyFormatter = amountFormatter(currency);
+
+/**
+ * @unreleased
+ */
+export default () => {
+ const {
+ register,
+ watch,
+ setValue,
+ formState: {errors},
+ } = useFormContext();
+
+ const [goalType, image, status, shortDescription, enableCampaignPage] = watch([
+ 'goalType',
+ 'image',
+ 'status',
+ 'shortDescription',
+ 'enableCampaignPage',
+ ]);
+ const isDisabled = status === 'archived';
+
+ return (
+
+ {/* Campaign Page */}
+
+
+
{__('Campaign page', 'give')}
+
+ {__(
+ 'Set up a landing page for your campaign. The default campaign page has the campaign details, the campaign form, and donor wall.',
+ 'give'
+ )}
+
+
+
+
+
+
+
+ {
+ setValue('enableCampaignPage', value, {shouldDirty: true});
+ }}
+ />
+
+
+ {!enableCampaignPage && (
+
+
+
+ {__(
+ 'This will affect the campaign blocks associated with this campaign. Ensure that no campaign blocks are being used on any page.',
+ 'give'
+ )}
+
+
+ )}
+
+ {errors.enableCampaignPage && (
+
{`${errors.enableCampaignPage.message}`}
+ )}
+
+
+
+
+ {/* Campaign Details */}
+
+
+
{__('Campaign Details', 'give')}
+
+ {__('This includes the campaign title, description, and the cover of your campaign.', 'give')}
+
+
+
+
+
+
{__("What's the title of your campaign?", 'give')}
+
+ {__("Give your campaign a title that tells donors what it's about.", 'give')}
+
+
+
+ {errors.title &&
{`${errors.title.message}`}
}
+
+
+
+
{__("What's your campaign about?", 'give')}
+
+ {__('Let your donors know the story behind your campaign.', 'give')}
+
+
+
+
+ {errors.shortDescription && (
+
{`${errors.shortDescription.message}`}
+ )}
+
+
+
+
+ {__('Add a cover image for your campaign.', 'give')}
+
+
+ {__('Upload an image to represent and inspire your campaign.', 'give')}
+
+
+ {
+ setValue('image', coverImageUrl, {shouldDirty: true});
+ }}
+ reset={() => setValue('image', '', {shouldDirty: true})}
+ />
+
+
+ {errors.title &&
{`${errors.title.message}`}
}
+
+
+
+
+ {/* Campaign Goal */}
+
+
+
{__('Campaign Goal', 'give')}
+
+ {__('How would you like to set your goal?', 'give')}
+
+
+
+
+
+
+ {__('Set the details of your campaign goal here.', 'give')}
+
+
+ {__('Amount raised', 'give')}
+ {__('Number of donations', 'give')}
+ {__('Number of donors', 'give')}
+ {isRecurringEnabled && (
+ <>
+
+ {__('Recurring amount raised', 'give')}
+
+ {__('Number of recurring donations', 'give')}
+
+ {__('Number of recurring donors', 'give')}
+
+ >
+ )}
+
+
+
{goalDescription(goalType)}
+
+ {errors.goalType &&
{`${errors.goalType.message}`}
}
+
+
+
+
{__('How much do you want to raise?', 'give')}
+
+ {__('Let us know the target amount you’re aiming for in your campaign.', 'give')}
+
+
+ {goalType === 'amount' || goalType === 'amountFromSubscriptions' ? (
+
+ ) : (
+
+ )}
+
+ {errors.goal &&
{`${errors.goal.message}`}
}
+
+
+
+
+ {/* Campaign Theme */}
+
+
+
{__('Campaign Theme', 'give')}
+
+ {__('Choose a preferred theme for your campaign.', 'give')}
+
+
+
+
+
+
+ {__('Select your preferred primary color', 'give')}
+
+
+ {__(
+ 'This will affect your main cta’s like your donate button, active and focus states of other UI elements.',
+ 'give'
+ )}
+
+
+
+
+
+
+ {__('Select your preferred secondary color', 'give')}
+
+
+ {__('This will affect your goal progress indicator, badges, icons, etc', 'give')}
+
+
+
+
+
+
+
+ );
+};
+
+const goalDescription = (type: string) => {
+ switch (type) {
+ case 'amount':
+ return sprintf(__('Your goal progress is measured by the total amount of funds raised eg. %s of %s raised.', 'give'),
+ currencyFormatter.format(500),
+ currencyFormatter.format(1000)
+ );
+ case 'donations':
+ return __('Your goal progress is measured by the number of donations. eg. 1 of 5 donations.', 'give');
+ case 'donors':
+ return __(
+ 'Your goal progress is measured by the number of donors. eg. 10 of 50 donors have given.',
+ 'give'
+ );
+ case 'amountFromSubscriptions':
+ return __('Only the first donation amount of a recurring donation is counted toward the goal.', 'give');
+ case 'subscriptions':
+ return __('Only the first donation of a recurring donation is counted toward the goal.', 'give');
+ case 'donorsFromSubscriptions':
+ return __('Only the donors that subscribed to a recurring donation are counted toward the goal.', 'give');
+ default:
+ return null;
+ }
+};
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/definitions.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/definitions.tsx
new file mode 100644
index 0000000000..d082e31bf3
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/definitions.tsx
@@ -0,0 +1,26 @@
+import {CampaignDetailsTab} from '../types';
+import {__} from '@wordpress/i18n';
+import OverviewTab from './Overview';
+import SettingsTab from './Settings';
+import FormsTab from './Forms';
+
+const campaignDetailsTabs: CampaignDetailsTab[] = [
+ {
+ id: 'overview',
+ title: __('Overview', 'give'),
+ content: () => ,
+ },
+ {
+ id: 'settings',
+ title: __('Settings', 'give'),
+ content: () => ,
+ },
+ {
+ id: 'forms',
+ title: __('Forms', 'give'),
+ content: () => ,
+ fullwidth: true,
+ },
+];
+
+export default campaignDetailsTabs;
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/images/campaign-page.svg b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/images/campaign-page.svg
new file mode 100644
index 0000000000..b9eaebbb88
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/images/campaign-page.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/index.tsx
new file mode 100644
index 0000000000..e473ac7461
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Tabs/index.tsx
@@ -0,0 +1,89 @@
+import {useEffect, useState} from '@wordpress/element';
+import {Tab, TabList, TabPanel, Tabs} from 'react-aria-components';
+import cx from 'classnames';
+import {CampaignDetailsTab} from '../types';
+
+import styles from '../CampaignDetailsPage.module.scss';
+import tabsDefinitions from './definitions';
+import NotificationsPlaceholder from '../../Notifications';
+
+const tabs: CampaignDetailsTab[] = tabsDefinitions;
+
+/**
+ * @unreleased
+ */
+export default () => {
+ const [activeTab, setActiveTab] = useState(tabs[0]);
+
+ const getTabFromURL = () => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const tabId = urlParams.get('tab') || activeTab.id;
+ return tabs.find((tab) => tab.id === tabId);
+ };
+
+ const handleTabNavigation = (tabId: string) => {
+ const newTab = tabs.find((tab) => tab.id === tabId);
+
+ if (!newTab) {
+ return;
+ }
+
+ const urlParams = new URLSearchParams(window.location.search);
+ urlParams.set('tab', newTab.id);
+
+ window.history.pushState(null, activeTab.title, `${window.location.pathname}?${urlParams.toString()}`);
+
+ setActiveTab(newTab);
+ };
+
+ const handleUrlTabParamOnFirstLoad = () => {
+ const urlParams = new URLSearchParams(window.location.search);
+ // Add the 'tab' parameter only if it's not in the URL yet
+ if (!urlParams.has('tab')) {
+ urlParams.set('tab', activeTab.id);
+ window.history.replaceState(null, activeTab.title, `${window.location.pathname}?${urlParams.toString()}`);
+ } else {
+ setActiveTab(getTabFromURL());
+ }
+ };
+
+ useEffect(() => {
+ handleUrlTabParamOnFirstLoad();
+
+ const handlePopState = () => setActiveTab(getTabFromURL());
+
+ // Updates state based on URL when user navigates with "Back" or "Forward" buttons
+ window.addEventListener('popstate', handlePopState);
+
+ // Cleanup listener on unmount
+ return () => {
+ window.removeEventListener('popstate', handlePopState);
+ };
+ }, []);
+
+ return (
+
+
+
+ {Object.values(tabs).map((tab) => (
+
+ {tab.title}{' '}
+
+ ))}
+
+
+
+
+
+
+
+
+ {Object.values(tabs).map((tab) => (
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx
new file mode 100644
index 0000000000..7878d5d2f1
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx
@@ -0,0 +1,308 @@
+import {__} from '@wordpress/i18n';
+import {useEffect, useState} from '@wordpress/element';
+import {useDispatch} from '@wordpress/data';
+import apiFetch from '@wordpress/api-fetch';
+import {JSONSchemaType} from 'ajv';
+import {ajvResolver} from '@hookform/resolvers/ajv';
+import {GiveCampaignOptions} from '@givewp/campaigns/types';
+import {Campaign} from '../types';
+import {FormProvider, SubmitHandler, useForm} from 'react-hook-form';
+import {Spinner as GiveSpinner} from '@givewp/components';
+import {Spinner} from '@wordpress/components';
+import Tabs from './Tabs';
+import ArchiveCampaignDialog from './Components/ArchiveCampaignDialog';
+import {ArrowReverse, BreadcrumbSeparatorIcon, DotsIcons, TrashIcon, ViewIcon} from '../Icons';
+import ArchivedCampaignNotice from './Components/Notices/ArchivedCampaignNotice';
+import NotificationPlaceholder from '../Notifications';
+import cx from 'classnames';
+import {useCampaignEntityRecord} from '@givewp/campaigns/utils';
+
+import styles from './CampaignDetailsPage.module.scss';
+
+declare const window: {
+ GiveCampaignOptions: GiveCampaignOptions;
+} & Window;
+
+interface Show {
+ contextMenu?: boolean;
+ confirmationModal?: boolean;
+}
+
+const StatusBadge = ({status}: { status: string }) => {
+ const statusMap = {
+ active: __('Active', 'give'),
+ archived: __('Archived', 'give'),
+ draft: __('Draft', 'give'),
+ };
+
+ return (
+
+ );
+};
+
+export default function CampaignsDetailsPage({campaignId}) {
+ const [resolver, setResolver] = useState({});
+ const [isSaving, setIsSaving] = useState(null);
+ const [show, _setShowValue] = useState({
+ contextMenu: false,
+ confirmationModal: false,
+ });
+
+ const dispatch = useDispatch('givewp/campaign-notifications');
+
+ const setShow = (data: Show) => {
+ _setShowValue((prevState) => {
+ return {
+ ...prevState,
+ ...data,
+ };
+ });
+ };
+
+ useEffect(() => {
+ apiFetch({
+ path: `/give-api/v2/campaigns/${campaignId}`,
+ method: 'OPTIONS',
+ }).then(({schema}: { schema: JSONSchemaType }) => {
+ setResolver({
+ resolver: ajvResolver(schema),
+ });
+ });
+ }, []);
+
+ const {
+ campaign,
+ hasResolved,
+ save,
+ edit,
+ } = useCampaignEntityRecord(campaignId);
+
+ const methods = useForm({
+ mode: 'onBlur',
+ ...resolver,
+ });
+
+ const {formState, handleSubmit, reset, setValue, watch} = methods;
+
+ const [enableCampaignPage] = watch(['enableCampaignPage']);
+
+ // Set default values when campaign is loaded
+ useEffect(() => {
+ if (hasResolved) {
+ reset({...campaign});
+ }
+ }, [hasResolved]);
+
+ // Show campaign archived notice
+ useEffect(() => {
+ if (campaign?.status !== 'archived') {
+ return;
+ }
+
+ dispatch.addNotice({
+ id: 'update-archive-notice',
+ type: 'warning',
+ onDismiss: () => updateStatus('draft'),
+ content: (onDismiss: Function) =>
+ });
+ }, [campaign?.status]);
+
+ const onSubmit: SubmitHandler = async (data) => {
+
+ const shouldSave = formState.isDirty
+ // Force save if first publish to account for a race condition.
+ || (campaign.status === 'draft' && data.status === 'active');
+
+ if (shouldSave) {
+ setIsSaving(data.status);
+
+ edit(data);
+
+ try {
+ const response = await save();
+
+ setIsSaving(null);
+ reset(response);
+
+ dispatch.addSnackbarNotice({
+ id: `save-${data.status}`,
+ content: __('Campaign updated', 'give'),
+ });
+ } catch (err) {
+ setIsSaving(null);
+
+ dispatch.addSnackbarNotice({
+ id: `save-error`,
+ type: 'error',
+ content: __('Campaign update failed', 'give'),
+ });
+ }
+ }
+ };
+
+ const updateStatus = async (status: 'archived' | 'draft') => {
+ setValue('status', status);
+
+ edit({...campaign, status})
+
+ try {
+ const response: Campaign = await save();
+
+ setShow({
+ contextMenu: false,
+ confirmationModal: false,
+ });
+ reset(response);
+
+ dispatch.addSnackbarNotice({
+ id: `update-${status}`,
+ content: getMessageByStatus(status),
+ });
+ } catch (err) {
+ setShow({
+ contextMenu: false,
+ confirmationModal: false,
+ });
+
+ dispatch.addSnackbarNotice({
+ id: 'update-error',
+ type: 'error',
+ content: __('Something went wrong', 'give'),
+ });
+ }
+ };
+
+ if (!hasResolved) {
+ return (
+
+
+
+
{__('Loading campaign...', 'give')}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+const getMessageByStatus = (status: string) => {
+ switch (status) {
+ case 'archived':
+ return __('Campaign is moved to archive', 'give');
+ case 'active':
+ return __('Campaign is now active', 'give');
+ case 'draft':
+ return __('Campaign is moved to draft', 'give');
+ }
+
+ return null;
+};
diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/types.ts b/src/Campaigns/resources/admin/components/CampaignDetailsPage/types.ts
new file mode 100644
index 0000000000..a0e3f24082
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/types.ts
@@ -0,0 +1,20 @@
+import {FC} from 'react';
+
+export interface GiveCampaignDetails {
+ adminUrl: string;
+ currency: string;
+ isRecurringEnabled: boolean;
+ defaultForm: string;
+}
+
+export type CampaignFormOption = {
+ id: number;
+ title: string;
+};
+
+export type CampaignDetailsTab = {
+ id: string;
+ title: string;
+ content: FC;
+ fullwidth?: boolean;
+};
diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss b/src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss
new file mode 100644
index 0000000000..cebb5faf88
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss
@@ -0,0 +1,115 @@
+.campaignForm {
+ .submitButton {
+ border-radius: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.43;
+ padding: var(--givewp-spacing-2) var(--givewp-spacing-4);
+ text-align: center;
+ }
+
+ input,
+ label {
+ font-size: 1rem;
+ }
+}
+
+.fieldRequired {
+ color: var(--givewp-red-500);
+}
+
+.goalType {
+
+ .goalTypeOption {
+ margin: 0.5rem 0 0.5rem 0;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 1rem;
+
+ cursor: pointer;
+ border: 1px solid #9CA0AF;
+ border-radius: 8px;
+ padding: 0.75rem 1.5rem 0.75rem 1.5rem;
+ }
+
+ .goalTypeOptionIcon {
+
+ line-height: 0.875rem;
+ margin-top: 0.175rem;
+
+ svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+ }
+
+ .goalTypeOptionText {
+
+ label,
+ span {
+ cursor: pointer;
+ }
+
+ > label {
+ font-size: 0.875rem !important;
+ }
+
+ > span {
+ font-size: 0.75rem !important;
+ margin-top: 0.2rem;
+ display: none;
+ line-height: 1rem !important;
+ }
+
+ /* Hide the radio button input */
+ input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+ width: 0;
+ }
+ }
+
+ .goalTypeOptionSelected {
+ background-color: #374151;
+
+ .goalTypeOptionIcon {
+
+ svg path:not([fill]) {
+ stroke: #F9FAFB;
+ }
+
+ svg path:not([stroke]) {
+ fill: #F9FAFB;
+ }
+ }
+
+ .goalTypeOptionText {
+ label,
+ span {
+ color: #F9FAFB !important;
+ }
+
+ span {
+ display: inline-block;
+ }
+ }
+ }
+}
+
+.button:is(:global(.button)) {
+ border-radius: var(--givewp-rounded-8)
+}
+
+.previousButton:is(:global(.button)) {
+ background-color: transparent;
+ border: solid 1px #9ca0af;
+ color: #060c1a;
+
+ &:hover {
+ border: solid 1px #9ca0af;
+ color: #060c1a;
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/GoalTypeIcons.tsx b/src/Campaigns/resources/admin/components/CampaignFormModal/GoalTypeIcons.tsx
new file mode 100644
index 0000000000..0ccc01eb34
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignFormModal/GoalTypeIcons.tsx
@@ -0,0 +1,95 @@
+export const AmountIcon = () => (
+
+
+
+);
+
+export const DonationsIcon = () => (
+
+
+
+
+);
+
+export const DonorsIcon = () => (
+
+
+
+);
+
+export const AmountFromSubscriptionsIcon = () => (
+
+
+
+
+
+);
+
+export const SubscriptionsIcon = () => (
+
+
+
+
+);
+
+export const DonorsFromSubscriptionsIcon = () => (
+
+
+
+
+
+);
diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx b/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx
new file mode 100644
index 0000000000..9258326f82
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx
@@ -0,0 +1,451 @@
+import {FormProvider, SubmitHandler, useForm} from 'react-hook-form';
+import {__, sprintf} from '@wordpress/i18n';
+import styles from './CampaignFormModal.module.scss';
+import FormModal from '../FormModal';
+import CampaignsApi from '../api';
+import {
+ CampaignFormInputs,
+ CampaignModalProps,
+ GoalInputAttributes,
+ GoalTypeOption as GoalTypeOptionType,
+} from './types';
+import {useRef, useState} from 'react';
+import {Currency, Upload} from '../Inputs';
+import {
+ AmountFromSubscriptionsIcon,
+ AmountIcon,
+ DonationsIcon,
+ DonorsFromSubscriptionsIcon,
+ DonorsIcon,
+ SubscriptionsIcon,
+} from './GoalTypeIcons';
+import {getGiveCampaignsListTableWindowData} from '../CampaignsListTable';
+import {amountFormatter} from '@givewp/campaigns/utils';
+import TextareaControl from '../CampaignDetailsPage/Components/TextareaControl';
+
+const {currency, isRecurringEnabled} = getGiveCampaignsListTableWindowData();
+const currencyFormatter = amountFormatter(currency);
+
+/**
+ * Get the next sharp hour
+ *
+ * @unreleased
+ */
+const getNextSharpHour = (hoursToAdd: number) => {
+ const date = new Date();
+ date.setHours(date.getHours() + hoursToAdd, 0, 0, 0);
+
+ return date;
+};
+
+/**
+ * Format a given date to be used in datetime inputs
+ *
+ * @unreleased
+ */
+const getDateString = (date: Date) => {
+ const offsetInMilliseconds = date.getTimezoneOffset() * 60 * 1000;
+ const dateWithOffset = new Date(date.getTime() - offsetInMilliseconds);
+
+ return removeTimezoneFromDateISOString(dateWithOffset.toISOString());
+};
+
+/**
+ * Remove timezone from date string
+ *
+ * @unreleased
+ */
+const removeTimezoneFromDateISOString = (date: string) => {
+ return date.slice(0, -5);
+};
+
+/**
+ * @unreleased
+ */
+const getGoalTypeIcon = (type: string) => {
+ switch (type) {
+ case 'amount':
+ return ;
+ case 'donations':
+ return ;
+ case 'donors':
+ return ;
+ case 'amountFromSubscriptions':
+ return ;
+ case 'subscriptions':
+ return ;
+ case 'donorsFromSubscriptions':
+ return ;
+ }
+};
+
+/**
+ * Goal Type Option component
+ *
+ * @unreleased
+ */
+const GoalTypeOption = ({type, label, description, selected, register}: GoalTypeOptionType) => {
+ const divRef = useRef(null);
+ const labelRef = useRef(null);
+
+ const handleDivClick = () => {
+ labelRef.current.click();
+ };
+
+ return (
+
+
{getGoalTypeIcon(type)}
+
+
+
+ {label}
+
+ {description}
+
+
+ );
+};
+
+/**
+ * Campaign Form Modal component
+ *
+ * @unreleased
+ */
+export default function CampaignFormModal({isOpen, handleClose, apiSettings, title, campaign}: CampaignModalProps) {
+ const API = new CampaignsApi(apiSettings);
+ const [step, setStep] = useState(1);
+
+ const methods = useForm({
+ defaultValues: {
+ title: campaign?.title ?? '',
+ shortDescription: campaign?.shortDescription ?? '',
+ image: campaign?.image ?? '',
+ goalType: campaign?.goalType ?? '',
+ goal: campaign?.goal ?? null,
+ startDateTime: getDateString(
+ campaign?.startDateTime?.date ? new Date(campaign?.startDateTime?.date) : getNextSharpHour(1)
+ ),
+ endDateTime: campaign?.endDateTime?.date ? getDateString(new Date(campaign.startDateTime.date)) : '',
+ },
+ });
+
+ const {
+ register,
+ handleSubmit,
+ formState: {errors, isDirty, isSubmitting},
+ setValue,
+ watch,
+ trigger,
+ } = methods;
+
+ const image = watch('image');
+ const selectedGoalType = watch('goalType');
+ const goal = watch('goal');
+
+ const getFormModalTitle = () => {
+ switch (step) {
+ case 1:
+ return __('Tell us about your fundraising cause', 'give');
+ case 2:
+ return __('Set up your campaign goal', 'give');
+ }
+
+ return null;
+ };
+
+ const goalInputAttributes: {[selectedGoalType: string]: GoalInputAttributes} = {
+ amount: {
+ label: __('How much do you want to raise?', 'give'),
+ description: __('Set the target amount your campaign should raise.', 'give'),
+ placeholder: sprintf(__('eg. %s', 'give'),
+ currencyFormatter.format(2000),
+ ),
+ },
+ donations: {
+ label: __('How many donations do you need?', 'give'),
+ description: __('Set the target number of donations your campaign should bring in.', 'give'),
+ placeholder: __('eg. 100 donations', 'give'),
+ },
+ donors: {
+ label: __('How many donors do you need?', 'give'),
+ description: __('Set the target number of donors your campaign should bring in.', 'give'),
+ placeholder: __('eg. 100 donors', 'give'),
+ },
+ amountFromSubscriptions: {
+ label: __('How much do you want to raise?', 'give'),
+ description: __(
+ 'Set the target recurring amount your campaign should raise. One-time donations do not count.',
+ 'give'
+ ),
+ placeholder: sprintf(__('eg. %s', 'give'),
+ currencyFormatter.format(2000),
+ ),
+ },
+ subscriptions: {
+ label: __('How many recurring donations do you need?', 'give'),
+ description: __(
+ 'Set the target number of recurring donations your campaign should bring in. One-time donations do not count.',
+ 'give'
+ ),
+ placeholder: __('eg. 100 subscriptions', 'give'),
+ },
+ donorsFromSubscriptions: {
+ label: __('How many recurring donors do you need?', 'give'),
+ description: __(
+ 'Set the target number of recurring donors your campaign should bring in. One-time donations do not count.',
+ 'give'
+ ),
+ placeholder: __('eg. 100 subscribers', 'give'),
+ },
+ };
+
+ const requiredAsterisk = * ;
+
+ const validateTitle = async () => {
+ return await trigger('title');
+ };
+
+ const onSubmit: SubmitHandler = async (inputs, event) => {
+ event.preventDefault();
+
+ if (step !== 2) {
+ return;
+ }
+
+ try {
+ inputs.startDateTime = getDateString(new Date(inputs.startDateTime));
+ inputs.endDateTime = inputs.endDateTime && getDateString(new Date(inputs.endDateTime));
+
+ const endpoint = campaign?.id ? `/campaign/${campaign.id}` : '';
+ const response = await API.fetchWithArgs(endpoint, inputs, 'POST');
+
+ handleClose(response);
+ } catch (error) {
+ console.error('Error submitting campaign campaign', error);
+ }
+ };
+
+ return (
+
+
+ {step === 1 && (
+ <>
+
+
+ {__("What's the title of your campaign?", 'give')} {requiredAsterisk}
+
+
{__("Give your campaign a title that tells donors what it's about.", 'give')}
+
+ {errors.title && (
+
+
{errors.title.message}
+
+ )}
+
+
+ {__("What's your campaign about?", 'give')}
+ {__('Let your donors know the story behind your campaign.', 'give')}
+
+
+
+ {__('Add a cover image for your campaign.', 'give')}
+ {__('Upload an image to represent and inspire your campaign.', 'give')}
+ {
+ setValue('image', coverImageUrl);
+ }}
+ reset={() => setValue('image', '')}
+ />
+
+ (await validateTitle()) && setStep(2)}
+ className={`button button-primary ${!isDirty ? 'disabled' : ''}`}
+ aria-disabled={!isDirty}
+ disabled={!isDirty}
+ >
+ {__('Continue', 'give')}
+
+ >
+ )}
+ {step === 2 && (
+ <>
+
+
+ {__('How would you like to set your goal?', 'give')} {requiredAsterisk}
+
+
{__('Set the goal your fundraising efforts will work toward.', 'give')}
+
+
+
+
+ {isRecurringEnabled && (
+ <>
+
+
+
+ >
+ )}
+
+ {errors.goalType && (
+
+
{errors.goalType.message}
+
+ )}
+
+ {selectedGoalType && (
+
+
+ {goalInputAttributes[selectedGoalType].label} {requiredAsterisk}
+
+
{goalInputAttributes[selectedGoalType].description}
+ {selectedGoalType === 'amount' || selectedGoalType === 'amountFromSubscriptions' ? (
+
+ ) : (
+
+ )}
+ {errors.goal && (
+
+
{errors.goal.message}
+
+ )}
+
+ )}
+ {/**/}
+
+ setStep(1)}
+ className={`button button-secondary ${styles.button} ${styles.previousButton}`}
+ >
+ {__('Previous', 'give')}
+
+
+
+ {__('Continue', 'give')}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/types.ts b/src/Campaigns/resources/admin/components/CampaignFormModal/types.ts
new file mode 100644
index 0000000000..5aa8ec6abd
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignFormModal/types.ts
@@ -0,0 +1,36 @@
+import {Campaign} from '../types';
+
+export interface CampaignModalProps {
+ isOpen: boolean;
+ handleClose: (response?: any) => void;
+ apiSettings: {
+ apiRoot: string;
+ apiNonce: string;
+ };
+ title: string;
+ campaign?: Campaign;
+}
+
+export type CampaignFormInputs = {
+ title: string;
+ shortDescription: string;
+ image: string;
+ goalType: string;
+ goal: number;
+ startDateTime: string;
+ endDateTime: string;
+};
+
+export type GoalInputAttributes = {
+ label: string;
+ description: string;
+ placeholder: string;
+};
+
+export type GoalTypeOption = {
+ type: string;
+ label: string;
+ description: string;
+ selected: boolean;
+ register: any;
+};
diff --git a/src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsListTable.module.scss b/src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsListTable.module.scss
new file mode 100644
index 0000000000..ed807500e9
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsListTable.module.scss
@@ -0,0 +1,36 @@
+.container {
+ text-align: center;
+ color: #424242;
+ margin: var(--givewp-spacing-2) 0;
+
+ img {
+ margin-bottom: 1rem;
+ }
+
+ > h3 {
+ font-size: 1.125rem;
+ line-height: 1.22;
+ margin: 0;
+ padding: 0;
+ }
+
+ > p:last-child {
+ margin-bottom: 0;
+ }
+
+ .helpMessage {
+ font-size:0.875rem;
+ font-weight: 400;
+ line-height: 1.57;
+ }
+
+ .button {
+ border-radius: var(--givewp-rounded-4);
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5;
+ min-width: 13.75rem;
+ padding: var(--givewp-spacing-3) var(--givewp-spacing-8);
+ text-align: center;
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsRowActions.tsx b/src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsRowActions.tsx
new file mode 100644
index 0000000000..cae05cd50b
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsRowActions.tsx
@@ -0,0 +1,11 @@
+import {__} from '@wordpress/i18n';
+import RowAction from '@givewp/components/ListTable/RowAction';
+
+export function CampaignsRowActions({item, setUpdateErrors, parameters}) {
+ return (
+
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx b/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx
new file mode 100644
index 0000000000..2d235fde02
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx
@@ -0,0 +1,145 @@
+import {__} from '@wordpress/i18n';
+import {ListTablePage} from '@givewp/components';
+import ListTableApi from '@givewp/components/ListTable/api';
+import {BulkActionsConfig, FilterConfig} from '@givewp/components/ListTable/ListTablePage';
+import {CampaignsRowActions} from './CampaignsRowActions';
+import styles from './CampaignsListTable.module.scss';
+import {GiveCampaignsListTable} from './types';
+import CreateCampaignModal from '../CreateCampaignModal';
+import {useState} from 'react';
+import MergeCampaignModal from '../MergeCampaign/Modal';
+
+declare const window: {
+ GiveCampaignsListTable: GiveCampaignsListTable;
+} & Window;
+
+/**
+ * Auto open modal if the URL has the query parameter id as new
+ *
+ * @unreleased
+ */
+const autoOpenModal = () => {
+ const queryParams = new URLSearchParams(window.location.search);
+ const newParam = queryParams.get('new');
+
+ return newParam === 'campaign';
+};
+
+export function getGiveCampaignsListTableWindowData() {
+ return window.GiveCampaignsListTable;
+}
+
+const API = new ListTableApi(getGiveCampaignsListTableWindowData());
+
+const campaignStatus = [
+ {
+ value: 'any',
+ text: __('All Status', 'give'),
+ },
+ {
+ value: 'active',
+ text: __('Active', 'give'),
+ },
+ {
+ value: 'inactive',
+ text: __('Archived', 'give'),
+ },
+ {
+ value: 'draft',
+ text: __('Draft', 'give'),
+ },
+ {
+ value: 'pending',
+ text: __('Pending', 'give'),
+ },
+ {
+ value: 'processing',
+ text: __('Processing', 'give'),
+ },
+ {
+ value: 'failed',
+ text: __('Failed', 'give'),
+ },
+];
+
+const filters: Array = [
+ {
+ name: 'status',
+ type: 'select',
+ text: __('status', 'give'),
+ ariaLabel: __('Filter campaign by status', 'give'),
+ options: campaignStatus,
+ },
+ {
+ name: 'search',
+ type: 'search',
+ text: __('Search by name or ID', 'give'),
+ ariaLabel: __('Search donation forms', 'give'),
+ },
+];
+
+const bulkActions: Array = [
+ {
+ label: __('Merge', 'give'),
+ value: 'merge',
+ type: 'custom',
+ confirm: (selected, names) => {
+ const urlParams = new URLSearchParams(window.location.search);
+ urlParams.set('action', 'merge');
+ window.history.replaceState(
+ {selected: selected, names: names},
+ __('Merge Campaigns', 'give'),
+ `${window.location.pathname}?${urlParams.toString()}`
+ );
+
+ return null;
+ },
+ },
+];
+
+export default function CampaignsListTable() {
+ const [isOpen, setOpen] = useState(autoOpenModal());
+
+ /**
+ * Displays a blank slate for the Campaigns table.
+ *
+ * @unreleased
+ */
+ const ListTableBlankSlate = () => {
+ const imagePath = `${
+ getGiveCampaignsListTableWindowData().pluginUrl
+ }/assets/dist/images/list-table/blank-slate-donation-forms-icon.svg`;
+ return (
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/CampaignsListTable/types.ts b/src/Campaigns/resources/admin/components/CampaignsListTable/types.ts
new file mode 100644
index 0000000000..8e7093ac17
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CampaignsListTable/types.ts
@@ -0,0 +1,9 @@
+export interface GiveCampaignsListTable {
+ apiNonce: string;
+ apiRoot: string;
+ table: {columns: Array};
+ adminUrl: string;
+ pluginUrl: string;
+ currency: string;
+ isRecurringEnabled: boolean;
+}
diff --git a/src/Campaigns/resources/admin/components/CreateCampaignModal/CreateCampaignModal.module.scss b/src/Campaigns/resources/admin/components/CreateCampaignModal/CreateCampaignModal.module.scss
new file mode 100644
index 0000000000..7de8a80964
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CreateCampaignModal/CreateCampaignModal.module.scss
@@ -0,0 +1,10 @@
+:global(#give-admin-campaigns-root) {
+ .createCampaignButton {
+ border-radius: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.43;
+ padding: var(--givewp-spacing-2) var(--givewp-spacing-4);
+ text-align: center;
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/CreateCampaignModal/index.tsx b/src/Campaigns/resources/admin/components/CreateCampaignModal/index.tsx
new file mode 100644
index 0000000000..4f1490131a
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/CreateCampaignModal/index.tsx
@@ -0,0 +1,47 @@
+import {useState} from 'react';
+import {__} from '@wordpress/i18n';
+import styles from './CreateCampaignModal.module.scss';
+import CampaignFormModal from '../CampaignFormModal';
+import {getGiveCampaignsListTableWindowData} from '../CampaignsListTable';
+
+/**
+ * Create Campaign Modal component
+ *
+ * @unreleased
+ */
+export default function CreateCampaignModal({isOpen, setOpen}) {
+
+ const openModal = () => setOpen(true);
+ const closeModal = (response: ResponseProps = {}) => {
+ setOpen(false);
+
+ if (response?.id) {
+ window.location.href =
+ getGiveCampaignsListTableWindowData().adminUrl +
+ 'edit.php?post_type=give_forms&page=give-campaigns&id=' +
+ response?.id;
+ }
+ };
+
+ const apiSettings = getGiveCampaignsListTableWindowData();
+ // Remove the /list-table from the apiRoot. This is a hack to make the API work while we don't refactor other list tables.
+ apiSettings.apiRoot = apiSettings.apiRoot.replace('/list-table', '');
+
+ return (
+ <>
+
+ {__('Create campaign', 'give')}
+
+
+ >
+ );
+}
+
+type ResponseProps = {
+ id?: string;
+};
diff --git a/src/Campaigns/resources/admin/components/FormModal/ErrorMessages.tsx b/src/Campaigns/resources/admin/components/FormModal/ErrorMessages.tsx
new file mode 100644
index 0000000000..ff1c1ac924
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/FormModal/ErrorMessages.tsx
@@ -0,0 +1,24 @@
+import {FieldError, FieldErrors} from 'react-hook-form';
+
+/**
+ * Output error messages
+ *
+ * @unreleased
+ */
+export default function ErrorMessages({errors}: ErrorMessagesProps) {
+ if (!(Object.values(errors).length > 0)) return null;
+
+ return (
+ <>
+
+ {Object.values(errors).map((error: FieldError, key) => (
+ {error?.message}
+ ))}
+
+ >
+ );
+}
+
+interface ErrorMessagesProps {
+ errors: FieldErrors;
+}
diff --git a/src/Campaigns/resources/admin/components/FormModal/FormModal.module.scss b/src/Campaigns/resources/admin/components/FormModal/FormModal.module.scss
new file mode 100644
index 0000000000..b74438ab86
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/FormModal/FormModal.module.scss
@@ -0,0 +1,113 @@
+.formModal {
+ :global {
+ .givewp-modal-dialog {
+ max-width: 35rem;
+
+ .givewp-modal-header {
+ font-size: 1.25rem;
+ font-weight: 600;
+ line-height: 1.6;
+ }
+
+ .givewp-modal-close {
+ translateY: 4.5px;
+ }
+ }
+
+ .givewp-campaigns {
+ &__form {
+ label {
+ color: var(--givewp-neutral-700);
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5;
+ }
+
+ input, textarea {
+ border-color: var(--givewp-grey-400);
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5;
+ padding: var(--givewp-spacing-3) var(--givewp-spacing-4);
+
+ &::placeholder {
+ color: var(--givewp-grey-400);
+ }
+
+ &[aria-invalid="true"] {
+ border-color: var(--givewp-red-500);
+ outline-color: var(--givewp-red-500);
+ }
+ }
+
+ span:not(.givewp-field-required, .givewp-textarea-control__counter) {
+ color: var(--givewp-neutral-500);
+ font-size: 0.875rem;
+ line-height: 1.5;
+ margin-bottom: var(--givewp-spacing-2);
+
+ strong {
+ color: var(--givewp-neutral-600);
+ font-weight: 600;
+ }
+ }
+
+ button[type="submit"].button {
+ display: block;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5;
+ padding: var(--givewp-spacing-3) var(--givewp-spacing-5);
+ width: 100%;
+ border-radius: 8px;
+
+ &.button-secondary {
+ color: #060c1a;
+ border-color: #9ca0af;
+ background-color: white;
+ }
+ }
+ }
+
+ &__form-row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--givewp-spacing-1);
+ margin-bottom: var(--givewp-spacing-6);
+
+ &--half {
+ flex-direction: row;
+ gap: var(--givewp-spacing-6);
+ }
+ }
+
+ &__form-column {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--givewp-spacing-1);
+ }
+
+ &__form-errors {
+
+ padding-left: var(--givewp-spacing-1);
+
+ ul {
+ list-style: disc;
+ margin-bottom: var(--givewp-spacing-6);
+ margin-top: 0;
+ padding-left: var(--givewp-spacing-4);
+ }
+
+ p,
+ li {
+ color: var(--givewp-red-500);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.5;
+ margin: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/FormModal/index.tsx b/src/Campaigns/resources/admin/components/FormModal/index.tsx
new file mode 100644
index 0000000000..fd46bdd118
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/FormModal/index.tsx
@@ -0,0 +1,44 @@
+import ModalDialog from '@givewp/components/AdminUI/ModalDialog';
+import ErrorMessages from './ErrorMessages';
+import styles from './FormModal.module.scss';
+
+/**
+ * Form Modal component that renders a modal with a styled form inside
+ *
+ * @unreleased
+ */
+export default function FormModal({
+ isOpen,
+ handleClose,
+ title,
+ handleSubmit,
+ errors,
+ className,
+ children,
+}: FormModalProps) {
+ return (
+
+
+
+ );
+}
+
+interface FormModalProps {
+ isOpen: boolean;
+ handleClose: () => void;
+ title: string;
+ handleSubmit: (e: React.FormEvent) => void;
+ errors: Record;
+ className: string;
+ children: JSX.Element | JSX.Element[];
+}
diff --git a/src/Campaigns/resources/admin/components/Icons/index.tsx b/src/Campaigns/resources/admin/components/Icons/index.tsx
new file mode 100644
index 0000000000..f7fffc1b9c
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Icons/index.tsx
@@ -0,0 +1,102 @@
+export const DotsIcons = () => (
+
+
+
+);
+
+export const ViewIcon = () => (
+
+
+
+
+);
+
+export const TrashIcon = () => (
+
+
+
+);
+
+export const BreadcrumbSeparatorIcon = () => (
+
+
+
+);
+
+export const ErrorIcon = () => (
+
+
+
+);
+
+export const ArrowReverse = () => (
+
+
+
+);
+
+export const CloseIcon = () => (
+
+
+
+);
+
+export const TriangleIcon = () => (
+
+
+
+);
+
+export const WarningIcon = () => (
+
+
+
+);
diff --git a/src/Campaigns/resources/admin/components/Inputs/Currency/index.tsx b/src/Campaigns/resources/admin/components/Inputs/Currency/index.tsx
new file mode 100644
index 0000000000..5627e946db
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Inputs/Currency/index.tsx
@@ -0,0 +1,38 @@
+import CurrencyInput from 'react-currency-input-field';
+import {Controller, useFormContext} from 'react-hook-form';
+
+type Props = {
+ name: string;
+ currency: string;
+ placeholder?: string;
+ disabled?: boolean;
+};
+
+/**
+ * @unreleased
+ */
+export default ({name, currency, placeholder, disabled, ...rest}: Props) => {
+ const {control} = useFormContext();
+
+ return (
+ (
+ {
+ field.onChange(Number(value ?? 0));
+ }}
+ value={field.value}
+ placeholder={placeholder}
+ allowDecimals={true}
+ allowNegativeValue={false}
+ maxLength={9}
+ intlConfig={{locale: window.navigator.language, currency}}
+ {...rest}
+ />
+ )}
+ />
+ );
+};
diff --git a/src/Campaigns/resources/admin/components/Inputs/Editor/index.tsx b/src/Campaigns/resources/admin/components/Inputs/Editor/index.tsx
new file mode 100644
index 0000000000..945f19e69f
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Inputs/Editor/index.tsx
@@ -0,0 +1,31 @@
+import {ClassicEditor} from '@givewp/form-builder-library';
+import {Controller, useFormContext} from 'react-hook-form';
+
+type Props = {
+ name: string;
+ rows?: number;
+};
+
+/**
+ * @unreleased
+ */
+export default ({name, rows = 4, ...rest}: Props) => {
+
+ const {control} = useFormContext();
+
+ return (
+ (
+ field.onChange(value)}
+ rows={rows}
+ {...rest}
+ />
+ )}
+ />
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/Inputs/Upload/index.tsx b/src/Campaigns/resources/admin/components/Inputs/Upload/index.tsx
new file mode 100644
index 0000000000..c600d1c891
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Inputs/Upload/index.tsx
@@ -0,0 +1,147 @@
+/**
+ * @link https://codex.wordpress.org/Javascript_Reference/wp.media
+ * @link https://wordpress.stackexchange.com/a/382291
+ */
+
+import React from 'react';
+import _ from 'lodash';
+import {__, sprintf} from '@wordpress/i18n';
+import './styles.scss';
+
+type MediaLibrary = {
+ id: string;
+ value: string;
+ onChange: (url: string, alt: string) => void;
+ reset: () => void;
+ label: string;
+ actionLabel?: string;
+ disabled?: boolean;
+};
+
+export default function UploadMedia({id, value, onChange, label, actionLabel, reset, disabled}: MediaLibrary) {
+ // The media library uses Backbone.js, which can conflict with lodash.
+ _.noConflict();
+ let frame;
+
+ const openMediaLibrary = (event) => {
+ event.preventDefault();
+
+ if (frame) {
+ frame.open();
+ return;
+ }
+
+ frame = window.wp.media({
+ title: __('Upload Media', 'give'),
+ button: {
+ text: __('Use this media', 'gie'),
+ },
+ library: {
+ type: 'image', // Restricts media library to image files only
+ },
+ multiple: false, // Set to true to allow multiple files to be selected
+ });
+
+ frame.on('select', function () {
+ // Get media attachment details from the frame state
+ var attachment = frame.state().get('selection').first().toJSON();
+
+ if (!attachment.type || attachment.type !== 'image') {
+ alert(__('Please select an image file only.', 'give'));
+ frame.open();
+ return;
+ }
+
+ onChange(attachment.url, attachment.alt);
+ });
+
+ // Finally, open the modal on click
+ frame.open();
+ };
+
+ const resetImage = (event) => {
+ reset();
+ };
+
+ const dropHandler = (event) => {
+ event.preventDefault();
+ openMediaLibrary(event);
+ };
+
+ if (value && disabled) {
+ return (
+
+ )
+ }
+
+ if (disabled) {
+ return;
+ }
+
+ return (
+
+ {value ? (
+
+
+
+
+
+
+
+
+
+
+
+ {sprintf(__('Remove %s', 'give'), label.toLowerCase())}
+
+
+ {sprintf(__('Change %s', 'give'), label.toLowerCase())}
+
+
+
+ ) : (
+
+
+
+
+
+ {actionLabel}
+
+
{__('or drag your image here', 'give')}
+
+ )}
+
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/Inputs/Upload/styles.scss b/src/Campaigns/resources/admin/components/Inputs/Upload/styles.scss
new file mode 100644
index 0000000000..84e0e563e2
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Inputs/Upload/styles.scss
@@ -0,0 +1,140 @@
+div.media-modal.wp-core-ui {
+ z-index: 99999999999999;
+}
+
+.givewp-media-library-drop-area {
+ text-align: center;
+ padding: var(--givewp-spacing-8) 0;
+ border: 1px dotted;
+ border-radius: var(--givewp-rounded-4);
+
+ button {
+ cursor: pointer;
+ width: fit-content;
+ margin: var(--givewp-spacing-4) auto var(--givewp-spacing-2);
+ background: var(--givewp-neutral-100);
+ height: initial;
+ border: initial;
+ padding: var(--givewp-spacing-1) var(--givewp-spacing-2);
+ border-radius: var(--givewp-rounded-4);
+ }
+
+ p, svg {
+ margin: 0;
+ }
+}
+
+
+.givewp-media-library-control {
+ cursor: pointer;
+
+ & > button:not(svg + button) {
+ min-height: 160px;
+
+ & > img {
+ height: 100%;
+ }
+ }
+
+ &__reset {
+ position: relative;
+ display: flex;
+ background: transparent;
+ height: 5rem;
+ width: 100%;
+ padding: 0;
+ color: var(--givewp-shades-white);
+ border: none;
+
+ &:hover {
+ svg {
+ visibility: visible;
+ pointer-events: none;
+ }
+ }
+
+ }
+
+ svg {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ visibility: hidden;
+ z-index: 999;
+ }
+
+ &__image {
+ width: 100%;
+ height: 5rem;
+ object-fit: cover;
+ object-position: center;
+ transition: filter 0.3s ease;
+ border-radius: var(--givewp-rounded-4);
+ cursor: pointer;
+
+ &:hover {
+ filter: brightness(80%); /* Adjust the brightness percentage for the desired tint */
+ }
+ }
+
+ &__button {
+ display: flex;
+ justify-content: center;
+ height: 3rem;
+ width: 100%;
+ border: 1px dotted;
+
+ &--update {
+ margin-top: var(--givewp-spacing-2);
+ border: 1px solid;
+ }
+ }
+
+ &__options {
+ display: flex;
+ justify-content: center;
+ width: fit-content;
+ background: none;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ border-radius: var(--givewp-rounded-4);
+
+ button {
+ margin-top: var(--givewp-spacing-2);
+ padding: 0.4rem 0.8rem 0.4rem 0.8rem;
+ }
+
+ &--remove {
+ border: 1px solid var(--givewp-red-400);
+ color: var(--givewp-red-400);
+
+ &:hover {
+ background-color: var(--givewp-red-25);
+ }
+ }
+
+ &--update {
+ border: 1px solid var(--givewp-neutral-300);
+ color: var(--givewp-neutral-900);
+
+ &:hover {
+ background-color: var(--givewp-neutral-50);
+ }
+ }
+ }
+
+ .components-button.has-text {
+ justify-content: center;
+ }
+
+ .components-button.is-secondary {
+ box-shadow: none;
+
+ &:hover {
+ box-shadow: none;
+ }
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/Inputs/index.ts b/src/Campaigns/resources/admin/components/Inputs/index.ts
new file mode 100644
index 0000000000..f63ebb3921
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Inputs/index.ts
@@ -0,0 +1,3 @@
+export {default as Currency} from './Currency';
+export {default as Editor} from './Editor';
+export {default as Upload} from './Upload';
diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss b/src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss
new file mode 100644
index 0000000000..fa9ba9b873
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss
@@ -0,0 +1,91 @@
+.campaignForm {
+
+ .intro {
+ font-size: 1rem;
+ color: #4b5563;
+ align-self: stretch;
+ padding: 0 1.25rem 0 0.625rem;
+ margin: unset;
+ }
+
+ .submitButton {
+ border-radius: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.43;
+ padding: var(--givewp-spacing-2) var(--givewp-spacing-4);
+ text-align: center;
+ }
+
+ label {
+ font-size: 1rem;
+ }
+
+ input,
+ select,
+ textarea {
+ 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);
+ }
+
+ select {
+ max-width: 100%;
+ }
+
+ .notice {
+ display: flex;
+ margin: 1rem 0 -0.2rem 0;
+ gap: 0.3rem;
+ padding: 0 0.5rem 0 0.5rem;
+ background-color: var(--givewp-blue-25);
+ border-radius: 4px;
+ border: 1px solid var(--givewp-blue-400);
+ border-left-width: 4px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #1a0f00;
+
+ svg {
+ margin: 0.7rem 0.3rem;
+ height: 1.25rem;
+ width: 1.25rem;
+ }
+ }
+
+ .returnMessage {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ gap: 1.5rem;
+
+ span {
+ margin: -1rem 3rem 1rem 3rem;
+ }
+ }
+}
+
+.fieldRequired {
+ color: var(--givewp-red-500);
+}
+
+.button:is(:global(.button)) {
+ border-radius: var(--givewp-rounded-8)
+}
+
+.previousButton:is(:global(.button)) {
+ background-color: transparent;
+ border: solid 1px #9ca0af;
+ color: #060c1a;
+
+ &:hover {
+ border: solid 1px #9ca0af;
+ color: #060c1a;
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx b/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx
new file mode 100644
index 0000000000..bc99544593
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx
@@ -0,0 +1,298 @@
+import {FormProvider, SubmitHandler, useForm} from 'react-hook-form';
+import {__} from '@wordpress/i18n';
+import styles from './Form.module.scss';
+import FormModal from '../../FormModal';
+import {MergeCampaignFormInputs, MergeCampaignFormProps} from './types';
+import {useState} from 'react';
+import apiFetch from '@wordpress/api-fetch';
+import {addQueryArgs} from '@wordpress/url';
+import {getGiveCampaignsListTableWindowData} from '../../CampaignsListTable';
+
+/**
+ * Campaign Form Modal component
+ *
+ * @unreleased
+ */
+export default function MergeCampaignsForm({isOpen, handleClose, title, campaigns}: MergeCampaignFormProps) {
+ if (!campaigns) {
+ return null;
+ }
+
+ const [step, setStep] = useState(1);
+
+ const methods = useForm({
+ defaultValues: {
+ destinationCampaignId: '',
+ },
+ });
+
+ const {
+ register,
+ handleSubmit,
+ formState: {isDirty, isSubmitting},
+ watch,
+ } = methods;
+
+ const destinationCampaignId = watch('destinationCampaignId');
+
+ const requiredAsterisk = * ;
+
+ const onSubmit: SubmitHandler = async (inputs, event) => {
+ event.preventDefault();
+
+ if (step !== 2) {
+ return;
+ }
+
+ const campaignsToMergeIds = campaigns.selected.filter((id) => id != inputs.destinationCampaignId);
+
+ try {
+ const response = await apiFetch({
+ path: addQueryArgs('/give-api/v2/campaigns/' + destinationCampaignId + '/merge', {
+ campaignsToMergeIds: campaignsToMergeIds,
+ }),
+ method: 'PATCH',
+ });
+
+ console.log('Merge campaigns response: ', response);
+
+ // Go to success page
+ setStep(3);
+
+ //Reset bulk actions selector
+ const selects = document.querySelectorAll('#give-admin-campaigns-root select');
+ selects.forEach((select) => {
+ const selectElement = select as HTMLSelectElement;
+ selectElement.selectedIndex = 0;
+ });
+
+ // Uncheck all checkboxes
+ const checkboxes = document.querySelectorAll(".giveListTable input[type='checkbox']");
+ checkboxes.forEach((checkbox) => {
+ const input = checkbox as HTMLInputElement;
+ input.checked = false;
+ });
+ // @ts-ignore
+ document.querySelector('.giveListTable #giveListTableSelectAll').checked = false;
+
+ //Remove campaignsToMergeIds from the list table.
+ const adminFormsListViewItems = document.querySelectorAll('tr');
+ if (adminFormsListViewItems.length > 0) {
+ adminFormsListViewItems.forEach((itemElement) => {
+ const select = itemElement.querySelector('.giveListTableSelect');
+
+ if (!select) {
+ return;
+ }
+
+ const campaignId = select.getAttribute('data-id');
+ if (campaignsToMergeIds.includes(campaignId)) {
+ itemElement.remove();
+ }
+ });
+ }
+ //handleClose(response);
+ } catch (error) {
+ // Go to error page
+ setStep(4);
+ console.error('Error merging campaigns: ', error);
+ }
+ };
+
+ const extractTextFromLink = (link) => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(link, 'text/html');
+ return doc.querySelector('a')?.textContent || link;
+ };
+
+ return (
+
+
+ {step === 1 && (
+ <>
+
+
+ {__(
+ 'All selected campaigns will be merged into the destination campaign. This means that forms, donors, donations, and all related data will be added to the destination campaign, and the merged campaigns will cease to exist.',
+ 'give'
+ )}
+
+
+ setStep(2)}
+ className={`button button-primary ${styles.button}`}
+ aria-disabled={false}
+ disabled={false}
+ >
+ {__('Proceed', 'give')}
+
+ >
+ )}
+ {step === 2 && (
+ <>
+
+
+ {__('Select your destination campaign', 'give')} {requiredAsterisk}
+
+ {__('All selected campaigns will be merged into this campaign.', 'give')}
+
+
+ {__('Choose from selected campaigns', 'give')}
+
+ {campaigns.selected.map((id, index) => (
+
+ {extractTextFromLink(campaigns.names[index])}
+
+ ))}
+
+
+
+ {isSubmitting ? __('Merging in progress...', 'give') : __('Merge', 'give')}
+
+ {isDirty && (
+
+
+
+
+
+
{__('Once completed, this action is irreversible.', 'give')}
+
+ )}
+ >
+ )}
+ {step === 3 && (
+ <>
+
+
+
+
+
+
+
{__('Campaigns have been successfully merged', 'give')}
+
+ {__(
+ 'All donations, donors, and forms from selected campaigns now belong to your destination campaign.',
+ 'give'
+ )}
+
+
+
+
+ handleClose()}
+ className={`button button-secondary ${styles.button} ${styles.previousButton}`}
+ >
+ {__('Back to campaign list', 'give')}
+
+
+
+ (window.location.href =
+ getGiveCampaignsListTableWindowData().adminUrl +
+ 'edit.php?post_type=give_forms&page=give-campaigns&id=' +
+ destinationCampaignId)
+ }
+ className={`button button-primary ${styles.button}`}
+ aria-disabled={false}
+ disabled={false}
+ >
+ {__('View destination campaign', 'give')}
+
+
+ >
+ )}
+ {step === 4 && (
+ <>
+
+
+
+
+
+
{__("Campaigns couldn't be merged", 'give')}
+
+ {__(
+ 'An error occurred during the merging process. Please try again, or contact our support team if the issue persists.',
+ 'give'
+ )}
+
+
+
+
+ handleClose()}
+ className={`button button-secondary ${styles.button} ${styles.previousButton}`}
+ >
+ {__('Back to campaign list', 'give')}
+
+
+ setStep(2)}
+ className={`button button-primary ${styles.button}`}
+ aria-disabled={false}
+ disabled={false}
+ >
+ {__('Try again', 'give')}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts b/src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts
new file mode 100644
index 0000000000..be57539ae9
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts
@@ -0,0 +1,14 @@
+export interface MergeCampaignFormProps {
+ isOpen: boolean;
+ handleClose: (response?: any) => void;
+ title: string;
+ campaigns: {
+ selected: string[];
+ names: string[];
+ };
+}
+
+export type MergeCampaignFormInputs = {
+ title: string;
+ destinationCampaignId: string;
+};
diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Modal/Modal.module.scss b/src/Campaigns/resources/admin/components/MergeCampaign/Modal/Modal.module.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx b/src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx
new file mode 100644
index 0000000000..3044af2122
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx
@@ -0,0 +1,90 @@
+import {useEffect, useState} from 'react';
+import {__} from '@wordpress/i18n';
+import MergeCampaignsForm from './../Form';
+
+/**
+ * Remove the "action" query parameter from the current URL
+ *
+ * @unreleased
+ */
+const removeActionParam = () => {
+ const queryParams = new URLSearchParams(window.location.search);
+ const actionParam = queryParams.get('action');
+
+ if (actionParam) {
+ queryParams.delete('action');
+ window.history.replaceState(null, '', `${window.location.pathname}?${queryParams.toString()}`);
+ }
+};
+
+/**
+ * Auto open modal if the URL has the query parameter action as "merge"
+ *
+ * @unreleased
+ */
+const autoOpenModal = () => {
+ const queryParams = new URLSearchParams(window.location.search);
+ const actionParam = queryParams.get('action');
+
+ if (actionParam && !window.history.state) {
+ removeActionParam();
+ return false;
+ }
+
+ return actionParam === 'merge';
+};
+
+/**
+ * Create Campaign Modal component
+ *
+ * @unreleased
+ */
+export default function MergeCampaignModal() {
+ const [isOpen, setOpen] = useState(autoOpenModal());
+ const closeModal = () => {
+ removeActionParam();
+ setOpen(false);
+ };
+
+ useEffect(() => {
+ // Override pushState and replaceState to trigger a custom event
+ const originalPushState = window.history.pushState;
+ const originalReplaceState = window.history.replaceState;
+
+ window.history.pushState = function (...args) {
+ originalPushState.apply(window.history, args);
+ window.dispatchEvent(new Event('urlChange'));
+ };
+
+ window.history.replaceState = function (...args) {
+ originalReplaceState.apply(window.history, args);
+ window.dispatchEvent(new Event('urlChange'));
+ };
+
+ // Add listeners for "popstate" and the custom "urlChange" event
+ const handleQueryParamsChange = () => setOpen(autoOpenModal());
+ window.addEventListener('popstate', handleQueryParamsChange);
+ window.addEventListener('urlChange', handleQueryParamsChange);
+
+ // Remove listeners when the component unmounts
+ return () => {
+ window.removeEventListener('popstate', handleQueryParamsChange);
+ window.removeEventListener('urlChange', handleQueryParamsChange);
+
+ // Restore the original pushState and replaceState functions
+ window.history.pushState = originalPushState;
+ window.history.replaceState = originalReplaceState;
+ };
+ }, []);
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/Notifications/Notices.module.scss b/src/Campaigns/resources/admin/components/Notifications/Notices.module.scss
new file mode 100644
index 0000000000..878427839d
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Notifications/Notices.module.scss
@@ -0,0 +1,96 @@
+
+.snackbarContainer {
+ position: fixed;
+ z-index: 9999;
+ bottom: 10px;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 10px;
+ width: 300px;
+ left: 50%;
+ transform: translateY(-50%);
+
+ .snackbar {
+ gap: 20px;
+ display: flex;
+ flex-direction: row;
+ border-radius: var(--givewp-spacing-1);
+ padding: var(--givewp-spacing-3) var(--givewp-spacing-4);
+ box-shadow: 0 5px 5px #b6b6b6;
+ align-items: center;
+ justify-content: space-between;
+
+ a {
+ color: #fff;
+ padding: 0;
+ line-height: 0.5;
+ text-decoration: none;
+ }
+ }
+
+ .type-error-snackbar {
+ background-color: #dc1e1e;
+ color: #fff;
+ }
+
+ .type-info-snackbar {
+ background-color: #000;
+ color: #fff;
+ }
+
+ .type-warning-snackbar {
+ background-color: #f29718;
+ color: #1a0f00;
+
+ a {
+ color: #1a0f00;
+ }
+ }
+
+}
+
+
+.noticeContainer {
+ display: flex;
+ flex-direction: column-reverse;
+ padding: var(--givewp-spacing-6);
+ gap: 10px;
+
+ .notice {
+ gap: 10px;
+ display: flex;
+ flex-direction: row;
+ border-radius: 4px;
+ padding: var(--givewp-spacing-2) var(--givewp-spacing-3);
+ align-items: center;
+ justify-content: space-between;
+ border-width: 1px 1px 1px 4px;
+ border-style: solid;
+ font-size: 14px;
+
+ a {
+ padding: 0;
+ line-height: 0.5;
+ text-decoration: none;
+ }
+ }
+
+ .type-warning {
+ background-color: #fffaf2;
+ color: #1a0f00;
+ border-color: #f29718;
+
+ a {
+ color: #1a0f00;
+ }
+ }
+
+ // Note: other notification types styles are not defined. You can define them below
+}
+
+
+.notificationContent {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
diff --git a/src/Campaigns/resources/admin/components/Notifications/Notification.tsx b/src/Campaigns/resources/admin/components/Notifications/Notification.tsx
new file mode 100644
index 0000000000..ed639a06fc
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Notifications/Notification.tsx
@@ -0,0 +1,74 @@
+import {useEffect} from '@wordpress/element';
+import {dispatch} from '@wordpress/data';
+import cx from 'classnames';
+import type {Notification} from '@givewp/campaigns/types';
+import {CloseIcon} from '../Icons';
+
+import styles from './Notices.module.scss';
+
+const Snackbar = ({notification, onDismiss}: { notification: Notification, onDismiss: () => void }) => {
+ return (
+
+
+
+ {typeof notification.content === 'function' ? notification.content(onDismiss, notification) : notification.content}
+
+ {notification.isDismissible && (
+
+
+
+ )}
+
+
+ );
+};
+
+const Notice = ({notification, onDismiss}: { notification: Notification, onDismiss: () => void }) => {
+ return (
+
+
+
+ {typeof notification.content === 'function' ? notification.content(onDismiss, notification) : notification.content}
+
+ {notification.isDismissible && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default ({notification}) => {
+
+ useEffect(() => {
+ if (notification.autoHide) {
+ setTimeout(() => {
+ dispatch('givewp/campaign-notifications').dismissNotification(notification.id);
+ }, notification.duration);
+ }
+ }, []);
+
+ const onDismiss = () => {
+ dispatch('givewp/campaign-notifications').dismissNotification(notification.id);
+
+ if (typeof notification.onDismiss === 'function') {
+ notification.onDismiss();
+ }
+ };
+
+
+ switch (notification.notificationType) {
+ case 'snackbar':
+ return
+ case 'notice':
+ return
+ default:
+ return null;
+ }
+}
diff --git a/src/Campaigns/resources/admin/components/Notifications/index.tsx b/src/Campaigns/resources/admin/components/Notifications/index.tsx
new file mode 100644
index 0000000000..17f49d77fb
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/Notifications/index.tsx
@@ -0,0 +1,18 @@
+import {useSelect} from '@wordpress/data';
+import Notification from './Notification';
+import styles from './Notices.module.scss';
+
+export default ({type}: {type: 'snackbar' | 'notice'}) => {
+ //@ts-ignore
+ const notifications = useSelect(select => select('givewp/campaign-notifications').getNotificationsByType(type));
+
+ if (!notifications.length) {
+ return null;
+ }
+
+ return (
+
+ {notifications.map(notification => )}
+
+ );
+}
diff --git a/src/Campaigns/resources/admin/components/api.ts b/src/Campaigns/resources/admin/components/api.ts
new file mode 100644
index 0000000000..00f527f64a
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/api.ts
@@ -0,0 +1,38 @@
+export default class CampaignsApi {
+ private readonly apiRoot: string;
+ private readonly headers: {'X-WP-Nonce': string; 'Content-Type': string};
+
+ constructor({apiNonce, apiRoot}) {
+ this.apiRoot = apiRoot;
+ this.headers = {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': apiNonce,
+ };
+ }
+
+ fetchWithArgs = (endpoint, args, method = 'GET', signal = null) => {
+ const url = new URL(this.apiRoot + endpoint);
+ for (const [param, value] of Object.entries(args)) {
+ value !== '' && url.searchParams.set(param, value as string);
+ }
+ return fetch(url.href, {
+ method: method,
+ signal: signal,
+ headers: this.headers,
+ })
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error();
+ }
+ return res.text();
+ })
+ .then((text) => {
+ try {
+ return text ? JSON.parse(text) : {};
+ } catch (error) {
+ console.error('Failed to parse JSON:', error);
+ return {};
+ }
+ });
+ };
+}
diff --git a/src/Campaigns/resources/admin/components/types.ts b/src/Campaigns/resources/admin/components/types.ts
new file mode 100644
index 0000000000..97d7f5f246
--- /dev/null
+++ b/src/Campaigns/resources/admin/components/types.ts
@@ -0,0 +1,55 @@
+export type Campaign = {
+ id?: number;
+ pageId: number;
+ pagePermalink: string & Location;
+ type: string;
+ title: string;
+ shortDescription: string;
+ longDescription: string;
+ logo: string;
+ image: string;
+ primaryColor: string;
+ secondaryColor: string;
+ goalType: string;
+ goal: number;
+ goalStats: {
+ actual: number,
+ percentage: number,
+ goal: number,
+ };
+ status: string;
+ startDateTime: {
+ date: string;
+ timezone_type: number;
+ timezone: string;
+ };
+ endDateTime: {
+ date: string;
+ timezone_type: number;
+ timezone: string;
+ };
+ createdAt: string;
+ //updatedAt: string;
+ enableCampaignPage: boolean;
+ defaultFormId: number;
+ defaultFormTitle: string;
+};
+
+export type CampaignEntity = {
+ campaign: Campaign;
+ hasResolved: boolean;
+ edit: (data: Campaign) => void
+ save: () => any
+}
+
+/*export interface Campaign {
+ id: number;
+ title: string;
+ type: string;
+ status: string;
+ shortDescription: string;
+ longDescription: string;
+ logo: string;
+ image: string;
+ goal: number;
+}*/
diff --git a/src/Campaigns/resources/editor/campaign-page-post-type-editor.tsx b/src/Campaigns/resources/editor/campaign-page-post-type-editor.tsx
new file mode 100644
index 0000000000..d734641485
--- /dev/null
+++ b/src/Campaigns/resources/editor/campaign-page-post-type-editor.tsx
@@ -0,0 +1,52 @@
+import { registerPlugin } from '@wordpress/plugins';
+import {
+ __experimentalFullscreenModeClose as FullscreenModeClose,
+ __experimentalMainDashboardButton as MainDashboardButton,
+ // @ts-ignore
+} from '@wordpress/edit-post';
+
+declare const window: {
+ giveCampaignPage: {
+ campaignDetailsURL: string;
+ };
+} & Window;
+
+registerPlugin( 'campaign-page-editor-back-button', {
+ render: () => (
+
+
+
+ )
+} );
+
+const GiveLogo = function () {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
diff --git a/src/Campaigns/resources/entity.ts b/src/Campaigns/resources/entity.ts
new file mode 100644
index 0000000000..cb4409ddcb
--- /dev/null
+++ b/src/Campaigns/resources/entity.ts
@@ -0,0 +1,19 @@
+import {__} from '@wordpress/i18n';
+import {dispatch} from '@wordpress/data';
+import {store as coreStore} from '@wordpress/core-data';
+import './store';
+
+//@ts-ignore
+dispatch(coreStore).addEntities([
+ {
+ name: 'campaign',
+ kind: 'givewp',
+ baseURL: '/give-api/v2/campaigns',
+ baseURLParams: {},
+ plural: 'campaigns',
+ label: __('Campaign', 'give'),
+ supportsPagination: true
+ }
+]);
+
+
diff --git a/src/Campaigns/resources/store/actions.ts b/src/Campaigns/resources/store/actions.ts
new file mode 100644
index 0000000000..3648dc7727
--- /dev/null
+++ b/src/Campaigns/resources/store/actions.ts
@@ -0,0 +1,36 @@
+import type {Notification} from '@givewp/campaigns/types';
+
+export function addSnackbarNotice(notification: Notification) {
+ return {
+ type: 'ADD_NOTIFICATION',
+ notification: {
+ ...notification,
+ autoHide: notification?.autoHide ?? true,
+ isDismissible: notification?.isDismissible ?? true,
+ duration: notification?.duration ?? 5000,
+ type: notification.type ?? 'info',
+ notificationType: 'snackbar',
+ },
+ };
+}
+
+export function addNotice(notification: Notification) {
+ return {
+ type: 'ADD_NOTIFICATION',
+ notification: {
+ ...notification,
+ autoHide: notification?.autoHide ?? false,
+ isDismissible: notification?.isDismissible ?? true,
+ duration: notification?.duration ?? 5000,
+ type: notification.type ?? 'info',
+ notificationType: 'notice',
+ },
+ };
+}
+
+export function dismissNotification(id: string) {
+ return {
+ type: 'DISMISS_NOTIFICATION',
+ id,
+ };
+}
diff --git a/src/Campaigns/resources/store/index.ts b/src/Campaigns/resources/store/index.ts
new file mode 100644
index 0000000000..c6155abafe
--- /dev/null
+++ b/src/Campaigns/resources/store/index.ts
@@ -0,0 +1,34 @@
+import {createReduxStore, register} from '@wordpress/data';
+import * as actions from './actions';
+import * as selectors from './selectors';
+
+export type Notification = {
+ id: string;
+ notificationType: 'notice' | 'snackbar';
+ type: 'error' | 'warning' | 'info' | 'success';
+ isDismissible?: boolean;
+ duration: number,
+ content: string;
+}
+
+export const store = createReduxStore('givewp/campaign-notifications', {
+ reducer(state = [], action) {
+ switch (action.type) {
+ case 'ADD_NOTIFICATION':
+ const notificationExist = state.filter((notification: { id: string }) => notification.id === action.notification.id);
+ if (!notificationExist.length) {
+ state.push(action.notification);
+ }
+ return state;
+
+ case 'DISMISS_NOTIFICATION':
+ return state.filter((notification: Notification) => notification.id !== action.id);
+ }
+
+ return state;
+ },
+ actions,
+ selectors,
+});
+
+register(store);
diff --git a/src/Campaigns/resources/store/selectors.ts b/src/Campaigns/resources/store/selectors.ts
new file mode 100644
index 0000000000..bd8cfabdc6
--- /dev/null
+++ b/src/Campaigns/resources/store/selectors.ts
@@ -0,0 +1,9 @@
+import type {Notification} from '@givewp/campaigns/types';
+
+export function getNotifications(state: []) {
+ return state;
+}
+
+export function getNotificationsByType(state: [], type: 'snackbar' | 'notice') {
+ return state.filter((notification: Notification) => notification.notificationType === type);
+}
diff --git a/src/Campaigns/resources/types.ts b/src/Campaigns/resources/types.ts
new file mode 100644
index 0000000000..dbc1b2d3a6
--- /dev/null
+++ b/src/Campaigns/resources/types.ts
@@ -0,0 +1,35 @@
+export type Notification = {
+ id: string;
+ content: string | JSX.Element | Function;
+ notificationType?: 'notice' | 'snackbar';
+ type?: 'error' | 'warning' | 'info' | 'success';
+ isDismissible?: boolean;
+ autoHide?: boolean;
+ onDismiss?: () => void;
+ duration?: number,
+}
+
+declare module "@wordpress/data" {
+ export function select(key: 'givewp/campaign-notifications'): {
+ getNotifications(): Notification[],
+ getNotificationsByType(type: 'snackbar' | 'notice'): Notification[]
+ };
+
+ export function dispatch(key: 'givewp/campaign-notifications'): {
+ addSnackbarNotice(notification: Notification): void,
+ addNotice(notification: Notification): void,
+ dismissNotification(id: string): void
+ };
+}
+
+export type GiveCampaignOptions = {
+ isAdmin: boolean;
+ adminUrl: string;
+ campaignsAdminUrl: string;s
+ currency: string;
+ isRecurringEnabled: boolean;
+ defaultForm: string;
+ admin: {
+ showCampaignInteractionNotice: boolean
+ }
+}
diff --git a/src/Campaigns/resources/utils.ts b/src/Campaigns/resources/utils.ts
new file mode 100644
index 0000000000..d9d61454e6
--- /dev/null
+++ b/src/Campaigns/resources/utils.ts
@@ -0,0 +1,46 @@
+import {useEntityRecord} from '@wordpress/core-data';
+import {Campaign} from '@givewp/campaigns/admin/components/types';
+import type {GiveCampaignOptions} from '@givewp/campaigns/types';
+
+declare const window: {
+ GiveCampaignOptions: GiveCampaignOptions;
+} & Window;
+
+/**
+ * @unreleased
+ */
+export function useCampaignEntityRecord(campaignId?: number) {
+ const urlParams = new URLSearchParams(window.location.search);
+
+ const {
+ record: campaign,
+ hasResolved,
+ save,
+ edit,
+ }: {
+ record: Campaign;
+ hasResolved: boolean;
+ save: () => any;
+ edit: (data: Campaign | Partial) => void;
+ } = useEntityRecord('givewp', 'campaign', campaignId ?? urlParams.get('id'));
+
+ return {campaign, hasResolved, save, edit};
+}
+
+/**
+ * @unreleased
+ */
+export function getCampaignOptionsWindowData(): GiveCampaignOptions {
+ return window.GiveCampaignOptions;
+}
+
+/**
+ * @unreleased
+ */
+export function amountFormatter(currency: Intl.NumberFormatOptions['currency'], options?: Intl.NumberFormatOptions): Intl.NumberFormat {
+ return new Intl.NumberFormat(navigator.language, {
+ style: 'currency',
+ currency: currency,
+ ...options
+ });
+}
diff --git a/src/DonationForms/DataTransferObjects/DonationFormGoalData.php b/src/DonationForms/DataTransferObjects/DonationFormGoalData.php
index 477cce01d7..c5a796ca2d 100644
--- a/src/DonationForms/DataTransferObjects/DonationFormGoalData.php
+++ b/src/DonationForms/DataTransferObjects/DonationFormGoalData.php
@@ -3,9 +3,7 @@
namespace Give\DonationForms\DataTransferObjects;
use Give\DonationForms\DonationQuery;
-use Give\DonationForms\Models\DonationForm;
use Give\DonationForms\Properties\FormSettings;
-use Give\DonationForms\Repositories\DonationFormRepository;
use Give\DonationForms\SubscriptionQuery;
use Give\DonationForms\ValueObjects\GoalProgressType;
use Give\DonationForms\ValueObjects\GoalType;
@@ -77,21 +75,15 @@ public function getCurrentAmount()
$query->form($this->formId);
- if($this->goalProgressType->isCustom()) {
- $query->between($this->goalStartDate, $this->goalEndDate);
- }
-
switch ($this->goalType):
case GoalType::DONORS():
+ case GoalType::DONORS_FROM_SUBSCRIPTIONS():
return $query->countDonors();
case GoalType::DONATIONS():
- return $query->count();
case GoalType::SUBSCRIPTIONS():
return $query->count();
case GoalType::AMOUNT_FROM_SUBSCRIPTIONS():
return $query->sumInitialAmount();
- case GoalType::DONORS_FROM_SUBSCRIPTIONS():
- return $query->countDonors();
case GoalType::AMOUNT():
default:
return $query->sumIntendedAmount();
diff --git a/src/DonationForms/DonationFormsAdminPage.php b/src/DonationForms/DonationFormsAdminPage.php
index 12bdd9e2c8..26a08dcc5b 100644
--- a/src/DonationForms/DonationFormsAdminPage.php
+++ b/src/DonationForms/DonationFormsAdminPage.php
@@ -11,13 +11,11 @@
class DonationFormsAdminPage
{
/**
+ * @unreleased Remove logic to add the "Add Form" menu item
* @since 3.15.0
*/
public function addFormSubmenuLink()
{
remove_submenu_page('edit.php?post_type=give_forms', 'post-new.php?post_type=give_forms');
- add_submenu_page('edit.php?post_type=give_forms', __('Add Form', 'give'), __('Add Form', 'give'),
- 'edit_give_forms',
- 'edit.php?post_type=give_forms&page=givewp-form-builder&locale=' . Language::getLocale(), '', 1);
}
}
diff --git a/src/DonationForms/Repositories/DonationFormRepository.php b/src/DonationForms/Repositories/DonationFormRepository.php
index e508be8757..18e2770121 100644
--- a/src/DonationForms/Repositories/DonationFormRepository.php
+++ b/src/DonationForms/Repositories/DonationFormRepository.php
@@ -274,7 +274,7 @@ public function prepareQuery(): ModelQueryBuilder
{
$builder = new ModelQueryBuilder(DonationForm::class);
- return $builder->from('posts')
+ return $builder->from('posts', 'forms')
->select(
['ID', 'id'],
['post_date', 'createdAt'],
diff --git a/src/DonationForms/V2/DonationFormsAdminPage.php b/src/DonationForms/V2/DonationFormsAdminPage.php
index 16182f9141..f813642ae8 100644
--- a/src/DonationForms/V2/DonationFormsAdminPage.php
+++ b/src/DonationForms/V2/DonationFormsAdminPage.php
@@ -2,6 +2,8 @@
namespace Give\DonationForms\V2;
+use Give\Campaigns\CampaignsAdminPage;
+use Give\Campaigns\Models\Campaign;
use Give\DonationForms\V2\ListTable\DonationFormsListTable;
use Give\FeatureFlags\OptionBasedFormEditor\OptionBasedFormEditor;
use Give\Helpers\EnqueueScript;
@@ -39,11 +41,17 @@ class DonationFormsAdminPage
*/
protected $migrationApiRoot;
+ /**
+ * @var string
+ */
+ protected $defaultFormActionUrl;
+
public function __construct()
{
$this->apiRoot = esc_url_raw(rest_url('give-api/v2/admin/forms'));
$this->bannerActionUrl = admin_url('admin-ajax.php?action=givewp_show_onboarding_banner');
$this->tooltipActionUrl = admin_url('admin-ajax.php?action=givewp_show_upgraded_tooltip');
+ $this->defaultFormActionUrl = admin_url('admin-ajax.php?action=givewp_show_default_form_tooltip');
$this->migrationApiRoot = esc_url_raw(rest_url('give-api/v2/admin/forms/migrate'));
$this->apiNonce = wp_create_nonce('wp_rest');
$this->adminUrl = admin_url();
@@ -100,6 +108,7 @@ public function loadScripts()
'apiRoot' => $this->apiRoot,
'bannerActionUrl' => $this->bannerActionUrl,
'tooltipActionUrl' => $this->tooltipActionUrl,
+ 'defaultFormActionUrl' => $this->defaultFormActionUrl,
'apiNonce' => $this->apiNonce,
'preload' => $this->preloadDonationForms(),
'authors' => $this->getAuthors(),
@@ -107,10 +116,14 @@ public function loadScripts()
'adminUrl' => $this->adminUrl,
'pluginUrl' => GIVE_PLUGIN_URL,
'showUpgradedTooltip' => !get_user_meta(get_current_user_id(), 'givewp-show-upgraded-tooltip', true),
+ 'showDefaultFormTooltip' => !get_user_meta(get_current_user_id(), 'givewp-show-default-form-tooltip', true),
'supportedAddons' => $this->getSupportedAddons(),
'supportedGateways' => $this->getSupportedGateways(),
'isOptionBasedFormEditorEnabled' => OptionBasedFormEditor::isEnabled(),
'locale' => Language::getLocale(),
+ 'swrConfig' => [
+ 'revalidateOnFocus' => false
+ ]
];
EnqueueScript::make('give-admin-donation-forms', 'assets/dist/js/give-admin-donation-forms.js')
@@ -150,6 +163,8 @@ public function loadMigrationScripts()
}
if ($this->isShowingEditV2FormPage()) {
+ $formId = (int)$_GET['post'];
+ $campaign = Campaign::findByFormId($formId);
EnqueueScript::make('give-edit-v2form', 'assets/dist/js/give-edit-v2form.js')
->loadInFooter()
->registerTranslations()
@@ -158,7 +173,8 @@ public function loadMigrationScripts()
'supportedGateways' => $this->getSupportedGateways(),
'migrationApiRoot' => $this->migrationApiRoot,
'apiNonce' => $this->apiNonce,
- 'isMigrated' => _give_is_form_migrated((int)$_GET['post']),
+ 'isMigrated' => _give_is_form_migrated($formId),
+ 'campaignUrl' => $campaign ? admin_url('edit.php?post_type=give_forms&page=give-campaigns&id=' . $campaign->id) : '',
])
->enqueue();
@@ -169,6 +185,7 @@ public function loadMigrationScripts()
/**
* Get first page of results from REST API to display as initial table data
*
+ * @unreleased Add campaignId parameter on campaigns page
* @since 2.20.0
* @return array
*/
@@ -177,8 +194,13 @@ private function preloadDonationForms()
$queryParameters = [
'page' => 1,
'perPage' => 30,
+
];
+ if (CampaignsAdminPage::isShowingDetailsPage()) {
+ $queryParameters['campaignId'] = isset($_GET['id']) ? absint($_GET['id']) : null;
+ }
+
$request = WP_REST_Request::from_url(
add_query_arg(
$queryParameters,
@@ -253,12 +275,13 @@ function showReactTable() {
}
jQuery(function() {
- jQuery(jQuery('.wrap .page-title-action')[0]).after(
- ' '
);
+ jQuery('.page-title-action:not(.switch-new-view)').remove();
});
[
+ 'type' => 'integer',
+ 'required' => false,
+ ],
],
]
);
@@ -120,6 +132,11 @@ public function handleRequest(WP_REST_Request $request): WP_REST_Response
{
$this->request = $request;
$this->listTable = give(DonationFormsListTable::class);
+ $campaignId = (int)($this->request->get_param('campaignId'));
+ $campaign = $campaignId ? Campaign::find($campaignId) : null;
+ $defaultCampaignForm = $campaign ? $campaign->defaultForm() : null;
+
+ $this->defaultForm = $defaultCampaignForm->id ?? 0;
$forms = $this->getForms();
$totalForms = $this->getTotalFormsCount();
@@ -138,6 +155,7 @@ public function handleRequest(WP_REST_Request $request): WP_REST_Response
$item['permalink'] = get_permalink($item['id']);
$item['v3form'] = (bool)give_get_meta($item['id'], 'formBuilderSettings');
$item['status_raw'] = $forms[$i]->status->getValue();
+ $item['isDefaultCampaignForm'] = $defaultCampaignForm && $item['id'] === $defaultCampaignForm->id;
}
}
@@ -147,6 +165,7 @@ public function handleRequest(WP_REST_Request $request): WP_REST_Response
'totalItems' => $totalForms,
'totalPages' => $totalPages,
'trash' => defined('EMPTY_TRASH_DAYS') && EMPTY_TRASH_DAYS > 0,
+ 'defaultForm' => $this->defaultForm
]
);
}
@@ -162,11 +181,13 @@ public function getForms(): 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()->donationForms->prepareQuery();
$query = $this->getWhereConditions($query);
+ $query->orderByRaw('FIELD(ID, %d) DESC', $this->defaultForm);
+
+ $sortDirection = $this->request->get_param('sortDirection') ?: 'desc';
foreach ($sortColumns as $sortColumn) {
$query->orderBy($sortColumn, $sortDirection);
}
@@ -199,6 +220,7 @@ public function getTotalFormsCount(): int
}
/**
+ * @unreleased Add "campaignId" support
* @since 2.24.0
*
* @param QueryBuilder $query
@@ -231,6 +253,13 @@ private function getWhereConditions(QueryBuilder $query): QueryBuilder
}
}
+ if ($campaignId = $this->request->get_param('campaignId')) {
+ $query->join(function (JoinQueryBuilder $builder) {
+ $builder->leftJoin('give_campaign_forms', 'campaign_forms')
+ ->on('campaign_forms.form_id', 'ID');
+ })->where('campaign_forms.campaign_id', $campaignId);
+ }
+
return $query;
}
}
diff --git a/src/DonationForms/V2/ListTable/Columns/TitleColumn.php b/src/DonationForms/V2/ListTable/Columns/TitleColumn.php
index ad94d68fff..fa75d310bb 100644
--- a/src/DonationForms/V2/ListTable/Columns/TitleColumn.php
+++ b/src/DonationForms/V2/ListTable/Columns/TitleColumn.php
@@ -50,7 +50,7 @@ public function getLabel(): string
public function getCellValue($model): string
{
return sprintf(
- '%s ',
+ '%s ',
add_query_arg(['locale' => Language::getLocale()], get_edit_post_link($model->id)),
wp_strip_all_tags($model->title)
);
diff --git a/src/DonationForms/V2/ServiceProvider.php b/src/DonationForms/V2/ServiceProvider.php
index cf7f4d514f..e8e978b1ce 100644
--- a/src/DonationForms/V2/ServiceProvider.php
+++ b/src/DonationForms/V2/ServiceProvider.php
@@ -49,12 +49,17 @@ public function boot()
Hooks::addAction('submitpost_box', DonationFormsAdminPage::class, 'renderMigrationGuideBox');
Hooks::addAction('admin_enqueue_scripts', DonationFormsAdminPage::class, 'loadMigrationScripts');
- add_action('wp_ajax_givewp_show_onboarding_banner', static function () {
- add_user_meta(get_current_user_id(), 'givewp-show-onboarding-banner', time(), true);
- });
+ // Dismiss notices
+ $noticeActions = [
+ 'givewp_show_onboarding_banner' => 'show-onboarding-banner',
+ 'givewp_show_upgraded_tooltip' => 'show-upgraded-tooltip',
+ 'givewp_show_default_form_tooltip' => 'show-default-form-tooltip',
+ ];
- add_action('wp_ajax_givewp_show_upgraded_tooltip', static function () {
- add_user_meta(get_current_user_id(), 'givewp-show-upgraded-tooltip', time(), true);
- });
+ foreach ($noticeActions as $action => $metaKey) {
+ add_action("wp_ajax_{$action}", static function () use ($metaKey) {
+ add_user_meta(get_current_user_id(), "givewp-{$metaKey}", time(), true);
+ });
+ }
}
}
diff --git a/src/DonationForms/V2/resources/add-v2form.tsx b/src/DonationForms/V2/resources/add-v2form.tsx
index ed8e3aeb1c..9f5e8ecee4 100644
--- a/src/DonationForms/V2/resources/add-v2form.tsx
+++ b/src/DonationForms/V2/resources/add-v2form.tsx
@@ -1,7 +1,7 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
-import AddForm from './components/Onboarding/Components/AddForm';
import './colors.scss';
+import AddForm from './components/Onboarding/Components/AddForm';
const appContainer = document.createElement('div');
const target = document.querySelector('.wp-header-end');
diff --git a/src/DonationForms/V2/resources/admin-donation-forms.tsx b/src/DonationForms/V2/resources/admin-donation-forms.tsx
index 6fde451ccd..df622d621b 100644
--- a/src/DonationForms/V2/resources/admin-donation-forms.tsx
+++ b/src/DonationForms/V2/resources/admin-donation-forms.tsx
@@ -5,9 +5,10 @@ import './colors.scss';
const root = document.getElementById('give-admin-donation-forms-root');
-createRoot(root).render(
-
-
-
-);
-
+if (!!root) {
+ createRoot(root).render(
+
+
+
+ );
+}
diff --git a/src/DonationForms/V2/resources/components/AddCampaignFormModal/AddCampaignFormModal.module.scss b/src/DonationForms/V2/resources/components/AddCampaignFormModal/AddCampaignFormModal.module.scss
new file mode 100644
index 0000000000..5ae2348e71
--- /dev/null
+++ b/src/DonationForms/V2/resources/components/AddCampaignFormModal/AddCampaignFormModal.module.scss
@@ -0,0 +1,135 @@
+.addFormModal {
+
+ :global {
+ .givewp-modal-dialog {
+ width: 100%;
+ max-width: 60rem;
+
+ .givewp-modal-header {
+ font-size: 1.25rem;
+ font-weight: 600;
+ line-height: 1.6;
+ }
+
+ .givewp-modal-close {
+ translateY: 4.5px;
+ }
+
+ .givewp-editor-options {
+ display: flex;
+
+ justify-content: space-between;
+ gap: 1rem;
+
+ &__option_recommended {
+ width: 112px;
+ height: 22px;
+ flex-grow: 0;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 12px;
+ border-radius: 24px;
+ background-color: var(--givewp-purple-500);
+
+ font-size: 12px;
+ font-weight: 600;
+ font-stretch: normal;
+ font-style: normal;
+ line-height: 1.5;
+ letter-spacing: normal;
+ text-align: left;
+ color: var(--givewp-purple-25);
+
+ position: relative;
+ margin: -3.3rem 0 1.75rem 0.3rem;
+ }
+
+ &__option_selected_icon {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+
+ svg {
+ margin-top: -1.5rem;
+ margin-right: -1.4rem;
+ margin-bottom: 0.2rem;
+ display: none;
+ }
+ }
+
+ &__option {
+ background-color: #f9fafb;
+ max-width: 28.25rem;
+ width: 100%;
+ padding: 1.5rem 1.5rem 0 1.5rem;
+ border: 1px solid #ddd;
+ border-radius: 0.5rem;
+ cursor: pointer;
+
+ /* Hide the radio button input */
+ input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+ width: 0;
+ }
+
+ img {
+ width: 100%;
+ max-height: 15rem;
+ object-fit: cover;
+ object-position: 0 0;
+ margin-bottom: 1rem;
+ }
+
+ label {
+ font-size: 1rem;
+ font-weight: 500;
+ color: #060c1a;
+ }
+
+ p {
+ font-size: 0.875rem;
+ margin-top: 0.4rem;
+ color: #4b5563;
+ }
+
+ &:hover {
+ border-color: var(--givewp-green-500);
+ }
+ }
+
+ &__option_selected {
+ border: 2px solid var(--givewp-green-500);
+
+ svg {
+ display: block;
+ }
+ }
+ }
+
+ .givewp-editor-actions {
+
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+ width: 100%;
+ margin-top: 2.5rem;
+
+ &__button {
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.25rem;
+ padding: 1.1rem;
+ width: 22.5rem;
+ text-align: center;
+ }
+ }
+ }
+ }
+}
diff --git a/src/DonationForms/V2/resources/components/AddCampaignFormModal/index.tsx b/src/DonationForms/V2/resources/components/AddCampaignFormModal/index.tsx
new file mode 100644
index 0000000000..ab1bb34620
--- /dev/null
+++ b/src/DonationForms/V2/resources/components/AddCampaignFormModal/index.tsx
@@ -0,0 +1,149 @@
+import ModalDialog from '@givewp/components/AdminUI/ModalDialog';
+import styles from './AddCampaignFormModal.module.scss';
+import {__} from '@wordpress/i18n';
+import {useRef, useState} from 'react';
+
+export type EditorTypeOptionProps = {
+ editorType: string;
+ label: string;
+ description: string;
+ editorSelected: string;
+ handleEditorSelected: any;
+};
+
+const EditorSelectedIcon = () => {
+ return (
+
+
+
+ );
+};
+
+/**
+ * Editor Type Option component
+ *
+ * @unreleased
+ */
+const EditorTypeOption = ({
+ editorType,
+ label,
+ description,
+ editorSelected,
+ handleEditorSelected,
+}: EditorTypeOptionProps) => {
+ const divRef = useRef(null);
+ const labelRef = useRef(null);
+
+ const handleDivClick = () => {
+ labelRef.current.click();
+ };
+
+ return (
+
+
+ {editorType === 'visualFormBuilder' && (
+
{__('Recommended', 'give')}
+ )}
+
+
+ {label}
+
+
{description}
+
+
+
+
+ );
+};
+
+/**
+ * Form Modal component that renders a modal with a styled form inside
+ *
+ * @unreleased
+ */
+export default function AddCampaignFormModal({isOpen, handleClose, title, campaignId}: FormModalProps) {
+ const [editorSelected, setEditorSelected] = useState('');
+
+ const handleEditorSelected = (event) => {
+ setEditorSelected(event.target.value);
+ };
+
+ return (
+
+ <>
+
+
+
+
+
+ >
+
+ );
+}
+
+interface FormModalProps {
+ isOpen: boolean;
+ handleClose: () => void;
+ title: string;
+ campaignId: string;
+}
diff --git a/src/DonationForms/V2/resources/components/DonationFormsListTable.tsx b/src/DonationForms/V2/resources/components/DonationFormsListTable.tsx
index 24f295d905..cdd59fa239 100644
--- a/src/DonationForms/V2/resources/components/DonationFormsListTable.tsx
+++ b/src/DonationForms/V2/resources/components/DonationFormsListTable.tsx
@@ -10,6 +10,10 @@ import {Interweave} from 'interweave';
import InterweaveSSR from '@givewp/components/ListTable/InterweaveSSR';
import BlankSlate from '@givewp/components/ListTable/BlankSlate';
import {CubeIcon} from '@givewp/components/AdminUI/Icons';
+import AddCampaignFormModal from './AddCampaignFormModal';
+import DefaultFormNotice from '@givewp/campaigns/admin/components/CampaignDetailsPage/Components/Notices/DefaultFormNotice';
+import apiFetch from '@wordpress/api-fetch';
+import {CampaignEntity} from '@givewp/campaigns/admin/components/types';
declare global {
interface Window {
@@ -18,6 +22,7 @@ declare global {
bannerActionUrl: string;
tooltipActionUrl: string;
migrationApiRoot: string;
+ defaultFormActionUrl: string;
apiRoot: string;
authors: Array<{id: string | number; name: string}>;
table: {columns: Array};
@@ -28,6 +33,8 @@ declare global {
supportedGateways: Array;
isOptionBasedFormEditorEnabled: boolean;
locale: string;
+ showDefaultFormTooltip: boolean;
+ campaignUrl: string;
};
GiveNextGen?: {
@@ -41,7 +48,7 @@ const API = new ListTableApi(window.GiveDonationForms);
const donationStatus = [
{
value: 'any',
- text: __('All', 'give'),
+ text: __('All Status', 'give'),
},
{
value: 'publish',
@@ -65,13 +72,12 @@ const donationStatus = [
},
];
+const urlParams = new URLSearchParams(window.location.search);
+
+const isCampaignDetailsPage = urlParams.get('id') && 'give-campaigns' === urlParams.get('page');
+const campaignId = urlParams.get('id');
+
const donationFormsFilters: Array = [
- {
- name: 'search',
- type: 'search',
- text: __('Search by name or ID', 'give'),
- ariaLabel: __('Search donation forms', 'give'),
- },
{
name: 'status',
type: 'select',
@@ -79,25 +85,48 @@ const donationFormsFilters: Array = [
ariaLabel: __('Filter donation forms by status', 'give'),
options: donationStatus,
},
+ {
+ name: 'search',
+ type: 'search',
+ text: __('Search by name or ID', 'give'),
+ ariaLabel: __('Search donation forms', 'give'),
+ },
];
+if (isCampaignDetailsPage) {
+ donationFormsFilters.push({
+ name: 'campaignId',
+ type: 'select',
+ text: __('Campaign ID', 'give'),
+ ariaLabel: __('Filter donation forms by Campaign ID', 'give'),
+ options: [
+ {
+ value: campaignId,
+ text: __('All Campaign Forms', 'give'),
+ },
+ ],
+ });
+}
+
const columnFilters: Array = [
{
column: 'title',
filter: (item) => {
- if (item?.v3form) {
- return (
-
-
-
-
{__('Uses the Visual Form Builder', 'give')}
+ return (
+ <>
+ {item?.v3form ? (
+
+
+
+
{__('Uses the Visual Form Builder', 'give')}
+
+
+ ) : (
-
- );
- }
-
- return
;
+ )}
+ >
+ );
},
},
{
@@ -155,7 +184,7 @@ const donationFormsBulkActions: Array
= [
},
confirm: (selected, names) => (
<>
- Donation forms to be edited:
+ {__(' Below are the donation forms to be edited.', 'give')}
{selected.map((id, index) => (
@@ -207,14 +236,14 @@ const donationFormsBulkActions: Array = [
),
},
{
- label: __('Move to Trash', 'give'),
+ label: __('Trash', 'give'),
value: 'trash',
type: 'danger',
isVisible: (data, parameters) => parameters.status !== 'trash' && data?.trash,
action: async (selected) => await API.fetchWithArgs('/trash', {ids: selected.join(',')}, 'DELETE'),
confirm: (selected, names) => (
-
{__('Really trash the following donation forms?', 'give')}
+
{__('Are you sure you want to trash the following donation forms?', 'give')}
{selected.map((id, index) => (
@@ -240,42 +269,95 @@ const ListTableBlankSlate = (
/>
);
-export default function DonationFormsListTable() {
+export default function DonationFormsListTable({entity}: {entity?: CampaignEntity}) {
const [state, setState] = useState({
showFeatureNoticeDialog: false,
+ showDefaultFormTooltip: window.GiveDonationForms.showDefaultFormTooltip,
});
+ const handleDefaultFormTooltipDismiss = () => {
+ apiFetch({
+ url: window.GiveDonationForms.defaultFormActionUrl,
+ method: 'POST',
+ }).then(() => {
+ setState((prevState) => {
+ return {
+ ...prevState,
+ showDefaultFormTooltip: false,
+ };
+ });
+ });
+ };
+
+ const [isOpen, setOpen] = useState(false);
+ const openModal = () => setOpen(true);
+ const closeModal = () => setOpen(false);
+
return (
{
+ return DonationFormsRowActions({
+ data,
+ item,
+ removeRow,
+ addRow,
+ setUpdateErrors,
+ parameters,
+ entity,
+ });
+ }}
bulkActions={donationFormsBulkActions}
apiSettings={window.GiveDonationForms}
filterSettings={donationFormsFilters}
listTableBlankSlate={ListTableBlankSlate}
columnFilters={columnFilters}
banner={Onboarding}
+ contentMode={isCampaignDetailsPage}
>
- {window.GiveDonationForms.isOptionBasedFormEditorEnabled && (
-
- {__('Switch to Legacy View', 'give')}
-
+ {isCampaignDetailsPage ? (
+
+ ) : (
+ <>
+ {window.GiveDonationForms.isOptionBasedFormEditorEnabled && (
+
+ {__('Switch to Legacy View', 'give')}
+
+ )}
+ >
+ )}
+ {state.showDefaultFormTooltip && isCampaignDetailsPage && (
+
)}
-
- {__('Add Form', 'give')}
-
);
diff --git a/src/DonationForms/V2/resources/components/DonationFormsRowActions.tsx b/src/DonationForms/V2/resources/components/DonationFormsRowActions.tsx
index 6ba994d34b..907be0eb16 100644
--- a/src/DonationForms/V2/resources/components/DonationFormsRowActions.tsx
+++ b/src/DonationForms/V2/resources/components/DonationFormsRowActions.tsx
@@ -5,15 +5,15 @@ import ListTableApi from '@givewp/components/ListTable/api';
import {useContext} from 'react';
import {ShowConfirmModalContext} from '@givewp/components/ListTable/ListTablePage';
import {Interweave} from 'interweave';
-import {OnboardingContext} from './Onboarding';
-import {UpgradeModalContent} from "./Migration";
+import {UpgradeModalContent} from './Migration';
+import {createInterpolateElement} from '@wordpress/element';
+
const donationFormsApi = new ListTableApi(window.GiveDonationForms);
-export function DonationFormsRowActions({data, item, removeRow, addRow, setUpdateErrors, parameters}) {
+export function DonationFormsRowActions({data, item, removeRow, addRow, setUpdateErrors, parameters, entity}) {
const {mutate} = useSWRConfig();
const showConfirmModal = useContext(ShowConfirmModalContext);
- const [OnboardingState, setOnboardingState] = useContext(OnboardingContext);
const trashEnabled = Boolean(data?.trash);
const deleteEndpoint = trashEnabled && !item.status.includes('trash') ? '/trash' : '/delete';
@@ -36,7 +36,7 @@ export function DonationFormsRowActions({data, item, removeRow, addRow, setUpdat
const confirmTrashForm = (selected) => (
- {__('Really trash the following form?', 'give')}
+ {__('Are you sure you want to trash the following donation form? ', 'give')}
@@ -51,14 +51,39 @@ export function DonationFormsRowActions({data, item, removeRow, addRow, setUpdat
};
const confirmUpgradeModal = (event) => {
+ showConfirmModal(__('Upgrade', 'give'), UpgradeModalContent, async (selected) => {
+ const response = await donationFormsApi.fetchWithArgs('/migrate/' + item.id, {}, 'POST');
+ await mutate(parameters);
+ return response;
+ });
+ };
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const isCampaignDetailsPage =
+ urlParams.get('id') && urlParams.get('page') && 'give-campaigns' === urlParams.get('page');
+
+ const defaultCampaignModalContent = createInterpolateElement(
+ __('This will set as the default form for this campaign. Do you want to proceed?', 'give'),
+ {
+ title_link: ,
+ }
+ );
+
+ const confirmDefaultCampaignFormModal = (event) => {
showConfirmModal(
- __('Upgrade', 'give'),
- UpgradeModalContent,
- async (selected) => {
- const response = await donationFormsApi.fetchWithArgs("/migrate/" + item.id, {}, 'POST');
+ __('Make as default', 'give'),
+ (selected) => {defaultCampaignModalContent}
,
+ async () => {
+ await entity.edit({
+ defaultFormId: item.id
+ })
+
+ const response = await entity.save();
+
await mutate(parameters);
return response;
- }
+ },
+ __('Yes proceed','give')
);
};
@@ -85,13 +110,15 @@ export function DonationFormsRowActions({data, item, removeRow, addRow, setUpdat
) : (
<>
-
+ {!item.isDefaultCampaignForm && (
+
+ )}
await fetchAndUpdateErrors(parameters, '/duplicate', id, 'POST'))}
@@ -99,12 +126,22 @@ export function DonationFormsRowActions({data, item, removeRow, addRow, setUpdat
displayText={__('Duplicate', 'give')}
hiddenText={item?.name}
/>
- {!item.v3form && ( )}
+ {!item.v3form && (
+
+ )}
+ {isCampaignDetailsPage && !item.isDefaultCampaignForm && (
+
+ )}
>
)}
>
diff --git a/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButton.tsx b/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButton.tsx
index d032fa0165..f6a827635d 100644
--- a/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButton.tsx
+++ b/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButton.tsx
@@ -4,11 +4,8 @@ import styles from '../style.module.scss';
export default function FormBuilderButton({onClick}) {
return (
-
+
{__('Use the new visual form builder', 'give')}
- )
+ );
}
diff --git a/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButtonPortal.tsx b/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButtonPortal.tsx
index f48097893f..5bbbba744f 100644
--- a/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButtonPortal.tsx
+++ b/src/DonationForms/V2/resources/components/Onboarding/Components/FormBuilderButtonPortal.tsx
@@ -1,27 +1,37 @@
import {useEffect} from 'react';
import {createPortal} from 'react-dom';
import {FeatureNoticeDialog} from '../Dialogs';
-import FormBuilderButton from './FormBuilderButton';
import styles from '../style.module.scss';
+import FormBuilderButton from './FormBuilderButton';
+import {__} from '@wordpress/i18n';
const portalContainer = document.createElement('div');
export default function FormBuilderButtonPortal({isUpgrading = false, isEditing = false, showDialog, setShowDialog}) {
-
useEffect(() => {
const target = document.querySelector('.wp-header-end');
target.parentNode.insertBefore(portalContainer, target);
}, [portalContainer]);
- const ButtonPortal = () => createPortal(
-
- setShowDialog({
- show: true,
- upgrading: false
- })} />
-
,
- portalContainer
- );
+ const ButtonPortal = () =>
+ createPortal(
+
+
+
+ setShowDialog({
+ show: true,
+ upgrading: false,
+ })
+ }
+ />
+
+ {window.GiveDonationForms.campaignUrl && (
+
{__('Manage Campaign', 'give')}
+ )}
+
,
+ portalContainer
+ );
return (
<>
@@ -35,5 +45,5 @@ export default function FormBuilderButtonPortal({isUpgrading = false, isEditing
/>
)}
>
- )
+ );
}
diff --git a/src/DonationForms/V2/resources/components/Onboarding/Components/HeaderExtension/HeaderExtension.module.scss b/src/DonationForms/V2/resources/components/Onboarding/Components/HeaderExtension/HeaderExtension.module.scss
new file mode 100644
index 0000000000..9abbd90ea0
--- /dev/null
+++ b/src/DonationForms/V2/resources/components/Onboarding/Components/HeaderExtension/HeaderExtension.module.scss
@@ -0,0 +1,11 @@
+.container {
+ display: flex;
+ justify-content: space-between;
+ background: red;
+ gap: 1rem;
+ width: 100%;
+ max-width: 700px;
+ position: absolute;
+ top: 18px;
+ right: 12%;
+}
diff --git a/src/DonationForms/V2/resources/components/Onboarding/Components/HeaderExtension/index.tsx b/src/DonationForms/V2/resources/components/Onboarding/Components/HeaderExtension/index.tsx
new file mode 100644
index 0000000000..43797a23b1
--- /dev/null
+++ b/src/DonationForms/V2/resources/components/Onboarding/Components/HeaderExtension/index.tsx
@@ -0,0 +1,10 @@
+import styles from './HeaderExtension.module.scss';
+
+export default function HeaderExtension() {
+ return (
+
+ );
+}
diff --git a/src/DonationForms/V2/resources/components/Onboarding/Dialogs/FeatureNoticeDialog.tsx b/src/DonationForms/V2/resources/components/Onboarding/Dialogs/FeatureNoticeDialog.tsx
index 5f7e53c15f..41e7e3af90 100644
--- a/src/DonationForms/V2/resources/components/Onboarding/Dialogs/FeatureNoticeDialog.tsx
+++ b/src/DonationForms/V2/resources/components/Onboarding/Dialogs/FeatureNoticeDialog.tsx
@@ -3,13 +3,11 @@ import ModalDialog from '@givewp/components/AdminUI/ModalDialog';
import {CheckVerified, StarsIcon} from '@givewp/components/AdminUI/Icons';
import Button from '@givewp/components/AdminUI/Button';
import styles from '../style.module.scss';
-import {createInterpolateElement} from "@wordpress/element";
-
+import {createInterpolateElement} from '@wordpress/element';
export default function FeatureNoticeDialog({isUpgrading, isEditing, handleClose}) {
const {supportedAddons, supportedGateways, migrationApiRoot, apiNonce} = window.GiveDonationForms;
const handleUpgrade = async () => {
-
// @ts-ignore
const response = await fetch(migrationApiRoot + '/' + window.give_vars.post_id, {
method: 'post',
@@ -26,22 +24,18 @@ export default function FeatureNoticeDialog({isUpgrading, isEditing, handleClose
} else {
alert('Error migrating form');
}
- }
+ };
// @note the component does not support the `className` prop.
const upgradeButtonStyles = {
width: '100%',
marginTop: 'var(--givewp-spacing-6)',
marginBottom: 'var(--givewp-spacing-4)',
- backgroundColor: 'var(--wp-blue-blue-50)'
- }
+ backgroundColor: 'var(--wp-blue-blue-50)',
+ };
return (
-
+
<>
{__("What's new", 'give')}
@@ -49,7 +43,14 @@ export default function FeatureNoticeDialog({isUpgrading, isEditing, handleClose
{createInterpolateElement(
- sprintf(__('GiveWP 3.0 introduces an enhanced forms experience powered by the new Visual Donation Form Builder. The team is still working on add-on and gateway compatibility. If you need to use an add-on or gateway that isn\'t listed, use the "%sAdd form%s" option for now.', 'give'), '',' '),
+ sprintf(
+ __(
+ 'GiveWP 3.0 introduces an enhanced forms experience powered by the new Visual Donation Form Builder. The team is still working on add-on and gateway compatibility. If you need to use an add-on or gateway that isn\'t listed, use the "%sAdd form%s" option for now.',
+ 'give'
+ ),
+ '',
+ ' '
+ ),
{
b: ,
}
@@ -58,13 +59,14 @@ export default function FeatureNoticeDialog({isUpgrading, isEditing, handleClose
{supportedAddons.length > 0 && (
<>
-
- {__('Supported add-ons', 'give')}
-
+
{__('Supported add-ons', 'give')}
- {supportedAddons.map(addon => (
-
{addon}
+ {supportedAddons.map((addon) => (
+
+
+ {addon}
+
))}
>
@@ -72,34 +74,31 @@ export default function FeatureNoticeDialog({isUpgrading, isEditing, handleClose
{supportedGateways.length > 0 && (
<>
-
- {__('Supported gateways', 'give')}
-
+
{__('Supported gateways', 'give')}
- {supportedGateways.map(gateway => (
-
{gateway}
+ {supportedGateways.map((gateway) => (
+
+
+ {gateway}
+
))}
>
)}
{isUpgrading ? (
-
+
{__('Proceed with upgrade', 'give')}
) : (
{
- if(isEditing) {
+ if (isEditing) {
sessionStorage.setItem('givewp-show-return-btn', 'true');
}
- window.location.href = 'edit.php?post_type=give_forms&page=givewp-form-builder'
+ window.location.href = 'edit.php?post_type=give_forms&page=give-campaigns&new=campaign';
}}
className={styles.proceedButton}
>
@@ -114,5 +113,5 @@ export default function FeatureNoticeDialog({isUpgrading, isEditing, handleClose
>
- )
+ );
}
diff --git a/src/DonationForms/V2/resources/components/Onboarding/index.tsx b/src/DonationForms/V2/resources/components/Onboarding/index.tsx
index 8050132ba8..319442b989 100644
--- a/src/DonationForms/V2/resources/components/Onboarding/index.tsx
+++ b/src/DonationForms/V2/resources/components/Onboarding/index.tsx
@@ -5,6 +5,7 @@ export const OnboardingContext = createContext([]);
export interface OnboardingStateProps {
showFeatureNoticeDialog: boolean;
+ showDefaultFormTooltip: boolean;
}
export default function Onboarding() {
diff --git a/src/DonationForms/V2/resources/components/Onboarding/style.module.scss b/src/DonationForms/V2/resources/components/Onboarding/style.module.scss
index 6514e25171..110c815958 100644
--- a/src/DonationForms/V2/resources/components/Onboarding/style.module.scss
+++ b/src/DonationForms/V2/resources/components/Onboarding/style.module.scss
@@ -153,13 +153,29 @@
}
}
-.tryNewFormBuilderBtnContainer {
+.actionsContainer {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ max-width: 60%;
position: absolute;
top: 18px;
- left: 50%;
- transform: translate(-50%, 0);
+ right: 1.8%;
+
+
+ a {
+ font-weight: bold;
+ margin-top: 22px;
+ }
}
+/*.tryNewFormBuilderBtnContainer {
+ position: absolute;
+ top: 18px;
+ left: 50%;
+ transform: translate(-50%, 0);
+}*/
+
.formName {
color: #459948;
}
diff --git a/src/Donations/Models/Donation.php b/src/Donations/Models/Donation.php
index b3d50f74bd..9b4583b646 100644
--- a/src/Donations/Models/Donation.php
+++ b/src/Donations/Models/Donation.php
@@ -3,6 +3,7 @@
namespace Give\Donations\Models;
use DateTime;
+use Give\Campaigns\Models\Campaign;
use Give\Donations\DataTransferObjects\DonationQueryData;
use Give\Donations\Factories\DonationFactory;
use Give\Donations\Properties\BillingAddress;
@@ -55,6 +56,7 @@
* @property string $levelId
* @property string $gatewayTransactionId
* @property Donor $donor
+ * @property Campaign $campaign
* @property Subscription $subscription
* @property DonationNote[] $notes
* @property string $company
@@ -99,6 +101,7 @@ class Donation extends Model implements ModelCrud, ModelHasFactory
* @inheritdoc
*/
protected $relationships = [
+ 'campaign' => Relationship::BELONGS_TO,
'donor' => Relationship::BELONGS_TO,
'subscription' => Relationship::BELONGS_TO,
'notes' => Relationship::HAS_MANY,
@@ -184,6 +187,16 @@ public function subscription(): ModelQueryBuilder
return give()->subscriptions->queryByDonationId($this->id);
}
+ /**
+ * @unreleased
+ *
+ * @return ModelQueryBuilder
+ */
+ public function campaign(): ModelQueryBuilder
+ {
+ return give()->campaigns->queryByFormId($this->formId);
+ }
+
/**
* @since 2.19.6
*
diff --git a/src/Donations/Repositories/DonationRepository.php b/src/Donations/Repositories/DonationRepository.php
index 167607ad24..a12674a4fa 100644
--- a/src/Donations/Repositories/DonationRepository.php
+++ b/src/Donations/Repositories/DonationRepository.php
@@ -355,6 +355,10 @@ private function getCoreDonationMetaForDatabase(Donation $donation): array
DonationMetaKeys::ANONYMOUS => (int)$donation->anonymous
];
+ if ($campaign = $donation->campaign) {
+ $meta[DonationMetaKeys::CAMPAIGN_ID] = $campaign->id;
+ }
+
if ($donation->feeAmountRecovered !== null) {
$meta[DonationMetaKeys::FEE_AMOUNT_RECOVERED] = $donation->feeAmountRecovered->formatToDecimal();
}
diff --git a/src/Donations/ValueObjects/DonationMetaKeys.php b/src/Donations/ValueObjects/DonationMetaKeys.php
index a35c6dccff..c8a682f402 100644
--- a/src/Donations/ValueObjects/DonationMetaKeys.php
+++ b/src/Donations/ValueObjects/DonationMetaKeys.php
@@ -12,6 +12,7 @@
* @since 2.19.6
*
* @method static DonationMetaKeys AMOUNT()
+ * @method static DonationMetaKeys CAMPAIGN_ID()
* @method static DonationMetaKeys CURRENCY()
* @method static DonationMetaKeys GATEWAY()
* @method static DonationMetaKeys DONOR_ID()
@@ -47,6 +48,7 @@ class DonationMetaKeys extends Enum
use EnumInteractsWithQueryBuilder;
const AMOUNT = '_give_payment_total';
+ const CAMPAIGN_ID = '_give_campaign_id';
const BASE_AMOUNT = '_give_cs_base_amount';
const CURRENCY = '_give_payment_currency';
const EXCHANGE_RATE = '_give_cs_exchange_rate';
diff --git a/src/Donations/resources/components/DonationRowActions.tsx b/src/Donations/resources/components/DonationRowActions.tsx
index 1fdb69f3a1..159668525f 100644
--- a/src/Donations/resources/components/DonationRowActions.tsx
+++ b/src/Donations/resources/components/DonationRowActions.tsx
@@ -20,7 +20,7 @@ export const DonationRowActions = ({item, removeRow, setUpdateErrors, parameters
const deleteItem = async (selected) => await fetchAndUpdateErrors(parameters, '/delete', item.id, 'DELETE');
- const confirmDelete = (selected) => {sprintf(__('Really delete donation #%d?', 'give'), item.id)}
;
+ const confirmDelete = (selected) => {sprintf(__('Are you sure you want to delete the following donation #%d?', 'give'), item.id)}
;
const confirmModal = (event) => {
showConfirmModal(__('Delete', 'give'), confirmDelete, deleteItem, 'danger');
diff --git a/src/Donations/resources/components/DonationsListTable.tsx b/src/Donations/resources/components/DonationsListTable.tsx
index aade8b40c5..efd1ba9c90 100644
--- a/src/Donations/resources/components/DonationsListTable.tsx
+++ b/src/Donations/resources/components/DonationsListTable.tsx
@@ -31,13 +31,6 @@ declare global {
const API = new ListTableApi(window.GiveDonations);
const filters: Array = [
- {
- name: 'search',
- type: 'search',
- inlineSize: '14rem',
- text: __('Name, Email, or Donation ID', 'give'),
- ariaLabel: __('search donations', 'give'),
- },
{
name: 'form',
type: 'formselect',
@@ -45,6 +38,13 @@ const filters: Array = [
ariaLabel: __('filter donation forms by status', 'give'),
options: window.GiveDonations.forms,
},
+ {
+ name: 'search',
+ type: 'search',
+ inlineSize: '14rem',
+ text: __('Name, Email, or Donation ID', 'give'),
+ ariaLabel: __('search donations', 'give'),
+ },
{
name: 'toggle',
type: 'checkbox',
@@ -64,7 +64,7 @@ const bulkActions: Array = [
},
confirm: (selected, names) => (
<>
- {__('Really delete the following donations?', 'give')}
+ {__('Are you sure you want to delete the following donations?', 'give')}
{selected.map((donationId, index) => (
diff --git a/src/Donors/DonorsAdminPage.php b/src/Donors/DonorsAdminPage.php
index 5e46d7f209..87c9c32795 100644
--- a/src/Donors/DonorsAdminPage.php
+++ b/src/Donors/DonorsAdminPage.php
@@ -84,6 +84,8 @@ public function loadScripts()
[],
null
);
+
+ wp_enqueue_style('givewp-design-system-foundation');
}
/**
@@ -173,7 +175,7 @@ private function getDismissedRecommendations(): array
$feeRecoveryAddonIsActive = Utils::isPluginActive('give-fee-recovery/give-fee-recovery.php');
$optionName = 'givewp_donors_fee_recovery_recommendation_dismissed';
-
+
$dismissed = get_option($optionName, false);
if ($dismissed || $feeRecoveryAddonIsActive) {
diff --git a/src/Donors/resources/components/DonorsListTable.tsx b/src/Donors/resources/components/DonorsListTable.tsx
index df90b03087..c64a2d1210 100644
--- a/src/Donors/resources/components/DonorsListTable.tsx
+++ b/src/Donors/resources/components/DonorsListTable.tsx
@@ -25,13 +25,6 @@ declare global {
const API = new ListTableApi(window.GiveDonors);
const donorsFilters: Array = [
- {
- name: 'search',
- type: 'search',
- inlineSize: '14rem',
- text: __('Name, Email, or Donor ID', 'give'),
- ariaLabel: __('Search donors', 'give'),
- },
{
name: 'form',
type: 'formselect',
@@ -39,6 +32,13 @@ const donorsFilters: Array = [
ariaLabel: __('Filter donation forms by status', 'give'),
options: window.GiveDonors.forms,
},
+ {
+ name: 'search',
+ type: 'search',
+ inlineSize: '14rem',
+ text: __('Name, Email, or Donor ID', 'give'),
+ ariaLabel: __('Search donors', 'give'),
+ },
];
const donorsBulkActions: Array = [
@@ -54,7 +54,7 @@ const donorsBulkActions: Array = [
},
confirm: (selected, names) => (
<>
- {__('Really delete the following donors?', 'give')}
+ {__('Are you sure you want to delete the following donors?', 'give')}
{selected.map((id, index) => (
@@ -62,12 +62,11 @@ const donorsBulkActions: Array = [
))}
-
+
+
-
- {__('Delete all associated donations and records', 'give')}
-
-
+ {__('Delete all associated donations and records', 'give')}
+
>
),
},
diff --git a/src/Donors/resources/components/style.scss b/src/Donors/resources/components/style.scss
index ff768df958..664b3416f0 100644
--- a/src/Donors/resources/components/style.scss
+++ b/src/Donors/resources/components/style.scss
@@ -49,3 +49,12 @@
}
}
+label[for="giveDonorsTableDeleteDonations"] {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--givewp-spacing-2);
+}
+
+#giveDonorsTableDeleteDonations {
+ margin: 0 var(--givewp-spacing-2) 0 0;
+}
diff --git a/src/FormBuilder/FormBuilderRouteBuilder.php b/src/FormBuilder/FormBuilderRouteBuilder.php
index 0b3abe72f5..fd2b7ac64e 100644
--- a/src/FormBuilder/FormBuilderRouteBuilder.php
+++ b/src/FormBuilder/FormBuilderRouteBuilder.php
@@ -63,12 +63,20 @@ public function __toString()
*/
public function getUrl(): string
{
+ $queryArgs = [
+ 'post_type' => 'give_forms',
+ 'page' => self::SLUG,
+ 'donationFormID' => $this->donationFormID,
+ 'locale' => $this->locale,
+ ];
+
+ if (isset($_GET['campaignId'])) {
+ $queryArgs['campaignId'] = $_GET['campaignId'];
+ }
+
return add_query_arg(
[
- 'post_type' => 'give_forms',
- 'page' => self::SLUG,
- 'donationFormID' => $this->donationFormID,
- 'locale' => $this->locale,
+ $queryArgs,
],
admin_url('edit.php')
);
diff --git a/src/FormBuilder/Routes/RegisterFormBuilderPageRoute.php b/src/FormBuilder/Routes/RegisterFormBuilderPageRoute.php
index 7a0fb2e714..836bfbf677 100644
--- a/src/FormBuilder/Routes/RegisterFormBuilderPageRoute.php
+++ b/src/FormBuilder/Routes/RegisterFormBuilderPageRoute.php
@@ -3,6 +3,7 @@
namespace Give\FormBuilder\Routes;
+use Give\Campaigns\Models\Campaign;
use Give\FormBuilder\FormBuilderRouteBuilder;
use Give\FormBuilder\ViewModels\FormBuilderViewModel;
use Give\Framework\Views\View;
@@ -169,6 +170,16 @@ public function renderPage()
'isDismissed' => get_user_meta(get_current_user_id(), 'givewp-additional-payment-gateways-notice-dismissed', true),
]);
+ /**
+ * @unreleased
+ */
+ if ($campaign = Campaign::findByFormId($donationFormId)) {
+ wp_localize_script('@givewp/form-builder/script', 'headerContainer', [
+ 'campaignUrl' => admin_url('edit.php?post_type=give_forms&page=give-campaigns&id=' . $campaign->id),
+ ]);
+ }
+
+
View::render('FormBuilder.admin-form-builder');
}
diff --git a/src/FormBuilder/resources/js/form-builder/src/containers/HeaderContainer.tsx b/src/FormBuilder/resources/js/form-builder/src/containers/HeaderContainer.tsx
index 5278a1146e..c138fb3f02 100644
--- a/src/FormBuilder/resources/js/form-builder/src/containers/HeaderContainer.tsx
+++ b/src/FormBuilder/resources/js/form-builder/src/containers/HeaderContainer.tsx
@@ -19,6 +19,14 @@ import {cleanForSlug} from '@wordpress/url';
import cn from 'classnames';
import EmbedFormModal from '@givewp/form-builder/components/EmbedForm';
+declare global {
+ interface Window {
+ headerContainer?: {
+ campaignUrl: string;
+ };
+ }
+}
+
const Logo = () => (
(
);
+const BackToCampaignOverview = () => (
+
+);
+
/**
* @since 3.1.0 dispatch page slug from form title on initial publish.
*/
@@ -107,7 +131,7 @@ const HeaderContainer = ({SecondarySidebarButtons = null, showSidebar, toggleSho
],
});
}
- }
+ };
const {mode} = useEditorState();
const dispatchEditorState = useEditorStateDispatch();
@@ -123,7 +147,7 @@ const HeaderContainer = ({SecondarySidebarButtons = null, showSidebar, toggleSho
-
+ {window.headerContainer ? : }
{SecondarySidebarButtons && }
process($payload)
->finally(function(FormMigrationPayload $payload) {
$payload->formV3->save();
+
+ // Associate upgraded form to a campaign
+ $campaignRepository = give(CampaignRepository::class);
+ if ($campaign = $campaignRepository->getByFormId($payload->formV2->id)) {
+ $campaignRepository->addCampaignForm($campaign, $payload->formV3->id);
+ }
+
Log::info(esc_html__('Form migrated from v2 to v3.', 'give'), $this->debugContext);
});
diff --git a/src/FormMigration/Controllers/TransferController.php b/src/FormMigration/Controllers/TransferController.php
index cccc2f32df..1819f40bf1 100644
--- a/src/FormMigration/Controllers/TransferController.php
+++ b/src/FormMigration/Controllers/TransferController.php
@@ -2,14 +2,13 @@
namespace Give\FormMigration\Controllers;
+use Give\Campaigns\Repositories\CampaignRepository;
use Give\DonationForms\V2\Models\DonationForm;
-use Give\DonationForms\ValueObjects\DonationFormStatus;
use Give\FormMigration\Actions\GetMigratedFormId;
use Give\FormMigration\Actions\TransferDonations;
use Give\FormMigration\Actions\TransferFormUrl;
use Give\FormMigration\DataTransferObjects\TransferOptions;
use Give\Framework\Database\DB;
-use Give\Framework\QueryBuilder\QueryBuilder;
use WP_REST_Request;
use WP_REST_Response;
@@ -35,6 +34,16 @@ public function __invoke(DonationForm $formV2, TransferOptions $options)
TransferFormUrl::from($formV2->id)->to($v3FormId);
TransferDonations::from($formV2->id)->to($v3FormId);
+ // Promote upgraded form to default form
+ $campaignRepository = give(CampaignRepository::class);
+ if ($campaign = $campaignRepository->getByFormId($formV2->id)) {
+ $defaultForm = $campaign->defaultForm();
+
+ if ($defaultForm->id === $formV2->id) {
+ $campaignRepository->updateDefaultCampaignForm($campaign, $v3FormId);
+ }
+ }
+
if($options->shouldDelete()) {
wp_trash_post($formV2->id);
}
diff --git a/src/Framework/Models/Factories/ModelFactory.php b/src/Framework/Models/Factories/ModelFactory.php
index 684dfa28d5..254bcbd99d 100644
--- a/src/Framework/Models/Factories/ModelFactory.php
+++ b/src/Framework/Models/Factories/ModelFactory.php
@@ -2,6 +2,7 @@
namespace Give\Framework\Models\Factories;
+use Closure;
use Exception;
use Faker\Generator;
use Give\Framework\Database\DB;
@@ -63,6 +64,18 @@ public function make(array $attributes = [])
return $this->count === 1 ? $results[0] : $results;
}
+ /**
+ * @unreleased
+ */
+ public function makeAndResolveTo($property): Closure
+ {
+ return function() use ($property) {
+ return is_array($results = $this->make())
+ ? array_column($results, $property)
+ : $results->$property;
+ };
+ }
+
/**
* @since 2.20.0
*
@@ -85,16 +98,31 @@ public function create(array $attributes = [])
return $this->count === 1 ? $instances[0] : $instances;
}
+ /**
+ * @unreleased
+ */
+ public function createAndResolveTo($property): Closure
+ {
+ return function() use ($property) {
+ return is_array($results = $this->create())
+ ? array_column($results, $property)
+ : $results->$property;
+ };
+ }
+
/**
* Creates an instance of the model from the attributes and definition.
*
+ * @unreleased Add support for resolving Closures.
* @since 2.23.0
*
* @return M
*/
protected function makeInstance(array $attributes)
{
- return new $this->model(array_merge($this->definition(), $attributes));
+ return new $this->model(array_map(function($attribute) {
+ return $attribute instanceof Closure ? $attribute() : $attribute;
+ }, array_merge($this->definition(), $attributes)));
}
/**
diff --git a/src/Framework/Models/Factories/README.md b/src/Framework/Models/Factories/README.md
new file mode 100644
index 0000000000..f81af5c6ce
--- /dev/null
+++ b/src/Framework/Models/Factories/README.md
@@ -0,0 +1,114 @@
+# Model Factory
+
+## Introduction
+
+Factory classes are used to programmatically create instances of models. This is useful for seeding databases, creating test data, and other situations where you need to create a lot of model instances.
+
+```php
+ __('GiveWP Campaign', 'give'),
+ 'description' => $this->faker->paragraph(),
+ ];
+ }
+}
+```
+
+Each instance of a model created by a factory class is populated with default values defined in the `definition` method, which can be hard-coded or generated dynamically using integrated the `fakerphp/faker` library.
+
+## Creating Models with Factories
+
+A model can be instantiated using the factory `make()` method, which uses the defaults provided by the `definition()` method to create the model instance.
+
+```php
+use Give\Campaigns\Models\Campaign;
+
+$campaign = Campaign::factory()->make();
+```
+
+Additionally, multiple model instances can be created using the `count()` method.
+
+```php
+use Give\Campaigns\Models\Campaign;
+
+$campaigns = Campaign::factory()->count(3)->make();
+```
+
+### Overriding Attributes
+
+You can override these default values by passing an array of attributes to the factory's `make()` or `create()` method.
+
+```php
+use Give\Campaigns\Models\Campaign;
+
+$campaign Campaign::factory()->create([
+ 'title' => 'My Custom Campaign',
+]);
+```
+
+### Persisting Models
+
+The `create()` method instantiates model instances and persists them to the database using model's `save()` method.
+
+```php
+use Give\Campaigns\Models\Campaign;
+
+$campaign Campaign::factory()->create();
+```
+
+### Deferred Resolution
+
+Sometimes you may need to defer the resolution of an attribute until the model is being created. Either the attribute definition is not a simple value or is otherwise expensive to instantiate.
+
+Factory attribute definitions can be deferred using `Closure` callbacks instead of a hard-coded or generated value.
+
+```php
+ public function definition(): array
+ {
+ return [
+ 'title' => __('GiveWP Campaign', 'give'),
+ 'description' => function() {
+ return prompt('Write a short description for a fundraising campaign.')
+ },
+ ];
+ }
+```
+
+Additionally, deferred attributes are not resolved when the attribute is overridden, which prevents unnecessary computation when the default value is not used.
+
+```php
+$campaign = Campaign::factory()->create([
+ 'description' => 'My custom description',
+]);
+```
+
+## Model Relationships with Factories
+
+Attribute definitions can also be other model factories, which can be resolved to a property value, such as an ID, to create required dependencies.
+
+```php
+ public function definition(): array
+ {
+ return [
+ 'title' => __('GiveWP Campaign', 'give'),
+ 'formId' => DonationForm::factory()->createAndResolveTo('id'),
+ ];
+ }
+```
+
+This is particularly useful when defining model relationships, where the related model is only instantiated when a model is not explicity provided as an override.
+
+If an existing model (or model ID) is provided as an override, the factory will not instantiate an additional model.
+
+```php
+Campaign::factory()->create([
+ 'formId' => $donationForm->id,
+]);
+```
diff --git a/src/Framework/QueryBuilder/Concerns/CRUD.php b/src/Framework/QueryBuilder/Concerns/CRUD.php
index ff306944f7..ebffb05e57 100644
--- a/src/Framework/QueryBuilder/Concerns/CRUD.php
+++ b/src/Framework/QueryBuilder/Concerns/CRUD.php
@@ -3,6 +3,7 @@
namespace Give\Framework\QueryBuilder\Concerns;
use Give\Framework\Database\DB;
+use Give\Vendors\StellarWP\Arrays\Arr;
/**
* @since 2.19.0
@@ -12,6 +13,7 @@ trait CRUD
/**
* @see https://developer.wordpress.org/reference/classes/wpdb/insert/
*
+ * @unreleased Add support for inserting multiple rows at once
* @since 2.19.0
*
* @param array|string $format
@@ -22,6 +24,12 @@ trait CRUD
*/
public function insert($data, $format = null)
{
+ if (Arr::is_list($data)) {
+ return DB::query(
+ $this->getInsertIntoSQL($data, $format)
+ );
+ }
+
return DB::insert(
$this->getTable(),
$data,
diff --git a/src/Framework/QueryBuilder/Concerns/InsertInto.php b/src/Framework/QueryBuilder/Concerns/InsertInto.php
new file mode 100644
index 0000000000..4d6701dbfa
--- /dev/null
+++ b/src/Framework/QueryBuilder/Concerns/InsertInto.php
@@ -0,0 +1,55 @@
+getTable()
+ . sprintf(' (%s) ', implode(',', array_keys($data[0])))
+ . 'VALUES ';
+
+ foreach ($data as $row) {
+ $sql .= DB::prepare(
+ sprintf('(%s),', implode(',', $format ?? $this->getInsertIntoRowValuesFormat($row))),
+ $row
+ );
+ }
+
+ return rtrim($sql, ',');
+ }
+
+ /**
+ * Get values format used by DB::prepare()
+ *
+ * @unreleased
+ *
+ * @param array $data
+ *
+ * @return array
+ */
+ private function getInsertIntoRowValuesFormat(array $data): array
+ {
+ return array_map(function ($value) {
+ if (is_int($value)) {
+ return '%d';
+ }
+
+ if (is_float($value)) {
+ return '%f';
+ }
+
+ return '%s';
+ }, $data);
+ }
+
+}
diff --git a/src/Framework/QueryBuilder/Concerns/OrderByStatement.php b/src/Framework/QueryBuilder/Concerns/OrderByStatement.php
index 9392850199..9b6ae56d7f 100644
--- a/src/Framework/QueryBuilder/Concerns/OrderByStatement.php
+++ b/src/Framework/QueryBuilder/Concerns/OrderByStatement.php
@@ -4,6 +4,7 @@
use Give\Framework\Database\DB;
use Give\Framework\QueryBuilder\Clauses\OrderBy;
+use Give\Framework\QueryBuilder\Clauses\RawSQL;
/**
* @since 2.19.0
@@ -28,6 +29,23 @@ public function orderBy($column, $direction = 'ASC')
return $this;
}
+ /**
+ * Add raw SQL Order By statement
+ *
+ * @unreleased
+ *
+ * @param $sql
+ * @param ...$args
+ *
+ * @return $this
+ */
+ public function orderByRaw($sql, ...$args)
+ {
+ $this->orderBys[] = new RawSQL($sql, $args);
+
+ return $this;
+ }
+
/**
* @return array|string[]
*/
@@ -39,7 +57,10 @@ protected function getOrderBySQL()
$orderBys = implode(
', ',
- array_map(function (OrderBy $order) {
+ array_map(function ($order) {
+ if ($order instanceof RawSQL) {
+ return DB::prepare('%1s', $order->sql);
+ }
return DB::prepare('%1s %2s', $order->column, $order->direction);
}, $this->orderBys)
);
diff --git a/src/Framework/QueryBuilder/QueryBuilder.php b/src/Framework/QueryBuilder/QueryBuilder.php
index 27ca86436f..94704ee4f4 100644
--- a/src/Framework/QueryBuilder/QueryBuilder.php
+++ b/src/Framework/QueryBuilder/QueryBuilder.php
@@ -7,6 +7,7 @@
use Give\Framework\QueryBuilder\Concerns\FromClause;
use Give\Framework\QueryBuilder\Concerns\GroupByStatement;
use Give\Framework\QueryBuilder\Concerns\HavingClause;
+use Give\Framework\QueryBuilder\Concerns\InsertInto;
use Give\Framework\QueryBuilder\Concerns\JoinClause;
use Give\Framework\QueryBuilder\Concerns\LimitStatement;
use Give\Framework\QueryBuilder\Concerns\MetaQuery;
@@ -36,6 +37,7 @@ class QueryBuilder
use TablePrefix;
use UnionOperator;
use WhereClause;
+ use InsertInto;
/**
* @return string
diff --git a/src/Framework/Support/Facades/DateTime/Temporal.php b/src/Framework/Support/Facades/DateTime/Temporal.php
index 24c3c6376f..441719b2b5 100644
--- a/src/Framework/Support/Facades/DateTime/Temporal.php
+++ b/src/Framework/Support/Facades/DateTime/Temporal.php
@@ -6,6 +6,8 @@
use Give\Framework\Support\Facades\Facade;
/**
+ * @unreleased added withStartOfDay, withEndOfDay, immutableOrClone
+ * @since 3.20. added getDateTimestamp
* @since 2.19.6
*
* @method static DateTimeInterface toDateTime(string $date)
@@ -13,6 +15,9 @@
* @method static string getFormattedDateTime(DateTimeInterface $dateTime)
* @method static string getCurrentFormattedDateForDatabase()
* @method static DateTimeInterface withoutMicroseconds(DateTimeInterface $dateTime)
+ * @method static DateTimeInterface withStartOfDay(DateTimeInterface $dateTime)
+ * @method static DateTimeInterface withEndOfDay(DateTimeInterface $dateTime)
+ * @method static DateTimeInterface immutableOrClone(DateTimeInterface $dateTime)
* @method static int getDateTimestamp(string $date, string $timezone = '')
*/
class Temporal extends Facade
diff --git a/src/Framework/Support/Facades/DateTime/TemporalFacade.php b/src/Framework/Support/Facades/DateTime/TemporalFacade.php
index 860a1e3e48..2c9806403d 100644
--- a/src/Framework/Support/Facades/DateTime/TemporalFacade.php
+++ b/src/Framework/Support/Facades/DateTime/TemporalFacade.php
@@ -57,27 +57,52 @@ public function getCurrentFormattedDateForDatabase()
/**
* Immutably returns a new DateTime instance with the microseconds set to 0.
*
+ * @unreleased Extracted new immutableOrClone method.
* @since 2.20.0
*/
public function withoutMicroseconds(DateTimeInterface $dateTime)
{
- if ($dateTime instanceof DateTimeImmutable) {
- return $dateTime->setTime(
+ return $this
+ ->immutableOrClone($dateTime)
+ ->setTime(
$dateTime->format('H'),
$dateTime->format('i'),
$dateTime->format('s')
);
- }
+ }
- $newDateTime = clone $dateTime;
+ /**
+ * Immutably returns a new DateTime instance with the time set to the start of the day.
+ *
+ * @unreleased
+ */
+ public function withStartOfDay(DateTimeInterface $dateTime): DateTimeInterface
+ {
+ return $this
+ ->immutableOrClone($dateTime)
+ ->setTime(0, 0, 0, 0);
+ }
- $newDateTime->setTime(
- $newDateTime->format('H'),
- $newDateTime->format('i'),
- $newDateTime->format('s')
- );
+ /**
+ * Immutably returns a new DateTime instance with the time set to the end of the day.
+ *
+ * @unreleased
+ */
+ public function withEndOfDay(DateTimeInterface $dateTime): DateTimeInterface
+ {
+ return $this
+ ->immutableOrClone($dateTime)
+ ->setTime(23, 59, 59, 999999);
+ }
- return $newDateTime;
+ /**
+ * @unreleased
+ */
+ public function immutableOrClone(DateTimeInterface $dateTime): DateTimeInterface
+ {
+ return $dateTime instanceof DateTimeImmutable
+ ? $dateTime
+ : clone $dateTime;
}
/**
diff --git a/src/Onboarding/FormRepository.php b/src/Onboarding/FormRepository.php
index 51639a61da..09fd136c1f 100644
--- a/src/Onboarding/FormRepository.php
+++ b/src/Onboarding/FormRepository.php
@@ -2,11 +2,13 @@
namespace Give\Onboarding;
+use Exception;
+use Give\Campaigns\Models\Campaign;
+use Give\Campaigns\ValueObjects\CampaignGoalType;
+use Give\Campaigns\ValueObjects\CampaignStatus;
+use Give\Campaigns\ValueObjects\CampaignType;
use Give\DonationForms\Models\DonationForm;
-use Give\DonationForms\Properties\FormSettings;
use Give\DonationForms\ValueObjects\DonationFormStatus;
-use Give\FormBuilder\Actions\GenerateDefaultDonationFormBlockCollection;
-use Give\Log\Log;
/**
* @since 2.8.0
@@ -72,30 +74,45 @@ protected function isFormAvailable($formID)
}
/**
+ * @unreleased Replace "Donation Form" with "Campaign Form"
* @since 3.15.0 Create the default v3 form.
* @since 2.8.0
* @return int Form ID
*
+ * @throws Exception
*/
- protected function makeAndPersist()
+ protected function makeAndPersist(): int
{
- $form = new DonationForm([
- 'title' => __('GiveWP Donation Form', 'give'),
- 'status' => DonationFormStatus::PUBLISHED(),
- 'settings' => FormSettings::fromArray([
- 'designId' => 'multi-step',
- 'designSettingsImageUrl' => GIVE_PLUGIN_URL . '/assets/dist/images/admin/onboarding/header-image.jpg',
- 'designSettingsImageStyle' => 'above',
- 'designSettingsImageAlt' => 'GiveWP Onboarding Donation Form',
- ]),
- 'blocks' => (new GenerateDefaultDonationFormBlockCollection())(),
+ $campaign = Campaign::create([
+ 'type' => CampaignType::CORE(),
+ 'title' => __('GiveWP Onboarding', 'give'),
+ 'shortDescription' => '',
+ 'longDescription' => '',
+ 'logo' => '',
+ 'image' => '',
+ 'primaryColor' => '#0b72d9',
+ 'secondaryColor' => '#27ae60',
+ 'goal' => 1000,
+ 'goalType' => CampaignGoalType::AMOUNT(),
+ 'status' => CampaignStatus::ACTIVE(),
]);
- $form->save();
+ $form = DonationForm::find($campaign->defaultFormId);
- $this->settingsRepository->set('form_id', $form->id);
+ if ($form) {
+ $form->title = $campaign->title;
+ $form->status = DonationFormStatus::PUBLISHED();
+ $form->settings->designId = 'multi-step';
+ $form->settings->designSettingsImageUrl = GIVE_PLUGIN_URL . '/assets/dist/images/admin/onboarding/header-image.jpg';
+ $form->settings->designSettingsImageStyle = 'above';
+ $form->settings->designSettingsImageAlt = $campaign->title;
+
+ $form->save();
+ }
+
+ $this->settingsRepository->set('form_id', $campaign->defaultFormId);
$this->settingsRepository->save();
- return $form->id;
+ return $campaign->defaultFormId;
}
}
diff --git a/src/Promotions/WelcomeBanner/resources/js/app/components/Sections/LeftContentSection.tsx b/src/Promotions/WelcomeBanner/resources/js/app/components/Sections/LeftContentSection.tsx
index 098221465f..6db4b435a4 100644
--- a/src/Promotions/WelcomeBanner/resources/js/app/components/Sections/LeftContentSection.tsx
+++ b/src/Promotions/WelcomeBanner/resources/js/app/components/Sections/LeftContentSection.tsx
@@ -29,7 +29,7 @@ export default function LeftContentSection({assets}: LeftContentSectionProps) {
{__('Create a donation form', 'give')}
{__('This is powered by the new Visual Donation Form Builder', 'give')}
-
+
{__('Try the new form builder', 'give')}
diff --git a/src/Revenue/DonationHandler.php b/src/Revenue/DonationHandler.php
index d321a9ff93..1336e6c963 100644
--- a/src/Revenue/DonationHandler.php
+++ b/src/Revenue/DonationHandler.php
@@ -17,6 +17,7 @@ class DonationHandler
/**
* Handle new donation.
*
+ * @unreleased - set campaign id
* @since 2.9.0
*
* @param int $donationId
@@ -24,33 +25,18 @@ class DonationHandler
*/
public function handle($donationId)
{
- /* @var Revenue $revenue */
- $revenue = give(Revenue::class);
-
- $revenue->insert($this->getData($donationId));
- }
-
- /**
- * Get revenue data.
- *
- * @since 2.9.0
- *
- * @param int $donationId
- *
- * @return array
- */
- public function getData($donationId)
- {
- /* @var Revenue $revenue */
$amount = give_donation_amount($donationId);
$currency = give_get_option('currency');
- $money = Money::of($amount, $currency);
$formId = give_get_payment_form_id($donationId);
+ $campaign = give()->campaigns->getByFormId($formId);
- return [
+ $data = [
'donation_id' => $donationId,
- 'form_id' => $formId,
- 'amount' => $money->getMinorAmount(),
+ 'form_id' => $formId,
+ 'amount' => Money::of($amount, $currency)->getMinorAmount(),
+ 'campaign_id' => $campaign ? $campaign->id : null,
];
+
+ give(Revenue::class)->insert($data);
}
}
diff --git a/src/Subscriptions/SubscriptionsAdminPage.php b/src/Subscriptions/SubscriptionsAdminPage.php
index ecb0dfdaac..a1aa799922 100644
--- a/src/Subscriptions/SubscriptionsAdminPage.php
+++ b/src/Subscriptions/SubscriptionsAdminPage.php
@@ -56,6 +56,8 @@ public function loadScripts()
[],
null
);
+
+ wp_enqueue_style('givewp-design-system-foundation');
}
/**
diff --git a/src/Subscriptions/resources/components/SubscriptionsListTable.tsx b/src/Subscriptions/resources/components/SubscriptionsListTable.tsx
index 90d1ffc8bd..6bd98c3ae9 100644
--- a/src/Subscriptions/resources/components/SubscriptionsListTable.tsx
+++ b/src/Subscriptions/resources/components/SubscriptionsListTable.tsx
@@ -24,13 +24,6 @@ declare global {
const API = new ListTableApi(window.GiveSubscriptions);
const filters: Array = [
- {
- name: 'search',
- type: 'search',
- inlineSize: '14rem',
- text: __('Name, Email, or ID', 'give'),
- ariaLabel: __('search donations', 'give'),
- },
{
name: 'form',
type: 'formselect',
@@ -38,6 +31,13 @@ const filters: Array = [
ariaLabel: __('filter donation forms by status', 'give'),
options: window.GiveSubscriptions.forms,
},
+ {
+ name: 'search',
+ type: 'search',
+ inlineSize: '14rem',
+ text: __('Name, Email, or ID', 'give'),
+ ariaLabel: __('search donations', 'give'),
+ },
{
name: 'toggle',
type: 'checkbox',
diff --git a/src/Views/Admin/DashboardWidgets/Reports.php b/src/Views/Admin/DashboardWidgets/Reports.php
index 4cf217e738..f25d87c923 100644
--- a/src/Views/Admin/DashboardWidgets/Reports.php
+++ b/src/Views/Admin/DashboardWidgets/Reports.php
@@ -46,7 +46,11 @@ public function add_dashboard_widget()
}
}
- // Enqueue app scripts
+ /**
+ * Enqueue app scripts
+ *
+ * @unreleased Replace "new form" with "new campaign form" link
+ */
public function enqueue_scripts($base)
{
if ($base !== 'index.php') {
@@ -64,7 +68,7 @@ public function enqueue_scripts($base)
'give-admin-reports-widget-js',
'giveReportsData',
[
- 'newFormUrl' => admin_url('/post-new.php?post_type=give_forms'),
+ 'newFormUrl' => admin_url('edit.php?post_type=give_forms&page=give-campaigns&new=campaign'),
'allTimeStart' => $this->get_all_time_start(),
'currency' => give_get_currency(),
'testMode' => give_is_test_mode(),
diff --git a/src/Views/Components/AdminUI/ModalDialog/style.scss b/src/Views/Components/AdminUI/ModalDialog/style.scss
index 6d6d232745..50db3829a4 100644
--- a/src/Views/Components/AdminUI/ModalDialog/style.scss
+++ b/src/Views/Components/AdminUI/ModalDialog/style.scss
@@ -15,11 +15,12 @@
.givewp-modal-dialog {
position: relative;
- font-family: 'Open Sans', sans-serif;
- max-width: 32rem;
+ font-family: 'Inter', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ max-width: 35rem;
width: 100%;
border-radius: var(--givewp-rounded-4);
- background-color: #fff;
+ background-color: var(--givewp-shades-white);
box-shadow: 0 0.25rem 0.5rem 0 rgba(14, 14, 14, 0.15);
animation: appear 112ms ease-in 0s;
color: var(--givewp-grey-700);
@@ -27,19 +28,21 @@
.givewp-modal-header {
display: flex;
align-self: stretch;
- font-size: 1rem;
- font-weight: 700;
+ justify-content: flex-start;
+ font-size: 1.25rem;
+ line-height: 2rem;
+ font-weight: 600;
align-items: center;
- padding: var(--givewp-spacing-4) var(--givewp-spacing-5);
- background-color: #fff;
+ padding: var(--givewp-spacing-4) var(--givewp-spacing-6);
+ background-color: var(--givewp-shades-white);
border-top-left-radius: var(--givewp-rounded-6);
border-top-right-radius: var(--givewp-rounded-6);
border-bottom: 1px solid var(--givewp-grey-50);
- color: var(--givewp-grey-900);
+ color: var(--givewp-neutral-900);
}
.givewp-modal-icon-header {
- margin-right: 10px;
+ margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
@@ -58,12 +61,12 @@
position: absolute;
cursor: pointer;
z-index: 999;
- fill: var(--givewp-grey-500);
+ fill: var(--givewp-neutral-500);
}
.givewp-modal-close {
- top: var(--givewp-spacing-4);
- right: var(--givewp-spacing-5);
+ top: var(--givewp-spacing-5);
+ right: var(--givewp-spacing-6);
}
.givewp-modal-close-headless {
diff --git a/src/Views/Components/ListTable/BulkActions/BulkActionSelect.module.scss b/src/Views/Components/ListTable/BulkActions/BulkActionSelect.module.scss
index 17274c4fd2..f7dd2658a3 100644
--- a/src/Views/Components/ListTable/BulkActions/BulkActionSelect.module.scss
+++ b/src/Views/Components/ListTable/BulkActions/BulkActionSelect.module.scss
@@ -4,5 +4,5 @@
flex-direction: row;
align-items: center;
justify-content: space-between;
- column-gap: 1rem;
+ column-gap: 0.4rem;
}
diff --git a/src/Views/Components/ListTable/BulkActions/BulkActionSelect.tsx b/src/Views/Components/ListTable/BulkActions/BulkActionSelect.tsx
index bdd4baa203..04f0acbaad 100644
--- a/src/Views/Components/ListTable/BulkActions/BulkActionSelect.tsx
+++ b/src/Views/Components/ListTable/BulkActions/BulkActionSelect.tsx
@@ -19,7 +19,7 @@ export const BulkActionSelect = ({bulkActions = null, selectedState, showModal,
};
return (
-
);
};
@@ -206,33 +247,58 @@ export default function ListTablePage({
return (
<>
-
- {banner && (
-
+ {contentMode ? (
+ <>
+
+
+
+ {filterSettings.map((filter) => (
+
+ ))}
+
+
+ >
+ ) : (
+ <>
+
+ {banner && }
+
+
+
+ {filterSettings.map((filter) => (
+
+ ))}
+
+
+ >
)}
-
- {filterSettings.map((filter) => (
-
- ))}
-
+
-
+ {contentMode && children ? <>{children}> :
}
(dialog.current = instance)}
- title={modalContent.label}
+ title={
+ <>
+ {modalContent?.type === 'danger' && }
+ {modalContent?.label}
+ >
+ }
titleId={styles.modalTitle}
classNames={{
container: styles.container,
@@ -286,7 +357,7 @@ export default function ListTablePage({
await mutate();
}}
>
- {__('Confirm', 'give')}
+ {modalContent?.confirmButtonText ?? __('Confirm', 'give')}
diff --git a/src/Views/Components/ListTable/ListTableRows/index.tsx b/src/Views/Components/ListTable/ListTableRows/index.tsx
index 82322c74ca..9c54bd873c 100644
--- a/src/Views/Components/ListTable/ListTableRows/index.tsx
+++ b/src/Views/Components/ListTable/ListTableRows/index.tsx
@@ -75,7 +75,7 @@ export default function ListTableRows({columns, data, isLoading, rowActions, set
return (
{columnFilter.length > 0 ? (
- columnFilter[0].filter(item, column)
+ columnFilter[0].filter(item, column, data)
) : (
)}
diff --git a/src/Views/Components/ListTable/api.ts b/src/Views/Components/ListTable/api.ts
index 17f2fa3c60..ca9d6d3845 100644
--- a/src/Views/Components/ListTable/api.ts
+++ b/src/Views/Components/ListTable/api.ts
@@ -8,7 +8,7 @@ export default class ListTableApi {
private readonly headers: {'X-WP-Nonce': string; 'Content-Type': string};
private readonly swrOptions;
- constructor({apiNonce, apiRoot, preload = null}) {
+ constructor({apiNonce, apiRoot, preload = null, swrConfig = {}}) {
this.controller = null;
this.apiRoot = apiRoot;
this.headers = {
@@ -17,6 +17,7 @@ export default class ListTableApi {
};
this.swrOptions = {
use: [lagData],
+ ...swrConfig,
onErrorRetry: (error, key, config, revalidate, {retryCount}) => {
//don't retry if we cancelled the initial request
if (error.name == 'AbortError') return;
diff --git a/src/Views/Components/ListTable/hooks/useDebounce.ts b/src/Views/Components/ListTable/hooks/useDebounce.ts
index abb52fa08a..80958ba68c 100644
--- a/src/Views/Components/ListTable/hooks/useDebounce.ts
+++ b/src/Views/Components/ListTable/hooks/useDebounce.ts
@@ -1,5 +1,5 @@
import {useEffect, useRef} from 'react';
-import {debounce} from 'lodash';
+import debounce from 'lodash.debounce';
export default function useDebounce(callback) {
const debouncedCallback = useRef(debounce(callback, 500)).current;
diff --git a/src/Views/Components/Spinner/style.module.scss b/src/Views/Components/Spinner/Spinner.module.scss
similarity index 100%
rename from src/Views/Components/Spinner/style.module.scss
rename to src/Views/Components/Spinner/Spinner.module.scss
diff --git a/src/Views/Components/Spinner/index.js b/src/Views/Components/Spinner/index.js
index c149a28da6..9a96386c66 100644
--- a/src/Views/Components/Spinner/index.js
+++ b/src/Views/Components/Spinner/index.js
@@ -1,7 +1,7 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
-import styles from './style.module.scss';
+import styles from './Spinner.module.scss';
const Spinner = ({size = 'small', ...rest}) => {
const spinnerClasses = classNames({
diff --git a/tests/Feature/Controllers/CampaignsRequestControllerTest.php b/tests/Feature/Controllers/CampaignsRequestControllerTest.php
new file mode 100644
index 0000000000..17ebf88ed3
--- /dev/null
+++ b/tests/Feature/Controllers/CampaignsRequestControllerTest.php
@@ -0,0 +1,114 @@
+create();
+ $campaignViewModel = new CampaignViewModel($campaign);
+
+ $request = $this->getMockRequest(WP_REST_Server::READABLE);
+ $request->set_param('id', $campaign->id);
+
+ $response = (new CampaignRequestController())->getCampaign($request);
+
+ $this->assertInstanceOf(WP_REST_Response::class, $response);
+ $this->assertSame(
+ $response->data,
+ $campaignViewModel->exports()
+ );
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpdateShouldReturnUpdatedCampaignData()
+ {
+ $campaign = Campaign::factory()->create();
+ $campaign->title = 'Updated Campaign Title';
+
+ $campaignViewModel = new CampaignViewModel($campaign);
+
+ $request = $this->getMockRequest(WP_REST_Server::CREATABLE);
+ $request->set_param('id', $campaign->id);
+ $request->set_param('title', $campaign->title);
+
+ $response = (new CampaignRequestController())->updateCampaign($request);
+
+ $this->assertInstanceOf(WP_REST_Response::class, $response);
+ $this->assertSame(
+ $response->data,
+ $campaignViewModel->exports()
+ );
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpdateShouldFailIfCampaignDoesNotExist()
+ {
+ $request = $this->getMockRequest(WP_REST_Server::CREATABLE);
+ $request->set_param('id', 99);
+
+ $response = (new CampaignRequestController())->updateCampaign($request);
+
+ $this->assertInstanceOf(WP_Error::class, $response);
+ $this->assertSame('campaign_not_found', $response->get_error_code());
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testShowShouldFailIfCampaignDoesNotExist()
+ {
+ $request = $this->getMockRequest(WP_REST_Server::READABLE);
+ $request->set_param('id', 99);
+
+ $response = (new CampaignRequestController())->getCampaign($request);
+
+ $this->assertInstanceOf(WP_Error::class, $response);
+ $this->assertSame('campaign_not_found', $response->get_error_code());
+ }
+
+
+ /**
+ *
+ * @unreleased
+ */
+ public function getMockRequest(string $method): WP_REST_Request
+ {
+ return new WP_REST_Request(
+ $method,
+ CampaignRoute::NAMESPACE . '/' . CampaignRoute::CAMPAIGN
+ );
+ }
+}
+
diff --git a/tests/Feature/FormMigration/Controllers/TestMigrationController.php b/tests/Feature/FormMigration/Controllers/TestMigrationController.php
index a2f628632e..74d10d575c 100644
--- a/tests/Feature/FormMigration/Controllers/TestMigrationController.php
+++ b/tests/Feature/FormMigration/Controllers/TestMigrationController.php
@@ -28,6 +28,8 @@ public function testShouldMigrateFormV2ToV3(): void
{
$formV2 = $this->createSimpleDonationForm();
+ $this->createCampaignForDonationForm($formV2->id);
+
$request = $this->getMockRequest(WP_REST_Server::CREATABLE);
$controller = new MigrationController($request);
diff --git a/tests/Unit/Campaigns/Actions/AssignDuplicatedFormToCampaignTest.php b/tests/Unit/Campaigns/Actions/AssignDuplicatedFormToCampaignTest.php
new file mode 100644
index 0000000000..8ae76793af
--- /dev/null
+++ b/tests/Unit/Campaigns/Actions/AssignDuplicatedFormToCampaignTest.php
@@ -0,0 +1,48 @@
+create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ // See give/src/DonationForms/V2/Endpoints/FormActions.php:131
+ require_once(GIVE_PLUGIN_DIR . '/includes/admin/forms/class-give-form-duplicator.php');
+ $duplicatedFormID = \Give_Form_Duplicator::handler($form->id);
+ $duplicatedFormCampaign = Campaign::findByFormId($duplicatedFormID);
+
+ $this->assertEquals($campaign->id, $duplicatedFormCampaign->id);
+ }
+
+ public function testDuplicatingFormWithoutCampaignDoesNotCauseFatalError()
+ {
+ $form = DonationForm::factory()->create();
+
+ // See give/src/DonationForms/V2/Endpoints/FormActions.php:131
+ require_once(GIVE_PLUGIN_DIR . '/includes/admin/forms/class-give-form-duplicator.php');
+ \Give_Form_Duplicator::handler($form->id);
+
+ // Prevent fatal error when duplicating form without campaign
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Campaigns/CampaignDonationQueryTest.php b/tests/Unit/Campaigns/CampaignDonationQueryTest.php
new file mode 100644
index 0000000000..a0664c7300
--- /dev/null
+++ b/tests/Unit/Campaigns/CampaignDonationQueryTest.php
@@ -0,0 +1,170 @@
+create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ ]);
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ ]);
+
+ $query = new CampaignDonationQuery($campaign);
+
+ $this->assertEquals(2, $query->countDonations());
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testSumCampaignDonations()
+ {
+ $campaign = Campaign::factory()->create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ ]);
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ ]);
+
+ $query = new CampaignDonationQuery($campaign);
+
+ $this->assertEquals(20.00, $query->sumIntendedAmount());
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testCountCampaignDonors()
+ {
+ $campaign = Campaign::factory()->create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ ]);
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ ]);
+
+ $query = new CampaignDonationQuery($campaign);
+
+ $this->assertEquals(2, $query->countDonors());
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testCoalesceIntendedAmountWithoutRecoveredFees()
+ {
+ $campaign = Campaign::factory()->create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ $donation = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1070, 'USD'),
+ ]);
+ give_update_meta($donation->id, '_give_fee_donation_amount', 10.00);
+
+ $query = new CampaignDonationQuery($campaign);
+
+ $this->assertEquals(10.00, $query->sumIntendedAmount());
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testGetDonationsByDate()
+ {
+ $campaign = Campaign::factory()->create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ $donations = [
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('2021-01-01 00:00:00'),
+ ]),
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('2021-01-02 00:00:00'),
+ ]),
+ Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('2021-01-02 00:00:00'),
+ ]),
+ ];
+
+ foreach($donations as $donation) {
+ give_update_meta($donation->id, '_give_completed_date', $donation->createdAt->format('Y-m-d H:i:s'));
+ }
+
+ $query = new CampaignDonationQuery($campaign);
+
+ $this->assertEquals([
+ (object) ['date' => '2021-01-01', 'amount' => 10.00],
+ (object) ['date' => '2021-01-02', 'amount' => 20.00],
+ ], $query->getDonationsByDay());
+ }
+}
diff --git a/tests/Unit/Campaigns/DataTransferObjects/CampaignGoalDataTest.php b/tests/Unit/Campaigns/DataTransferObjects/CampaignGoalDataTest.php
new file mode 100644
index 0000000000..b9922fa40f
--- /dev/null
+++ b/tests/Unit/Campaigns/DataTransferObjects/CampaignGoalDataTest.php
@@ -0,0 +1,35 @@
+create([
+ 'goal' => 0
+ ])
+ );
+
+ $this->assertEquals(0.00, $goalData->percentage);
+ }
+}
diff --git a/tests/Unit/Campaigns/Migrations/MigrateFormsToCampaignFormsTest.php b/tests/Unit/Campaigns/Migrations/MigrateFormsToCampaignFormsTest.php
new file mode 100644
index 0000000000..de122911c7
--- /dev/null
+++ b/tests/Unit/Campaigns/Migrations/MigrateFormsToCampaignFormsTest.php
@@ -0,0 +1,151 @@
+create();
+ $migration = new MigrateFormsToCampaignForms();
+
+ $migration->run();
+
+ $relationship = DB::table('give_campaign_forms')->where('form_id', $form->id)->get();
+
+ $this->assertNotNull(Campaign::find($relationship->campaign_id));
+ $this->assertEquals($form->id, $relationship->form_id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCreatesParentCampaignForOptionBasedDonationForm()
+ {
+ $formId = $this->factory()->post->create(
+ [
+ 'post_title' => 'Test Form',
+ 'post_type' => 'give_forms',
+ 'post_status' => 'publish',
+ ]
+ );
+
+ $migration = new MigrateFormsToCampaignForms();
+
+ $migration->run();
+
+ $relationship = DB::table('give_campaign_forms')->where('form_id', $formId)->get();
+
+ $this->assertNotNull(Campaign::find($relationship->campaign_id));
+ $this->assertEquals($formId, $relationship->form_id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testExistingPeerToPeerCampaignFormsAreNotMigrated()
+ {
+ $form = DonationForm::factory()->create();
+ DB::table('give_campaigns')->insert([
+ 'form_id' => $form->id,
+ ]);
+
+ $migration = new MigrateFormsToCampaignForms();
+ $migration->run();
+
+ $relationship = DB::table('give_campaign_forms')->where('form_id', $form->id)->get();
+
+ $this->assertNull($relationship);
+ $this->assertEquals(1, DB::table('give_campaigns')->count());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpgradedFormsAreNotMigrated()
+ {
+ $upgradedForm = DonationForm::factory()->create([
+ 'status' => DonationFormStatus::UPGRADED(),
+ ]);
+
+ $newForm = DonationForm::factory()->create([
+ 'status' => DonationFormStatus::PUBLISHED(),
+ ]);
+
+ Give()->form_meta->update_meta($newForm->id, 'migratedFormId', $upgradedForm->id);
+
+
+ $migration = new MigrateFormsToCampaignForms();
+ $migration->run();
+
+ $campaign = Campaign::findByFormId($upgradedForm->id);
+
+ $this->assertNotNull($campaign);
+ $this->assertEquals(0, DB::table('give_campaigns')->where('form_id', $upgradedForm->id)->count());
+ $this->assertEquals(1, DB::table('give_campaigns')->where('form_id', $newForm->id)->count());
+ $this->assertEquals(2, DB::table('give_campaign_forms')->where('campaign_id', $campaign->id)->count());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMigratedFormsAreDefault()
+ {
+ $form = DonationForm::factory()->create();
+
+ $migration = new MigrateFormsToCampaignForms();
+ $migration->run();
+
+ $campaign = Campaign::findByFormId($form->id);
+
+ $this->assertEquals($form->id, $campaign->defaultFormId);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpgradedFormsAreNotDefault()
+ {
+ $form1 = DonationForm::factory()->create([
+ 'status' => DonationFormStatus::UPGRADED(),
+ ]);
+ $form2 = DonationForm::factory()->create();
+ give_update_meta($form2->id, 'migratedFormId', $form1->id);
+
+ $migration = new MigrateFormsToCampaignForms();
+ $migration->run();
+
+ $campaign = Campaign::findByFormId($form2->id);
+
+ $this->assertNotEquals($form1->id, $campaign->defaultFormId);
+ }
+}
diff --git a/tests/Unit/Campaigns/Models/CampaignModelTest.php b/tests/Unit/Campaigns/Models/CampaignModelTest.php
new file mode 100644
index 0000000000..239612b666
--- /dev/null
+++ b/tests/Unit/Campaigns/Models/CampaignModelTest.php
@@ -0,0 +1,61 @@
+create();
+ $campaign = Campaign::find($mockCampaign->id);
+
+ $this->assertInstanceOf(Campaign::class, $campaign);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testCampaignHasManyForms()
+ {
+ $campaign = Campaign::factory()->create();
+ $form1 = DonationForm::factory()->create();
+ $form2 = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form1->id, 'campaign_id' => $campaign->id]);
+ $db->insert(['form_id' => $form2->id, 'campaign_id' => $campaign->id]);
+
+ $this->assertEquals(3, $campaign->forms()->count());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCampaignHasDefaultForm()
+ {
+ /** @var Campaign $campaign */
+ $campaign = Campaign::factory()->create();
+ $newDefaultForm = DonationForm::factory()->create();
+ give(CampaignRepository::class)->addCampaignForm($campaign, $newDefaultForm->id, true);
+
+ $this->assertEquals($newDefaultForm->id, $campaign->defaultFormId);
+ }
+}
diff --git a/tests/Unit/Campaigns/Models/CampaignPageTest.php b/tests/Unit/Campaigns/Models/CampaignPageTest.php
new file mode 100644
index 0000000000..d122c84bbb
--- /dev/null
+++ b/tests/Unit/Campaigns/Models/CampaignPageTest.php
@@ -0,0 +1,32 @@
+create();
+ $campaignPage = CampaignPage::create([
+ 'campaignId' => $campaign->id,
+ ]);
+
+ $campaignPageFresh = CampaignPage::find($campaignPage->id);
+
+ $this->assertInstanceOf(CampaignPage::class, $campaignPageFresh);
+ $this->assertEquals($campaignPage->id, $campaignPageFresh->id);
+ }
+}
diff --git a/tests/Unit/Campaigns/Repositories/CampaignPageRepositoryTest.php b/tests/Unit/Campaigns/Repositories/CampaignPageRepositoryTest.php
new file mode 100644
index 0000000000..def14180ba
--- /dev/null
+++ b/tests/Unit/Campaigns/Repositories/CampaignPageRepositoryTest.php
@@ -0,0 +1,146 @@
+create();
+ $campaignPage = CampaignPage::create([
+ 'campaignId' => $campaign->id,
+ ]);
+
+ $campaignPageFresh = give(CampaignPageRepository::class)->getById($campaignPage->id);
+
+ $this->assertInstanceOf(CampaignPage::class, $campaignPageFresh);
+ $this->assertEquals($campaignPage->id, $campaignPageFresh->id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testInsertShouldAddCampaignPageToDatabase()
+ {
+ $campaign = Campaign::factory()->create();
+ $campaignPage = new CampaignPage([
+ 'campaignId' => $campaign->id,
+ ]);
+
+ give(CampaignPageRepository::class)->insert($campaignPage);
+
+ $campaignPageFresh = give(CampaignPageRepository::class)->getById($campaignPage->id);
+
+ $this->assertEquals($campaignPage->getAttributes(), $campaignPageFresh->getAttributes());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCampaignPageInsertShouldFailValidationWhenMissingKeyAndThrowException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $campaignPageMissingCampaignId = new CampaignPage([
+ // Note: `campaignId` intentionally not set.
+ ]);
+
+ (new CampaignPageRepository())->insert($campaignPageMissingCampaignId);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCampaignPageUpdateShouldFailValidationWhenMissingKeyAndThrowException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $campaignPageMissingCampaignId = new CampaignPage([
+ // Note: `campaignId` intentionally not set.
+ ]);
+
+ (new CampaignPageRepository())->update($campaignPageMissingCampaignId);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCampaignPageUpdateShouldUpdateCampaignPageValuesInTheDatabase()
+ {
+ $campaign1 = Campaign::factory()->create();
+ $campaignPage = CampaignPage::create([
+ 'campaignId' => $campaign1->id,
+ ]);
+
+ $campaign2 = Campaign::factory()->create();
+ $campaignPage->campaignId = $campaign2->id;
+ give(CampaignPageRepository::class)->update($campaignPage);
+
+ $campaignPageFresh = give(CampaignPageRepository::class)->getById($campaignPage->id);
+
+ $this->assertEquals($campaign2->id, $campaignPageFresh->campaignId);
+ $this->assertNotEquals($campaign1->id, $campaignPageFresh->campaignId);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCampaignPageDeleteShouldRemoveCampaignPageFromTheDatabase()
+ {
+ $campaign = Campaign::factory()->create();
+ $campaignPage = CampaignPage::create([
+ 'campaignId' => $campaign->id,
+ ]);
+
+ give(CampaignPageRepository::class)->delete($campaignPage);
+
+ $campaignPageFresh = CampaignPage::find($campaignPage->id);
+
+ $this->assertNull($campaignPageFresh);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testCampaignPageShouldBeCreatedWithCampaignTitle()
+ {
+ $campaign = Campaign::factory()->create();
+ $campaignPage = CampaignPage::create([
+ 'campaignId' => $campaign->id,
+ ]);
+
+ $this->assertEquals($campaign->title, get_the_title($campaignPage->id));
+ }
+}
diff --git a/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php b/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php
new file mode 100644
index 0000000000..7f704f3b70
--- /dev/null
+++ b/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php
@@ -0,0 +1,451 @@
+create();
+ $repository = new CampaignRepository();
+
+ $campaign = $repository->getById($campaignFactory->id);
+
+ $this->assertInstanceOf(Campaign::class, $campaignFactory);
+ $this->assertEquals($campaignFactory->id, $campaign->id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testGetByFormIdShouldReturnCampaign()
+ {
+ $campaignFactory = Campaign::factory()->create();
+ $form = DonationForm::factory()->create();
+
+ $repository = new CampaignRepository();
+ $repository->addCampaignForm($campaignFactory, $form->id);
+
+ $campaign = $repository->getByFormId($form->id);
+
+ $this->assertInstanceOf(Campaign::class, $campaignFactory);
+ $this->assertEquals($campaignFactory->id, $campaign->id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testInsertShouldAddCampaignToDatabase()
+ {
+ $campaignFactory = new Campaign(Campaign::factory()->definition());
+ $repository = new CampaignRepository();
+
+ $repository->insert($campaignFactory);
+
+ $campaign = $repository->getById($campaignFactory->id);
+
+ $this->assertEquals($campaign->getAttributes(), $campaignFactory->getAttributes());
+ }
+
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testInsertShouldFailValidationWhenMissingKeyAndThrowException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $currentDate = Temporal::getCurrentDateTime();
+
+ $campaignMissingStatus = new Campaign([
+ 'pageId' => 1,
+ 'type' => CampaignType::CORE(),
+ 'title' => __('GiveWP Campaign', 'give'),
+ 'shortDescription' => __('Campaign short description', 'give'),
+ 'longDescription' => __('Campaign long description', 'give'),
+ 'goal' => 10000000,
+ 'logo' => '',
+ 'image' => '',
+ 'primaryColor' => '#28C77B',
+ 'secondaryColor' => '#FFA200',
+ 'createdAt' => Temporal::withoutMicroseconds($currentDate),
+ 'startDate' => Temporal::withoutMicroseconds($currentDate),
+ 'endDate' => Temporal::withoutMicroseconds($currentDate->modify('+1 day')),
+ ]);
+
+ (new CampaignRepository())->insert($campaignMissingStatus);
+ }
+
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpdateShouldFailValidationWhenMissingKeyAndThrowException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $currentDate = Temporal::getCurrentDateTime();
+
+ $campaignMissingStatus = new Campaign([
+ 'pageId' => 1,
+ 'type' => CampaignType::CORE(),
+ 'title' => __('GiveWP Campaign', 'give'),
+ 'shortDescription' => __('Campaign short description', 'give'),
+ 'longDescription' => __('Campaign long description', 'give'),
+ 'goal' => 10000000,
+ 'logo' => '',
+ 'image' => '',
+ 'primaryColor' => '#28C77B',
+ 'secondaryColor' => '#FFA200',
+ 'createdAt' => Temporal::withoutMicroseconds($currentDate),
+ 'startDate' => Temporal::withoutMicroseconds($currentDate),
+ 'endDate' => Temporal::withoutMicroseconds($currentDate->modify('+1 day')),
+ ]);
+
+ (new CampaignRepository())->update($campaignMissingStatus);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpdateShouldUpdateCampaignValuesInTheDatabase()
+ {
+ $repository = new CampaignRepository();
+ $campaignFactory = Campaign::factory()->create();
+
+ // update campaign
+ $campaignFactory->title = 'Updated campaign title';
+ $campaignFactory->shortDescription = 'Updated short description';
+ $campaignFactory->status = CampaignStatus::INACTIVE();
+
+ $repository->update($campaignFactory);
+
+ $campaign = $repository->prepareQuery()
+ ->where('id', $campaignFactory->id)
+ ->get();
+
+ $this->assertNotEquals(CampaignStatus::ACTIVE()->getValue(), $campaign->status->getValue());
+ $this->assertEquals('Updated campaign title', $campaign->title);
+ $this->assertEquals('Updated short description', $campaign->shortDescription);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testDeleteShouldRemoveCampaignFromTheDatabase()
+ {
+ $repository = new CampaignRepository();
+ $campaignFactory = Campaign::factory()->create();
+
+ $repository->delete($campaignFactory);
+
+ $campaign = $repository->prepareQuery()
+ ->where('id', $campaignFactory->id)
+ ->get();
+
+ $this->assertNull($campaign);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testPeerToPeerCampaignsAreExcludedFromQuery()
+ {
+ $repository = new CampaignRepository();
+
+ $p2p_campaign = Campaign::factory()->create([
+ 'type' => CampaignType::PEER_TO_PEER(),
+ ]);
+
+ $this->assertNull($repository->getById($p2p_campaign->id));
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testPeerToPeerCampaignsAreExcludedFromCount()
+ {
+ $repository = new CampaignRepository();
+
+ Campaign::factory()->create([
+ 'type' => CampaignType::CORE(),
+ ]);
+
+ Campaign::factory()->create([
+ 'type' => CampaignType::PEER_TO_PEER(),
+ ]);
+
+ $this->assertEquals(1, $repository->prepareQuery()->count());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testAddCampaignFormShouldAddNewFormToCampaign()
+ {
+ /** @var Campaign $campaign */
+ $campaign = Campaign::factory()->create();
+
+ /** @var DonationForm $form */
+ $newForm = DonationForm::factory()->create();
+
+ $repository = new CampaignRepository();
+ $repository->addCampaignForm($campaign, $newForm->id);
+
+ $campaignReturn = $repository->getByFormId($newForm->id);
+
+ $this->assertEquals($campaignReturn->getAttributes(), $campaign->getAttributes());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testAddCampaignFormShouldAddNewDefaultFormToCampaign()
+ {
+ /** @var Campaign $campaign */
+ $campaign = Campaign::factory()->create();
+
+ /** @var DonationForm $form */
+ $newDefaultForm = DonationForm::factory()->create();
+
+ $repository = new CampaignRepository();
+ $repository->addCampaignForm($campaign, $newDefaultForm->id, true);
+
+ //Re-fetch
+ $campaign = $campaign::find($campaign->id);
+
+ $this->assertEquals($newDefaultForm->id, $campaign->defaultForm()->id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testUpdateCampaignFormShouldUpdateDefaultFormToCampaign()
+ {
+ /** @var Campaign $campaign */
+ $campaign = Campaign::factory()->create();
+
+ /** @var DonationForm $form1 */
+ $form1 = DonationForm::factory()->create();
+
+ /** @var DonationForm $form2 */
+ $form2 = DonationForm::factory()->create();
+
+ $repository = new CampaignRepository();
+ $repository->addCampaignForm($campaign, $form1->id, true);
+ $repository->addCampaignForm($campaign, $form2->id);
+ $repository->updateDefaultCampaignForm($campaign, $form2->id);
+
+ //Re-fetch
+ $campaign = Campaign::find($campaign->id);
+
+ $this->assertEquals($form2->id, $campaign->defaultForm()->id);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMergeCampaignsShouldReturnTrue()
+ {
+ /** @var Campaign $campaign1 */
+ $campaign1 = Campaign::factory()->create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ $repository = new CampaignRepository();
+ $merged = $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2);
+
+ $this->assertTrue($merged);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMergeCampaignsShouldMigrateFormsToDestinationCampaign()
+ {
+ /** @var Campaign $campaign1 */
+ $campaign1 = Campaign::factory()->create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ $formCampaign1 = $campaign1->defaultForm();
+ $formCampaign2 = $campaign2->defaultForm();
+
+ $repository = new CampaignRepository();
+ $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2);
+
+ //Re-fetch
+ $destinationCampaign = Campaign::find($destinationCampaign->id);
+
+ $campaignReturn = $repository->getByFormId($formCampaign1->id);
+ $this->assertEquals($campaignReturn->getAttributes(), $destinationCampaign->getAttributes());
+
+ $campaignReturn = $repository->getByFormId($formCampaign2->id);
+ $this->assertEquals($campaignReturn->getAttributes(), $destinationCampaign->getAttributes());
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMergeCampaignsShouldMigrateRevenueToDestinationCampaign()
+ {
+ /** @var Campaign $campaign1 */
+ $campaign1 = Campaign::factory()->create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ /** @var Donation $donationCampaign1 */
+ $donationCampaign1 = Donation::factory()->create(['formId' => $campaign1->defaultForm()->id]);
+ /** @var Donation $donationCampaign2 */
+ $donationCampaign2 = Donation::factory()->create(['formId' => $campaign2->defaultForm()->id]);
+
+ // TODO Remove this updates clauses when the logic to automatically set the campaign_id in the revenue table entries for new donations is implemented
+ DB::query(
+ DB::prepare('UPDATE ' . DB::prefix('give_revenue') . ' SET campaign_id = %d WHERE donation_id = %d',
+ [
+ $campaign1->id,
+ $donationCampaign1->id,
+ ])
+ );
+ DB::query(
+ DB::prepare('UPDATE ' . DB::prefix('give_revenue') . ' SET campaign_id = %d WHERE donation_id = %d',
+ [
+ $campaign2->id,
+ $donationCampaign2->id,
+ ])
+ );
+
+ $repository = new CampaignRepository();
+ $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2);
+
+ $revenueEntry = DB::table('give_revenue')->where('donation_id', $donationCampaign1->id)->get();
+ $this->assertEquals($destinationCampaign->id, $revenueEntry->campaign_id);
+
+ $revenueEntry = DB::table('give_revenue')->where('donation_id', $donationCampaign2->id)->get();
+ $this->assertEquals($destinationCampaign->id, $revenueEntry->campaign_id);
+ }
+
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMergeCampaignsShouldDeleteMergedCampaigns()
+ {
+ /** @var Campaign $campaign1 */
+ $campaign1 = Campaign::factory()->create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ $repository = new CampaignRepository();
+ $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2);
+
+ $this->assertNull(Campaign::find($campaign1->id));
+ $this->assertNull(Campaign::find($campaign2->id));
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMergeCampaignsShouldKeepDefaultFormFromDestinationCampaign()
+ {
+ /** @var Campaign $campaign1 */
+ $campaign1 = Campaign::factory()->create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ $defaultFormBeforeMerge = $destinationCampaign->defaultForm();
+
+ $repository = new CampaignRepository();
+ $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2);
+
+ //Re-fetch
+ $destinationCampaign = Campaign::find($destinationCampaign->id);
+
+ $this->assertEquals($defaultFormBeforeMerge->id, $destinationCampaign->defaultForm()->id);
+ }
+
+ /**
+ * @unreleased
+ * @throws Exception
+ */
+ public function testUpdateCampaignShouldAllowNullableEndDate(): void
+ {
+ $repository = new CampaignRepository();
+ $campaignFactory = Campaign::factory()->create();
+
+ $campaignFactory->endDate = null;
+
+ $repository->update($campaignFactory);
+
+ $campaign = $repository->getById($campaignFactory->id);
+
+ $this->assertNull($campaign->endDate);
+ }
+}
diff --git a/tests/Unit/Campaigns/Routes/GetCampaignStatisticsTest.php b/tests/Unit/Campaigns/Routes/GetCampaignStatisticsTest.php
new file mode 100644
index 0000000000..a73049c397
--- /dev/null
+++ b/tests/Unit/Campaigns/Routes/GetCampaignStatisticsTest.php
@@ -0,0 +1,120 @@
+create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ $donation1 = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('-35 days'),
+ ]);
+ give_update_meta($donation1->id, '_give_completed_date', $donation1->createdAt->format('Y-m-d H:i:s'));
+
+ $donation2 = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('-5 days'),
+ ]);
+ give_update_meta($donation2->id, '_give_completed_date', $donation2->createdAt->format('Y-m-d H:i:s'));
+
+ $donation3 = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('now'),
+ ]);
+ give_update_meta($donation3->id, '_give_completed_date', $donation3->createdAt->format('Y-m-d H:i:s'));
+
+ $request = new WP_REST_Request('GET', "/give-api/v2/campaigns/$campaign->id/statistics");
+ $request->set_param('id', $campaign->id);
+
+ $route = new GetCampaignStatistics;
+ $response = $route->handleRequest($request);
+
+ $this->assertEquals(3, $response->data[0]['donorCount']);
+ $this->assertEquals(3, $response->data[0]['donationCount']);
+ $this->assertEquals(30, $response->data[0]['amountRaised']);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testReturnsPeriodStatisticsWithPreviousPeriod()
+ {
+ $campaign = Campaign::factory()->create();
+ $form = DonationForm::factory()->create();
+
+ $db = DB::table('give_campaign_forms');
+ $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);
+
+ $donation1 = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('-35 days'),
+ ]);
+ give_update_meta($donation1->id, '_give_completed_date', $donation1->createdAt->format('Y-m-d H:i:s'));
+
+ $donation2 = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('-5 days'),
+ ]);
+ give_update_meta($donation2->id, '_give_completed_date', $donation2->createdAt->format('Y-m-d H:i:s'));
+
+ $donation3 = Donation::factory()->create([
+ 'formId' => $form->id,
+ 'status' => DonationStatus::COMPLETE(),
+ 'amount' => new Money(1000, 'USD'),
+ 'createdAt' => new DateTime('now'),
+ ]);
+ give_update_meta($donation3->id, '_give_completed_date', $donation3->createdAt->format('Y-m-d H:i:s'));
+
+ $request = new WP_REST_Request('GET', "/give-api/v2/campaigns/$campaign->id/statistics");
+ $request->set_param('id', $campaign->id);
+ $request->set_param('rangeInDays', 30);
+
+ $route = new GetCampaignStatistics;
+ $response = $route->handleRequest($request);
+
+ $this->assertEquals(2, $response->data[0]['donorCount']);
+ $this->assertEquals(2, $response->data[0]['donationCount']);
+ $this->assertEquals(20, $response->data[0]['amountRaised']);
+
+ $this->assertEquals(1, $response->data[1]['donorCount']);
+ $this->assertEquals(1, $response->data[1]['donationCount']);
+ $this->assertEquals(10, $response->data[1]['amountRaised']);
+ }
+}
diff --git a/tests/Unit/Campaigns/Routes/MergeCampaignsTest.php b/tests/Unit/Campaigns/Routes/MergeCampaignsTest.php
new file mode 100644
index 0000000000..4da13b0c44
--- /dev/null
+++ b/tests/Unit/Campaigns/Routes/MergeCampaignsTest.php
@@ -0,0 +1,80 @@
+create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ $request = new WP_REST_Request('PUT', "/give-api/v2/campaigns/$destinationCampaign->id/merge");
+ $request->set_query_params(
+ [
+ 'id' => $destinationCampaign->id,
+ 'campaignsToMergeIds' => [$campaign1->id, $campaign2->id],
+ ]
+ );
+
+ $response = $this->dispatchRequest($request);
+ $errorCode = $response->get_status();
+
+ $this->assertEquals(401, $errorCode);
+ }
+
+ /**
+ * @unreleased
+ *
+ * @throws Exception
+ */
+ public function testMergeCampaignsRouteShouldReturnTrueForAdminUsers()
+ {
+ /** @var Campaign $campaign1 */
+ $campaign1 = Campaign::factory()->create();
+ /** @var Campaign $campaign2 */
+ $campaign2 = Campaign::factory()->create();
+ /** @var Campaign $destinationCampaign */
+ $destinationCampaign = Campaign::factory()->create();
+
+ $newAdminUser = $this->factory()->user->create(
+ [
+ 'role' => 'administrator',
+ 'user_login' => 'admin38974238473824',
+ 'user_pass' => 'admin38974238473824',
+ 'user_email' => 'admin38974238473824@test.com',
+ ]
+ );
+ wp_set_current_user($newAdminUser);
+
+ $request = new WP_REST_Request('PUT', "/give-api/v2/campaigns/$destinationCampaign->id/merge");
+ $request->set_query_params(
+ [
+ 'id' => $destinationCampaign->id,
+ 'campaignsToMergeIds' => [$campaign1->id, $campaign2->id],
+ ]
+ );
+
+ $response = $this->dispatchRequest($request);
+ $merged = $response->get_data();
+
+ $this->assertTrue($merged);
+ }
+}
diff --git a/tests/Unit/DonationForms/Endpoints/TestListDonationForms.php b/tests/Unit/DonationForms/Endpoints/TestListDonationForms.php
index bee7d21d2b..b1f0c95ae5 100644
--- a/tests/Unit/DonationForms/Endpoints/TestListDonationForms.php
+++ b/tests/Unit/DonationForms/Endpoints/TestListDonationForms.php
@@ -91,6 +91,7 @@ public function getMockRequest(): WP_REST_Request
}
/**
+ * @unreleased Add support to isDefaultCampaignForm key
* @since 2.25.0
*
* @param array $donationForms
@@ -117,6 +118,8 @@ public function getMockColumns(array $donationForms, string $sortDirection = 'de
$expectedItem['v3form'] = false;
$expectedItem['status_raw'] = $donationForm->status->getValue();
+ $expectedItem['isDefaultCampaignForm'] = false;
+
$expectedItems[] = $expectedItem;
}
diff --git a/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php b/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php
index 16d95bc6af..4a1cd0aacb 100644
--- a/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php
+++ b/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php
@@ -2,6 +2,9 @@
namespace Give\Tests\Unit\DonationForms\TestTraits;
+use Exception;
+use Give\Campaigns\Models\Campaign;
+use Give\Campaigns\Repositories\CampaignRepository;
use Give\DonationForms\V2\Models\DonationForm;
use Give\DonationForms\V2\Properties\DonationFormLevel;
use Give\DonationForms\V2\ValueObjects\DonationFormStatus;
@@ -58,4 +61,13 @@ public function getDonationFormModelFromLegacyGiveDonateForm(Give_Donate_Form $g
]);
}
+ /**
+ * @unreleased
+ */
+ public function createCampaignForDonationForm($formId)
+ {
+ $campaign = Campaign::factory()->create();
+ give(CampaignRepository::class)->addCampaignForm($campaign, $formId);
+ }
+
}
diff --git a/tests/Unit/Framework/Models/Factories/TestModelFactory.php b/tests/Unit/Framework/Models/Factories/TestModelFactory.php
new file mode 100644
index 0000000000..62d89e242f
--- /dev/null
+++ b/tests/Unit/Framework/Models/Factories/TestModelFactory.php
@@ -0,0 +1,370 @@
+ 123,
+ ];
+ }
+ };
+
+ $object = $factory->make();
+
+ $this->assertEquals(123, $object->id);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testPassedAttributesOverrideDefinition()
+ {
+ $factory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 123,
+ ];
+ }
+ };
+
+ $object = $factory->make([
+ 'id' => 456,
+ ]);
+
+ $this->assertEquals(456, $object->id);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testResolvesCallableDefinitions()
+ {
+ $factory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => function() {
+ return 123;
+ },
+ ];
+ }
+ };
+
+ $object = $factory->make();
+
+ $this->assertEquals(123, $object->id);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testDoesNotResolveCallableWhenPassedAttribute()
+ {
+ $factory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => function() {
+ throw new Exception('Should not be called');
+ },
+ ];
+ }
+ };
+
+ $object = $factory->make([
+ 'id' => 123,
+ ]);
+
+ $this->assertEquals(123, $object->id);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testMakeResolvesDependencyDefinition()
+ {
+ $factory = new class(MockModelWithDependency::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 123,
+ ];
+ }
+ };
+
+ $nestedFactory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 789,
+ ];
+ }
+ };
+
+ $object = $factory->make([
+ 'nestedId' => $nestedFactory->makeAndResolveTo('id'),
+ ]);
+
+ $this->assertEquals(789, $object->nestedId);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testMakeResolvesDependencyDefinitionWithCount()
+ {
+ $factory = new class(MockModelWithDependency::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 123,
+ ];
+ }
+ };
+
+ $nestedFactory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ static $counter = 1;
+ return [
+ 'id' => $counter++,
+ ];
+ }
+ };
+
+ $instances = $factory->count(2)->make([
+ 'nestedId' => $nestedFactory->makeAndResolveTo('id'),
+ ]);
+
+ $this->assertIsArray($instances);
+ $this->assertEquals(1, $instances[0]->nestedId);
+ $this->assertEquals(2, $instances[1]->nestedId);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testMakeDoesNotResolveDependencyWhenPassedAttribute()
+ {
+ $factory = new class(MockModelWithDependency::class) extends ModelFactory {
+ public function definition(): array {
+
+ $nestedFactory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 456,
+ ];
+ }
+
+ public function make(array $attributes = [])
+ {
+ throw new Exception('Should not be called');
+ }
+ };
+
+ return [
+ 'id' => 123,
+ 'nestedId' => $nestedFactory->makeAndResolveTo('id')
+ ];
+ }
+ };
+
+ $object = $factory->make([
+ 'nestedId' => 789,
+ ]);
+
+ $this->assertEquals(789, $object->nestedId);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testCreateResolvesDependencyDefinition()
+ {
+ $factory = new class(MockModelWithDependency::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 123,
+ ];
+ }
+ };
+
+ $nestedFactory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ // Defer id assignment until save
+ ];
+ }
+ };
+
+ $object = $factory->create([
+ 'nestedId' => $nestedFactory->createAndResolveTo('id'),
+ ]);
+
+ $this->assertEquals(1, $object->nestedId);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testCreateResolvesDependencyDefinitionWithCount()
+ {
+ $factory = new class(MockModelWithDependency::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'id' => 123,
+ ];
+ }
+ };
+
+ $nestedFactory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ // Defer id assignment until save
+ ];
+ }
+ };
+
+ $instances = $factory->count(2)->make([
+ 'nestedId' => $nestedFactory->createAndResolveTo('id'),
+ ]);
+
+ $this->assertIsArray($instances);
+ $this->assertEquals(1, $instances[0]->nestedId);
+ $this->assertEquals(2, $instances[1]->nestedId);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testCreateDoesNotResolveDependencyWhenPassedAttribute()
+ {
+ $factory = new class(MockModelWithDependency::class) extends ModelFactory {
+ public function definition(): array {
+
+ $nestedFactory = new class(MockModel::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ // Defer id assignment until save
+ ];
+ }
+
+ public function create(array $attributes = [])
+ {
+ throw new Exception('Should not be called');
+ }
+ };
+
+ return [
+ 'id' => 123,
+ 'nestedId' => $nestedFactory->createAndResolveTo('id')
+ ];
+ }
+ };
+
+ $object = $factory->make([
+ 'nestedId' => 789,
+ ]);
+
+ $this->assertEquals(789, $object->nestedId);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testDoesNotResolveInvokableClasses()
+ {
+ $factory = new class(MockModelWithInvokableProperty::class) extends ModelFactory {
+ public function definition(): array {
+ return [
+ 'invokable' => new class extends MockInvokableClass {
+ public function __invoke() {
+ throw new \Exception('Invokable classes should not be resolved by factories.');
+ }
+ },
+ ];
+ }
+ };
+
+ $object = $factory->make();
+
+ $this->assertInstanceOf(MockInvokableClass::class, $object->invokable);
+ }
+}
+
+/**
+ * @unreleased
+ *
+ * @property int $id
+ */
+class MockModel extends Model
+{
+ protected static $autoIncrementId = 1;
+
+ protected $properties = [
+ 'id' => 'int',
+ ];
+
+ public function save() {
+ $this->id = $this->id ?: self::$autoIncrementId++;
+ return $this;
+ }
+
+ public static function resetAutoIncrementId() {
+ self::$autoIncrementId = 1;
+ }
+}
+
+/**
+ * @unreleased
+ *
+ * @property int $id
+ * @property int $nestedId
+ */
+class MockModelWithDependency extends MockModel
+{
+ protected $properties = [
+ 'id' => 'int',
+ 'nestedId' => 'int',
+ ];
+}
+
+/**
+ * @unreleased
+ */
+abstract class MockInvokableClass
+{
+ abstract public function __invoke();
+}
+
+/**
+ * @unreleased
+ *
+ * @property MockInvokableClass $invokable
+ */
+class MockModelWithInvokableProperty extends MockModel
+{
+ protected $properties = [
+ 'invokable' => MockInvokableClass::class,
+ ];
+}
diff --git a/tests/Unit/Framework/QueryBuilder/CRUDTest.php b/tests/Unit/Framework/QueryBuilder/CRUDTest.php
index d64ee01eb5..4092ee168a 100644
--- a/tests/Unit/Framework/QueryBuilder/CRUDTest.php
+++ b/tests/Unit/Framework/QueryBuilder/CRUDTest.php
@@ -129,4 +129,35 @@ public function testDeleteShouldDeleteRowInDatabase()
$this->assertNull($post);
}
+
+ /**
+ * @unreleased
+ *
+ * @return void
+ */
+ public function testInsertManyRowsAtOnceTest()
+ {
+ $testData = [
+ [
+ 'post_title' => 'Query Builder CRUD test 1',
+ 'post_type' => 'crud_test',
+ 'post_content' => 'Hello World!',
+ ],
+ [
+ 'post_title' => 'Query Builder CRUD test 2',
+ 'post_type' => 'crud_test',
+ 'post_content' => 'Hello World!',
+ ]
+ ];
+
+
+ DB::table('posts')->insert($testData);
+
+ $posts = DB::table('posts')
+ ->where('post_type', 'crud_test')
+ ->count();
+
+ $this->assertEquals(2, $posts);
+ }
+
}
diff --git a/tests/Unit/Framework/QueryBuilder/InsertIntoTest.php b/tests/Unit/Framework/QueryBuilder/InsertIntoTest.php
new file mode 100644
index 0000000000..6cd6fead5f
--- /dev/null
+++ b/tests/Unit/Framework/QueryBuilder/InsertIntoTest.php
@@ -0,0 +1,37 @@
+ 'Query Builder CRUD test 1',
+ 'post_type' => 'crud_test',
+ 'post_content' => 'Hello World 1!',
+ ],
+ [
+ 'post_title' => 'Query Builder CRUD test 2',
+ 'post_type' => 'crud_test',
+ 'post_content' => 'Hello World 2!',
+ ]
+ ];
+
+ $sql = DB::table('posts')->getInsertIntoSQL($testData, null);
+
+ $this->assertEquals(
+ "INSERT INTO " . DB::prefix('posts') . " (post_title,post_type,post_content) VALUES ('Query Builder CRUD test 1','crud_test','Hello World 1!'),('Query Builder CRUD test 2','crud_test','Hello World 2!')",
+ $sql
+ );
+ }
+}
diff --git a/tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php b/tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php
new file mode 100644
index 0000000000..079e4c2a81
--- /dev/null
+++ b/tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php
@@ -0,0 +1,70 @@
+immutableOrClone($dateTime);
+
+ $this->assertNotSame($dateTime, $newDateTime);
+ $this->assertInstanceOf(DateTime::class, $newDateTime);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testImmutableOrCloneReturnsSameImmutableDateTimeObject()
+ {
+ $dateTime = new DateTimeImmutable;
+ $temporal = new TemporalFacade;
+
+ $newDateTime = $temporal->immutableOrClone($dateTime);
+
+ $this->assertSame($dateTime, $newDateTime);
+ $this->assertInstanceOf(DateTimeImmutable::class, $newDateTime);
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testImmutableStartOfDay()
+ {
+ $dateTime = new DateTime('2020-01-01 12:34:56');
+ $temporal = new TemporalFacade;
+
+ $newDateTime = $temporal->withStartOfDay($dateTime);
+
+ $this->assertNotSame($dateTime, $newDateTime);
+ $this->assertEquals('2020-01-01 00:00:00', $newDateTime->format('Y-m-d H:i:s'));
+ }
+
+ /**
+ * @unreleased
+ */
+ public function testImmutableEndOfDay()
+ {
+ $dateTime = new DateTime('2020-01-01 12:34:56');
+ $temporal = new TemporalFacade;
+
+ $newDateTime = $temporal->withEndOfDay($dateTime);
+
+ $this->assertNotSame($dateTime, $newDateTime);
+ $this->assertEquals('2020-01-01 23:59:59.999999', $newDateTime->format('Y-m-d H:i:s.u'));
+ }
+}
diff --git a/tests/includes/legacy/tests-post-types.php b/tests/includes/legacy/tests-post-types.php
index 8f4e660613..03cdfcdbdc 100644
--- a/tests/includes/legacy/tests-post-types.php
+++ b/tests/includes/legacy/tests-post-types.php
@@ -29,8 +29,8 @@ public function test_give_post_type_labels() {
$this->assertEquals( 'Donation Forms', $wp_post_types['give_forms']->labels->name );
$this->assertEquals( 'Form', $wp_post_types['give_forms']->labels->singular_name );
$this->assertEquals( 'Add Form', $wp_post_types['give_forms']->labels->add_new );
- $this->assertEquals('Add New Donation Form', $wp_post_types['give_forms']->labels->add_new_item);
- $this->assertEquals('Edit Donation Form', $wp_post_types['give_forms']->labels->edit_item);
+ $this->assertEquals('Add New Campaign Form', $wp_post_types['give_forms']->labels->add_new_item);
+ $this->assertEquals('Edit Campaign Form', $wp_post_types['give_forms']->labels->edit_item);
$this->assertEquals( 'New Form', $wp_post_types['give_forms']->labels->new_item );
$this->assertEquals( 'All Forms', $wp_post_types['give_forms']->labels->all_items );
$this->assertEquals( 'View Form', $wp_post_types['give_forms']->labels->view_item );
@@ -38,7 +38,7 @@ public function test_give_post_type_labels() {
$this->assertEquals( 'No forms found.', $wp_post_types['give_forms']->labels->not_found );
$this->assertEquals( 'No forms found in Trash.', $wp_post_types['give_forms']->labels->not_found_in_trash );
$this->assertEquals( 'Donations', $wp_post_types['give_forms']->labels->menu_name );
- $this->assertEquals( 'Donation Form', $wp_post_types['give_forms']->labels->name_admin_bar );
+ $this->assertEquals('Campaign', $wp_post_types['give_forms']->labels->name_admin_bar);
$this->assertEquals( 1, $wp_post_types['give_forms']->publicly_queryable );
$this->assertEquals( 'give_form', $wp_post_types['give_forms']->capability_type );
$this->assertEquals( 1, $wp_post_types['give_forms']->map_meta_cap );
diff --git a/tsconfig.json b/tsconfig.json
index 09e043b1f6..97c4d8f187 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -49,6 +49,9 @@
],
"@givewp/form-builder/registrars/*": [
"./src/FormBuilder/resources/js/registrars/*"
+ ],
+ "@givewp/campaigns/*": [
+ "./src/Campaigns/resources/*"
]
}
},
diff --git a/webpack.config.js b/webpack.config.js
index 26f20732ab..8d43a627a0 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -9,6 +9,7 @@ const path = require('path');
module.exports = {
resolve: {
alias: {
+ '@givewp/campaigns': path.resolve(__dirname, 'src/Campaigns/resources'),
'@givewp/components': path.resolve(__dirname, 'src/Views/Components/'),
'@givewp/css': path.resolve(__dirname, 'assets/src/css/'),
'@givewp/promotions': path.resolve(__dirname, 'src/Promotions/sharedResources/'),
diff --git a/webpack.mix.js b/webpack.mix.js
index ee96c85f78..7670372307 100644
--- a/webpack.mix.js
+++ b/webpack.mix.js
@@ -60,6 +60,7 @@ mix.setPublicPath('assets/dist')
.ts('src/Promotions/InPluginUpsells/resources/js/donation-options.ts', 'js/donation-options.js')
.ts('src/Promotions/InPluginUpsells/resources/js/payment-gateway.ts', 'js/payment-gateway.js')
.ts('src/Promotions/WelcomeBanner/resources/js/index.tsx', 'js/welcome-banner.js')
+ .ts('src/Campaigns/resources/admin/campaigns-list-table.tsx', 'js/give-admin-campaigns-list-table.js')
.react()
.sourceMaps(false, 'source-map')
diff --git a/wordpress-scripts-webpack.config.js b/wordpress-scripts-webpack.config.js
index bc837daf69..c294d4148d 100644
--- a/wordpress-scripts-webpack.config.js
+++ b/wordpress-scripts-webpack.config.js
@@ -8,6 +8,11 @@ const path = require('path');
*/
const defaultConfig = require('@wordpress/scripts/config/webpack.config.js');
+/**
+ * Webpack config used by Laravel Mix, we can pull in the other aliases from here.
+ */
+const legacyConfig = require('./webpack.config.js');
+
/**
* Custom config
*/
@@ -17,6 +22,7 @@ module.exports = {
...defaultConfig.resolve,
alias: {
...defaultConfig.resolve.alias,
+ ...legacyConfig.resolve.alias,
'@givewp/forms/types': srcPath('DonationForms/resources/types.ts'),
'@givewp/forms/propTypes': srcPath('DonationForms/resources/propTypes.ts'),
'@givewp/forms/app': srcPath('DonationForms/resources/app'),
@@ -25,6 +31,7 @@ module.exports = {
'@givewp/form-builder': srcPath('FormBuilder/resources/js/form-builder/src'),
'@givewp/form-builder/registrars': srcPath('FormBuilder/resources/js/registrars/index.ts'),
'@givewp/components': srcPath('Views/Components/'),
+ '@givewp/campaigns': srcPath('Campaigns/resources')
},
},
entry: {
@@ -59,7 +66,25 @@ module.exports = {
formBuilderApp: srcPath('FormBuilder/resources/js/form-builder/src/index.tsx'),
formBuilderRegistrars: srcPath('FormBuilder/resources/js/registrars/index.ts'),
formTaxonomySettings: srcPath('FormTaxonomies/resources/form-builder/index.tsx'),
+ campaignEntity: srcPath('Campaigns/resources/entity.ts'),
+ campaignDetails: srcPath('Campaigns/resources/admin/campaign-details.tsx'),
adminBlocks: path.resolve(process.cwd(), 'blocks', 'load.js'),
+ campaignBlocks: srcPath('Campaigns/Blocks/blocks.ts'),
+ campaignBlocksLandingPage: srcPath('Campaigns/Blocks/landingPage.ts'),
+ campaignDonationsBlockApp: srcPath('Campaigns/Blocks/CampaignDonations/app.tsx'),
+ campaignDonorsBlockApp: srcPath('Campaigns/Blocks/CampaignDonors/app.tsx'),
+ campaignStatsBlockApp: srcPath('Campaigns/Blocks/CampaignStats/app.tsx'),
+ campaignGoalBlockApp: srcPath('Campaigns/Blocks/CampaignGoal/app.tsx'),
+ campaignGridBlock: srcPath('Campaigns/Blocks/CampaignGrid/index.tsx'),
+ campaignGridApp: srcPath('Campaigns/Blocks/CampaignGrid/app.tsx'),
+ campaignGoalBlock: srcPath('Campaigns/Blocks/CampaignGoal/index.tsx'),
+ campaignDonateButtonBlock: srcPath('Campaigns/Blocks/DonateButton/index.tsx'),
+ campaignTitleBlock: srcPath('Campaigns/Blocks/CampaignTitle/index.tsx'),
+ campaignCoverBlock: srcPath('Campaigns/Blocks/CampaignCover/index.tsx'),
+ campaignCommentsBlockApp: srcPath('Campaigns/Blocks/CampaignComments/resources/app.tsx'),
+ campaignBlock: srcPath('Campaigns/Blocks/Campaign/index.tsx'),
+ campaignBlockApp: srcPath('Campaigns/Blocks/Campaign/app.tsx'),
+ campaignPagePostTypeEditor: srcPath('Campaigns/resources/editor/campaign-page-post-type-editor.tsx'),
},
};