diff --git a/002_source/cms/src/app/dashboard/lesson_categories/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/lesson_categories/[customerId]/page.tsx new file mode 100644 index 0000000..a12ec06 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_categories/[customerId]/page.tsx @@ -0,0 +1,308 @@ +import * as React from 'react'; +import type { Metadata } from 'next'; +import RouterLink from 'next/link'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { CreditCard as CreditCardIcon } from '@phosphor-icons/react/dist/ssr/CreditCard'; +import { House as HouseIcon } from '@phosphor-icons/react/dist/ssr/House'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShieldWarning as ShieldWarningIcon } from '@phosphor-icons/react/dist/ssr/ShieldWarning'; +import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User'; + +import { config } from '@/config'; +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { Notifications } from '@/components/dashboard/lesson_category/notifications'; +import { Payments } from '@/components/dashboard/lesson_category/payments'; +import type { Address } from '@/components/dashboard/lesson_category/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/lesson_category/shipping-address'; + +export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Lesson Categories + +
+ + + + MV + +
+ + Miron Vitold + } + label="Active" + size="small" + variant="outlined" + /> + + + miron.vitold@domain.com + +
+
+
+ +
+
+
+ + + + + + + + } + avatar={ + + + + } + title="Basic details" + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { key: 'Customer ID', value: }, + { key: 'Name', value: 'Miron Vitold' }, + { key: 'Email', value: 'miron.vitold@domain.com' }, + { key: 'Phone', value: '(425) 434-5535' }, + { key: 'Company', value: 'Devias IO' }, + { + key: 'Quota', + value: ( + + + + 50% + + + ), + }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + + + + + } + title="Security" + /> + + +
+ +
+ + A deleted customer cannot be restored. All data will be permanently removed. + +
+
+
+
+
+ + + + + }> + Edit + + } + avatar={ + + + + } + title="Billing details" + /> + + + } sx={{ '--PropertyItem-padding': '16px' }}> + {( + [ + { key: 'Credit card', value: '**** 4142' }, + { key: 'Country', value: 'United States' }, + { key: 'State', value: 'Michigan' }, + { key: 'City', value: 'Southfield' }, + { key: 'Address', value: '1721 Bartlett Avenue, 48034' }, + { key: 'Tax ID', value: 'EU87956621' }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + + + + }> + Add + + } + avatar={ + + + + } + title="Shipping addresses" + /> + + + {( + [ + { + id: 'ADR-001', + country: 'United States', + state: 'Michigan', + city: 'Lansing', + zipCode: '48933', + street: '480 Haven Lane', + primary: true, + }, + { + id: 'ADR-002', + country: 'United States', + state: 'Missouri', + city: 'Springfield', + zipCode: '65804', + street: '4807 Lighthouse Drive', + }, + ] satisfies Address[] + ).map((address) => ( + + + + ))} + + + + + + +
+
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lesson_categories/create/page.tsx b/002_source/cms/src/app/dashboard/lesson_categories/create/page.tsx new file mode 100644 index 0000000..6ffef92 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_categories/create/page.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import type { Metadata } from 'next'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; + +import { config } from '@/config'; +import { paths } from '@/paths'; +import { CustomerCreateForm } from '@/components/dashboard/lesson_category/lesson-category-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Lesson Categories + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx b/002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx new file mode 100644 index 0000000..41405ea --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_categories/lesson-categories-sample-data.tsx @@ -0,0 +1,55 @@ +import { dayjs } from '@/lib/dayjs'; +import type { LessonCategory } from '@/components/dashboard/lesson_category/lesson-categories-table'; + +export const lessonCategoriesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, + { + id: 'USR-003', + name: 'Carson Darrin', + avatar: '/assets/avatar-3.png', + email: 'carson.darrin@domain.com', + phone: '(715) 278-5041', + quota: 10, + status: 'blocked', + createdAt: dayjs().subtract(1, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-002', + name: 'Siegbert Gottfried', + avatar: '/assets/avatar-2.png', + email: 'siegbert.gottfried@domain.com', + phone: '(603) 766-0431', + quota: 0, + status: 'pending', + createdAt: dayjs().subtract(7, 'hour').subtract(1, 'day').toDate(), + }, + { + id: 'USR-001', + name: 'Miron Vitold', + avatar: '/assets/avatar-1.png', + email: 'miron.vitold@domain.com', + phone: '(425) 434-5535', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), + }, +] satisfies LessonCategory[]; diff --git a/002_source/cms/src/app/dashboard/lesson_categories/page.tsx b/002_source/cms/src/app/dashboard/lesson_categories/page.tsx new file mode 100644 index 0000000..4c5b739 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_categories/page.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import type { Metadata } from 'next'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; + +import { config } from '@/config'; +import { LessonCategoriesFilters } from '@/components/dashboard/lesson_category/lesson-categories-filters'; +import type { Filters } from '@/components/dashboard/lesson_category/lesson-categories-filters'; +import { LessonCategoriesPagination } from '@/components/dashboard/lesson_category/lesson-categories-pagination'; +import { LessonCategoriesSelectionProvider } from '@/components/dashboard/lesson_category/lesson-categories-selection-context'; +import { LessonCategoriesTable } from '@/components/dashboard/lesson_category/lesson-categories-table'; +import type { LessonCategory } from '@/components/dashboard/lesson_category/lesson-categories-table'; + +import { lessonCategoriesSampleData } from './lesson-categories-sample-data'; + +export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +interface PageProps { + searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string }; +} + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { email, phone, sortDir, status } = searchParams; + + const sortedLessonCategories = applySort(lessonCategoriesSampleData, sortDir); + const filteredLessonCategories = applyFilters(sortedLessonCategories, { email, phone, status }); + + return ( + + + + + Lesson Categories + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LessonCategory[], sortDir: 'asc' | 'desc' | undefined): LessonCategory[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LessonCategory[], { email, phone, status }: Filters): LessonCategory[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + return true; + }); +} diff --git a/002_source/cms/src/app/dashboard/lesson_types/[typeId]/page.tsx b/002_source/cms/src/app/dashboard/lesson_types/[typeId]/page.tsx new file mode 100644 index 0000000..b7038f0 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_types/[typeId]/page.tsx @@ -0,0 +1,339 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { CreditCard as CreditCardIcon } from '@phosphor-icons/react/dist/ssr/CreditCard'; +import { House as HouseIcon } from '@phosphor-icons/react/dist/ssr/House'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShieldWarning as ShieldWarningIcon } from '@phosphor-icons/react/dist/ssr/ShieldWarning'; +import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { getLessonTypeById } from '@/components/dashboard/lesson_type/http-actions'; +import { LessonTypeDefaultValue, type LessonType } from '@/components/dashboard/lesson_type/ILessonType'; +import { Notifications } from '@/components/dashboard/lesson_type/notifications'; +import { Payments } from '@/components/dashboard/lesson_type/payments'; +import type { Address } from '@/components/dashboard/lesson_type/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/lesson_type/shipping-address'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + const { typeId } = useParams<{ typeId: string }>(); + const [isLoading, setIsLoading] = React.useState(true); + const [showLessonType, setShowLessonType] = React.useState(LessonTypeDefaultValue); + const router = useRouter(); + + function handleEditClick() { + router.push(paths.dashboard.lesson_types.edit(showLessonType.id)); + } + + React.useEffect(() => { + getLessonTypeById(typeId) + .then((lessonType: LessonType) => { + setIsLoading(false); + setShowLessonType(lessonType); + }) + .catch((err) => { + // console.error(err); + console.error(t('lessonType.load_error')); + }); + // console.log('hello'); + }, []); + + if (isLoading) return
{t('common.loading')}
; + + return ( + + + +
+ + + {t('Lesson Types')} + +
+ + + + MV + +
+ + {showLessonType.name} + } + label={showLessonType.visible} + size="small" + variant="outlined" + /> + + + {showLessonType.id} + +
+
+
+ +
+
+
+ + + + + { + handleEditClick(); + }} + > + + + } + avatar={ + + + + } + title="Basic details" + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { key: 'Customer ID', value: }, + { key: 'Name', value: showLessonType.name }, + { key: 'Type', value: showLessonType.type }, + { key: 'Pos', value: showLessonType.pos }, + { key: 'Visible', value: }, + { + key: 'Quota', + value: ( + + + + 50% + + + ), + }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + + + + + } + title="Security" + /> + + +
+ +
+ + A deleted lesson type cannot be restored. All data will be permanently removed. + +
+
+
+
+
+ + + + + }> + Edit + + } + avatar={ + + + + } + title="Billing details" + /> + + + } sx={{ '--PropertyItem-padding': '16px' }}> + {( + [ + { key: 'Credit card', value: '**** 4142' }, + { key: 'Country', value: 'United States' }, + { key: 'State', value: 'Michigan' }, + { key: 'City', value: 'Southfield' }, + { key: 'Address', value: '1721 Bartlett Avenue, 48034' }, + { key: 'Tax ID', value: 'EU87956621' }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + + + + }> + Add + + } + avatar={ + + + + } + title="Shipping addresses" + /> + + + {( + [ + { + id: 'ADR-001', + country: 'United States', + state: 'Michigan', + city: 'Lansing', + zipCode: '48933', + street: '480 Haven Lane', + primary: true, + }, + { + id: 'ADR-002', + country: 'United States', + state: 'Missouri', + city: 'Springfield', + zipCode: '65804', + street: '4807 Lighthouse Drive', + }, + ] satisfies Address[] + ).map((address) => ( + + + + ))} + + + + + + +
+
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lesson_types/create/page.tsx b/002_source/cms/src/app/dashboard/lesson_types/create/page.tsx new file mode 100644 index 0000000..df7a8ae --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_types/create/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LessonTypeCreateForm } from '@/components/dashboard/lesson_type/lesson-type-create-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + return ( + + + +
+ + + {t('dashboard.lessonTypes.title')} + +
+
+ {t('dashboard.lessonTypes.create.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx b/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx new file mode 100644 index 0000000..91e89ef --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { LessonTypeEditForm } from '@/components/dashboard/lesson_type/lesson-type-edit-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + return ( + + + +
+ + + {t('dashboard.lessonTypes.title')} + +
+
+ {t('dashboard.lessonTypes.edit.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lesson_types/lesson-types-data.tsx b/002_source/cms/src/app/dashboard/lesson_types/lesson-types-data.tsx new file mode 100644 index 0000000..cf88d17 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_types/lesson-types-data.tsx @@ -0,0 +1,37 @@ +import { dayjs } from '@/lib/dayjs'; +import type { LessonType } from '@/components/dashboard/lesson_type/ILessonType'; + +// import { helloworld } from '@/components/dashboard/lesson_type/helloworld'; +// export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; +export const lessonTypesSampleData = [ + { + id: 'USR-005', + name: 'Fran Perez', + type: 'vocabulary', + pos: 1, + visible: 'visible', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Penjani Inyene', + type: 'connectives', + pos: 1, + visible: 'visible', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, +] satisfies LessonType[]; + +export const lessonTypesData = (): LessonType[] => { + return lessonTypesSampleData; +}; diff --git a/002_source/cms/src/app/dashboard/lesson_types/lesson-types-sample-data.tsx b/002_source/cms/src/app/dashboard/lesson_types/lesson-types-sample-data.tsx new file mode 100644 index 0000000..1d2deed --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_types/lesson-types-sample-data.tsx @@ -0,0 +1,33 @@ +import { dayjs } from '@/lib/dayjs'; +import type { LessonType } from '@/components/dashboard/lesson_type/ILessonType'; + +// import { helloworld } from '@/components/dashboard/lesson_type/helloworld'; +// export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; +export const lessonTypesSampleData = [ + { + id: 'USR-005', + name: 'Vocabulary', + type: 'vocabulary', + pos: 1, + visible: 'visible', + avatar: '/assets/avatar-5.png', + email: 'fran.perez@domain.com', + phone: '(815) 704-0045', + quota: 50, + status: 'active', + createdAt: dayjs().subtract(1, 'hour').toDate(), + }, + { + id: 'USR-004', + name: 'Connectives', + type: 'connectives', + pos: 2, + visible: 'visible', + avatar: '/assets/avatar-4.png', + email: 'penjani.inyene@domain.com', + phone: '(803) 937-8925', + quota: 100, + status: 'active', + createdAt: dayjs().subtract(3, 'hour').toDate(), + }, +] satisfies LessonType[]; diff --git a/002_source/cms/src/app/dashboard/lesson_types/page.tsx b/002_source/cms/src/app/dashboard/lesson_types/page.tsx new file mode 100644 index 0000000..ad1d7ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/lesson_types/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { LoadingButton } from '@mui/lab'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { toast } from '@/components/core/toaster'; +import { listLessonTypes } from '@/components/dashboard/lesson_type/http-actions'; +import type { LessonType } from '@/components/dashboard/lesson_type/ILessonType'; +import { LessonTypesFilters } from '@/components/dashboard/lesson_type/lesson-types-filters'; +import type { Filters } from '@/components/dashboard/lesson_type/lesson-types-filters'; +import { LessonTypesPagination } from '@/components/dashboard/lesson_type/lesson-types-pagination'; +import { LessonTypesSelectionProvider } from '@/components/dashboard/lesson_type/lesson-types-selection-context'; +import { LessonTypesTable } from '@/components/dashboard/lesson_type/lesson-types-table'; +import FormLoading from '@/components/loading'; + +interface PageProps { + searchParams: { + email?: string; + phone?: string; + sortDir?: 'asc' | 'desc'; + status?: string; + name?: string; + visible?: string; + type?: string; + // + }; +} + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const { t } = useTranslation(); + const { email, phone, sortDir, status, name, visible, type } = searchParams; + const router = useRouter(); + + const [isLoadingAddPage, setIsLoadingAddPage] = React.useState(false); + const [lessonTypesData, setLessonTypesData] = React.useState([]); + const sortedLessonTypes = applySort(lessonTypesData, sortDir); + const filteredLessonTypes = applyFilters(sortedLessonTypes, { + email, + phone, + status, + name, + type, + visible, + // + }); + + const reloadRows = () => { + listLessonTypes() + .then((lessonTypes: LessonType[]) => { + setLessonTypesData(lessonTypes); + }) + .catch((err) => { + logger.error(err); + toast(t('dashboard.lessonTypes.list.error')); + }); + }; + + React.useEffect(() => { + reloadRows(); + }, []); + + if (lessonTypesData.length < 1) return ; + + return ( + + + + + {t('Lesson Types')} + + + { + setIsLoadingAddPage(true); + router.push(paths.dashboard.lesson_types.create); + }} + startIcon={} + variant="contained" + > + {/* add new lesson type */} + {t('dashboard.lessonTypes.add')} + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LessonType[], sortDir: 'asc' | 'desc' | undefined): LessonType[] { + return row.sort((a, b) => { + if (sortDir === 'asc') { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +function applyFilters(row: LessonType[], { email, phone, status, name, visible }: Filters): LessonType[] { + return row.filter((item) => { + if (email) { + if (!item.email?.toLowerCase().includes(email.toLowerCase())) { + return false; + } + } + + if (phone) { + if (!item.phone?.toLowerCase().includes(phone.toLowerCase())) { + return false; + } + } + + if (status) { + if (item.status !== status) { + return false; + } + } + + if (name) { + if (!item.name?.toLowerCase().includes(name.toLowerCase())) { + return false; + } + } + + if (visible) { + if (!item.visible?.toLowerCase().includes(visible.toLowerCase())) { + return false; + } + } + + return true; + }); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/helloworld.tsx b/002_source/cms/src/components/dashboard/lesson_category/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx new file mode 100644 index 0000000..ab380d4 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-filters.tsx @@ -0,0 +1,244 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; + +import { paths } from '@/paths'; +import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; +import { Option } from '@/components/core/option'; + +import { useLessonCategoriesSelection } from './lesson-categories-selection-context'; + +// The tabs should be generated using API data. +const tabs = [ + { label: 'All', value: '', count: 5 }, + { label: 'Active', value: 'active', count: 3 }, + { label: 'Pending', value: 'pending', count: 1 }, + { label: 'Blocked', value: 'blocked', count: 1 }, +] as const; + +export interface Filters { + email?: string; + phone?: string; + status?: string; +} + +export type SortDir = 'asc' | 'desc'; + +export interface LessonCategoriesFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function LessonCategoriesFilters({ + filters = {}, + sortDir = 'desc', +}: LessonCategoriesFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useLessonCategoriesSelection(); + + const updateSearchParams = React.useCallback( + (newFilters: Filters, newSortDir: SortDir): void => { + const searchParams = new URLSearchParams(); + + if (newSortDir === 'asc') { + searchParams.set('sortDir', newSortDir); + } + + if (newFilters.status) { + searchParams.set('status', newFilters.status); + } + + if (newFilters.email) { + searchParams.set('email', newFilters.email); + } + + if (newFilters.phone) { + searchParams.set('phone', newFilters.phone); + } + + router.push(`${paths.dashboard.lesson_categories.list}?${searchParams.toString()}`); + }, + [router] + ); + + const handleClearFilters = React.useCallback(() => { + updateSearchParams({}, sortDir); + }, [updateSearchParams, sortDir]); + + const handleStatusChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, status: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleEmailChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, email: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handlePhoneChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, phone: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleSortChange = React.useCallback( + (event: SelectChangeEvent) => { + updateSearchParams(filters, event.target.value as SortDir); + }, + [updateSearchParams, filters] + ); + + const hasFilters = status || email || phone; + + return ( +
+ + {tabs.map((tab) => ( + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleEmailChange(value as string); + }} + onFilterDelete={() => { + handleEmailChange(); + }} + popover={} + value={email} + /> + { + handlePhoneChange(value as string); + }} + onFilterDelete={() => { + handlePhoneChange(); + }} + popover={} + value={phone} + /> + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} selected + + + + ) : null} + + +
+ ); +} + +function EmailFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function PhoneFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx new file mode 100644 index 0000000..a78bea3 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-pagination.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface LessonCategoriesPaginationProps { + count: number; + page: number; +} + +export function LessonCategoriesPagination({ count, page }: LessonCategoriesPaginationProps): React.JSX.Element { + // You should implement the pagination using a similar logic as the filters. + // Note that when page change, you should keep the filter search params. + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx new file mode 100644 index 0000000..26fefa6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-selection-context.tsx @@ -0,0 +1,47 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { LessonCategory } from './lesson-categories-table'; + +function noop(): void { + return undefined; +} + +export interface LessonCategoriesSelectionContextValue extends Selection {} + +export const LessonCategoriesSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LessonCategoriesSelectionProviderProps { + children: React.ReactNode; + lessonCategories: LessonCategory[]; +} + +export function LessonCategoriesSelectionProvider({ + children, + lessonCategories = [], +}: LessonCategoriesSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => lessonCategories.map((customer) => customer.id), [lessonCategories]); + const selection = useSelection(customerIds); + + return ( + + {children} + + ); +} + +export function useLessonCategoriesSelection(): LessonCategoriesSelectionContextValue { + return React.useContext(LessonCategoriesSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx new file mode 100644 index 0000000..9ee83d8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-categories-table.tsx @@ -0,0 +1,139 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; +import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; + +import { useLessonCategoriesSelection } from './lesson-categories-selection-context'; + +export interface LessonCategory { + id: string; + name: string; + avatar?: string; + email: string; + phone?: string; + quota: number; + status: 'pending' | 'active' | 'blocked'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {' '} +
+ + {row.name} + + + {row.email} + +
+
+ ), + name: 'Name', + width: '250px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + name: 'Quota', + width: '250px', + }, + { field: 'phone', name: 'Phone number', width: '150px' }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY h:mm A'); + }, + name: 'Created at', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + active: { label: 'Active', icon: }, + blocked: { label: 'Blocked', icon: }, + pending: { label: 'Pending', icon: }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ; + }, + name: 'Status', + width: '150px', + }, + { + formatter: (): React.JSX.Element => ( + + + + ), + name: 'Actions', + hideName: true, + width: '100px', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface LessonCategoriesTableProps { + rows: LessonCategory[]; +} + +export function LessonCategoriesTable({ rows }: LessonCategoriesTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLessonCategoriesSelection(); + + return ( + + + columns={columns} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + No lesson categories found + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-category-create-form.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-category-create-form.tsx new file mode 100644 index 0000000..c8c4f35 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-category-create-form.tsx @@ -0,0 +1,398 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +import { Controller, useForm } from 'react-hook-form'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { Option } from '@/components/core/option'; +import { toast } from '@/components/core/toaster'; + +function fileToBase64(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.onerror = () => { + reject(new Error('Error converting file to base64')); + }; + }); +} + +const schema = zod.object({ + avatar: zod.string().optional(), + name: zod.string().min(1, 'Name is required').max(255), + email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255), + phone: zod.string().min(1, 'Phone is required').max(15), + company: zod.string().max(255), + billingAddress: zod.object({ + country: zod.string().min(1, 'Country is required').max(255), + state: zod.string().min(1, 'State is required').max(255), + city: zod.string().min(1, 'City is required').max(255), + zipCode: zod.string().min(1, 'Zip code is required').max(255), + line1: zod.string().min(1, 'Street line 1 is required').max(255), + line2: zod.string().max(255).optional(), + }), + taxId: zod.string().max(255).optional(), + timezone: zod.string().min(1, 'Timezone is required').max(255), + language: zod.string().min(1, 'Language is required').max(255), + currency: zod.string().min(1, 'Currency is required').max(255), +}); + +type Values = zod.infer; + +const defaultValues = { + avatar: '', + name: '', + email: '', + phone: '', + company: '', + billingAddress: { country: '', state: '', city: '', zipCode: '', line1: '', line2: '' }, + taxId: '', + timezone: 'new_york', + language: 'en', + currency: 'USD', +} satisfies Values; + +export function CustomerCreateForm(): React.JSX.Element { + const router = useRouter(); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (_: Values): Promise => { + try { + // Make API request + toast.success('Customer updated'); + router.push(paths.dashboard.lesson_categories.details('1')); + } catch (err) { + logger.error(err); + toast.error('Something went wrong!'); + } + }, + [router] + ); + + const avatarInputRef = React.useRef(null); + const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + const url = await fileToBase64(file); + setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } spacing={4}> + + Account information + + + + + + + + + + Avatar + Min 400x400px, PNG or JPEG + + + + + + + ( + + Name + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + Email address + + {errors.email ? {errors.email.message} : null} + + )} + /> + + + ( + + Phone number + + {errors.phone ? {errors.phone.message} : null} + + )} + /> + + + ( + + Company + + {errors.company ? {errors.company.message} : null} + + )} + /> + + + + + Billing information + + + ( + + Country + + {errors.billingAddress?.country ? ( + {errors.billingAddress?.country?.message} + ) : null} + + )} + /> + + + ( + + State + + {errors.billingAddress?.state ? ( + {errors.billingAddress?.state?.message} + ) : null} + + )} + /> + + + ( + + City + + {errors.billingAddress?.city ? ( + {errors.billingAddress?.city?.message} + ) : null} + + )} + /> + + + ( + + Zip code + + {errors.billingAddress?.zipCode ? ( + {errors.billingAddress?.zipCode?.message} + ) : null} + + )} + /> + + + ( + + Address + + {errors.billingAddress?.line1 ? ( + {errors.billingAddress?.line1?.message} + ) : null} + + )} + /> + + + ( + + Tax ID + + {errors.taxId ? {errors.taxId.message} : null} + + )} + /> + + + + + Shipping information + } label="Same as billing address" /> + + + Additional information + + + ( + + Timezone + + {errors.timezone ? {errors.timezone.message} : null} + + )} + /> + + + ( + + Language + + {errors.language ? {errors.language.message} : null} + + )} + /> + + + ( + + Currency + + {errors.currency ? {errors.currency.message} : null} + + )} + /> + + + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/notifications.tsx b/002_source/cms/src/components/dashboard/lesson_category/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/notifications.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple'; + +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; +import { Option } from '@/components/core/option'; + +export interface Notification { + id: string; + type: string; + status: 'delivered' | 'pending' | 'failed'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {row.type} + + ), + name: 'Type', + width: '300px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + delivered: { label: 'Delivered', color: 'success' }, + pending: { label: 'Pending', color: 'warning' }, + failed: { label: 'Failed', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface NotificationsProps { + notifications: Notification[]; +} + +export function Notifications({ notifications }: NotificationsProps): React.JSX.Element { + return ( + + + + + } + title="Notifications" + /> + + + + +
+ +
+
+ + + columns={columns} rows={notifications} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/payments.tsx b/002_source/cms/src/components/dashboard/lesson_category/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/payments.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple'; + +import { dayjs } from '@/lib/dayjs'; +import type { ColumnDef } from '@/components/core/data-table'; +import { DataTable } from '@/components/core/data-table'; + +export interface Payment { + currency: string; + amount: number; + invoiceId: string; + status: 'pending' | 'completed' | 'canceled' | 'refunded'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)} + + ), + name: 'Amount', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + pending: { label: 'Pending', color: 'warning' }, + completed: { label: 'Completed', color: 'success' }, + canceled: { label: 'Canceled', color: 'error' }, + refunded: { label: 'Refunded', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + return {row.invoiceId}; + }, + name: 'Invoice ID', + width: '150px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface PaymentsProps { + ordersValue: number; + payments: Payment[]; + refundsValue: number; + totalOrders: number; +} + +export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element { + return ( + + }> + Create Payment + + } + avatar={ + + + + } + title="Payments" + /> + + + + } + spacing={3} + sx={{ justifyContent: 'space-between', p: 2 }} + > +
+ + Total orders + + {new Intl.NumberFormat('en-US').format(totalOrders)} +
+
+ + Orders value + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)} + +
+
+ + Refunds + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)} + +
+
+
+ + + columns={columns} rows={payments} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/shipping-address.tsx b/002_source/cms/src/components/dashboard/lesson_category/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_category/shipping-address.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +export interface Address { + id: string; + country: string; + state: string; + city: string; + zipCode: string; + street: string; + primary?: boolean; +} + +export interface ShippingAddressProps { + address: Address; +} + +export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement { + return ( + + + + + {address.street}, +
+ {address.city}, {address.state}, {address.country}, +
+ {address.zipCode} +
+ + {address.primary ? : } + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/ILessonType.tsx b/002_source/cms/src/components/dashboard/lesson_type/ILessonType.tsx new file mode 100644 index 0000000..65a4930 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/ILessonType.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { dayjs } from '@/lib/dayjs'; + +export interface LessonType { + id: string; + name: string; + type: string; + pos: number; + visible: 'visible' | 'hidden'; + createdAt: Date; + // + // original + // id: string; + // name: string; + // + avatar?: string; + email: string; + phone?: string; + quota: number; + status: 'pending' | 'active' | 'blocked'; + // createdAt: Date; +} + +export const LessonTypeDefaultValue: LessonType = { + id: 'string', + name: 'string', + type: 'string', + pos: 1, + visible: 'visible', + createdAt: dayjs().toDate(), + // + // original + // id: 'string', + // name: 'string', + // + avatar: 'string', + email: 'string', + phone: 'string', + quota: 1, + status: 'pending', + // createdAt: Date; +}; + +export interface DBLessonType { + id: string; + name: string; + type: string; + pos: number; + visible: 'visible' | 'hidden'; + createdAt: Date; + created: 'string'; +} + +export interface Helloworld { + id: string; +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/confirm-delete-modal.tsx b/002_source/cms/src/components/dashboard/lesson_type/confirm-delete-modal.tsx new file mode 100644 index 0000000..1aea8ae --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/confirm-delete-modal.tsx @@ -0,0 +1,104 @@ +'use client'; + +import * as React from 'react'; +import { LoadingButton } from '@mui/lab'; +import { Button, Container, Modal, Paper } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note'; +import { useTranslation } from 'react-i18next'; + +import { deleteLessonType } from './http-actions'; + +export default function ConfirmDeleteModal({ + open, + setOpen, + idToDelete, + reloadRows, +}: { + open: boolean; + setOpen: (b: boolean) => void; + idToDelete: string; + reloadRows: () => void; +}): React.JSX.Element { + const { t } = useTranslation(); + + // const handleClose = () => setOpen(false); + function handleClose(): void { + setOpen(false); + } + + const [isDeleteing, setIsDeleteing] = React.useState(false); + const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }; + + const handleUserConfirmDelete = (): void => { + if (idToDelete) { + setIsDeleteing(true); + deleteLessonType(idToDelete) + .then(() => { + reloadRows(); + handleClose(); + }) + .catch((err) => { + // console.error(err); + setIsDeleteing(false); + }) + .finally(() => { + setIsDeleteing(false); + }); + } + }; + + return ( +
+ + + + + + + + + + + {t('Delete Lesson Type ?')} + + {t('Are you sure you want to delete lesson type ?')} + + + + + { + handleUserConfirmDelete(); + }} + loading={isDeleteing} + > + {t('Delete')} + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/helloworld.tsx b/002_source/cms/src/components/dashboard/lesson_type/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/lesson_type/http-actions.ts b/002_source/cms/src/components/dashboard/lesson_type/http-actions.ts new file mode 100644 index 0000000..f4042e5 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/http-actions.ts @@ -0,0 +1,170 @@ +'use client'; + +// store CRUD operations of lesson_types +// import { lessonTypesSampleData } from '@/app/dashboard/lesson_types/lesson-types-data'; +import axios, { AxiosResponse } from 'axios'; + +import { dayjs } from '@/lib/dayjs'; + +import { DBLessonType, LessonType } from './ILessonType'; +import { LessonTypeCreateForm, LessonTypeEditFormProps, RestLessonTypeUpdateForm } from './interfaces'; + +// { AxiosError } + +// const ERR_CANNOT_CONNECT_POCKETBASE = new AxiosError( +// 'Request failed with status code 500', +// AxiosError.ERR_BAD_RESPONSE +// ); + +export const defaultGetJsonHeaders = { + 'Content-Type': 'application/json', + 'cache-control': 'no-cache', +}; + +export const axiosGetJson = (url: string): Promise => { + return axios.get(url, { + headers: defaultGetJsonHeaders, + }); +}; + +const axiosUpdateJson = (url: string, jsonToUpdate: T): Promise => { + return axios.put(url, jsonToUpdate, { + headers: defaultGetJsonHeaders, + }); +}; + +interface ApiResponseItems { + data: { + items: T; + // ... other possible fields in data + }; + // ... other possible fields in response +} + +interface ApiResponseItem { + data: T; +} + +export interface LessonTypeEditForm { + name: string; + type: string; + pos: number; + visible: string; +} + +interface LessonTypeUpdateForm { + id: string; + name: string; + type: string; + pos: number; + visible: string; +} + +export async function listLessonTypes(): Promise { + const restResult = await axiosGetJson('/api/db/lesson_types/list'); + const { + data: { items: lessonTypes }, + } = restResult as ApiResponseItems; + + const output: LessonType[] = []; + for (const lessonType of lessonTypes) { + output.push({ + id: lessonType.id, + name: lessonType.name, + pos: lessonType.pos, + type: lessonType.type, + visible: lessonType.visible, + createdAt: dayjs(lessonType.created).toDate(), + // TODO: remove me + avatar: 'string', + email: 'string', + phone: 'string', + quota: 1, + status: 'pending', + }); + } + return output; +} + +export async function getLessonTypeById(id: string): Promise { + const restResult = await axiosGetJson(`/api/db/lesson_types/getById/${id}`); + const { data: lessonType } = restResult as ApiResponseItem; + + const output: LessonType = { + name: '', + id: '', + pos: 1, + type: '', + visible: 'visible', + createdAt: dayjs().toDate(), + // not used + email: '', + phone: '', + quota: 1, + status: 'pending', + avatar: 'string', + }; + output.id = lessonType.id; + output.name = lessonType.name; + output.pos = lessonType.pos; + output.type = lessonType.type; + output.visible = lessonType.visible; + output.createdAt = dayjs(lessonType.created).toDate(); + + return output; +} + +export async function updateLessonType(updateContent: LessonTypeEditFormProps, typeId: string): Promise { + const restResult = await axiosUpdateJson(`/api/db/lesson_types/update`, { + id: typeId, + data: updateContent, + }); + return restResult; +} + +export async function deleteLessonType(id: string): Promise { + const restResult = await axios.delete(`/api/db/lesson_types/delete`, { data: { id } }); + return restResult; +} + +export async function createLessonType(lessonTypeData: LessonTypeCreateForm): Promise { + const restResult = await axios.post(`/api/db/lesson_types/create`, { data: lessonTypeData }); + return restResult; +} + +// const createLessonType = async (lessonTypeData: any) => { +// let { data } = await axios.post('/api/db/lesson_types/helloworld', lessonTypeData, { +// headers: defaultGetJsonHeaders, +// }); +// return data; +// }; + +// const deleteLessonType = async (id: string) => { +// // throw ERR_CANNOT_CONNECT_POCKETBASE; + +// let { data } = await axios.delete(`/api/db/lesson_types/helloworld`, { +// headers: defaultGetJsonHeaders, +// data: { id }, +// }); + +// // axios.delete(`/api/db/lesson_types/helloworld`, +// return data; +// }; + +// const getLessonTypeById = async (id: string) => { +// let { data } = await axiosGetJson(`/api/db/lesson_types/getById/${id}`); +// return data; +// }; + +// const updateLessonType = async (id: string, lessonTypeData: any) => { +// let data_payload = { +// id, +// content: lessonTypeData, +// }; + +// // throw ERR_CANNOT_CONNECT_POCKETBASE; + +// let { data } = await axios.put('/api/db/lesson_types/helloworld', data_payload); + +// return data; +// }; diff --git a/002_source/cms/src/components/dashboard/lesson_type/interfaces.ts b/002_source/cms/src/components/dashboard/lesson_type/interfaces.ts new file mode 100644 index 0000000..5a74d89 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/interfaces.ts @@ -0,0 +1,25 @@ +export interface LessonTypeEditFormProps { + name: string; + type: string; + pos: number; + visible: string; +} + +export interface RestLessonTypeUpdateForm { + id: string; + data: LessonTypeEditFormProps; +} + +export interface LessonTypeCreateForm { + name: string; + type: string; + pos: number; + visible: string; +} + +export const LessonTypeCreateFormDefault: LessonTypeCreateForm = { + name: '', + type: '', + pos: 1, + visible: 'visible', +}; diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-type-create-form copy.tsx.1 b/002_source/cms/src/components/dashboard/lesson_type/lesson-type-create-form copy.tsx.1 new file mode 100644 index 0000000..9977546 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-type-create-form copy.tsx.1 @@ -0,0 +1,230 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +import { MenuItem } from '@mui/material'; +// import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +// import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +// import FormControlLabel from '@mui/material/FormControlLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +// import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// import axios from 'axios'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +// import { Option } from '@/components/core/option'; +import { toast } from '@/components/core/toaster'; + +import { createLessonType } from './http-actions'; +import { LessonTypeCreateForm, LessonTypeCreateFormDefault } from './interfaces'; + +// import { createLessonType } from './httpActions'; + +// function fileToBase64(file: Blob): Promise { +// return new Promise((resolve, reject) => { +// const reader = new FileReader(); +// reader.readAsDataURL(file); +// reader.onload = () => { +// resolve(reader.result as string); +// }; +// reader.onerror = () => { +// reject(new Error('Error converting file to base64')); +// }; +// }); +// } + +const schema = zod.object({ + name: zod.string().min(1, 'Name is required').max(255), + type: zod.string().min(1, 'Name is required').max(255), + pos: zod.string().min(1, 'Phone is required').max(15), + visible_to_user: zod.string().max(255), +}); + +type Values = zod.infer; + +const defaultValues = { + name: '', + type: '', + pos: '1', + visible_to_user: 'visible', +} satisfies Values; + +export function LessonTypeCreateForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(); + const [isCreating, setIsCreating] = React.useState(false); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting, isSubmitted }, + setValue, + // watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsCreating(true); + const tempCreate: LessonTypeCreateForm = LessonTypeCreateFormDefault; + + tempCreate.name = values.name; + tempCreate.type = values.type; + tempCreate.pos = 1; + tempCreate.visible = 'visible'; + + createLessonType(tempCreate) + .then((res) => { + router.push(paths.dashboard.lesson_types.list); + toast.success(t('dashboard.lessonTypes.create.success')); + }) + .catch((err) => { + logger.error(err); + toast.error(t('dashboard.lessonTypes.create.error')); + setIsCreating(false); + }); + }, + [router] + ); + + const avatarInputRef = React.useRef(null); + // const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + // const url = await fileToBase64(file); + // setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } spacing={4}> + + {t('dashboard.lessonTypes.create.typeInformation')} + + + + + + {t('dashboard.lessonTypes.create.avatar')} + {t('dashboard.lessonTypes.create.avatarRequirements')} + + + + + + + ( + + {t('dashboard.lessonTypes.create.name')} + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.create.type')} + + {errors.type ? {errors.type.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.create.position')} + + {errors.pos ? {errors.pos.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.create.visibleToUser')} + + + {errors.visible_to_user ? ( + {errors.visible_to_user.message} + ) : null} + + )} + /> + + + + + + + + + {t('dashboard.lessonTypes.create.createButton')} + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-type-create-form.tsx b/002_source/cms/src/components/dashboard/lesson_type/lesson-type-create-form.tsx new file mode 100644 index 0000000..9977546 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-type-create-form.tsx @@ -0,0 +1,230 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useRouter } from 'next/navigation'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +import { MenuItem } from '@mui/material'; +// import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +// import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +// import FormControlLabel from '@mui/material/FormControlLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +// import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// import axios from 'axios'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +// import { Option } from '@/components/core/option'; +import { toast } from '@/components/core/toaster'; + +import { createLessonType } from './http-actions'; +import { LessonTypeCreateForm, LessonTypeCreateFormDefault } from './interfaces'; + +// import { createLessonType } from './httpActions'; + +// function fileToBase64(file: Blob): Promise { +// return new Promise((resolve, reject) => { +// const reader = new FileReader(); +// reader.readAsDataURL(file); +// reader.onload = () => { +// resolve(reader.result as string); +// }; +// reader.onerror = () => { +// reject(new Error('Error converting file to base64')); +// }; +// }); +// } + +const schema = zod.object({ + name: zod.string().min(1, 'Name is required').max(255), + type: zod.string().min(1, 'Name is required').max(255), + pos: zod.string().min(1, 'Phone is required').max(15), + visible_to_user: zod.string().max(255), +}); + +type Values = zod.infer; + +const defaultValues = { + name: '', + type: '', + pos: '1', + visible_to_user: 'visible', +} satisfies Values; + +export function LessonTypeCreateForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(); + const [isCreating, setIsCreating] = React.useState(false); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting, isSubmitted }, + setValue, + // watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsCreating(true); + const tempCreate: LessonTypeCreateForm = LessonTypeCreateFormDefault; + + tempCreate.name = values.name; + tempCreate.type = values.type; + tempCreate.pos = 1; + tempCreate.visible = 'visible'; + + createLessonType(tempCreate) + .then((res) => { + router.push(paths.dashboard.lesson_types.list); + toast.success(t('dashboard.lessonTypes.create.success')); + }) + .catch((err) => { + logger.error(err); + toast.error(t('dashboard.lessonTypes.create.error')); + setIsCreating(false); + }); + }, + [router] + ); + + const avatarInputRef = React.useRef(null); + // const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + // const url = await fileToBase64(file); + // setValue('avatar', url); + } + }, + [setValue] + ); + + return ( +
+ + + } spacing={4}> + + {t('dashboard.lessonTypes.create.typeInformation')} + + + + + + {t('dashboard.lessonTypes.create.avatar')} + {t('dashboard.lessonTypes.create.avatarRequirements')} + + + + + + + ( + + {t('dashboard.lessonTypes.create.name')} + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.create.type')} + + {errors.type ? {errors.type.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.create.position')} + + {errors.pos ? {errors.pos.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.create.visibleToUser')} + + + {errors.visible_to_user ? ( + {errors.visible_to_user.message} + ) : null} + + )} + /> + + + + + + + + + {t('dashboard.lessonTypes.create.createButton')} + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-type-edit-form.tsx b/002_source/cms/src/components/dashboard/lesson_type/lesson-type-edit-form.tsx new file mode 100644 index 0000000..4846915 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-type-edit-form.tsx @@ -0,0 +1,266 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LoadingButton } from '@mui/lab'; +import { MenuItem } from '@mui/material'; +// import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +// import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +// import FormControlLabel from '@mui/material/FormControlLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Unstable_Grid2'; +// import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera'; +// import axios from 'axios'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +// import { Option } from '@/components/core/option'; +import { toast } from '@/components/core/toaster'; + +import { getLessonTypeById, updateLessonType } from './http-actions'; +// TODO: this may be wrong +import type { LessonType } from './ILessonType'; +import type { LessonTypeEditFormProps } from './interfaces'; + +// function fileToBase64(file: Blob): Promise { +// return new Promise((resolve, reject) => { +// const reader = new FileReader(); +// reader.readAsDataURL(file); +// reader.onload = () => { +// resolve(reader.result as string); +// }; +// reader.onerror = () => { +// reject(new Error('Error converting file to base64')); +// }; +// }); +// } + +const schema = zod.object({ + name: zod.string().min(1, 'Name is required').max(255), + type: zod.string().min(1, 'Name is required').max(255), + pos: zod.number().min(1, 'Phone is required').max(15), + visible_to_user: zod.string().max(255), +}); + +type Values = zod.infer; + +const defaultValues = { + name: '', + type: '', + pos: 1, + visible_to_user: 'visible', +} satisfies Values; + +export function LessonTypeEditForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(); + const { typeId } = useParams<{ typeId: string }>(); + const [isUpdating, setIsUpdating] = React.useState(false); + + const { + control, + handleSubmit, + formState: { errors }, + setValue, + reset, + // watch, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + setIsUpdating(true); + const tempUpdate: LessonTypeEditFormProps = { + name: values.name, + type: values.type, + pos: values.pos, + visible: values.visible_to_user ? 'visible' : 'hidden', + }; + + updateLessonType(tempUpdate, typeId) + .then((res) => { + logger.debug(res); + toast.success(t('dashboard.lessonTypes.update.success')); + setIsUpdating(false); + router.push(paths.dashboard.lesson_types.list); + }) + .catch((err) => { + logger.error(err); + toast.error('Something went wrong!'); + setIsUpdating(false); + }); + }, + [router] + ); + + const avatarInputRef = React.useRef(null); + // const avatar = watch('avatar'); + + const handleAvatarChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + // const url = await fileToBase64(file); + // setValue('avatar', url); + } + }, + [setValue] + ); + + React.useEffect(() => { + getLessonTypeById(typeId) + .then((lessonType: LessonType) => { + reset({ + name: lessonType.name, + type: lessonType.type, + pos: lessonType.pos, + visible_to_user: lessonType.visible, + }); + }) + .catch((err) => { + // console.error(err); + }); + }, []); + + return ( +
+ + + } spacing={4}> + + {t('dashboard.lessonTypes.edit.typeInformation')} + + + + + {/* + + + + */} + + + {t('dashboard.lessonTypes.edit.avatar')} + {t('dashboard.lessonTypes.edit.avatarRequirements')} + + + + + + + ( + + {t('dashboard.lessonTypes.edit.name')} + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.edit.type')} + + {errors.type ? {errors.type.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.edit.position')} + + {errors.pos ? {errors.pos.message} : null} + + )} + /> + + + ( + + {t('dashboard.lessonTypes.edit.visibleToUser')} + + + {errors.visible_to_user ? ( + {errors.visible_to_user.message} + ) : null} + + )} + /> + + + + + + + + + {t('dashboard.lessonTypes.edit.updateButton')} + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-types-filters.tsx b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-filters.tsx new file mode 100644 index 0000000..abb6f42 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-filters.tsx @@ -0,0 +1,403 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; + +import { paths } from '@/paths'; +import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; +import { Option } from '@/components/core/option'; + +import { LessonType } from './ILessonType'; +import { useLessonTypesSelection } from './lesson-types-selection-context'; + +export interface Filters { + email?: string; + phone?: string; + status?: string; + name?: string; + visible?: string; + type?: string; +} + +export type SortDir = 'asc' | 'desc'; + +export interface LessonTypesFiltersProps { + filters?: Filters; + sortDir?: SortDir; + fullData: LessonType[]; +} + +export function LessonTypesFilters({ + filters = {}, + sortDir = 'desc', + fullData, +}: LessonTypesFiltersProps): React.JSX.Element { + const { t } = useTranslation(); + const { email, phone, status, name, visible, type } = filters; + + const router = useRouter(); + + const selection = useLessonTypesSelection(); + + function getVisible(): number { + return fullData.reduce((count, item: LessonType) => { + return item.visible === 'visible' ? count + 1 : count; + }, 0); + } + + function getHidden(): number { + return fullData.reduce((count, item: LessonType) => { + return item.visible === 'hidden' ? count + 1 : count; + }, 0); + } + + // The tabs should be generated using API data. + const tabs = [ + { label: 'All', value: '', count: fullData.length }, + // { label: 'Active', value: 'active', count: 3 }, + // { label: 'Pending', value: 'pending', count: 1 }, + // { label: 'Blocked', value: 'blocked', count: 1 }, + { label: t('visible'), value: 'visible', count: getVisible() }, + { label: t('hidden'), value: 'hidden', count: getHidden() }, + ] as const; + + const updateSearchParams = React.useCallback( + (newFilters: Filters, newSortDir: SortDir): void => { + const searchParams = new URLSearchParams(); + + if (newSortDir === 'asc') { + searchParams.set('sortDir', newSortDir); + } + + if (newFilters.status) { + searchParams.set('status', newFilters.status); + } + + if (newFilters.email) { + searchParams.set('email', newFilters.email); + } + + if (newFilters.phone) { + searchParams.set('phone', newFilters.phone); + } + + if (newFilters.name) { + searchParams.set('name', newFilters.name); + } + + if (newFilters.type) { + searchParams.set('type', newFilters.type); + } + + if (newFilters.visible) { + searchParams.set('visible', newFilters.visible); + } + + router.push(`${paths.dashboard.lesson_types.list}?${searchParams.toString()}`); + }, + [router] + ); + + const handleClearFilters = React.useCallback(() => { + updateSearchParams({}, sortDir); + }, [updateSearchParams, sortDir]); + + const handleStatusChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, status: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleVisibleChange = React.useCallback( + (_: React.SyntheticEvent, value: string) => { + updateSearchParams({ ...filters, visible: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleNameChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, name: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleTypeChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, type: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleEmailChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, email: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handlePhoneChange = React.useCallback( + (value?: string) => { + updateSearchParams({ ...filters, phone: value }, sortDir); + }, + [updateSearchParams, filters, sortDir] + ); + + const handleSortChange = React.useCallback( + (event: SelectChangeEvent) => { + updateSearchParams(filters, event.target.value as SortDir); + }, + [updateSearchParams, filters] + ); + + const hasFilters = status || email || phone || visible || name || type; + + return ( +
+ + {tabs.map((tab) => ( + } + iconPosition="end" + key={tab.value} + label={tab.label} + sx={{ minHeight: 'auto' }} + tabIndex={0} + value={tab.value} + /> + ))} + + + + + { + handleNameChange(value as string); + }} + onFilterDelete={() => { + handleNameChange(); + }} + popover={} + value={name} + /> + + { + handleTypeChange(value as string); + }} + onFilterDelete={() => { + handleTypeChange(); + }} + popover={} + value={type} + /> + + {/* + { + handleEmailChange(value as string); + }} + onFilterDelete={() => { + handleEmailChange(); + }} + popover={} + value={email} + /> + */} + + {/* + { + handlePhoneChange(value as string); + }} + onFilterDelete={() => { + handlePhoneChange(); + }} + popover={} + value={phone} + /> + */} + + {hasFilters ? : null} + + {selection.selectedAny ? ( + + + {selection.selected.size} {t('selected')} + + + + ) : null} + + +
+ ); +} + +function TypeFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function NameFilterPopover(): React.JSX.Element { + const { t } = useTranslation(); + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function EmailFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} + +function PhoneFilterPopover(): React.JSX.Element { + const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext(); + const [value, setValue] = React.useState(''); + + React.useEffect(() => { + setValue((initialValue as string | undefined) ?? ''); + }, [initialValue]); + + return ( + + + { + setValue(event.target.value); + }} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onApply(value); + } + }} + value={value} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-types-pagination.tsx b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-pagination.tsx new file mode 100644 index 0000000..a124e14 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-pagination.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface LessonTypesPaginationProps { + count: number; + page: number; +} + +export function LessonTypesPagination({ count, page }: LessonTypesPaginationProps): React.JSX.Element { + // You should implement the pagination using a similar logic as the filters. + // Note that when page change, you should keep the filter search params. + + return ( + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-types-selection-context.tsx b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-selection-context.tsx new file mode 100644 index 0000000..e492466 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-selection-context.tsx @@ -0,0 +1,45 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import { LessonType } from './ILessonType'; + +function noop(): void { + return undefined; +} + +export interface LessonTypesSelectionContextValue extends Selection {} + +export const LessonTypesSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LessonTypesSelectionProviderProps { + children: React.ReactNode; + lessonTypes: LessonType[]; +} + +export function LessonTypesSelectionProvider({ + children, + lessonTypes = [], +}: LessonTypesSelectionProviderProps): React.JSX.Element { + const lessonTypeIds = React.useMemo(() => lessonTypes.map((lessonType) => lessonType.id), [lessonTypes]); + const selection = useSelection(lessonTypeIds); + + return ( + {children} + ); +} + +export function useLessonTypesSelection(): LessonTypesSelectionContextValue { + return React.useContext(LessonTypesSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/lesson-types-table.tsx b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-table.tsx new file mode 100644 index 0000000..63541b8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/lesson-types-table.tsx @@ -0,0 +1,162 @@ +'use client'; + +import * as React from 'react'; +import RouterLink from 'next/link'; +import { Button } from '@mui/material'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; +import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; +import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; +import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +import { paths } from '@/paths'; +import { dayjs } from '@/lib/dayjs'; +import { i18n } from '@/lib/i18n'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; + +import ConfirmDeleteModal from './confirm-delete-modal'; +import type { LessonType } from './ILessonType'; +import { useLessonTypesSelection } from './lesson-types-selection-context'; + +function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] { + return [ + { + formatter: (row): React.JSX.Element => ( + +
+ + {row.name} + +
+
+ ), + name: 'Name', + width: '250px', + }, + { field: 'type', name: 'Lesson type', width: '150px' }, + { field: 'pos', name: 'Lesson position', width: '150px' }, + { + formatter: (row): React.JSX.Element => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { t } = useTranslation(); + + const mapping = { + active: { label: 'Active', icon: }, + blocked: { label: 'Blocked', icon: }, + pending: { label: 'Pending', icon: }, + visible: { + label: t('visible'), + icon: , + }, + hidden: { + label: t('hidden'), + icon: , + }, + } as const; + + const { label, icon } = mapping[row.visible] ?? { label: 'Unknown', icon: null }; + + return ( + + ); + }, + name: 'visible', + width: '150px', + }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY h:mm A'); + }, + name: 'Created at', + width: '200px', + }, + + { + formatter: (row): React.JSX.Element => ( + + + + + { + handleDeleteClick(row.id); + }} + > + + + + ), + name: 'Actions', + width: '100px', + align: 'right', + }, + ]; +} + +export interface LessonTypesTableProps { + rows: LessonType[]; + reloadRows: () => void; +} + +export function LessonTypesTable({ rows, reloadRows }: LessonTypesTableProps): React.JSX.Element { + const { t } = useTranslation(); + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLessonTypesSelection(); + + const [idToDelete, setIdToDelete] = React.useState(''); + const [open, setOpen] = React.useState(false); + + function handleDeleteClick(testId: string): void { + setOpen(true); + setIdToDelete(testId); + } + + return ( + + + + columns={columns(handleDeleteClick)} + onDeselectAll={deselectAll} + onDeselectOne={(_, row) => { + deselectOne(row.id); + }} + onSelectAll={selectAll} + onSelectOne={(_, row) => { + selectOne(row.id); + }} + rows={rows} + selectable + selected={selected} + /> + {!rows.length ? ( + + + {t('No lesson types found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/notifications.tsx b/002_source/cms/src/components/dashboard/lesson_type/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/notifications.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Select from '@mui/material/Select'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple'; + +import { dayjs } from '@/lib/dayjs'; +import { DataTable } from '@/components/core/data-table'; +import type { ColumnDef } from '@/components/core/data-table'; +import { Option } from '@/components/core/option'; + +export interface Notification { + id: string; + type: string; + status: 'delivered' | 'pending' | 'failed'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {row.type} + + ), + name: 'Type', + width: '300px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + delivered: { label: 'Delivered', color: 'success' }, + pending: { label: 'Pending', color: 'warning' }, + failed: { label: 'Failed', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface NotificationsProps { + notifications: Notification[]; +} + +export function Notifications({ notifications }: NotificationsProps): React.JSX.Element { + return ( + + + + + } + title="Notifications" + /> + + + + +
+ +
+
+ + + columns={columns} rows={notifications} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/payments.tsx b/002_source/cms/src/components/dashboard/lesson_type/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/payments.tsx @@ -0,0 +1,138 @@ +'use client'; + +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; +import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple'; + +import { dayjs } from '@/lib/dayjs'; +import type { ColumnDef } from '@/components/core/data-table'; +import { DataTable } from '@/components/core/data-table'; + +export interface Payment { + currency: string; + amount: number; + invoiceId: string; + status: 'pending' | 'completed' | 'canceled' | 'refunded'; + createdAt: Date; +} + +const columns = [ + { + formatter: (row): React.JSX.Element => ( + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)} + + ), + name: 'Amount', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + const mapping = { + pending: { label: 'Pending', color: 'warning' }, + completed: { label: 'Completed', color: 'success' }, + canceled: { label: 'Canceled', color: 'error' }, + refunded: { label: 'Refunded', color: 'error' }, + } as const; + const { label, color } = mapping[row.status] ?? { label: 'Unknown', color: 'secondary' }; + + return ; + }, + name: 'Status', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => { + return {row.invoiceId}; + }, + name: 'Invoice ID', + width: '150px', + }, + { + formatter: (row): React.JSX.Element => ( + + {dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')} + + ), + name: 'Date', + align: 'right', + }, +] satisfies ColumnDef[]; + +export interface PaymentsProps { + ordersValue: number; + payments: Payment[]; + refundsValue: number; + totalOrders: number; +} + +export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element { + return ( + + }> + Create Payment + + } + avatar={ + + + + } + title="Payments" + /> + + + + } + spacing={3} + sx={{ justifyContent: 'space-between', p: 2 }} + > +
+ + Total orders + + {new Intl.NumberFormat('en-US').format(totalOrders)} +
+
+ + Orders value + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)} + +
+
+ + Refunds + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)} + +
+
+
+ + + columns={columns} rows={payments} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lesson_type/shipping-address.tsx b/002_source/cms/src/components/dashboard/lesson_type/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/lesson_type/shipping-address.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; + +export interface Address { + id: string; + country: string; + state: string; + city: string; + zipCode: string; + street: string; + primary?: boolean; +} + +export interface ShippingAddressProps { + address: Address; +} + +export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement { + return ( + + + + + {address.street}, +
+ {address.city}, {address.state}, {address.country}, +
+ {address.zipCode} +
+ + {address.primary ? : } + + +
+
+
+ ); +}