From b64313470ea9b07e52ff79ccfa6610ebf0603ccc Mon Sep 17 00:00:00 2001 From: abaicus Date: Wed, 15 Jan 2025 17:48:15 +0200 Subject: [PATCH] feat: new backend dashboard [wip][ref Codeinwp/neve-pro-addon#2914] --- .github/workflows/playwright.yml | 4 +- assets/apps/dashboard/src/Components/App.js | 36 +- .../dashboard/src/Components/Common/Button.js | 8 +- .../src/Components/Common/Multiselect.js | 122 +++++++ .../src/Components/Common/Notification.js | 2 +- .../dashboard/src/Components/Common/Pill.js | 2 +- .../dashboard/src/Components/Common/Select.js | 92 ++++++ .../src/Components/Common/TextInput.js | 52 +++ .../src/Components/{ => Common}/Toast.js | 18 +- .../dashboard/src/Components/Common/Toggle.js | 56 +++- .../Components/Common/TransitionWrapper.js | 43 +++ .../src/Components/Content/Changelog.js | 5 +- .../src/Components/Content/FreePro.js | 49 +-- .../src/Components/Content/ModuleGrid.js | 162 ++++++++- .../Content/ModuleGridPlaceholder.js | 62 ---- .../dashboard/src/Components/Content/Pro.js | 23 -- .../src/Components/Content/Settings.js | 139 ++++++++ .../Content/Settings/AccessRestriction.js | 195 +++++++++++ .../Content/Settings/GeneralTabContent.js | 154 +++++++++ .../Settings/ManageModulesTabContent.js | 5 + .../Content/Settings/OptionGroup.js | 123 +++++++ .../Content/Settings/PerformanceTabContent.js | 100 ++++++ .../Content/Settings/WhiteLabelTabContent.js | 310 ++++++++++++++++++ .../{ => Content/Sidebar}/LicenseCard.js | 30 +- .../{ => Content/Sidebar}/PluginsCard.js | 15 +- .../{ => Content/Sidebar}/Sidebar.js | 55 ++-- .../{ => Content/Sidebar}/SupportCard.js | 4 +- .../Content/StarterSitesUnavailable.js | 76 +++-- .../src/Components/Content/Welcome.js | 33 +- .../src/Components/Controls/ControlWrap.js | 52 +++ .../Components/Controls/MultiselectControl.js | 80 +++++ .../src/Components/Controls/SelectControl.js | 66 ++++ .../src/Components/Controls/TextControl.js | 90 +++++ .../src/Components/Controls/ToggleControl.js | 76 +++++ .../apps/dashboard/src/Components/Header.js | 104 +++--- .../dashboard/src/Components/Notifications.js | 14 +- .../dashboard/src/Components/PluginCard.js | 179 ---------- .../{Loading.js => SkeletonLoader.js} | 8 +- .../apps/dashboard/src/Components/Snackbar.js | 77 +++-- .../dashboard/src/Components/TabsContent.js | 7 - .../dashboard/src/Hooks/useLicenseData.js | 19 ++ assets/apps/dashboard/src/Layout/Container.js | 2 +- assets/apps/dashboard/src/store/reducer.js | 1 - assets/apps/dashboard/src/tailwind.config.js | 1 - assets/apps/dashboard/src/utils/common.js | 35 +- assets/apps/dashboard/src/utils/constants.js | 18 +- assets/apps/dashboard/src/utils/rest.js | 9 +- inc/admin/dashboard/main.php | 18 +- 48 files changed, 2239 insertions(+), 592 deletions(-) create mode 100644 assets/apps/dashboard/src/Components/Common/Multiselect.js create mode 100644 assets/apps/dashboard/src/Components/Common/Select.js create mode 100644 assets/apps/dashboard/src/Components/Common/TextInput.js rename assets/apps/dashboard/src/Components/{ => Common}/Toast.js (71%) create mode 100644 assets/apps/dashboard/src/Components/Common/TransitionWrapper.js delete mode 100644 assets/apps/dashboard/src/Components/Content/ModuleGridPlaceholder.js delete mode 100644 assets/apps/dashboard/src/Components/Content/Pro.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings/AccessRestriction.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings/GeneralTabContent.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings/ManageModulesTabContent.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings/OptionGroup.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings/PerformanceTabContent.js create mode 100644 assets/apps/dashboard/src/Components/Content/Settings/WhiteLabelTabContent.js rename assets/apps/dashboard/src/Components/{ => Content/Sidebar}/LicenseCard.js (85%) rename assets/apps/dashboard/src/Components/{ => Content/Sidebar}/PluginsCard.js (89%) rename assets/apps/dashboard/src/Components/{ => Content/Sidebar}/Sidebar.js (68%) rename assets/apps/dashboard/src/Components/{ => Content/Sidebar}/SupportCard.js (88%) create mode 100644 assets/apps/dashboard/src/Components/Controls/ControlWrap.js create mode 100644 assets/apps/dashboard/src/Components/Controls/MultiselectControl.js create mode 100644 assets/apps/dashboard/src/Components/Controls/SelectControl.js create mode 100644 assets/apps/dashboard/src/Components/Controls/TextControl.js create mode 100644 assets/apps/dashboard/src/Components/Controls/ToggleControl.js delete mode 100644 assets/apps/dashboard/src/Components/PluginCard.js rename assets/apps/dashboard/src/Components/{Loading.js => SkeletonLoader.js} (94%) delete mode 100644 assets/apps/dashboard/src/Components/TabsContent.js create mode 100644 assets/apps/dashboard/src/Hooks/useLicenseData.js diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c0c31e96cc..60a282ee2e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -27,7 +27,7 @@ jobs: - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 + - uses: actions/cache@v3 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -39,7 +39,7 @@ jobs: run: | echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Configure Composer cache - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} diff --git a/assets/apps/dashboard/src/Components/App.js b/assets/apps/dashboard/src/Components/App.js index 2bd5178632..e64ca42f4f 100644 --- a/assets/apps/dashboard/src/Components/App.js +++ b/assets/apps/dashboard/src/Components/App.js @@ -1,25 +1,25 @@ +import Container from '../Layout/Container'; +import { fetchOptions } from '../utils/rest'; +import Sidebar from './Content/Sidebar/Sidebar'; import Header from './Header'; import Notifications from './Notifications'; -import TabsContent from './TabsContent'; -import Sidebar from './Sidebar'; -import Loading from './Loading'; +import SkeletonLoader from './SkeletonLoader'; import Snackbar from './Snackbar'; -import Container from '../Layout/Container'; -import { fetchOptions } from '../utils/rest'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useState, useEffect } from '@wordpress/element'; -import Deal from './Deal'; +import { useEffect, useState } from '@wordpress/element'; +import { tabs } from '../utils/common'; +import { TransitionWrapper } from './Common/TransitionWrapper'; +import { NEVE_STORE } from '../utils/constants'; const App = () => { const [loading, setLoading] = useState(true); - const { setSettings, setTab } = useDispatch('neve-dashboard'); + const { setSettings, setTab } = useDispatch(NEVE_STORE); - const { toast, currentTab } = useSelect((select) => { - const { getToast, getTab } = select('neve-dashboard'); + const { currentTab } = useSelect((select) => { + const { getTab } = select(NEVE_STORE); return { - toast: getToast(), currentTab: getTab(), }; }); @@ -32,7 +32,7 @@ const App = () => { }, []); if (loading) { - return ; + return ; } return (
@@ -42,18 +42,16 @@ const App = () => { {'starter-sites' !== currentTab && } -
- -
+
{tabs[currentTab].render(setTab)}
- {'starter-sites' !== currentTab && ( -
+ {!['starter-sites', 'settings'].includes(currentTab) && ( + -
+ )}
- {toast && } +
); }; diff --git a/assets/apps/dashboard/src/Components/Common/Button.js b/assets/apps/dashboard/src/Components/Common/Button.js index cd06b52110..414c35cb46 100644 --- a/assets/apps/dashboard/src/Components/Common/Button.js +++ b/assets/apps/dashboard/src/Components/Common/Button.js @@ -6,7 +6,7 @@ const Button = (props) => { const { href, onClick, - className, + className = '', isSubmit, isPrimary, isSecondary, @@ -17,20 +17,22 @@ const Button = (props) => { } = props; const classNames = cn([ - 'flex items-center px-3 py-2 transition-colors duration-150 rounded text-sm border gap-2', + 'flex items-center px-3 py-2 transition-colors duration-150 text-sm border gap-2', { + rounded: !className.includes('rounded'), 'border-transparent bg-blue-600 text-white hover:bg-blue-700 hover:text-white': isPrimary, 'border-blue-600 text-blue-600 hover:bg-blue-600 hover:text-white': isSecondary, 'border-transparent text-gray-600 hover:text-gray-900': isLink, - 'cursor-not-allowed opacity-50 pointer-events-none': disabled, + 'cursor-not-allowed opacity-50': disabled, }, className, ]); const passedProps = { className: classNames, + disabled, onClick, }; diff --git a/assets/apps/dashboard/src/Components/Common/Multiselect.js b/assets/apps/dashboard/src/Components/Common/Multiselect.js new file mode 100644 index 0000000000..ea4c9582da --- /dev/null +++ b/assets/apps/dashboard/src/Components/Common/Multiselect.js @@ -0,0 +1,122 @@ +import { useRef, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import cn from 'classnames'; +import { Check, ChevronDown } from 'lucide-react'; +import { useEffect } from 'react'; +const MultiSelect = ({ value, label, disabled, choices = {}, onChange }) => { + const [isOpen, setIsOpen] = useState(false); + + const dropdownRef = useRef(null); + + const closeDropdown = (e) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { + setIsOpen(false); + } + }; + + useEffect(() => { + if (isOpen) { + document.addEventListener('click', closeDropdown); + } else { + document.removeEventListener('click', closeDropdown); + } + + return () => { + document.removeEventListener('click', closeDropdown); + }; + }, [isOpen]); + + const handleChange = (optionValue) => { + const nextValues = value.includes(optionValue) + ? value.filter((v) => v !== optionValue) + : [...value, optionValue]; + onChange(nextValues); + }; + + return ( +
+ {label && ( + + {label} + + )} +
+ + + {isOpen && ( +
+
+ {Object.entries(choices).map( + ([optionValue, optionLabel]) => ( + + ) + )} +
+
+ )} +
+
+ ); +}; + +export default MultiSelect; diff --git a/assets/apps/dashboard/src/Components/Common/Notification.js b/assets/apps/dashboard/src/Components/Common/Notification.js index 27a7a8d28c..8811fe691a 100644 --- a/assets/apps/dashboard/src/Components/Common/Notification.js +++ b/assets/apps/dashboard/src/Components/Common/Notification.js @@ -15,7 +15,7 @@ import Tooltip from './Tooltip'; import TransitionInOut from './TransitionInOut'; import { useEffect } from 'react'; -const Notification = ({ data, slug }) => { +const Notification = ({ data }) => { const [hidden, setHidden] = useState(false); const { text, cta, type, update, url, targetBlank } = data; const { canInstallPlugins } = neveDash; diff --git a/assets/apps/dashboard/src/Components/Common/Pill.js b/assets/apps/dashboard/src/Components/Common/Pill.js index d732a7e1fd..6d4137c8c8 100644 --- a/assets/apps/dashboard/src/Components/Common/Pill.js +++ b/assets/apps/dashboard/src/Components/Common/Pill.js @@ -8,7 +8,7 @@ export default ({ children, type = 'primary', className }) => { const typeClasses = { primary: 'bg-blue-100 text-blue-700', secondary: 'bg-gray-100 text-gray-700', - success: 'bg-green-100 text-green-700', + success: 'bg-lime-100 text-lime-700', error: 'bg-red-100 text-red-700', warning: 'bg-yellow-100 text-yellow-700', }; diff --git a/assets/apps/dashboard/src/Components/Common/Select.js b/assets/apps/dashboard/src/Components/Common/Select.js new file mode 100644 index 0000000000..9f3e3b7e00 --- /dev/null +++ b/assets/apps/dashboard/src/Components/Common/Select.js @@ -0,0 +1,92 @@ +import cn from 'classnames'; +import { LoaderCircle, LucideChevronDown } from 'lucide-react'; + +import { + Field, + Label, + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from '@headlessui/react'; +import { __ } from '@wordpress/i18n'; + +export default ({ + label, + value, + onChange, + disabled = false, + loading, + choices, +}) => { + return ( + + {label && ( + + )} +
+ {loading && ( + + )} + + {({ open }) => ( + <> + + {choices[value] || + __('Select an option', 'neve')} + + + + + {Object.entries(choices).map( + ([optionValue, optionLabel]) => ( + + {optionLabel} + + ) + )} + + + )} + +
+
+ ); +}; diff --git a/assets/apps/dashboard/src/Components/Common/TextInput.js b/assets/apps/dashboard/src/Components/Common/TextInput.js new file mode 100644 index 0000000000..6c178baa1a --- /dev/null +++ b/assets/apps/dashboard/src/Components/Common/TextInput.js @@ -0,0 +1,52 @@ +import { Description, Field, Input, Label } from '@headlessui/react'; +import { Fragment } from '@wordpress/element'; +import cn from 'classnames'; + +const TextInput = ({ + value, + label, + disabled, + onChange, + name, + className = '', + type = 'text', + description, +}) => { + let TagName = 'input'; + + if (type === 'textarea') { + TagName = 'textarea'; + } + + return ( + + {label && ( + + )} + {description && ( + + {description} + + )} + + + + + ); +}; + +export default TextInput; diff --git a/assets/apps/dashboard/src/Components/Toast.js b/assets/apps/dashboard/src/Components/Common/Toast.js similarity index 71% rename from assets/apps/dashboard/src/Components/Toast.js rename to assets/apps/dashboard/src/Components/Common/Toast.js index ca6851c000..009b11beab 100644 --- a/assets/apps/dashboard/src/Components/Toast.js +++ b/assets/apps/dashboard/src/Components/Common/Toast.js @@ -7,9 +7,9 @@ import { LucideCircleX, LucideInfo, } from 'lucide-react'; -import TransitionInOut from './Common/TransitionInOut'; +import TransitionInOut from './TransitionInOut'; -const Toast = ({ message, dismiss, time, type = 'info' }) => { +const Toast = ({ message, dismiss, time, type = 'info', className }) => { const [show, setShow] = useState(false); useEffect(() => { @@ -23,7 +23,7 @@ const Toast = ({ message, dismiss, time, type = 'info' }) => { }, timeBeforeHide); const dismissTimeout = setTimeout(() => { - dismiss(''); + if (dismiss) dismiss(''); }, timeBeforeDismiss); return () => { @@ -42,12 +42,12 @@ const Toast = ({ message, dismiss, time, type = 'info' }) => { const classes = cn( 'px-2 py-1.5 flex items-center text-sm border rounded gap-2', { - 'border-sky-300 bg-sky-50 text-sky-800': type === 'info', - 'border-red-300 bg-red-50 text-red-800': type === 'error', - 'border-lime-300 bg-lime-50 text-lime-800': type === 'success', - 'border-orange-300 bg-orange-50 text-orange-800': - type === 'warning', - } + 'bg-sky-50 text-sky-800': type === 'info', + 'bg-red-50 text-red-800': type === 'error', + 'bg-lime-50 text-lime-800': type === 'success', + 'bg-orange-50 text-orange-800': type === 'warning', + }, + className ); const ICON = iconMap[type]; diff --git a/assets/apps/dashboard/src/Components/Common/Toggle.js b/assets/apps/dashboard/src/Components/Common/Toggle.js index 0a82469904..d5e94a462b 100644 --- a/assets/apps/dashboard/src/Components/Common/Toggle.js +++ b/assets/apps/dashboard/src/Components/Common/Toggle.js @@ -1,7 +1,16 @@ -import { Switch, Label, Field } from '@headlessui/react'; +import { Switch, Label, Field, Description } from '@headlessui/react'; import cn from 'classnames'; -export default ({ checked, onToggle, label, disabled = false, className }) => { +export default ({ + checked, + onToggle, + label, + disabled = false, + className, + labelBefore = false, + labelClassName = '', + description, +}) => { const switchClasses = cn( 'group inline-flex h-6 w-11 items-center rounded-full bg-gray-300 transition data-[checked]:bg-blue-600', { @@ -11,18 +20,41 @@ export default ({ checked, onToggle, label, disabled = false, className }) => { const wrapClasses = cn('flex items-center gap-3', className); + const labelClasses = cn( + { + 'font-medium': !labelClassName.includes('font-'), + 'text-sm': !labelClassName.includes('text-'), + 'text-gray-600': !labelClassName.includes('text-'), + }, + labelClassName + ); + return ( - - - - + +
+ {label && labelBefore && ( + + )} + + + + + + {label && !labelBefore && ( + + )} +
- + {description && ( + + {description} + + )}
); }; diff --git a/assets/apps/dashboard/src/Components/Common/TransitionWrapper.js b/assets/apps/dashboard/src/Components/Common/TransitionWrapper.js new file mode 100644 index 0000000000..eb89938b58 --- /dev/null +++ b/assets/apps/dashboard/src/Components/Common/TransitionWrapper.js @@ -0,0 +1,43 @@ +import { Transition } from '@headlessui/react'; +import cn from 'classnames'; + +export const TransitionWrapper = ({ children, from = 'bottom', className }) => { + const directionClasses = { + left: { + enterFrom: '-translate-x-2', + enterTo: 'translate-x-0', + }, + right: { + enterFrom: 'translate-x-2', + enterTo: 'translate-x-0', + }, + top: { + enterFrom: '-translate-y-2', + enterTo: 'translate-y-0', + }, + bottom: { + enterFrom: 'translate-y-2', + enterTo: 'translate-y-0', + }, + }; + + const animationData = directionClasses[from] || directionClasses.bottom; + + const transitionClasses = { + enter: 'ease-out duration-150', + enterFrom: `opacity-0 ${animationData.enterFrom}`, + enterTo: `opacity-100 ${animationData.enterTo}`, + }; + + return ( + + {children} + + ); +}; diff --git a/assets/apps/dashboard/src/Components/Content/Changelog.js b/assets/apps/dashboard/src/Components/Content/Changelog.js index 6f9033c725..6cfefdac64 100644 --- a/assets/apps/dashboard/src/Components/Content/Changelog.js +++ b/assets/apps/dashboard/src/Components/Content/Changelog.js @@ -7,6 +7,7 @@ import { useState } from '@wordpress/element'; import Card from '../../Layout/Card'; import Button from '../Common/Button'; import Pill from '../Common/Pill'; +import { TransitionWrapper } from '../Common/TransitionWrapper'; const TAB_CHOICES = { FREE: 'free', @@ -112,7 +113,7 @@ const Changelog = () => { activeTab === TAB_CHOICES.FREE ? changelog : changelogPro; return ( -
+ {changelogPro && (
@@ -165,7 +166,7 @@ const Changelog = () => {
)} -
+ ); }; diff --git a/assets/apps/dashboard/src/Components/Content/FreePro.js b/assets/apps/dashboard/src/Components/Content/FreePro.js index 9c629d9623..43346990c9 100644 --- a/assets/apps/dashboard/src/Components/Content/FreePro.js +++ b/assets/apps/dashboard/src/Components/Content/FreePro.js @@ -13,42 +13,7 @@ import { import Card from '../../Layout/Card'; import Tooltip from '../Common/Tooltip'; import Button from '../Common/Button'; - -const IntroCard = () => { - const upgradeList = [ - __('Need advanced header/footer customization options', 'neve'), - __( - 'Run an online store and want enhanced WooCommerce features', - 'neve' - ), - __('Build multilingual or RTL websites', 'neve'), - __('Create websites for clients and need white-labeling', 'neve'), - ]; - - return ( - -
-

- {__( - "While Neve's free version includes everything you need to build a great website, Neve Pro adds advanced customization options and specific features for e-commerce, multilingual sites, and client projects. Here's a detailed comparison to help you decide if Pro is right for your needs.", - 'neve' - )} -

-
- - {__('Considering an upgrade?', 'neve')} - - {__('Pro features are most helpful if you:', 'neve')} -
    - {upgradeList.map((item, index) => ( -
  • • {item}
  • - ))} -
-
-
-
- ); -}; +import { TransitionWrapper } from '../Common/TransitionWrapper'; const FreeProCard = () => ( @@ -122,14 +87,14 @@ const UpsellCard = () => {

{__('Need help deciding?', 'neve')}

-
+

{__( 'Our support team is happy to answer your questions about specific Pro features and help you determine if they match your needs.', 'neve' )}

-
+
{__( 'Average response time: ~8 hours during business days', @@ -166,8 +131,12 @@ const UpsellCard = () => { export default () => { return (
- - + + + + + +
); }; diff --git a/assets/apps/dashboard/src/Components/Content/ModuleGrid.js b/assets/apps/dashboard/src/Components/Content/ModuleGrid.js index bb7a63a6e1..754bd26ef7 100644 --- a/assets/apps/dashboard/src/Components/Content/ModuleGrid.js +++ b/assets/apps/dashboard/src/Components/Content/ModuleGrid.js @@ -1,6 +1,164 @@ +/* global neveDash */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { NEVE_HAS_VALID_PRO } from '../../utils/constants'; +import { LoaderCircle, LucideSettings } from 'lucide-react'; + +import useLicenseData from '../../Hooks/useLicenseData'; +import Card from '../../Layout/Card'; +import { + NEVE_HAS_PRO, + NEVE_MODULE_ICON_MAP, + NEVE_STORE, +} from '../../utils/constants'; +import Link from '../Common/Link'; +import Pill from '../Common/Pill'; +import Toggle from '../Common/Toggle'; +import Tooltip from '../Common/Tooltip'; +import { changeOption } from '../../utils/rest'; +import Button from '../Common/Button'; + +const ModuleToggle = ({ slug, moduleData }) => { + const [loading, setLoading] = useState(false); + + const { licenseTier, isLicenseValid } = useLicenseData(); + const { changeModuleStatus, setToast } = useDispatch(NEVE_STORE); + const { moduleStatus } = useSelect((select) => { + const { getModuleStatus } = select(NEVE_STORE); + + return { + moduleStatus: getModuleStatus(slug) || false, + }; + }); + + if (!NEVE_HAS_PRO) { + return ( + + + {__('Pro', 'neve')} + + + ); + } + + const { nicename, availabilityLevel } = moduleData; + const { upgradeLinks } = neveDash; + + if (!isLicenseValid || licenseTier < availabilityLevel) { + return ( + + ); + } + + const handleToggle = (value) => { + setLoading(true); + changeModuleStatus(slug, value); + + changeOption(slug, value, true).then((r) => { + if (r.success) { + setLoading(false); + setToast( + (value + ? __('Module Activated', 'neve') + : __('Module Deactivated.', 'neve')) + ` (${nicename})` + ); + return; + } + changeModuleStatus(slug, !value); + setLoading(false); + setToast( + __('Could not activate module. Please try again.', 'neve') + ); + }); + }; + + return ( +
+ {loading && } + +
+ ); +}; + +const ModuleCard = ({ moduleData, slug }) => { + const { nicename, description, documentation, hide } = moduleData; + const CardIcon = NEVE_MODULE_ICON_MAP[slug] || LucideSettings; + + if (hide) { + return null; + } + + return ( + } + title={nicename} + className="bg-white p-6 rounded-lg shadow-sm" + afterTitle={} + > +

+ {description}{' '} + {documentation && documentation.url && ( + + )} +

+
+ ); +}; + +const ModulesHeader = () => { + const { isLicenseValid } = useLicenseData(); + + return ( +
+

+ {__('Neve Pro Modules', 'neve')} +

+ {!isLicenseValid && ( + + )} +
+ ); +}; export default () => { - return
; + const unorderedModuels = Object.entries(neveDash.modules); + + const orderedModules = unorderedModuels.sort((a, b) => { + if (a[1].order && b[1].order) { + return a[1].order - b[1].order; + } + return 0; + }); + + return ( + <> + +
+ {orderedModules.map(([slug, moduleData]) => ( + + ))} +
+ + ); }; diff --git a/assets/apps/dashboard/src/Components/Content/ModuleGridPlaceholder.js b/assets/apps/dashboard/src/Components/Content/ModuleGridPlaceholder.js deleted file mode 100644 index 2b53eeca3c..0000000000 --- a/assets/apps/dashboard/src/Components/Content/ModuleGridPlaceholder.js +++ /dev/null @@ -1,62 +0,0 @@ -/* global neveDash */ -import { __ } from '@wordpress/i18n'; -import { LucideSettings } from 'lucide-react'; -import Card from '../../Layout/Card'; -import { - NEVE_HAS_VALID_PRO, - NEVE_MODULE_ICON_MAP, -} from '../../utils/constants'; -import Tooltip from '../Common/Tooltip'; -import Pill from '../Common/Pill'; -import Link from '../Common/Link'; - -const ModuleCardPlaceholder = ({ slug, title, description }) => { - const CardIcon = NEVE_MODULE_ICON_MAP[slug] || LucideSettings; - - const ProBadge = ( - - {__('Pro', 'neve')} - - ); - - return ( - } - title={title} - className="bg-white p-6 rounded-lg shadow-sm" - afterTitle={ProBadge} - > -

- {description} -

-
- ); -}; - -export default () => { - return ( -
-
-

- {__('Neve Pro Modules', 'neve')} -

- -
- {Object.entries(neveDash.modules).map( - ([slug, { nicename, description }]) => ( - - ) - )} -
- ); -}; diff --git a/assets/apps/dashboard/src/Components/Content/Pro.js b/assets/apps/dashboard/src/Components/Content/Pro.js deleted file mode 100644 index ad358d15fd..0000000000 --- a/assets/apps/dashboard/src/Components/Content/Pro.js +++ /dev/null @@ -1,23 +0,0 @@ -/* global neveDash */ -import { CircleFadingArrowUp } from 'lucide-react'; -import Notice from '../Common/Notice'; -import ModuleCard from '../ModuleCard'; -const Pro = () => { - const { modules, hasOldPro, strings } = neveDash; - - if (hasOldPro) { - return ( - {strings.updateOldPro} - ); - } - - return ( -
- {Object.keys(modules).map((id, index) => { - return ; - })} -
- ); -}; - -export default Pro; diff --git a/assets/apps/dashboard/src/Components/Content/Settings.js b/assets/apps/dashboard/src/Components/Content/Settings.js new file mode 100644 index 0000000000..ab8ce0cfe4 --- /dev/null +++ b/assets/apps/dashboard/src/Components/Content/Settings.js @@ -0,0 +1,139 @@ +/* global neveDash */ +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import cn from 'classnames'; +import { + CircleFadingArrowUp, + LucideBriefcase, + LucideGauge, + LucidePuzzle, + LucideSettings, +} from 'lucide-react'; + +import { useSelect } from '@wordpress/data'; +import Card from '../../Layout/Card'; +import { + NEVE_HAS_PRO, + NEVE_IS_WHITELABEL, + NEVE_STORE, +} from '../../utils/constants'; +import Notice from '../Common/Notice'; +import { TransitionWrapper } from '../Common/TransitionWrapper'; +import GeneralTabContent from './Settings/GeneralTabContent'; +import ManageModulesTabContent from './Settings/ManageModulesTabContent'; +import PerformanceTabContent from './Settings/PerformanceTabContent'; +import WhiteLabelTabContent from './Settings/WhiteLabelTabContent'; + +const NAV_ITEMS = [ + { + id: 'general', + label: __('General', 'neve'), + icon: LucideSettings, + }, + { + id: 'performance', + label: __('Performance', 'neve'), + icon: LucideGauge, + }, + { + id: 'white-label', + label: __('White Label', 'neve'), + icon: LucideBriefcase, + }, + { + id: 'manage-modules', + label: __('Manage Modules', 'neve'), + icon: LucidePuzzle, + }, +]; + +const Menu = ({ tab, setTab }) => { + const { whiteLabelStatus } = useSelect((select) => { + const { getModuleStatus } = select(NEVE_STORE); + + return { + whiteLabelStatus: getModuleStatus('white_label') || false, + }; + }); + + const menuItems = NAV_ITEMS.filter(({ id }) => { + if (id === 'manage-modules') return NEVE_HAS_PRO; + + if (id === 'white-label') + return whiteLabelStatus && !NEVE_IS_WHITELABEL; + + return true; + }); + + return ( + + + {menuItems.map(({ id, label, icon }) => { + const Icon = icon; + const classes = cn( + 'w-full flex items-center px-4 py-3 text-left', + { + 'text-gray-600 hover:bg-gray-50': tab !== id, + 'bg-blue-50 text-blue-600': tab === id, + } + ); + + return ( + + ); + })} + + + ); +}; + +const Settings = () => { + const { hasOldPro, strings } = neveDash; + + const [tab, setTab] = useState(NAV_ITEMS[0].id); + + if (hasOldPro) { + return ( + {strings.updateOldPro} + ); + } + + return ( +
+
+ +
+ + {tab === 'general' && ( + + + + )} + {tab === 'performance' && ( + + + + )} + {tab === 'white-label' && ( + + + + )} + {tab === 'manage-modules' && ( + + + + )} + +
+ ); +}; + +export default Settings; diff --git a/assets/apps/dashboard/src/Components/Content/Settings/AccessRestriction.js b/assets/apps/dashboard/src/Components/Content/Settings/AccessRestriction.js new file mode 100644 index 0000000000..cba6a517bc --- /dev/null +++ b/assets/apps/dashboard/src/Components/Content/Settings/AccessRestriction.js @@ -0,0 +1,195 @@ +/* global neveAccessRestriction */ + +import apiFetch from '@wordpress/api-fetch'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { LucideLoaderCircle } from 'lucide-react'; +import Notice from '../../Common/Notice'; +import Select from '../../Common/Select'; +import Toggle from '../../Common/Toggle'; +import ControlWrap from '../../Controls/ControlWrap'; +import { NEVE_STORE } from '../../../utils/constants'; +import { useDispatch } from '@wordpress/data'; + +export const saveOption = (value) => { + return new Promise((resolve) => { + apiFetch({ + path: neveAccessRestriction.settingsRoute, + method: 'POST', + data: { settings: value }, + }) + .then((responseRaw) => { + const response = JSON.parse(responseRaw); + const status = response.status === 'success'; + resolve({ success: status }); + }) + .catch(() => { + resolve({ success: false }); + }); + }); +}; + +const AccessRestriction = ({ optionData }) => { + const [settings, setSettings] = useState(neveAccessRestriction.options); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const { setToast } = useDispatch(NEVE_STORE); + + const updateContentTypeStatus = (slug, status) => { + const newSettings = { ...settings }; + + newSettings.content_types[slug].enabled = status; + + setSettings(newSettings); + saveAsync(newSettings); + }; + + const updateSetting = (slug, value) => { + const newSettings = { + ...settings, + [slug]: value, + }; + + setSettings(newSettings); + saveAsync(newSettings); + }; + + const saveAsync = (newSettings = null) => { + const settingsToSave = newSettings || settings; + setSaving(true); + setError(''); + saveOption(JSON.stringify(settingsToSave)) + .then((r) => { + if (!r.success) { + setError( + __('An error occurred. Please try again.', 'neve') + ); + setToast(false); + return; + } + setToast(true); + neveAccessRestriction.options = newSettings; + }) + .finally(() => { + setSaving(false); + }); + }; + + return ( + + + {__('Saving', 'neve')}... +
+ ) : null + } + > +
+ { + return callbackSettings[callbackKey].enabled; + }} + settings={neveAccessRestriction.options.content_types} + /> +
+ +
+ +
+ + {'' !== error && ( + + {error} + + )} + + ); +}; + +const defaultValueCallback = (settings, key) => settings[key]; + +const Fields = ({ + type, + updateSetting, + settings, + valueCallback = defaultValueCallback, +}) => { + const { fields } = neveAccessRestriction.fields[type]; + + return ( + <> + {Object.keys(fields).map((key, index) => { + const { type: fieldType, label, description } = fields[key]; + + if (fields[key].parent) { + const parent = fields[key].parent; + + if (settings[parent.fieldKey] !== parent.fieldValue) { + return null; + } + } + + const value = valueCallback(settings, key); + + return ( +
+ {'toggle' === fieldType && ( + <> + { + const status = newValue ? 'yes' : 'no'; + updateSetting(key, status); + }} + /> + {value && description && ( +

{description}

+ )} + + )} + {'select' === fieldType && ( + <> +