diff --git a/assets/react/v3/entries/payment-settings/components/App.tsx b/assets/react/v3/entries/payment-settings/components/App.tsx index 643840103b..273e3dc2ef 100644 --- a/assets/react/v3/entries/payment-settings/components/App.tsx +++ b/assets/react/v3/entries/payment-settings/components/App.tsx @@ -1,6 +1,6 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState } from 'react'; +import { lazy, Suspense, useState } from 'react'; import ToastProvider from '@Atoms/Toast'; @@ -8,8 +8,9 @@ import RTLProvider from '@Components/RTLProvider'; import { ModalProvider } from '@Components/modals/Modal'; import { createGlobalCss } from '@Utils/style-utils'; -import PaymentSettings from './PaymentSettings'; import { PaymentProvider } from '../contexts/payment-context'; +import { LoadingSection } from '@/v3/shared/atoms/LoadingSpinner'; +const PaymentSettings = lazy(() => import('./PaymentSettings')); function App() { const [queryClient] = useState( @@ -36,7 +37,9 @@ function App() { - + }> + + diff --git a/assets/react/v3/entries/pro/membership-settings/components/App.tsx b/assets/react/v3/entries/pro/membership-settings/components/App.tsx new file mode 100644 index 0000000000..9e3e5cf5d4 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/App.tsx @@ -0,0 +1,52 @@ +import { lazy, Suspense, useState } from 'react'; +import { Global } from '@emotion/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; + +import ToastProvider from '@Atoms/Toast'; + +import RTLProvider from '@Components/RTLProvider'; +import { ModalProvider } from '@Components/modals/Modal'; + +import { createGlobalCss } from '@Utils/style-utils'; +import { LoadingSection } from '@/v3/shared/atoms/LoadingSpinner'; +const MembershipSettings = lazy(() => import('./MembershipSettings')); + +function App() { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + networkMode: 'always', + }, + mutations: { + retry: false, + networkMode: 'always', + }, + }, + }), + ); + + return ( + + + + + + + }> + + + + + + + + ); +} + +export default App; diff --git a/assets/react/v3/entries/pro/membership-settings/components/Categories.tsx b/assets/react/v3/entries/pro/membership-settings/components/Categories.tsx new file mode 100644 index 0000000000..74d07be7a0 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/Categories.tsx @@ -0,0 +1,85 @@ +import Show from '@/v3/shared/controls/Show'; +import { type MembershipFormData } from '../services/memberships'; +import For from '@/v3/shared/controls/For'; +import CategoryItem from './CategoryItem'; +import { __, sprintf } from '@wordpress/i18n'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import { useModal } from '@/v3/shared/components/modals/Modal'; +import Button from '@/v3/shared/atoms/Button'; +import CourseCategorySelectModal from '@/v3/shared/components/modals/CourseCategorySelectModal'; +import { css } from '@emotion/react'; +import { borderRadius, colorTokens } from '@/v3/shared/config/styles'; +import { type UseFormReturn } from 'react-hook-form'; + +interface CategoriesProps { + form: UseFormReturn; +} + +export default function Categories({ form }: CategoriesProps) { + const { showModal } = useModal(); + const categories = form.watch('categories'); + + return ( + <> + +
+ + {(category) => ( + { + form.setValue( + 'categories', + categories.filter((item) => item.id !== category.id), + { shouldDirty: true }, + ); + }} + /> + )} + +
+
+ + + + ); +} + +const styles = { + categoriesWrapper: css` + background-color: ${colorTokens.background.white}; + border: 1px solid ${colorTokens.stroke.divider}; + border-radius: ${borderRadius[6]}; + `, + addCategoriesButton: css` + width: fit-content; + background-color: ${colorTokens.background.white}; + color: ${colorTokens.text.brand}; + + svg, + :active svg { + color: ${colorTokens.text.brand} !important; + } + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/CategoryItem.tsx b/assets/react/v3/entries/pro/membership-settings/components/CategoryItem.tsx new file mode 100644 index 0000000000..395ad97860 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/CategoryItem.tsx @@ -0,0 +1,67 @@ +import Button from '@/v3/shared/atoms/Button'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import { borderRadius, colorTokens, spacing } from '@/v3/shared/config/styles'; +import { typography } from '@/v3/shared/config/typography'; +import { css } from '@emotion/react'; +import { type ReactNode } from 'react'; +import coursePlaceholder from '@Images/course-placeholder.png'; + +interface CategoryItemProps { + image: string; + title: string; + subTitle: string | ReactNode; + handleDeleteClick: () => void; +} + +export default function CategoryItem({ image, title, subTitle, handleDeleteClick }: CategoryItemProps) { + return ( +
+
+ course item +
+
+
{title}
+
{subTitle}
+
+
+ +
+
+ ); +} + +const styles = { + selectedItem: css` + padding: ${spacing[12]}; + display: flex; + align-items: center; + gap: ${spacing[16]}; + + &:not(:last-child) { + border-bottom: 1px solid ${colorTokens.stroke.divider}; + } + `, + selectedContent: css` + width: 100%; + `, + selectedTitle: css` + ${typography.small()}; + color: ${colorTokens.text.primary}; + margin-bottom: ${spacing[4]}; + `, + selectedSubTitle: css` + ${typography.small()}; + color: ${colorTokens.text.hints}; + `, + selectedThumb: css` + height: 48px; + `, + thumbnail: css` + width: 48px; + height: 48px; + border-radius: ${borderRadius[4]}; + object-fit: cover; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/IconsAndFeatures.tsx b/assets/react/v3/entries/pro/membership-settings/components/IconsAndFeatures.tsx new file mode 100644 index 0000000000..dca0d3157c --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/IconsAndFeatures.tsx @@ -0,0 +1,111 @@ +import Button from '@/v3/shared/atoms/Button'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import { borderRadius, colorTokens, spacing } from '@/v3/shared/config/styles'; +import { typography } from '@/v3/shared/config/typography'; +import For from '@/v3/shared/controls/For'; +import Show from '@/v3/shared/controls/Show'; +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import FormFeatureItem from './fields/FormFeatureItem'; +import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { nanoid } from '@/v3/shared/utils/util'; +import { restrictToParentElement } from '@dnd-kit/modifiers'; + +export default function IconsAndFeatures() { + const form = useFormContext(); + const { fields, append, remove, move } = useFieldArray({ + control: form.control, + name: 'features', + }); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( +
+
+ + +
+ 0}> +
+ { + const { active, over } = event; + if (!over) { + return; + } + + if (active.id !== over.id) { + const activeIndex = fields.findIndex((item) => item.id === active.id); + const overIndex = fields.findIndex((item) => item.id === over.id); + + move(activeIndex, overIndex); + } + }} + > + + + {(item, index) => ( + !!value?.content || __('Content is required', 'tutor'), + }} + render={(controllerProps) => ( + remove(index)} /> + )} + /> + )} + + + +
+
+
+ ); +} + +const styles = { + wrapper: css` + background-color: ${colorTokens.background.white}; + border: 1px solid ${colorTokens.stroke.divider}; + border-radius: ${borderRadius[6]}; + padding: ${spacing[12]} ${spacing[16]}; + `, + header: css` + display: flex; + align-items: center; + justify-content: space-between; + + label { + ${typography.caption()}; + color: ${colorTokens.text.title}; + } + + button { + color: ${colorTokens.icon.default}; + border: 1px solid ${colorTokens.stroke.default}; + border-radius: ${borderRadius[4]}; + padding: 3px; + } + `, + features: css` + display: flex; + flex-direction: column; + gap: ${spacing[8]}; + padding: ${spacing[12]} 0 ${spacing[8]}; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/MembershipFormFields.tsx b/assets/react/v3/entries/pro/membership-settings/components/MembershipFormFields.tsx new file mode 100644 index 0000000000..78c7af448f --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/MembershipFormFields.tsx @@ -0,0 +1,444 @@ +import { css } from '@emotion/react'; +import { __, sprintf } from '@wordpress/i18n'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { borderRadius, Breakpoint, colorTokens, spacing } from '@Config/styles'; +import { typography } from '@Config/typography'; +import FormInput from '@/v3/shared/components/fields/FormInput'; +import FormInputWithContent from '@/v3/shared/components/fields/FormInputWithContent'; +import { styleUtils } from '@/v3/shared/utils/style-utils'; +import FormSelectInput from '@/v3/shared/components/fields/FormSelectInput'; +import { requiredRule } from '@Utils/validation'; +import { tutorConfig } from '@/v3/shared/config/config'; +import FormInputWithPresets from '@/v3/shared/components/fields/FormInputWithPresets'; +import FormCheckbox from '@/v3/shared/components/fields/FormCheckbox'; +import Show from '@/v3/shared/controls/Show'; +import FormTimeInput from '@/v3/shared/components/fields/FormTimeInput'; +import FormDateInput from '@/v3/shared/components/fields/FormDateInput'; +import FormSwitch from '@/v3/shared/components/fields/FormSwitch'; +import { CURRENT_VIEWPORT } from '@/v3/shared/config/constants'; + +import FormRadioGroup from '@/v3/shared/components/fields/FormRadioGroup'; +import IconsAndFeatures from './IconsAndFeatures'; +import Categories from './Categories'; +import { type MembershipFormData } from '../services/memberships'; +const { tutor_currency } = tutorConfig; + +export default function MembershipFormFields() { + const form = useFormContext(); + + const chargeEnrolmentFee = form.watch('charge_enrollment_fee'); + const hasSale = form.watch('offer_sale_price'); + const regularPrice = form.watch('regular_price'); + const hasScheduledSale = !!form.watch('schedule_sale_price'); + const isFeatured = !!form.watch('is_featured'); + + const lifetimePresets = [3, 6, 9, 12]; + const lifetimeOptions = [ + ...lifetimePresets.map((preset) => ({ + label: sprintf(__('%s times', 'tutor'), preset.toString()), + value: String(preset), + })), + { + label: __('Until cancelled', 'tutor'), + value: 'Until cancelled', + }, + ]; + + const planType = form.watch('plan_type'); + const planTypeOptions = [ + { label: __('Full Site', 'tutor'), value: 'full_site' }, + { label: __('Specific Categories', 'tutor'), value: 'category' }, + ]; + + return ( +
+ ( + + )} + /> + + ( + + )} + /> + +
+ { + if (Number(value) <= 0) { + return __('Price must be greater than 0', 'tutor'); + } + }, + }} + render={(controllerProps) => ( + + )} + /> + + { + if (Number(value) < 1) { + return __('This value must be equal to or greater than 1', 'tutor'); + } + }, + }} + render={(controllerProps) => ( + + )} + /> + + ( +  
: __('Recurring Options', 'tutor')} + options={[ + { label: __('Day(s)', 'tutor'), value: 'day' }, + { label: __('Week(s)', 'tutor'), value: 'week' }, + { label: __('Month(s)', 'tutor'), value: 'month' }, + { label: __('Year(s)', 'tutor'), value: 'year' }, + ]} + removeOptionsMinWidth + /> + )} + /> + + { + if (value === 'Until cancelled') { + return true; + } + + if (Number(value) <= 0) { + return __('Renew plan must be greater than 0', 'tutor'); + } + return true; + }, + }} + render={(controllerProps) => ( + + )} + /> +
+ + ( + + )} + /> + + + + + + + + } + /> + + + { + if (Number(value) <= 0) { + return __('Enrollment fee must be greater than 0', 'tutor'); + } + return true; + }, + }} + render={(controllerProps) => ( + + )} + /> + + + ( + + )} + /> + + } + /> + + + } + /> + + +
+
+ } + /> +
+ +
+ { + if (value && regularPrice && Number(value) >= Number(regularPrice)) { + return __('Sale price should be less than regular price', 'tutor'); + } + + if (value && regularPrice && Number(value) <= 0) { + return __('Sale price should be greater than 0', 'tutor'); + } + + return undefined; + }, + }} + render={(props) => ( + + )} + /> + + } + /> + + +
+ +
+ ( + + )} + /> + + ( + + )} + /> +
+
+
+ +
+ { + const startDate = form.watch(`sale_price_from_date`); + const endDate = value; + if (startDate && endDate) { + return new Date(startDate) > new Date(endDate) + ? __('Sales End date should be greater than start date', 'tutor') + : undefined; + } + return undefined; + }, + }, + deps: ['sale_price_from_date'], + }} + render={(controllerProps) => ( + + )} + /> + + { + const startDate = form.watch(`sale_price_from_date`); + const startTime = form.watch(`sale_price_from_time`); + const endDate = form.watch(`sale_price_to_date`); + const endTime = value; + if (startDate && endDate && startTime && endTime) { + return new Date(`${startDate} ${startTime}`) > new Date(`${endDate} ${endTime}`) + ? __('Sales End time should be greater than start time', 'tutor') + : undefined; + } + return undefined; + }, + }, + deps: ['sale_price_from_date', 'sale_price_from_time', 'sale_price_to_date'], + }} + render={(controllerProps) => ( + + )} + /> +
+
+
+
+
+
+ + ); +} + +const styles = { + container: css` + width: 100%; + max-width: 640px; + margin: 0 auto; + border: 1px solid ${colorTokens.stroke.default}; + border-radius: ${borderRadius.card}; + padding: ${spacing[16]}; + + display: flex; + flex-direction: column; + gap: ${spacing[12]}; + `, + salePriceWrapper: css` + background-color: ${colorTokens.background.white}; + display: flex; + flex-direction: column; + gap: ${spacing[20]}; + + padding: ${spacing[12]}; + border: 1px solid ${colorTokens.stroke.default}; + border-radius: ${borderRadius.card}; + `, + salePriceInputs: css` + display: flex; + flex-direction: column; + gap: ${spacing[8]}; + `, + inputGroup: css` + display: grid; + grid-template-columns: 1fr 0.7fr 1fr 1fr; + align-items: start; + gap: ${spacing[8]}; + + ${Breakpoint.mobile} { + grid-template-columns: 1fr; + } + `, + datetimeWrapper: css` + label { + ${typography.caption()}; + color: ${colorTokens.text.title}; + } + `, + planTypeWrapper: css` + display: flex; + gap: ${spacing[8]}; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/MembershipItem.tsx b/assets/react/v3/entries/pro/membership-settings/components/MembershipItem.tsx new file mode 100644 index 0000000000..be4a97e603 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/MembershipItem.tsx @@ -0,0 +1,284 @@ +import { + type MembershipSettings, + type MembershipPlan, + useDeleteMembershipPlanMutation, + type DurationUnit, + useDuplicateMembershipPlanMutation, +} from '../services/memberships'; +import MembershipModal from './modals/MembershipModal'; +import { __, sprintf } from '@wordpress/i18n'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import { useModal } from '@/v3/shared/components/modals/Modal'; +import { css } from '@emotion/react'; +import { borderRadius, colorTokens, fontSize, fontWeight, lineHeight, spacing } from '@/v3/shared/config/styles'; +import { Controller, useFormContext } from 'react-hook-form'; +import FormSwitch from '@/v3/shared/components/fields/FormSwitch'; +import ThreeDots from '@/v3/shared/molecules/ThreeDots'; +import { useState } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { animateLayoutChanges } from '@/v3/shared/utils/dndkit'; +import { CSS } from '@dnd-kit/utilities'; +import StaticConfirmationModal from '@/v3/shared/components/modals/StaticConfirmationModal'; +import Show from '@/v3/shared/controls/Show'; +import { formatPrice } from '@/v3/shared/utils/currency'; +import { makeFirstCharacterUpperCase } from '@/v3/shared/utils/util'; +import { AnimationType } from '@/v3/shared/hooks/useAnimation'; + +interface MembershipItemProps { + data: MembershipPlan; + index: number; +} + +function formatRepeatUnit(unit: Omit, value: number) { + switch (unit) { + case 'hour': + return value > 1 ? __('Hours', 'tutor') : __('Hour', 'tutor'); + case 'day': + return value > 1 ? __('Days', 'tutor') : __('Day', 'tutor'); + case 'week': + return value > 1 ? __('Weeks', 'tutor') : __('Week', 'tutor'); + case 'month': + return value > 1 ? __('Months', 'tutor') : __('Month', 'tutor'); + case 'year': + return value > 1 ? __('Years', 'tutor') : __('Year', 'tutor'); + case 'until_cancellation': + return __('Until Cancellation', 'tutor'); + } +} + +export default function MembershipItem({ data, index }: MembershipItemProps) { + const form = useFormContext(); + const { showModal } = useModal(); + + const [isOpen, setIsOpen] = useState(false); + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: data.id, + animateLayoutChanges, + }); + + const duplicateMembershipPlanMutation = useDuplicateMembershipPlanMutation(); + const deleteMembershipPlanMutation = useDeleteMembershipPlanMutation(); + + const style = { + transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null), + transition, + zIndex: isDragging ? 1 : 0, + }; + + return ( +
+ + +
+ +
+
+ {data.plan_name} + +
+ {sprintf( + __('%s per %s', 'tutor'), + formatPrice(Number(data.regular_price)), + makeFirstCharacterUpperCase(data.recurring_interval), + )} +
+
+

+ + {sprintf( + __('Renews every %s %s', 'tutor'), + data.recurring_value.toString().padStart(2, '0'), + formatRepeatUnit(data.recurring_interval, Number(data.recurring_value)), + )} + + + | + {__('Certificate available', 'tutor')} + + + | + {sprintf(__('%s Times', 'tutor'), data.recurring_limit.toString().padStart(2, '0'))} + + } + > + | + {__('Until Cancellation', 'tutor')} + +

