Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Membership Frontend: Modals, Features, and API Integrations #1494

Merged
merged 20 commits into from
Jan 1, 2025
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 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,49 @@
import { 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 MembershipSettings from './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()} />
<MembershipSettings />
</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 { type FormWithGlobalErrorType } from '@/v3/shared/hooks/useFormWithGlobalError';
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';

interface CategoriesProps {
form: FormWithGlobalErrorType<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
Loading