Skip to content

Commit

Permalink
Merge pull request #1494 from themeum/membership-frontend
Browse files Browse the repository at this point in the history
Membership Frontend: Modals, Features, and API Integrations
  • Loading branch information
sazedul-haque authored Jan 1, 2025
2 parents c286913 + 0fabfa7 commit 7f65149
Show file tree
Hide file tree
Showing 29 changed files with 2,515 additions and 18 deletions.
9 changes: 6 additions & 3 deletions assets/react/v3/entries/payment-settings/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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';

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(
Expand All @@ -36,7 +37,9 @@ function App() {
<PaymentProvider>
<ModalProvider>
<Global styles={createGlobalCss()} />
<PaymentSettings />
<Suspense fallback={<LoadingSection />}>
<PaymentSettings />
</Suspense>
</ModalProvider>
</PaymentProvider>
</ToastProvider>
Expand Down
52 changes: 52 additions & 0 deletions assets/react/v3/entries/pro/membership-settings/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RTLProvider>
<QueryParamProvider adapter={ReactRouter6Adapter}>
<QueryClientProvider client={queryClient}>
<ToastProvider position="bottom-right">
<ModalProvider>
<Global styles={createGlobalCss()} />
<Suspense fallback={<LoadingSection />}>
<MembershipSettings />
</Suspense>
</ModalProvider>
</ToastProvider>
</QueryClientProvider>
</QueryParamProvider>
</RTLProvider>
);
}

export default App;
Original file line number Diff line number Diff line change
@@ -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<MembershipFormData>;
}

export default function Categories({ form }: CategoriesProps) {
const { showModal } = useModal();
const categories = form.watch('categories');

return (
<>
<Show when={categories.length}>
<div css={styles.categoriesWrapper}>
<For each={categories}>
{(category) => (
<CategoryItem
title={category.title}
subTitle={sprintf(__('%s Courses', 'tutor'), category.total_courses)}
image={category.image}
handleDeleteClick={() => {
form.setValue(
'categories',
categories.filter((item) => item.id !== category.id),
{ shouldDirty: true },
);
}}
/>
)}
</For>
</div>
</Show>

<Button
variant="tertiary"
isOutlined
buttonCss={styles.addCategoriesButton}
icon={<SVGIcon name="plusSquareBrand" width={24} height={25} />}
onClick={() => {
showModal({
component: CourseCategorySelectModal,
props: {
title: __('Selected items', 'tutor'),
type: 'categories',
form,
},
closeOnOutsideClick: true,
depthIndex: 9999,
});
}}
>
{__('Add Categories', 'tutor')}
</Button>
</>
);
}

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;
}
`,
};
Original file line number Diff line number Diff line change
@@ -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 (
<div css={styles.selectedItem}>
<div css={styles.selectedThumb}>
<img src={image || coursePlaceholder} css={styles.thumbnail} alt="course item" />
</div>
<div css={styles.selectedContent}>
<div css={styles.selectedTitle}>{title}</div>
<div css={styles.selectedSubTitle}>{subTitle}</div>
</div>
<div>
<Button variant="text" onClick={handleDeleteClick}>
<SVGIcon name="delete" width={24} height={24} />
</Button>
</div>
</div>
);
}

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;
`,
};
Original file line number Diff line number Diff line change
@@ -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 (
<div css={styles.wrapper}>
<div css={styles.header}>
<label>{__('Features', 'tutor')}</label>
<Button variant="text" onClick={() => append({ id: nanoid(), icon: 'tick_circle_fill', content: '' })}>
<SVGIcon name="plus" width={24} height={24} />
</Button>
</div>
<Show when={fields.length > 0}>
<div css={styles.features}>
<DndContext
sensors={sensors}
modifiers={[restrictToParentElement]}
onDragEnd={(event) => {
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);
}
}}
>
<SortableContext items={fields} strategy={verticalListSortingStrategy}>
<For each={fields}>
{(item, index) => (
<Controller
key={item.id}
control={form.control}
name={`features.${index}` as 'features.0'}
rules={{
validate: (value) => !!value?.content || __('Content is required', 'tutor'),
}}
render={(controllerProps) => (
<FormFeatureItem id={item.id} {...controllerProps} handleDeleteClick={() => remove(index)} />
)}
/>
)}
</For>
</SortableContext>
</DndContext>
</div>
</Show>
</div>
);
}

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]};
`,
};
Loading

0 comments on commit 7f65149

Please sign in to comment.