+
+
+ +
+ } + /> + { + setIsOpen(true); + }} + closePopover={() => setIsOpen(false)} + > + } + onClick={() => { + showModal({ + component: MembershipModal, + props: { + title: __('Update Membership', 'tutor'), + icon: , + plan: data, + }, + depthIndex: 9999, + }); + }} + onClosePopover={() => setIsOpen(false)} + /> + } + onClick={() => { + duplicateMembershipPlanMutation.mutate(data.id); + }} + onClosePopover={() => setIsOpen(false)} + /> + } + isTrash={true} + onClick={async () => { + const { action } = await showModal({ + component: StaticConfirmationModal, + props: { + title: __('Are you sure to delete this?', 'tutor'), + icon: , + }, + depthIndex: 9999, + }); + + if (action === 'CONFIRM') { + deleteMembershipPlanMutation.mutate(data.id); + } + }} + onClosePopover={() => setIsOpen(false)} + /> + +
+
+ ); +} + +const styles = { + wrapper: css` + background-color: ${colorTokens.background.white}; + padding: ${spacing[16]} ${spacing[24]}; + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + + &:hover { + [data-drag-button] { + display: block; + } + } + + &:not(:last-of-type) { + border-bottom: 1px solid ${colorTokens.stroke.divider}; + } + + &:first-of-type { + border-top-left-radius: ${borderRadius[6]}; + border-top-right-radius: ${borderRadius[6]}; + } + + &:last-of-type { + border-bottom-left-radius: ${borderRadius[6]}; + border-bottom-right-radius: ${borderRadius[6]}; + } + `, + content: css` + display: flex; + align-items: center; + gap: ${spacing[12]}; + + svg { + color: ${colorTokens.icon.default}; + } + `, + planInfo: css` + display: flex; + flex-direction: column; + gap: ${spacing[6]}; + `, + planTitle: css` + display: flex; + align-items: center; + gap: ${spacing[8]}; + + font-size: ${fontSize[16]}; + line-height: ${lineHeight[20]}; + font-weight: ${fontWeight.regular}; + color: ${colorTokens.text.primary}; + + strong { + font-weight: ${fontWeight.medium}; + } + + span { + height: 2px; + width: 2px; + border-radius: ${borderRadius.circle}; + background-color: ${colorTokens.icon.default}; + } + `, + planPerMonth: css` + color: ${colorTokens.text.title}; + `, + planFeatures: css` + font-size: ${fontSize[11]}; + line-height: ${lineHeight[16]}; + color: ${colorTokens.text.hints}; + `, + actions: css` + display: flex; + align-items: center; + gap: ${spacing[16]}; + `, + dragButton: css` + display: flex; + align-items: center; + padding: 0; + color: ${colorTokens.icon.default}; + background: transparent; + border: none; + cursor: grab; + + position: absolute; + height: 100%; + left: -${spacing[24]}; + top: 0; + display: none; + + :focus-visible { + border-radius: ${borderRadius[4]}; + outline: 2px solid ${colorTokens.stroke.brand}; + } + `, + pipe: css` + display: inline-block; + color: ${colorTokens.stroke.divider}; + padding-inline: ${spacing[8]}; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/MembershipList.tsx b/assets/react/v3/entries/pro/membership-settings/components/MembershipList.tsx new file mode 100644 index 0000000000..c9186b5782 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/MembershipList.tsx @@ -0,0 +1,88 @@ +import Button from '@/v3/shared/atoms/Button'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import For from '@/v3/shared/controls/For'; +import { __ } from '@wordpress/i18n'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import MembershipItem from './MembershipItem'; +import { type MembershipSettings } from '../services/memberships'; +import { css } from '@emotion/react'; +import { colorTokens, spacing } from '@/v3/shared/config/styles'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToParentElement } from '@dnd-kit/modifiers'; + +interface MembershipListProps { + onNewMembershipClick: () => void; +} + +export default function MembershipList({ onNewMembershipClick }: MembershipListProps) { + const form = useFormContext(); + + const { fields, move } = useFieldArray({ + control: form.control, + name: 'plans', + keyName: '_id', + }); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( +
+
+ { + const { active, over } = event; + if (!over) { + return; + } + + if (active.id !== over.id) { + const activeIndex = fields.findIndex((item) => item.id === active.id); + const overIndex = fields.findIndex((item) => item.id === over.id); + + move(activeIndex, overIndex); + } + }} + > +
+ + {(item, idx) => } + +
+
+
+ +
+ +
+
+ ); +} + +const styles = { + wrapper: css` + display: flex; + flex-direction: column; + gap: ${spacing[16]}; + `, + membershipList: css` + background-color: ${colorTokens.background.white}; + border: 1px solid ${colorTokens.stroke.divider}; + border-radius: ${spacing[6]}; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/MembershipSettings.tsx b/assets/react/v3/entries/pro/membership-settings/components/MembershipSettings.tsx new file mode 100644 index 0000000000..13db1457cf --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/MembershipSettings.tsx @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import { FormProvider } from 'react-hook-form'; +import { css } from '@emotion/react'; +import { LoadingSection } from '@Atoms/LoadingSpinner'; +import Show from '@Controls/Show'; +import { spacing } from '@Config/styles'; +import { useFormWithGlobalError } from '@Hooks/useFormWithGlobalError'; +import { type MembershipSettings, useMembershipSettingsQuery } from '../services/memberships'; +import EmptyState from '../molecules/EmptyState'; +import { useModal } from '@/v3/shared/components/modals/Modal'; +import MembershipModal from './modals/MembershipModal'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import { __ } from '@wordpress/i18n'; +import MembershipList from './MembershipList'; + +function MembershipSettings() { + const { showModal } = useModal(); + const form = useFormWithGlobalError({ + defaultValues: { + plans: [], + }, + }); + + const { reset } = form; + + const membershipSettingsQuery = useMembershipSettingsQuery(); + + const memberships = membershipSettingsQuery.data?.length ? membershipSettingsQuery.data : form.getValues('plans'); + + const formData = form.watch(); + + useEffect(() => { + if (form.formState.isDirty) { + document.getElementById('save_tutor_option')?.removeAttribute('disabled'); + } + }, [form.formState.isDirty]); + + useEffect(() => { + if (membershipSettingsQuery.data) { + const updatedPlans = membershipSettingsQuery.data?.map((item) => ({ + ...item, + is_enabled: !!Number(item.is_enabled), + })); + reset({ plans: updatedPlans }); + } + }, [reset, membershipSettingsQuery.data]); + + if (membershipSettingsQuery.isLoading) { + return ; + } + + function handleNewMembershipClick() { + showModal({ + component: MembershipModal, + props: { + title: __('Create Membership', 'tutor'), + icon: , + }, + depthIndex: 9999, + }); + } + + return ( +
+ }> + + + + + + ({ id, plan_name, is_enabled })), + })} + /> +
+ ); +} + +export default MembershipSettings; + +const styles = { + wrapper: css` + display: flex; + flex-direction: column; + gap: ${spacing[16]}; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/fields/FormFeatureItem.tsx b/assets/react/v3/entries/pro/membership-settings/components/fields/FormFeatureItem.tsx new file mode 100644 index 0000000000..c472405927 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/fields/FormFeatureItem.tsx @@ -0,0 +1,274 @@ +import Button from '@/v3/shared/atoms/Button'; +import SVGIcon from '@/v3/shared/atoms/SVGIcon'; +import FormFieldWrapper from '@/v3/shared/components/fields/FormFieldWrapper'; +import { animateLayoutChanges } from '@/v3/shared/utils/dndkit'; +import { borderRadius, Breakpoint, colorTokens, shadow, spacing, zIndex } from '@/v3/shared/config/styles'; +import { typography } from '@/v3/shared/config/typography'; +import For from '@/v3/shared/controls/For'; +import { type FormControllerProps } from '@/v3/shared/utils/form'; +import { css } from '@emotion/react'; +import { CSS } from '@dnd-kit/utilities'; +import { useState } from 'react'; +import { featureIcons } from '../../config/constants'; +import { Portal, usePortalPopover } from '@/v3/shared/hooks/usePortalPopover'; +import { isRTL } from '@/v3/shared/config/constants'; +import { __ } from '@wordpress/i18n'; +import { useSortable } from '@dnd-kit/sortable'; + +export interface Feature { + id: string; + icon: string; + content: string; +} + +interface FormFeatureItemProps extends FormControllerProps { + id: string; + handleDeleteClick: () => void; +} + +export default function FormFeatureItem({ id, field, fieldState, handleDeleteClick }: FormFeatureItemProps) { + const [isOpen, setIsOpen] = useState(false); + const { triggerRef, position, popoverRef } = usePortalPopover({ + isOpen, + }); + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: id, + animateLayoutChanges, + }); + + const style = { + transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null), + transition, + zIndex: isDragging ? 1 : 0, + }; + + function handleIconChange(icon: keyof typeof featureIcons) { + field.onChange({ ...field.value, icon: icon }); + } + + function handleContentChange(content: string) { + field.onChange({ ...field.value, content }); + } + + return ( +
+ + {(inputProps) => { + return ( + <> +
+ + +
+ + { + setIsOpen(false); + }} + onEscape={() => { + setIsOpen(false); + }} + > +
+
+ + +
+
+ + {(icon: keyof typeof featureIcons) => { + return ( +
+
+
+ + ); + }} +
+
+ ); +} + +const styles = { + featureItem: css` + position: relative; + display: flex; + + &:hover { + button[data-delete-button] { + opacity: 1; + } + } + `, + input: css` + &.tutor-input-field { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding: ${spacing[4]} ${spacing[36]} ${spacing[4]} ${spacing[8]}; + + &:focus { + border-radius: ${borderRadius[6]}; + } + } + `, + iconSelector: css` + height: 40px; + display: flex; + align-items: center; + background-color: ${colorTokens.background.white}; + color: ${colorTokens.icon.hover}; + border: 1px solid ${colorTokens.stroke.default}; + border-right: none; + border-top-left-radius: ${borderRadius[6]}; + border-bottom-left-radius: ${borderRadius[6]}; + cursor: pointer; + transition: background-color 0.25s; + + :hover { + background-color: ${colorTokens.background.hover}; + } + + :focus-visible { + border-radius: ${borderRadius[4]}; + outline: 2px solid ${colorTokens.stroke.brand}; + outline-offset: 2px; + z-index: 1; + } + `, + dragButton: css` + display: flex; + align-items: center; + padding: 0; + color: ${colorTokens.icon.default}; + background: transparent; + border: none; + cursor: grab; + + :focus-visible { + border-radius: ${borderRadius[4]}; + outline: 2px solid ${colorTokens.stroke.brand}; + } + `, + deleteButton: css` + display: flex; + position: absolute; + right: ${spacing[12]}; + top: ${spacing[8]}; + padding: 0; + color: ${colorTokens.icon.default}; + background: transparent; + border: none; + cursor: pointer; + opacity: 0; + transition: opacity 0.25s; + + :focus-visible { + border-radius: ${borderRadius[2]}; + outline: 2px solid ${colorTokens.stroke.brand}; + outline-offset: 2px; + opacity: 1; + } + + ${Breakpoint.mobile} { + opacity: 1; + } + `, + popoverWrapper: css` + position: absolute; + width: 100%; + z-index: ${zIndex.dropdown}; + background-color: ${colorTokens.background.white}; + box-shadow: ${shadow.popover}; + border-radius: ${borderRadius[6]}; + max-height: 300px; + overflow-y: auto; + `, + popoverHeader: css` + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid ${colorTokens.stroke.divider}; + padding: ${spacing[8]}; + + label { + ${typography.caption('medium')}; + color: ${colorTokens.text.title}; + } + + button { + padding: 0px; + } + `, + popoverContent: css` + display: flex; + flex-wrap: wrap; + gap: ${spacing[8]}; + padding: ${spacing[12]}; + `, + popoverContentButton: css` + display: flex; + background-color: ${colorTokens.background.default}; + color: ${colorTokens.icon.hover}; + border: none; + border-radius: ${borderRadius[4]}; + padding: ${spacing[8]}; + cursor: pointer; + transition: + background-color 0.25s, + box-shadow 0.25s; + + :hover { + background-color: ${colorTokens.background.hover}; + box-shadow: inset 0px 0px 0px 1px ${colorTokens.action.primary.hover}; + } + + :focus-visible { + border-radius: ${borderRadius[6]}; + outline: 2px solid ${colorTokens.stroke.brand}; + outline-offset: 2px; + z-index: 1; + } + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/components/modals/MembershipModal.tsx b/assets/react/v3/entries/pro/membership-settings/components/modals/MembershipModal.tsx new file mode 100644 index 0000000000..6dd3f71c38 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/components/modals/MembershipModal.tsx @@ -0,0 +1,103 @@ +import { lazy, Suspense, useEffect } from 'react'; +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { FormProvider } from 'react-hook-form'; + +import Button from '@Atoms/Button'; +import SVGIcon from '@Atoms/SVGIcon'; + +import { type ModalProps } from '@Components/modals/Modal'; +import ModalWrapper from '@Components/modals/ModalWrapper'; + +import { Breakpoint, spacing } from '@Config/styles'; +import { useFormWithGlobalError } from '@Hooks/useFormWithGlobalError'; +import { + convertFormDataToPayload, + convertPlanToFormData, + defaultValues, + type MembershipPlan, + useSaveMembershipPlanMutation, +} from '../../services/memberships'; +import { LoadingSection } from '@/v3/shared/atoms/LoadingSpinner'; +const MembershipFormFields = lazy(() => import('../MembershipFormFields')); + +interface MembershipModalProps extends ModalProps { + closeModal: (props?: { action: 'CONFIRM' | 'CLOSE' }) => void; + plan?: MembershipPlan; +} + +export default function MembershipModal({ title, subtitle, icon, plan, closeModal }: MembershipModalProps) { + const form = useFormWithGlobalError({ + defaultValues: plan ? convertPlanToFormData(plan) : defaultValues, + }); + + const saveMembershipPlanMutation = useSaveMembershipPlanMutation(); + + useEffect(() => { + if (plan) { + form.reset(convertPlanToFormData(plan)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plan]); + + function handleSubmit() { + form.handleSubmit(async (data) => { + const payload = convertFormDataToPayload(data); + const response = await saveMembershipPlanMutation.mutateAsync(payload); + if (response.status_code === 200 || response.status_code === 201) { + closeModal({ action: 'CONFIRM' }); + } + })(); + } + + const isFormDirty = form.formState.isDirty; + + return ( + + closeModal({ action: 'CLOSE' })} + icon={isFormDirty ? : icon} + title={isFormDirty ? __('Unsaved Changes', 'tutor') : title} + subtitle={isFormDirty ? title?.toString() : subtitle} + actions={ + <> + + + + } + > +
+ }> + + +
+
+
+ ); +} + +const styles = { + wrapper: css` + padding: ${spacing[40]} ${spacing[16]}; + + ${Breakpoint.mobile} { + padding: ${spacing[24]} ${spacing[16]}; + } + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/config/constants.ts b/assets/react/v3/entries/pro/membership-settings/config/constants.ts new file mode 100644 index 0000000000..e1a2b6fc7f --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/config/constants.ts @@ -0,0 +1,29 @@ +export const featureIcons = { + tick_circle: + '', + cross_circle: + '', + tick: '', + cross: + '', + plus_square: + '', + minus_square: + '', + plus_circle: + '', + minus_circle: + '', + tick_circle_fill: + '', + cross_circle_fill: + '', + plus_circle_fill: + '', + minus_circle_fill: + '', + plus_square_fill: + '', + minus_square_fill: + '', +}; diff --git a/assets/react/v3/entries/pro/membership-settings/index.tsx b/assets/react/v3/entries/pro/membership-settings/index.tsx new file mode 100644 index 0000000000..71ce68ac49 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { HashRouter } from 'react-router-dom'; + +import ErrorBoundary from '@Components/ErrorBoundary'; +import App from './components/App'; + +const element = document.getElementById('tutor-membership-settings'); +if (element) { + const root = createRoot(element as HTMLElement); + root.render( + + + + + + + , + ); +} else { + console.error('Target element not found.'); +} diff --git a/assets/react/v3/entries/pro/membership-settings/molecules/EmptyState.tsx b/assets/react/v3/entries/pro/membership-settings/molecules/EmptyState.tsx new file mode 100644 index 0000000000..58932d77a5 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/molecules/EmptyState.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import SVGIcon from '@Atoms/SVGIcon'; +import Button from '@Atoms/Button'; +import { borderRadius, colorTokens, lineHeight, spacing } from '@Config/styles'; +import { typography } from '@Config/typography'; +import emptyStateImage from '@Images/membership-empty-state.webp'; + +interface EmptyStateProps { + onActionClick: () => void; +} + +const EmptyState = ({ onActionClick }: EmptyStateProps) => { + return ( +
+ {__('No +
{__('No Membership Added Yet', 'tutor')}
+
{__('Set up memberships or package plans to sell on your site.', 'tutor')}
+ +
+ ); +}; + +export default EmptyState; + +const styles = { + wrapper: css` + display: flex; + align-items: center; + flex-direction: column; + gap: ${spacing[8]}; + + background-color: ${colorTokens.background.white}; + border: 1px solid ${colorTokens.stroke.divider}; + border-radius: ${borderRadius[6]}; + padding: ${spacing[32]} ${spacing[24]}; + + img { + max-width: 234px; + } + `, + title: css` + ${typography.heading6('medium')}; + line-height: ${lineHeight[28]}; + `, + content: css` + ${typography.body()}; + line-height: ${lineHeight[22]}; + color: ${colorTokens.text.title}; + margin-bottom: ${spacing[12]}; + max-width: 306px; + text-align: center; + `, +}; diff --git a/assets/react/v3/entries/pro/membership-settings/services/memberships.ts b/assets/react/v3/entries/pro/membership-settings/services/memberships.ts new file mode 100644 index 0000000000..3266075db4 --- /dev/null +++ b/assets/react/v3/entries/pro/membership-settings/services/memberships.ts @@ -0,0 +1,272 @@ +import { useToast } from '@/v3/shared/atoms/Toast'; +import { DateFormats } from '@/v3/shared/config/constants'; +import { type ErrorResponse } from '@/v3/shared/utils/form'; +import { type TutorMutationResponse } from '@/v3/shared/utils/types'; +import { convertGMTtoLocalDate, convertToErrorMessage, convertToGMT } from '@/v3/shared/utils/util'; +import { wpAjaxInstance } from '@Utils/api'; +import endpoints from '@Utils/endpoints'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { format } from 'date-fns'; +import { type Feature } from '../components/fields/FormFeatureItem'; + +export interface Category { + id: number; + title: string; + image: string; + total_courses: string; +} + +export interface MembershipPlan { + id: string; + is_enabled: boolean; + plan_name: string | null; + plan_order: string; + plan_type: 'full_site' | 'category'; + categories: Category[]; + payment_type: string; + short_description: string | null; + description: string | null; + provide_certificate: '0' | '1'; + recurring_interval: string; + recurring_limit: string; + recurring_value: string; + regular_price: string; + sale_price: string | null; + sale_price_from: string | null; + sale_price_to: string | null; + enrollment_fee: string | null; + is_featured: '0' | '1'; + featured_text: string | null; +} + +export interface MembershipSettings { + plans: MembershipPlan[]; +} + +export interface MembershipFormData { + id?: string; + plan_name: string; + plan_type: 'full_site' | 'category'; + short_description: string; + features: Feature[]; + categories: Category[]; + recurring_value: string; + recurring_interval: string; + recurring_limit: string; + regular_price: string; + offer_sale_price: boolean; + sale_price: string; + schedule_sale_price: boolean; + sale_price_from_date: string; + sale_price_from_time: string; + sale_price_to_date: string; + sale_price_to_time: string; + charge_enrollment_fee: boolean; + enrollment_fee: string; + do_not_provide_certificate: boolean; + is_featured: boolean; + featured_text: string; +} + +export const defaultValues: MembershipFormData = { + plan_name: '', + plan_type: 'full_site', + short_description: '', + features: [], + categories: [], + recurring_value: '', + recurring_interval: '', + recurring_limit: '', + regular_price: '', + offer_sale_price: false, + sale_price: '', + schedule_sale_price: false, + sale_price_from_date: '', + sale_price_from_time: '', + sale_price_to_date: '', + sale_price_to_time: '', + charge_enrollment_fee: false, + enrollment_fee: '', + do_not_provide_certificate: false, + is_featured: false, + featured_text: '', +}; + +export type DurationUnit = 'hour' | 'day' | 'week' | 'month' | 'year'; + +export type MembershipPayload = { + id?: string; // only for update + plan_name: string; + short_description: string; + description: string; + plan_type: 'category' | 'full_site'; + cat_ids?: number[]; + recurring_value: string; + recurring_interval: Omit; + recurring_limit: string; // 0 for until canceled + regular_price: string; + sale_price?: string; + sale_price_from?: string; // start date + sale_price_to?: string; // end date + enrollment_fee?: string; + provide_certificate: '0' | '1'; + is_featured: '0' | '1'; + featured_text: string; +}; + +export const convertPlanToFormData = (plan: MembershipPlan): MembershipFormData => { + return { + id: plan.id, + plan_name: plan.plan_name ?? '', + short_description: plan.short_description ?? '', + regular_price: plan.regular_price ?? '0', + plan_type: plan.plan_type ?? 'course', + categories: plan.categories ?? [], + recurring_value: plan.recurring_value ?? '0', + recurring_interval: plan.recurring_interval ?? 'month', + recurring_limit: plan.recurring_limit === '0' ? 'Until cancelled' : plan.recurring_limit || '', + features: plan.description ? (JSON.parse(plan.description) as Feature[]) : [], + charge_enrollment_fee: !!Number(plan.enrollment_fee), + enrollment_fee: plan.enrollment_fee ?? '0', + do_not_provide_certificate: !Number(plan.provide_certificate), + is_featured: !!Number(plan.is_featured), + featured_text: plan.featured_text ?? '', + offer_sale_price: !!Number(plan.sale_price), + sale_price: plan.sale_price ?? '0', + schedule_sale_price: !!plan.sale_price_from, + sale_price_from_date: plan.sale_price_from + ? format(convertGMTtoLocalDate(plan.sale_price_from), DateFormats.yearMonthDay) + : '', + sale_price_from_time: plan.sale_price_from + ? format(convertGMTtoLocalDate(plan.sale_price_from), DateFormats.hoursMinutes) + : '', + sale_price_to_date: plan.sale_price_to + ? format(convertGMTtoLocalDate(plan.sale_price_to), DateFormats.yearMonthDay) + : '', + sale_price_to_time: plan.sale_price_to + ? format(convertGMTtoLocalDate(plan.sale_price_to), DateFormats.hoursMinutes) + : '', + }; +}; + +export const convertFormDataToPayload = (formData: MembershipFormData): MembershipPayload => { + return { + ...(formData.id && String(formData.id) !== '0' && { id: formData.id }), + plan_name: formData.plan_name, + short_description: formData.short_description, + description: JSON.stringify(formData.features), + plan_type: formData.plan_type, + ...(formData.plan_type === 'category' && { cat_ids: formData.categories.map((item) => item.id) }), + regular_price: formData.regular_price, + recurring_value: formData.recurring_value, + recurring_interval: formData.recurring_interval, + recurring_limit: formData.recurring_limit === 'Until cancelled' ? '0' : formData.recurring_limit, + is_featured: formData.is_featured ? '1' : '0', + featured_text: formData.featured_text, + ...(formData.charge_enrollment_fee && { enrollment_fee: formData.enrollment_fee }), + sale_price: formData.offer_sale_price ? formData.sale_price : '0', + ...(formData.schedule_sale_price && { + sale_price_from: convertToGMT(new Date(`${formData.sale_price_from_date} ${formData.sale_price_from_time}`)), + sale_price_to: convertToGMT(new Date(`${formData.sale_price_to_date} ${formData.sale_price_to_time}`)), + }), + provide_certificate: formData.do_not_provide_certificate ? '0' : '1', + }; +}; + +const getMembershipSettings = () => { + return wpAjaxInstance.get(endpoints.GET_MEMBERSHIP_PLANS).then((response) => response.data); +}; + +export const useMembershipSettingsQuery = () => { + return useQuery({ + queryKey: ['MembershipSettings'], + queryFn: getMembershipSettings, + }); +}; + +const saveMembershipPlan = (payload: MembershipPayload) => { + return wpAjaxInstance.post>(endpoints.SAVE_MEMBERSHIP_PLAN, { + ...(payload.id && { id: payload.id }), + ...payload, + }); +}; + +export const useSaveMembershipPlanMutation = () => { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: saveMembershipPlan, + onSuccess: (response) => { + if (response.status_code === 200 || response.status_code === 201) { + showToast({ + message: response.message, + type: 'success', + }); + + queryClient.invalidateQueries({ + queryKey: ['MembershipSettings'], + }); + } + }, + onError: (error: ErrorResponse) => { + showToast({ type: 'danger', message: convertToErrorMessage(error) }); + }, + }); +}; + +const duplicateMembershipPlan = (id: string) => { + return wpAjaxInstance.post>(endpoints.DUPLICATE_MEMBERSHIP_PLAN, { id }); +}; + +export const useDuplicateMembershipPlanMutation = () => { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: duplicateMembershipPlan, + onSuccess: (response) => { + if (response.status_code === 201) { + showToast({ + message: response.message, + type: 'success', + }); + + queryClient.invalidateQueries({ + queryKey: ['MembershipSettings'], + }); + } + }, + onError: (error: ErrorResponse) => { + showToast({ type: 'danger', message: convertToErrorMessage(error) }); + }, + }); +}; + +const deleteMembershipPlan = (id: string) => { + return wpAjaxInstance.post>(endpoints.DELETE_MEMBERSHIP_PLAN, { id }); +}; + +export const useDeleteMembershipPlanMutation = () => { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + return useMutation({ + mutationFn: deleteMembershipPlan, + onSuccess: (response) => { + if (response.status_code === 200) { + showToast({ + message: response.message, + type: 'success', + }); + + queryClient.invalidateQueries({ + queryKey: ['MembershipSettings'], + }); + } + }, + onError: (error: ErrorResponse) => { + showToast({ type: 'danger', message: convertToErrorMessage(error) }); + }, + }); +}; diff --git a/assets/react/v3/entries/tax-settings/components/App.tsx b/assets/react/v3/entries/tax-settings/components/App.tsx index 17e2232af7..fe2a580bc7 100644 --- a/assets/react/v3/entries/tax-settings/components/App.tsx +++ b/assets/react/v3/entries/tax-settings/components/App.tsx @@ -1,6 +1,6 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState } from 'react'; +import { lazy, Suspense, useState } from 'react'; import ToastProvider from '@Atoms/Toast'; @@ -8,7 +8,8 @@ import RTLProvider from '@Components/RTLProvider'; import { ModalProvider } from '@Components/modals/Modal'; import { createGlobalCss } from '@Utils/style-utils'; -import TaxSettingsPage from './TaxSettings'; +import { LoadingSection } from '@/v3/shared/atoms/LoadingSpinner'; +const TaxSettingsPage = lazy(() => import('./TaxSettings')); function App() { const [queryClient] = useState( @@ -34,7 +35,9 @@ function App() { - + }> + + diff --git a/assets/react/v3/public/images/membership-empty-state.webp b/assets/react/v3/public/images/membership-empty-state.webp new file mode 100644 index 0000000000..84cc835de0 Binary files /dev/null and b/assets/react/v3/public/images/membership-empty-state.webp differ diff --git a/assets/react/v3/shared/components/modals/CourseCategorySelectModal/CategoryListTable.tsx b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/CategoryListTable.tsx new file mode 100644 index 0000000000..7b0f26eb6c --- /dev/null +++ b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/CategoryListTable.tsx @@ -0,0 +1,147 @@ +import Checkbox from '@Atoms/CheckBox'; +import { LoadingSection } from '@Atoms/LoadingSpinner'; +import { borderRadius, spacing } from '@Config/styles'; +import { typography } from '@Config/typography'; +import { usePaginatedTable } from '@Hooks/usePaginatedTable'; +import Paginator from '@Molecules/Paginator'; +import Table, { type Column } from '@Molecules/Table'; +import { css } from '@emotion/react'; + +import coursePlaceholder from '@Images/course-placeholder.png'; +import { __ } from '@wordpress/i18n'; +import type { UseFormReturn } from 'react-hook-form'; +import SearchField from './SearchField'; +import { useCourseCategoryQuery, type Category } from '@/v3/shared/services/course_category'; + +interface CategoryListTableProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: UseFormReturn; +} + +const CategoryListTable = ({ form }: CategoryListTableProps) => { + const categoryList: Category[] = form.watch('categories') ?? []; + const { pageInfo, onPageChange, itemsPerPage, offset, onFilterItems } = usePaginatedTable({ + updateQueryParams: false, + }); + const categoryListQuery = useCourseCategoryQuery({ + applies_to: 'specific_category', + offset, + limit: itemsPerPage, + filter: pageInfo.filter, + }); + + function toggleSelection(isChecked = false) { + form.setValue('categories', isChecked ? (categoryListQuery.data?.results as Category[]) : []); + } + + function handleAllIsChecked() { + return ( + categoryList.length === categoryListQuery.data?.results.length && + categoryList?.every((item) => categoryListQuery.data?.results?.map((result) => result.id).includes(item.id)) + ); + } + + const columns: Column[] = [ + { + Header: categoryListQuery.data?.results.length ? ( + + ) : ( + __('Category', 'tutor') + ), + Cell: (item) => { + return ( +
+ { + const filteredItems = categoryList.filter((category) => category.id !== item.id); + const isNewItem = filteredItems?.length === categoryList.length; + + if (isNewItem) { + form.setValue('categories', [...filteredItems, item]); + } else { + form.setValue('categories', filteredItems); + } + }} + checked={categoryList.map((category) => category.id).includes(item.id)} + /> + {__('category +
+
{item.title}
+

{`${item.total_courses} ${__('Courses', 'tutor')}`}

+
+
+ ); + }, + width: 720, + }, + ]; + + if (categoryListQuery.isLoading) { + return ; + } + + if (!categoryListQuery.data) { + return
{__('Something went wrong', 'tutor')}
; + } + + return ( + <> +
+ +
+ +
+ + + +
+ +
+ + ); +}; + +export default CategoryListTable; + +const styles = { + tableActions: css` + padding: ${spacing[20]}; + `, + tableWrapper: css` + max-height: calc(100vh - 350px); + overflow: auto; + `, + paginatorWrapper: css` + margin: ${spacing[20]} ${spacing[16]}; + `, + checkboxWrapper: css` + display: flex; + align-items: center; + gap: ${spacing[12]}; + `, + courseItem: css` + ${typography.caption()}; + margin-left: ${spacing[4]}; + `, + thumbnail: css` + width: 48px; + height: 48px; + border-radius: ${borderRadius[4]}; + `, + errorMessage: css` + height: 100px; + display: flex; + align-items: center; + justify-content: center; + `, +}; diff --git a/assets/react/v3/shared/components/modals/CourseCategorySelectModal/CourseListTable.tsx b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/CourseListTable.tsx new file mode 100644 index 0000000000..55937a2981 --- /dev/null +++ b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/CourseListTable.tsx @@ -0,0 +1,185 @@ +import Checkbox from '@Atoms/CheckBox'; +import { LoadingSection } from '@Atoms/LoadingSpinner'; +import { borderRadius, colorTokens, spacing } from '@Config/styles'; +import { typography } from '@Config/typography'; +import { usePaginatedTable } from '@Hooks/usePaginatedTable'; +import Paginator from '@Molecules/Paginator'; +import Table, { type Column } from '@Molecules/Table'; +import { css } from '@emotion/react'; + +import coursePlaceholder from '@Images/course-placeholder.png'; +import { __, sprintf } from '@wordpress/i18n'; +import type { UseFormReturn } from 'react-hook-form'; +import SearchField from './SearchField'; +import { type Course, useCourseCategoryQuery } from '@/v3/shared/services/course_category'; + +interface CourseListTableProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: UseFormReturn; + type: 'bundles' | 'courses'; +} + +const CourseListTable = ({ type, form }: CourseListTableProps) => { + const courseList: Course[] = form.watch(type) || []; + const { pageInfo, onPageChange, itemsPerPage, offset, onFilterItems } = usePaginatedTable({ + updateQueryParams: false, + }); + const courseListQuery = useCourseCategoryQuery({ + applies_to: type === 'courses' ? 'specific_courses' : 'specific_bundles', + offset, + limit: itemsPerPage, + filter: pageInfo.filter, + }); + + function toggleSelection(isChecked = false) { + form.setValue(type, isChecked ? (courseListQuery.data?.results as Course[]) : []); + } + + function handleAllIsChecked() { + return ( + courseList.length === courseListQuery.data?.results.length && + courseList?.every((item) => courseListQuery.data?.results?.map((result) => result.id).includes(item.id)) + ); + } + + const columns: Column[] = [ + { + Header: courseListQuery.data?.results.length ? ( + + ) : ( + '#' + ), + Cell: (item) => { + return ( +
+ { + const filteredItems = courseList.filter((course) => course.id !== item.id); + const isNewItem = filteredItems?.length === courseList.length; + + if (isNewItem) { + form.setValue(type, [...filteredItems, item]); + } else { + form.setValue(type, filteredItems); + } + }} + checked={courseList.map((course) => course.id).includes(item.id)} + /> + {__('course +
+
{item.title}
+

{item.author}

+
+
+ ); + }, + }, + { + Header: __('Price', 'tutor'), + Cell: (item) => { + return ( +
+ {item.plan_start_price ? ( + {sprintf(__('Starting from %s', 'tutor'), item.plan_start_price)} + ) : ( + <> + {item.sale_price ? item.sale_price : item.regular_price} + {item.sale_price && {item.regular_price}} + + )} +
+ ); + }, + }, + ]; + + if (courseListQuery.isLoading) { + return ; + } + + if (!courseListQuery.data) { + return
{__('Something went wrong', 'tutor')}
; + } + + return ( + <> +
+ +
+ +
+
+ + +
+ +
+ + ); +}; + +export default CourseListTable; + +const styles = { + tableActions: css` + padding: ${spacing[20]}; + `, + tableWrapper: css` + max-height: calc(100vh - 350px); + overflow: auto; + `, + paginatorWrapper: css` + margin: ${spacing[20]} ${spacing[16]}; + `, + checkboxWrapper: css` + display: flex; + align-items: center; + gap: ${spacing[12]}; + `, + courseItem: css` + ${typography.caption()}; + margin-left: ${spacing[4]}; + `, + thumbnail: css` + width: 48px; + height: 48px; + border-radius: ${borderRadius[4]}; + `, + checkboxLabel: css` + ${typography.body()}; + color: ${colorTokens.text.primary}; + `, + price: css` + display: flex; + gap: ${spacing[4]}; + justify-content: end; + `, + discountPrice: css` + text-decoration: line-through; + color: ${colorTokens.text.subdued}; + `, + errorMessage: css` + height: 100px; + display: flex; + align-items: center; + justify-content: center; + `, + startingFrom: css` + color: ${colorTokens.text.hints}; + `, +}; diff --git a/assets/react/v3/shared/components/modals/CourseCategorySelectModal/SearchField.tsx b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/SearchField.tsx new file mode 100644 index 0000000000..52251faa35 --- /dev/null +++ b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/SearchField.tsx @@ -0,0 +1,44 @@ +import SVGIcon from '@Atoms/SVGIcon'; +import FormInputWithContent from '@Components/fields/FormInputWithContent'; +import { useDebounce } from '@Hooks/useDebounce'; +import { useFormWithGlobalError } from '@Hooks/useFormWithGlobalError'; +import type { Filter } from '@Hooks/usePaginatedTable'; +import { __ } from '@wordpress/i18n'; +import { useEffect } from 'react'; +import { Controller } from 'react-hook-form'; + +interface FilterFormValues { + search: string; +} + +interface SearchFieldProps { + onFilterItems: (filter: Filter) => void; +} + +const SearchField = ({ onFilterItems }: SearchFieldProps) => { + const actionsForm = useFormWithGlobalError({ defaultValues: { search: '' } }); + const searchValue = useDebounce(actionsForm.watch('search')); + + useEffect(() => { + onFilterItems({ + ...(searchValue.length > 0 && { search: searchValue }), + }); + }, [onFilterItems, searchValue]); + + return ( + ( + } + placeholder={__('Search...', 'tutor')} + showVerticalBar={false} + /> + )} + /> + ); +}; + +export default SearchField; diff --git a/assets/react/v3/shared/components/modals/CourseCategorySelectModal/index.tsx b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/index.tsx new file mode 100644 index 0000000000..826936c40b --- /dev/null +++ b/assets/react/v3/shared/components/modals/CourseCategorySelectModal/index.tsx @@ -0,0 +1,62 @@ +import Button from '@Atoms/Button'; +import BasicModalWrapper from '@Components/modals/BasicModalWrapper'; +import type { ModalProps } from '@Components/modals/Modal'; +import { spacing } from '@Config/styles'; +import Show from '@Controls/Show'; +import { useFormWithGlobalError } from '@Hooks/useFormWithGlobalError'; +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import type { UseFormReturn } from 'react-hook-form'; +import CategoryListTable from './CategoryListTable'; +import CourseListTable from './CourseListTable'; + +interface CourseCategorySelectModalProps extends ModalProps { + closeModal: (props?: { action: 'CONFIRM' | 'CLOSE' }) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: UseFormReturn; + type: 'bundles' | 'courses' | 'categories'; +} + +function CourseCategorySelectModal({ title, closeModal, actions, form, type }: CourseCategorySelectModalProps) { + const _form = useFormWithGlobalError({ + defaultValues: form.getValues(), + }); + + function handleApply() { + form.setValue(type, _form.getValues(type)); + closeModal({ action: 'CONFIRM' }); + } + + return ( + closeModal({ action: 'CLOSE' })} title={title} actions={actions} maxWidth={720}> + } + > + + +
+ + +
+
+ ); +} + +export default CourseCategorySelectModal; + +const styles = { + footer: css` + box-shadow: 0px 1px 0px 0px #e4e5e7 inset; + height: 56px; + display: flex; + align-items: center; + justify-content: end; + gap: ${spacing[16]}; + padding-inline: ${spacing[16]}; + `, +}; diff --git a/assets/react/v3/shared/config/icon-list.ts b/assets/react/v3/shared/config/icon-list.ts index d992d3ff7b..a0d88b853c 100644 --- a/assets/react/v3/shared/config/icon-list.ts +++ b/assets/react/v3/shared/config/icon-list.ts @@ -68,7 +68,7 @@ const collection = { viewBox: '0 0 14 12', }, storeImage: { - icon: '', + icon: '', viewBox: '0 0 28 22', }, storeEye: { @@ -84,7 +84,7 @@ const collection = { viewBox: '0 0 20 20', }, coupon: { - icon: '', + icon: '', viewBox: '0 0 20 20', }, seo: { @@ -116,7 +116,7 @@ const collection = { viewBox: '0 0 20 20', }, preview: { - icon: '', + icon: '', viewBox: '0 0 20 20', }, threeDots: { @@ -128,7 +128,7 @@ const collection = { viewBox: '0 0 32 32', }, plusSquare: { - icon: '', + icon: '', viewBox: '0 0 16 16', }, plusSquareBrand: { @@ -136,7 +136,7 @@ const collection = { viewBox: '0 0 25 24', }, minusSquare: { - icon: '', + icon: '', viewBox: '0 0 16 16', }, markCircle: { @@ -228,7 +228,7 @@ const collection = { viewBox: '0 0 26 26', }, calendar: { - icon: '', + icon: '', viewBox: '0 0 18 18', }, pauseCircle: { @@ -871,6 +871,10 @@ const collection = { icon: '', viewBox: '0 0 28 28', }, + priceTag: { + icon: '', + viewBox: '0 0 32 32', + }, } as const; export default collection; diff --git a/assets/react/v3/shared/config/styles.ts b/assets/react/v3/shared/config/styles.ts index 5702af6bc3..56faa43a4a 100644 --- a/assets/react/v3/shared/config/styles.ts +++ b/assets/react/v3/shared/config/styles.ts @@ -338,6 +338,7 @@ export const lineHeight = { 18: '1.125rem', 20: '1.25rem', 21: '1.313rem', + 22: '1.375rem', 24: '1.5rem', 26: '1.625rem', 28: '1.75rem', diff --git a/assets/react/v3/shared/services/course_category.ts b/assets/react/v3/shared/services/course_category.ts new file mode 100644 index 0000000000..41939a50f6 --- /dev/null +++ b/assets/react/v3/shared/services/course_category.ts @@ -0,0 +1,45 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { wpAjaxInstance } from '../utils/api'; +import { type PaginatedParams, type PaginatedResult } from '../utils/types'; +import endpoints from '../utils/endpoints'; + +export interface Category { + id: number; + title: string; + image: string; + total_courses: number; +} + +export interface Course { + id: number; + title: string; + image: ''; + author: string; + regular_price: string; + sale_price: string | null; + plan_start_price?: string; +} + +interface GetCourseCategoryParam extends PaginatedParams { + applies_to: 'specific_courses' | 'specific_bundles' | 'specific_category'; +} + +const getCourseCategoryList = (params: GetCourseCategoryParam) => { + return wpAjaxInstance.get>(endpoints.COUPON_APPLIES_TO, { + params: { + ...params, + }, + }); +}; + +export const useCourseCategoryQuery = (params: GetCourseCategoryParam) => { + return useQuery({ + queryKey: ['CourseCategory', params], + placeholderData: keepPreviousData, + queryFn: () => { + return getCourseCategoryList(params).then((res) => { + return res.data; + }); + }, + }); +}; diff --git a/assets/react/v3/shared/utils/endpoints.ts b/assets/react/v3/shared/utils/endpoints.ts index fcb293b033..19aab5d376 100644 --- a/assets/react/v3/shared/utils/endpoints.ts +++ b/assets/react/v3/shared/utils/endpoints.ts @@ -102,6 +102,12 @@ const endpoints = { CREATE_ENROLLMENT: 'tutor_enroll_bulk_student', GET_COURSE_BUNDLE_LIST: 'tutor_course_bundle_list', GET_UNENROLLED_USERS: 'tutor_unenrolled_users', + + // MEMBERSHIP + GET_MEMBERSHIP_PLANS: 'tutor_membership_plans', + SAVE_MEMBERSHIP_PLAN: 'tutor_membership_plan_save', + DUPLICATE_MEMBERSHIP_PLAN: 'tutor_membership_plan_duplicate', + DELETE_MEMBERSHIP_PLAN: 'tutor_membership_plan_delete', }; export default endpoints; diff --git a/assets/react/v3/shared/utils/types.ts b/assets/react/v3/shared/utils/types.ts index 888ea277de..b5b5ecb975 100644 --- a/assets/react/v3/shared/utils/types.ts +++ b/assets/react/v3/shared/utils/types.ts @@ -6,7 +6,7 @@ export type CourseProgressSteps = 'basic' | 'curriculum' | 'additional' | 'certi export type IconCollection = keyof typeof collection; -export const localHasOwnProperty = (obj: T, key: PropertyKey): key is keyof T => { +export const localHasOwnProperty = (obj: T, key: PropertyKey): key is keyof T => { return key in obj; }; @@ -117,3 +117,9 @@ export interface WPResponse { message: string; status_code: AxiosResponse['status']; } + +export interface TutorMutationResponse { + data: T; + message: string; + status_code: number; +} diff --git a/classes/Assets.php b/classes/Assets.php index ed5ede5310..b6083f7b17 100644 --- a/classes/Assets.php +++ b/classes/Assets.php @@ -231,8 +231,13 @@ public function admin_scripts() { // @since 3.0.0 add tax react app on the settings page. if ( 'tutor_settings' === $page && ! Input::has( 'edit' ) ) { wp_enqueue_script( 'tutor-shared', tutor()->url . 'assets/js/tutor-shared.min.js', array( 'wp-i18n', 'wp-element' ), TUTOR_VERSION, true ); - wp_enqueue_script( 'tutor-tax-settings.min', tutor()->url . 'assets/js/tutor-tax-settings.min.js', array( 'wp-i18n', 'wp-element', 'tutor-shared' ), TUTOR_VERSION, true ); - wp_enqueue_script( 'tutor-payment-settings.min', tutor()->url . 'assets/js/tutor-payment-settings.min.js', array( 'wp-i18n', 'wp-element', 'tutor-shared' ), TUTOR_VERSION, true ); + wp_enqueue_script( 'tutor-tax-settings.min', tutor()->url . 'assets/js/tutor-tax-settings.min.js', array( 'tutor-shared' ), TUTOR_VERSION, true ); + wp_enqueue_script( 'tutor-payment-settings.min', tutor()->url . 'assets/js/tutor-payment-settings.min.js', array( 'tutor-shared' ), TUTOR_VERSION, true ); + + // @since 3.2.0 membership settings options. + if ( tutor_utils()->is_addon_enabled( 'tutor-pro/addons/subscription/subscription.php' ) ) { + wp_enqueue_script( 'tutor-membership-settings.min', tutor()->url . 'assets/js/tutor-membership-settings.min.js', array( 'tutor-shared' ), TUTOR_VERSION, true ); + } } } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 0e6f8b0a25..b986a76569 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,7 @@ export default [ 'react/react-in-jsx-scope': 'off', 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', + 'react/display-name': 'off', '@typescript-eslint/consistent-type-imports': [ 'error', { diff --git a/webpack.config.js b/webpack.config.js index bab9d5baca..dec2ae6715 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -51,8 +51,8 @@ module.exports = (env, options) => { config.optimization = { splitChunks: { cacheGroups: { - shared: { - test: /[\\/]assets[\\/]react[\\/]v3[\\/]shared[\\/]/, + commons: { + test: /[\\/]node_modules[\\/]/, name: 'tutor-shared.min', chunks: 'all', }, @@ -84,9 +84,10 @@ module.exports = (env, options) => { 'tutor-gutenberg.min': './assets/react/gutenberg/index.js', 'tutor-course-builder.min': './assets/react/v3/entries/course-builder/index.tsx', 'tutor-order-details.min': './assets/react/v3/entries/order-details/index.tsx', + 'tutor-coupon.min': './assets/react/v3/entries/coupon-details/index.tsx', 'tutor-tax-settings.min': './assets/react/v3/entries/tax-settings/index.tsx', 'tutor-payment-settings.min': './assets/react/v3/entries/payment-settings/index.tsx', - 'tutor-coupon.min': './assets/react/v3/entries/coupon-details/index.tsx', + 'tutor-membership-settings.min': './assets/react/v3/entries/pro/membership-settings/index.tsx', }, clean: true, },