diff --git a/src/common/generic/DesignSelector.tsx b/src/common/generic/DesignSelector.tsx index c6becf46ef..08ce3180c3 100644 --- a/src/common/generic/DesignSelector.tsx +++ b/src/common/generic/DesignSelector.tsx @@ -23,6 +23,9 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { $refetch } from '../hooks/useRefetch'; import { useAdmin } from '../hooks/permissions/useHasPermission'; +import { enterprisePlan } from '../guards/guards/enterprise-plan'; +import { proPlan } from '../guards/guards/pro-plan'; +import { useFreePlanDesigns } from '../hooks/useFreePlanDesigns'; interface Props extends GenericSelectorProps { actionVisibility?: boolean; @@ -30,15 +33,20 @@ interface Props extends GenericSelectorProps { } export function DesignSelector(props: Props) { + const [t] = useTranslation(); + + const { isAdmin, isOwner } = useAdmin(); + + const freePlanDesigns = useFreePlanDesigns(); + + const { actionVisibility = true } = props; + const [isModalVisible, setIsModalVisible] = useState(false); const [design, setDesign] = useState(null); const [errors, setErrors] = useState(null); - const { t } = useTranslation(); const { data } = useBlankDesignQuery({ enabled: isModalVisible }); - const { isAdmin, isOwner } = useAdmin(); - useEffect(() => { if (data) { setDesign(data); @@ -105,9 +113,7 @@ export function DesignSelector(props: Props) { action={{ label: t('new_design'), onClick: () => setIsModalVisible(true), - visible: - typeof props.actionVisibility === 'undefined' || - props.actionVisibility, + visible: actionVisibility, }} sortBy="name|asc" onDismiss={() => setDesign(null)} @@ -141,14 +147,19 @@ export function DesignSelector(props: Props) { label: t('new_design'), onClick: () => setIsModalVisible(true), visible: - (typeof props.actionVisibility === 'undefined' || - props.actionVisibility) && - (isAdmin || isOwner), + actionVisibility && + (isAdmin || isOwner) && + (proPlan() || enterprisePlan()), }} sortBy="name|asc" onDismiss={props.onClearButtonClick} disableWithQueryParameter={props.disableWithQueryParameter} errorMessage={props.errorMessage} + {...(!proPlan() && + !enterprisePlan() && { + includeOnly: freePlanDesigns, + includeByLabel: true, + })} /> ); diff --git a/src/common/hooks/useFreePlanDesigns.ts b/src/common/hooks/useFreePlanDesigns.ts new file mode 100644 index 0000000000..4d72f03f1a --- /dev/null +++ b/src/common/hooks/useFreePlanDesigns.ts @@ -0,0 +1,13 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +export function useFreePlanDesigns() { + return ['Plain', 'Clean', 'Bold', 'Modern']; +} diff --git a/src/common/queries/designs.ts b/src/common/queries/designs.ts index cb53657213..bb1f4bc2f3 100644 --- a/src/common/queries/designs.ts +++ b/src/common/queries/designs.ts @@ -17,17 +17,26 @@ import { AxiosResponse } from 'axios'; import { GenericQueryOptions } from '$app/common/queries/invoices'; import { route } from '$app/common/helpers/route'; import { GenericSingleResourceResponse } from '$app/common/interfaces/generic-api-response'; +import { useFreePlanDesigns } from '../hooks/useFreePlanDesigns'; +import { enterprisePlan } from '../guards/guards/enterprise-plan'; +import { proPlan } from '../guards/guards/pro-plan'; export function useDesignsQuery() { + const freePlanDesigns = useFreePlanDesigns(); + return useQuery( ['/api/v1/designs'], () => request( 'GET', endpoint('/api/v1/designs?status=active&sort=name|asc') - ).then( - (response: AxiosResponse>) => - response.data.data + ).then((response: AxiosResponse>) => + response.data.data.filter( + (design) => + freePlanDesigns.includes(design.name) || + proPlan() || + enterprisePlan() + ) ), { staleTime: Infinity } ); diff --git a/src/components/forms/Combobox.tsx b/src/components/forms/Combobox.tsx index c374763b5b..d8447ea09e 100644 --- a/src/components/forms/Combobox.tsx +++ b/src/components/forms/Combobox.tsx @@ -53,6 +53,8 @@ export interface ComboboxStaticProps { nullable?: boolean; initiallyVisible?: boolean; exclude?: (string | number | boolean)[]; + includeOnly?: (string | number | boolean)[]; + includeByLabel?: boolean; action?: Action; onChange: (entry: Entry) => unknown; onEmptyValues: (query: string) => unknown; @@ -89,6 +91,8 @@ export function Combobox({ nullable, initiallyVisible = false, exclude = [], + includeOnly = [], + includeByLabel, action, onChange, onDismiss, @@ -124,6 +128,12 @@ export function Combobox({ exclude.length > 0 ? !exclude.includes(entry.value) : true ); + filteredOptions = filteredOptions.filter((entry) => + includeOnly.length > 0 + ? includeOnly.includes(entry[includeByLabel ? 'label' : 'value']) + : true + ); + useEffect(() => { const entry = entries.findIndex( (entry) => @@ -423,6 +433,8 @@ export function ComboboxStatic({ nullable, initiallyVisible = false, exclude = [], + includeOnly = [], + includeByLabel, action, onEmptyValues, onChange, @@ -453,6 +465,12 @@ export function ComboboxStatic({ exclude.length > 0 ? !exclude.includes(entry.value) : true ); + filteredValues = filteredValues.filter((entry) => + includeOnly.length > 0 + ? includeOnly.includes(entry[includeByLabel ? 'label' : 'value']) + : true + ); + const comboboxRef = useRef(null); const comboboxInputRef = useRef(null); @@ -731,6 +749,8 @@ export interface ComboboxAsyncProps { querySpecificEntry?: string; sortBy?: string | null; exclude?: (string | number | boolean)[]; + includeOnly?: (string | number | boolean)[]; + includeByLabel?: boolean; action?: Action; nullable?: boolean; onChange: (entry: Entry) => unknown; @@ -749,6 +769,8 @@ export function ComboboxAsync({ initiallyVisible, sortBy = 'created_at|desc', exclude, + includeOnly, + includeByLabel, action, nullable, onChange, @@ -886,6 +908,8 @@ export function ComboboxAsync({ onDismiss={onDismiss} initiallyVisible={initiallyVisible} exclude={exclude} + includeOnly={includeOnly} + includeByLabel={includeByLabel} action={action} nullable={nullable} entryOptions={entryOptions} @@ -905,6 +929,8 @@ export function ComboboxAsync({ onDismiss={onDismiss} initiallyVisible={initiallyVisible} exclude={exclude} + includeOnly={includeOnly} + includeByLabel={includeByLabel} action={action} nullable={nullable} entryOptions={entryOptions} diff --git a/src/pages/settings/invoice-design/pages/custom-designs/CustomDesigns.tsx b/src/pages/settings/invoice-design/pages/custom-designs/CustomDesigns.tsx index 0005466bf0..acdf38d23b 100644 --- a/src/pages/settings/invoice-design/pages/custom-designs/CustomDesigns.tsx +++ b/src/pages/settings/invoice-design/pages/custom-designs/CustomDesigns.tsx @@ -8,31 +8,39 @@ * @license https://www.elastic.co/licensing/elastic-license */ +import { enterprisePlan } from '$app/common/guards/guards/enterprise-plan'; +import { proPlan } from '$app/common/guards/guards/pro-plan'; import { DataTable } from '$app/components/DataTable'; import { EntityStatus } from '$app/components/EntityStatus'; import { Inline } from '$app/components/Inline'; +import { CustomDesignsPlanAlert } from './components/CustomDesignsPlanAlert'; export default function CustomDesigns() { return ( - ( - - -

{field}

-
- ), - }, - ]} - resource="design" - linkToCreate="/settings/invoice_design/custom_designs/create" - bulkRoute="/api/v1/designs/bulk" - linkToEdit="/settings/invoice_design/custom_designs/:id/edit" - withResourcefulActions - /> + <> + + + ( + + +

{field}

+
+ ), + }, + ]} + resource="design" + linkToCreate="/settings/invoice_design/custom_designs/create" + bulkRoute="/api/v1/designs/bulk" + linkToEdit="/settings/invoice_design/custom_designs/:id/edit" + withResourcefulActions + hideEditableOptions={!proPlan() && !enterprisePlan()} + /> + ); } diff --git a/src/pages/settings/invoice-design/pages/custom-designs/components/CustomDesignsPlanAlert.tsx b/src/pages/settings/invoice-design/pages/custom-designs/components/CustomDesignsPlanAlert.tsx new file mode 100644 index 0000000000..250f46d388 --- /dev/null +++ b/src/pages/settings/invoice-design/pages/custom-designs/components/CustomDesignsPlanAlert.tsx @@ -0,0 +1,55 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +import { useCurrentUser } from '$app/common/hooks/useCurrentUser'; +import { useTranslation } from 'react-i18next'; +import { MdInfoOutline } from 'react-icons/md'; +import { route } from '$app/common/helpers/route'; +import { Alert } from '$app/components/Alert'; +import { Link } from '$app/components/forms'; +import { proPlan } from '$app/common/guards/guards/pro-plan'; +import { enterprisePlan } from '$app/common/guards/guards/enterprise-plan'; +import CommonProps from '$app/common/interfaces/common-props.interface'; + +export function CustomDesignsPlanAlert(props?: CommonProps) { + const [t] = useTranslation(); + + const user = useCurrentUser(); + + return ( + <> + {!proPlan() && !enterprisePlan() && ( +
+ +
+

+ + {t('upgrade_to_paid_plan')}. +

+ + {user?.company_user && ( + + {t('plan_change')} + + )} +
+
+
+ )} + + ); +} diff --git a/src/pages/settings/invoice-design/pages/custom-designs/pages/create/Create.tsx b/src/pages/settings/invoice-design/pages/custom-designs/pages/create/Create.tsx index 9c97300340..bb10e390ff 100644 --- a/src/pages/settings/invoice-design/pages/custom-designs/pages/create/Create.tsx +++ b/src/pages/settings/invoice-design/pages/custom-designs/pages/create/Create.tsx @@ -9,6 +9,8 @@ */ import { DesignSelector } from '$app/common/generic/DesignSelector'; +import { enterprisePlan } from '$app/common/guards/guards/enterprise-plan'; +import { proPlan } from '$app/common/guards/guards/pro-plan'; import { endpoint } from '$app/common/helpers'; import { request } from '$app/common/helpers/request'; import { route } from '$app/common/helpers/route'; @@ -26,6 +28,7 @@ import { AxiosError } from 'axios'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { CustomDesignsPlanAlert } from '../../components/CustomDesignsPlanAlert'; export default function Create() { const { t } = useTranslation(); @@ -73,12 +76,15 @@ export default function Create() { } }); }, + disableSaveButton: !proPlan() && !enterprisePlan(), }, [design] ); return ( + + - - - - - - - - - - - + + {(proPlan() || enterprisePlan()) && ( + <> + + + + + + + + + + + + + )} )} diff --git a/src/pages/settings/invoice-design/pages/general-settings/components/ClientDetails.tsx b/src/pages/settings/invoice-design/pages/general-settings/components/ClientDetails.tsx index 46ae775859..c4d3557d46 100644 --- a/src/pages/settings/invoice-design/pages/general-settings/components/ClientDetails.tsx +++ b/src/pages/settings/invoice-design/pages/general-settings/components/ClientDetails.tsx @@ -12,6 +12,9 @@ import { Card } from '$app/components/cards'; import { useTranslation } from 'react-i18next'; import { SortableVariableList } from './SortableVariableList'; import { useCustomField } from '$app/components/CustomField'; +import { CustomDesignsPlanAlert } from '../../custom-designs/components/CustomDesignsPlanAlert'; +import { proPlan } from '$app/common/guards/guards/pro-plan'; +import { enterprisePlan } from '$app/common/guards/guards/enterprise-plan'; export function ClientDetails() { const [t] = useTranslation(); @@ -67,10 +70,17 @@ export function ClientDetails() { ]; return ( - + + + ); diff --git a/src/pages/settings/invoice-design/pages/general-settings/components/SortableVariableList.tsx b/src/pages/settings/invoice-design/pages/general-settings/components/SortableVariableList.tsx index ea002f5263..f9d4103608 100644 --- a/src/pages/settings/invoice-design/pages/general-settings/components/SortableVariableList.tsx +++ b/src/pages/settings/invoice-design/pages/general-settings/components/SortableVariableList.tsx @@ -28,6 +28,7 @@ import { useDispatch } from 'react-redux'; interface Props { defaultVariables: { value: string; label: string }[]; for: string; + disabled?: boolean; } export function SortableVariableList(props: Props) { @@ -35,6 +36,8 @@ export function SortableVariableList(props: Props) { const company = useCompanyChanges(); const dispatch = useDispatch(); + const { disabled } = props; + const defaultVariables = props.defaultVariables; const [defaultVariablesFiltered, setDefaultVariablesFiltered] = @@ -107,7 +110,7 @@ export function SortableVariableList(props: Props) { return ( <> - + {defaultVariablesFiltered.map((option, index) => ( @@ -120,12 +123,17 @@ export function SortableVariableList(props: Props) { - + {(provided) => (
{company?.settings?.pdf_variables?.[props.for]?.map( (label: string, index: number) => ( - + {(provided) => (
remove(label)} behavior="button" + disableWithoutIcon={disabled} + disabled={disabled} >