From 1c865595bf3c45465af11cb3ac938ff8db9f38f9 Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Sat, 19 Apr 2025 06:42:55 +0800 Subject: [PATCH] update init lp_category, --- .../cr_categories/[customerId]/page.tsx | 308 ++++++++++++++ .../dashboard/cr_categories/create/page.tsx | 48 +++ .../src/app/dashboard/cr_categories/page.tsx | 255 +++++++++++ .../cr_questions/[customerId]/page.tsx | 308 ++++++++++++++ .../dashboard/cr_questions/create/page.tsx | 48 +++ .../src/app/dashboard/cr_questions/page.tsx | 255 +++++++++++ .../src/app/dashboard/lp_categories/PROMPT.md | 0 .../[customerId]/SampleAddresses.tsx | 23 + .../[customerId]/SampleNotifications.tsx | 20 + .../[customerId]/SamplePayments.tsx | 43 ++ .../lp_categories/[customerId]/page.tsx | 236 +++++++++++ .../dashboard/lp_categories/create/page.tsx | 48 +++ .../dashboard/lp_categories/lp-categories.tsx | 155 +++++++ .../src/app/dashboard/lp_categories/page.tsx | 115 +++++ .../lp_questions/[customerId]/page.tsx | 308 ++++++++++++++ .../dashboard/lp_questions/create/page.tsx | 48 +++ .../src/app/dashboard/lp_questions/page.tsx | 255 +++++++++++ .../mf_categories/[customerId]/page.tsx | 308 ++++++++++++++ .../dashboard/mf_categories/create/page.tsx | 48 +++ .../src/app/dashboard/mf_categories/page.tsx | 255 +++++++++++ .../mf_questions/[customerId]/page.tsx | 308 ++++++++++++++ .../dashboard/mf_questions/create/page.tsx | 48 +++ .../src/app/dashboard/mf_questions/page.tsx | 255 +++++++++++ .../cf_categories/customer-create-form.tsx | 398 ++++++++++++++++++ .../cf_categories/customers-filters.tsx | 241 +++++++++++ .../cf_categories/customers-pagination.tsx | 31 ++ .../customers-selection-context.tsx | 43 ++ .../cf_categories/customers-table.tsx | 139 ++++++ .../dashboard/cf_categories/helloworld.tsx | 3 + .../dashboard/cf_categories/notifications.tsx | 101 +++++ .../dashboard/cf_categories/payments.tsx | 138 ++++++ .../cf_categories/shipping-address.tsx | 46 ++ .../cf_questions/customer-create-form.tsx | 398 ++++++++++++++++++ .../cf_questions/customers-filters.tsx | 241 +++++++++++ .../cf_questions/customers-pagination.tsx | 31 ++ .../customers-selection-context.tsx | 43 ++ .../cf_questions/customers-table.tsx | 139 ++++++ .../dashboard/cf_questions/helloworld.tsx | 3 + .../dashboard/cf_questions/notifications.tsx | 101 +++++ .../dashboard/cf_questions/payments.tsx | 138 ++++++ .../cf_questions/shipping-address.tsx | 46 ++ .../lesson-category-create-form.tsx | 3 +- .../lesson-category-edit-form.tsx | 2 +- .../dashboard/lp_categories/_PROMPT.MD | 1 + .../dashboard/lp_categories/helloworld.tsx | 3 + .../lp-categories-create-form.tsx | 388 +++++++++++++++++ .../lp_categories/lp-categories-filters.tsx | 264 ++++++++++++ .../lp-categories-pagination.tsx | 31 ++ .../lp-categories-selection-context.tsx | 43 ++ .../lp_categories/lp-categories-table.tsx | 254 +++++++++++ .../dashboard/lp_categories/notifications.tsx | 98 +++++ .../dashboard/lp_categories/payments.tsx | 140 ++++++ .../lp_categories/shipping-address.tsx | 43 ++ .../lp_questions/customer-create-form.tsx | 398 ++++++++++++++++++ .../lp_questions/customers-filters.tsx | 241 +++++++++++ .../lp_questions/customers-pagination.tsx | 31 ++ .../customers-selection-context.tsx | 43 ++ .../lp_questions/customers-table.tsx | 139 ++++++ .../dashboard/lp_questions/helloworld.tsx | 3 + .../dashboard/lp_questions/notifications.tsx | 101 +++++ .../dashboard/lp_questions/payments.tsx | 138 ++++++ .../lp_questions/shipping-address.tsx | 46 ++ .../mf_categories/customer-create-form.tsx | 398 ++++++++++++++++++ .../mf_categories/customers-filters.tsx | 241 +++++++++++ .../mf_categories/customers-pagination.tsx | 31 ++ .../customers-selection-context.tsx | 43 ++ .../mf_categories/customers-table.tsx | 139 ++++++ .../dashboard/mf_categories/helloworld.tsx | 3 + .../dashboard/mf_categories/notifications.tsx | 101 +++++ .../dashboard/mf_categories/payments.tsx | 138 ++++++ .../mf_categories/shipping-address.tsx | 46 ++ .../mf_questions/customer-create-form.tsx | 398 ++++++++++++++++++ .../mf_questions/customers-filters.tsx | 241 +++++++++++ .../mf_questions/customers-pagination.tsx | 31 ++ .../customers-selection-context.tsx | 43 ++ .../mf_questions/customers-table.tsx | 139 ++++++ .../dashboard/mf_questions/helloworld.tsx | 3 + .../dashboard/mf_questions/notifications.tsx | 101 +++++ .../dashboard/mf_questions/payments.tsx | 138 ++++++ .../mf_questions/shipping-address.tsx | 46 ++ 002_source/cms/src/constants.ts | 2 + .../cms/src/db/QuizListenings/GetAll.tsx | 8 + .../cms/src/db/QuizListenings/GetAllCount.tsx | 9 + .../file-to-base64.tsx | 0 002_source/cms/src/types/Address.tsx | 9 + 002_source/cms/src/types/LpCategory.tsx | 13 + 002_source/cms/src/types/Payment.tsx | 7 + 002_source/cms/src/types/notification.ts | 6 + 88 files changed, 10716 insertions(+), 3 deletions(-) create mode 100644 002_source/cms/src/app/dashboard/cr_categories/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr_categories/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr_categories/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr_questions/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr_questions/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/cr_questions/page.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/PROMPT.md create mode 100644 002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleAddresses.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleNotifications.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/[customerId]/SamplePayments.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/lp-categories.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_categories/page.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_questions/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_questions/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/lp_questions/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_categories/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_questions/[customerId]/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_questions/create/page.tsx create mode 100644 002_source/cms/src/app/dashboard/mf_questions/page.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/customer-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/customers-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/customers-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/customers-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/customers-table.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_categories/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/customer-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/customers-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/customers-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/customers-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/customers-table.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/cf_questions/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/_PROMPT.MD create mode 100644 002_source/cms/src/components/dashboard/lp_categories/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/lp-categories-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/lp-categories-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/lp-categories-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/lp-categories-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/lp-categories-table.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_categories/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/customer-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/customers-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/customers-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/customers-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/customers-table.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/lp_questions/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/customer-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_categories/shipping-address.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/customer-create-form.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/customers-filters.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/customers-pagination.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/customers-selection-context.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/customers-table.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/helloworld.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/notifications.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/payments.tsx create mode 100644 002_source/cms/src/components/dashboard/mf_questions/shipping-address.tsx create mode 100644 002_source/cms/src/db/QuizListenings/GetAll.tsx create mode 100644 002_source/cms/src/db/QuizListenings/GetAllCount.tsx rename 002_source/cms/src/{components/dashboard/lesson_category => lib}/file-to-base64.tsx (100%) create mode 100644 002_source/cms/src/types/Address.tsx create mode 100644 002_source/cms/src/types/LpCategory.tsx create mode 100644 002_source/cms/src/types/Payment.tsx create mode 100644 002_source/cms/src/types/notification.ts diff --git a/002_source/cms/src/app/dashboard/cr_categories/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/cr_categories/[customerId]/page.tsx new file mode 100644 index 0000000..edf064e --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr_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/customer/notifications'; +import { Payments } from '@/components/dashboard/customer/payments'; +import type { Address } from '@/components/dashboard/customer/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/customer/shipping-address'; + +export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+ + + + 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/cr_categories/create/page.tsx b/002_source/cms/src/app/dashboard/cr_categories/create/page.tsx new file mode 100644 index 0000000..a0460ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr_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/customer/customer-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr_categories/page.tsx b/002_source/cms/src/app/dashboard/cr_categories/page.tsx new file mode 100644 index 0000000..552f8dd --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr_categories/page.tsx @@ -0,0 +1,255 @@ +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 { dayjs } from '@/lib/dayjs'; +import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; +import type { Filters } from '@/components/dashboard/customer/customers-filters'; +import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; +import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; +import { CustomersTable } from '@/components/dashboard/customer/customers-table'; +import type { Customer } from '@/components/dashboard/customer/customers-table'; + +export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +const customers = [ + { + 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(), + }, + { + 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(), + }, + { + 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 Customer[]; + +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 sortedCustomers = applySort(customers, sortDir); + const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); + + return ( + + + + + Customers + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] { + 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: Customer[], { email, phone, status }: Filters): Customer[] { + 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/cr_questions/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/cr_questions/[customerId]/page.tsx new file mode 100644 index 0000000..edf064e --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr_questions/[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/customer/notifications'; +import { Payments } from '@/components/dashboard/customer/payments'; +import type { Address } from '@/components/dashboard/customer/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/customer/shipping-address'; + +export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+ + + + 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/cr_questions/create/page.tsx b/002_source/cms/src/app/dashboard/cr_questions/create/page.tsx new file mode 100644 index 0000000..a0460ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr_questions/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/customer/customer-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/cr_questions/page.tsx b/002_source/cms/src/app/dashboard/cr_questions/page.tsx new file mode 100644 index 0000000..552f8dd --- /dev/null +++ b/002_source/cms/src/app/dashboard/cr_questions/page.tsx @@ -0,0 +1,255 @@ +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 { dayjs } from '@/lib/dayjs'; +import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; +import type { Filters } from '@/components/dashboard/customer/customers-filters'; +import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; +import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; +import { CustomersTable } from '@/components/dashboard/customer/customers-table'; +import type { Customer } from '@/components/dashboard/customer/customers-table'; + +export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +const customers = [ + { + 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(), + }, + { + 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(), + }, + { + 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 Customer[]; + +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 sortedCustomers = applySort(customers, sortDir); + const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); + + return ( + + + + + Customers + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] { + 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: Customer[], { email, phone, status }: Filters): Customer[] { + 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/lp_categories/PROMPT.md b/002_source/cms/src/app/dashboard/lp_categories/PROMPT.md new file mode 100644 index 0000000..e69de29 diff --git a/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleAddresses.tsx b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleAddresses.tsx new file mode 100644 index 0000000..f2b5a72 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleAddresses.tsx @@ -0,0 +1,23 @@ +'use client'; + +import type { Address } from '@/types/Address'; + +export const SampleAddresses: Address[] = [ + { + 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', + }, +]; diff --git a/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleNotifications.tsx b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleNotifications.tsx new file mode 100644 index 0000000..37d8ad5 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SampleNotifications.tsx @@ -0,0 +1,20 @@ +'use client'; + +// import { dayjs } from 'dayjs'; +import type { Notification } from '@/types/notification'; +import { dayjs } from '@/lib/dayjs'; + +export const SampleNotifications: Notification[] = [ + { + id: 'EV-002', + type: 'Refund request approved', + status: 'pending', + createdAt: dayjs().subtract(34, 'minute').subtract(5, 'hour').subtract(3, 'day').toDate(), + }, + { + id: 'EV-001', + type: 'Order confirmation', + status: 'delivered', + createdAt: dayjs().subtract(49, 'minute').subtract(11, 'hour').subtract(4, 'day').toDate(), + }, +]; diff --git a/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SamplePayments.tsx b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SamplePayments.tsx new file mode 100644 index 0000000..b9eba69 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/SamplePayments.tsx @@ -0,0 +1,43 @@ +'use client'; + +// import { dayjs } from 'dayjs'; +import type { Payment } from '@/types/Payment'; +import { dayjs } from '@/lib/dayjs'; + +export const SamplePayments: Payment[] = [ + { + currency: 'USD', + amount: 500, + invoiceId: 'INV-005', + status: 'completed', + createdAt: dayjs().subtract(5, 'minute').subtract(1, 'hour').toDate(), + }, + { + currency: 'USD', + amount: 324.5, + invoiceId: 'INV-004', + status: 'refunded', + createdAt: dayjs().subtract(21, 'minute').subtract(2, 'hour').toDate(), + }, + { + currency: 'USD', + amount: 746.5, + invoiceId: 'INV-003', + status: 'completed', + createdAt: dayjs().subtract(7, 'minute').subtract(3, 'hour').toDate(), + }, + { + currency: 'USD', + amount: 56.89, + invoiceId: 'INV-002', + status: 'completed', + createdAt: dayjs().subtract(48, 'minute').subtract(4, 'hour').toDate(), + }, + { + currency: 'USD', + amount: 541.59, + invoiceId: 'INV-001', + status: 'completed', + createdAt: dayjs().subtract(31, 'minute').subtract(5, 'hour').toDate(), + }, +]; diff --git a/002_source/cms/src/app/dashboard/lp_categories/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/page.tsx new file mode 100644 index 0000000..7c0bc32 --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/[customerId]/page.tsx @@ -0,0 +1,236 @@ +'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 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 type { Address } from '@/types/Address'; +import { paths } from '@/paths'; +import { PropertyItem } from '@/components/core/property-item'; +import { PropertyList } from '@/components/core/property-list'; +import { Notifications } from '@/components/dashboard/lp_categories/notifications'; +import { Payments } from '@/components/dashboard/lp_categories/payments'; +import { ShippingAddress } from '@/components/dashboard/lp_categories/shipping-address'; + +import { SampleAddresses } from './SampleAddresses'; +import { SampleNotifications } from './SampleNotifications'; +import { SamplePayments } from './SamplePayments'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(); + + return ( + + + +
+ + + {t('Customers')} + +
+ + + + MV + +
+ + {t('Customer name')} + } + label={t('Active')} + size="small" + variant="outlined" + /> + + + {t('Customer email')} + +
+
+
+ +
+
+
+ + + + + + + + } + avatar={ + + + + } + title={t('Basic details')} + /> + } + orientation="vertical" + sx={{ '--PropertyItem-padding': '12px 24px' }} + > + {( + [ + { key: t('Customer ID'), value: }, + { key: t('Name'), value: t('Customer name') }, + { key: t('Email'), value: t('Customer email') }, + { key: t('Phone'), value: t('Customer phone') }, + { key: t('Company'), value: t('Company name') }, + { + key: t('Quota'), + value: ( + + + + 50% + + + ), + }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + + + + + } + title={t('Security')} + /> + + +
+ +
+ + {t('A deleted customer cannot be restored. All data will be permanently removed.')} + +
+
+
+
+
+ + + + + }> + {t('Edit')} + + } + avatar={ + + + + } + title={t('Billing details')} + /> + + + } sx={{ '--PropertyItem-padding': '16px' }}> + {( + [ + { key: t('Credit card'), value: '**** 4142' }, + { key: t('Country'), value: t('United States') }, + { key: t('State'), value: t('Michigan') }, + { key: t('City'), value: t('Southfield') }, + { key: t('Address'), value: t('Address') }, + { key: t('Tax ID'), value: t('Tax ID') }, + ] satisfies { key: string; value: React.ReactNode }[] + ).map( + (item): React.JSX.Element => ( + + ) + )} + + + + + + }> + {t('Add')} + + } + avatar={ + + + + } + title={t('Shipping addresses')} + /> + + + {(SampleAddresses satisfies Address[]).map((address) => ( + + + + ))} + + + + + + +
+
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lp_categories/create/page.tsx b/002_source/cms/src/app/dashboard/lp_categories/create/page.tsx new file mode 100644 index 0000000..f9c08ed --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/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 { LpCategoryCreateForm } from '@/components/dashboard/lp_categories/lp-categories-create-form'; + +export default function Page(): React.JSX.Element { + const { t } = useTranslation(['lp_category']); + return ( + + + +
+ + + {t('list.title')} + +
+
+ {t('create.title')} +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lp_categories/lp-categories.tsx b/002_source/cms/src/app/dashboard/lp_categories/lp-categories.tsx new file mode 100644 index 0000000..41f39fb --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/lp-categories.tsx @@ -0,0 +1,155 @@ +import { dayjs } from '@/lib/dayjs'; +import type { Customer } from '@/components/dashboard/customer/customers-table'; + +export const LpCategories = [ + { + 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(), + }, + { + 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(), + }, + { + 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 Customer[]; diff --git a/002_source/cms/src/app/dashboard/lp_categories/page.tsx b/002_source/cms/src/app/dashboard/lp_categories/page.tsx new file mode 100644 index 0000000..81f818b --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_categories/page.tsx @@ -0,0 +1,115 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import getAllQuizListenings from '@/db/QuizListenings/GetAll'; +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 { useTranslation } from 'react-i18next'; + +import type { LpCategory } from '@/types/LpCategory'; +import { paths } from '@/paths'; +import { LpCategoriesFilters } from '@/components/dashboard/lp_categories/lp-categories-filters'; +import type { Filters } from '@/components/dashboard/lp_categories/lp-categories-filters'; +import { LpCategoriesPagination } from '@/components/dashboard/lp_categories/lp-categories-pagination'; +import { LpCategoriesSelectionProvider } from '@/components/dashboard/lp_categories/lp-categories-selection-context'; +import { LpCategoriesTable } from '@/components/dashboard/lp_categories/lp-categories-table'; + +import { LpCategories } from './lp-categories'; + +export default function Page({ searchParams }: PageProps): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(['listening_practice']); + const { email, phone, sortDir, status } = searchParams; + + const sortedCustomers = applySort(LpCategories, sortDir); + const lpCategories = applyFilters(sortedCustomers, { email, phone, status }); + + React.useEffect(() => {}, []); + + return ( + + + + + {t('listening-practice')} + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: LpCategory[], sortDir: 'asc' | 'desc' | undefined): LpCategory[] { + 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: LpCategory[], { email, phone, status }: Filters): LpCategory[] { + 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; + }); +} + +interface PageProps { + searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string }; +} diff --git a/002_source/cms/src/app/dashboard/lp_questions/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/lp_questions/[customerId]/page.tsx new file mode 100644 index 0000000..edf064e --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_questions/[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/customer/notifications'; +import { Payments } from '@/components/dashboard/customer/payments'; +import type { Address } from '@/components/dashboard/customer/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/customer/shipping-address'; + +export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+ + + + 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/lp_questions/create/page.tsx b/002_source/cms/src/app/dashboard/lp_questions/create/page.tsx new file mode 100644 index 0000000..a0460ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_questions/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/customer/customer-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/lp_questions/page.tsx b/002_source/cms/src/app/dashboard/lp_questions/page.tsx new file mode 100644 index 0000000..552f8dd --- /dev/null +++ b/002_source/cms/src/app/dashboard/lp_questions/page.tsx @@ -0,0 +1,255 @@ +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 { dayjs } from '@/lib/dayjs'; +import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; +import type { Filters } from '@/components/dashboard/customer/customers-filters'; +import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; +import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; +import { CustomersTable } from '@/components/dashboard/customer/customers-table'; +import type { Customer } from '@/components/dashboard/customer/customers-table'; + +export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +const customers = [ + { + 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(), + }, + { + 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(), + }, + { + 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 Customer[]; + +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 sortedCustomers = applySort(customers, sortDir); + const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); + + return ( + + + + + Customers + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] { + 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: Customer[], { email, phone, status }: Filters): Customer[] { + 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/mf_categories/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/[customerId]/page.tsx new file mode 100644 index 0000000..edf064e --- /dev/null +++ b/002_source/cms/src/app/dashboard/mf_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/customer/notifications'; +import { Payments } from '@/components/dashboard/customer/payments'; +import type { Address } from '@/components/dashboard/customer/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/customer/shipping-address'; + +export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+ + + + 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/mf_categories/create/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/create/page.tsx new file mode 100644 index 0000000..a0460ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/mf_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/customer/customer-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/mf_categories/page.tsx b/002_source/cms/src/app/dashboard/mf_categories/page.tsx new file mode 100644 index 0000000..552f8dd --- /dev/null +++ b/002_source/cms/src/app/dashboard/mf_categories/page.tsx @@ -0,0 +1,255 @@ +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 { dayjs } from '@/lib/dayjs'; +import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; +import type { Filters } from '@/components/dashboard/customer/customers-filters'; +import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; +import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; +import { CustomersTable } from '@/components/dashboard/customer/customers-table'; +import type { Customer } from '@/components/dashboard/customer/customers-table'; + +export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +const customers = [ + { + 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(), + }, + { + 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(), + }, + { + 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 Customer[]; + +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 sortedCustomers = applySort(customers, sortDir); + const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); + + return ( + + + + + Customers + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] { + 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: Customer[], { email, phone, status }: Filters): Customer[] { + 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/mf_questions/[customerId]/page.tsx b/002_source/cms/src/app/dashboard/mf_questions/[customerId]/page.tsx new file mode 100644 index 0000000..edf064e --- /dev/null +++ b/002_source/cms/src/app/dashboard/mf_questions/[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/customer/notifications'; +import { Payments } from '@/components/dashboard/customer/payments'; +import type { Address } from '@/components/dashboard/customer/shipping-address'; +import { ShippingAddress } from '@/components/dashboard/customer/shipping-address'; + +export const metadata = { title: `Details | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+ + + + 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/mf_questions/create/page.tsx b/002_source/cms/src/app/dashboard/mf_questions/create/page.tsx new file mode 100644 index 0000000..a0460ab --- /dev/null +++ b/002_source/cms/src/app/dashboard/mf_questions/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/customer/customer-create-form'; + +export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +export default function Page(): React.JSX.Element { + return ( + + + +
+ + + Customers + +
+
+ Create customer +
+
+ +
+
+ ); +} diff --git a/002_source/cms/src/app/dashboard/mf_questions/page.tsx b/002_source/cms/src/app/dashboard/mf_questions/page.tsx new file mode 100644 index 0000000..552f8dd --- /dev/null +++ b/002_source/cms/src/app/dashboard/mf_questions/page.tsx @@ -0,0 +1,255 @@ +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 { dayjs } from '@/lib/dayjs'; +import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; +import type { Filters } from '@/components/dashboard/customer/customers-filters'; +import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; +import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; +import { CustomersTable } from '@/components/dashboard/customer/customers-table'; +import type { Customer } from '@/components/dashboard/customer/customers-table'; + +export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; + +const customers = [ + { + 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(), + }, + { + 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(), + }, + { + 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 Customer[]; + +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 sortedCustomers = applySort(customers, sortDir); + const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); + + return ( + + + + + Customers + + + + + + + + + + + + + + + + + + + ); +} + +// Sorting and filtering has to be done on the server. + +function applySort(row: Customer[], sortDir: 'asc' | 'desc' | undefined): Customer[] { + 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: Customer[], { email, phone, status }: Filters): Customer[] { + 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/components/dashboard/cf_categories/customer-create-form.tsx b/002_source/cms/src/components/dashboard/cf_categories/customer-create-form.tsx new file mode 100644 index 0000000..7be8fc8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/customer-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.customers.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/cf_categories/customers-filters.tsx b/002_source/cms/src/components/dashboard/cf_categories/customers-filters.tsx new file mode 100644 index 0000000..1567e3b --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/customers-filters.tsx @@ -0,0 +1,241 @@ +'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 { useCustomersSelection } from './customers-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 CustomersFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useCustomersSelection(); + + 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.customers.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/cf_categories/customers-pagination.tsx b/002_source/cms/src/components/dashboard/cf_categories/customers-pagination.tsx new file mode 100644 index 0000000..ab01272 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/customers-pagination.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface CustomersPaginationProps { + count: number; + page: number; +} + +export function CustomersPagination({ count, page }: CustomersPaginationProps): 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/cf_categories/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/cf_categories/customers-selection-context.tsx new file mode 100644 index 0000000..023dbc0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/customers-selection-context.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { Customer } from './customers-table'; + +function noop(): void { + return undefined; +} + +export interface CustomersSelectionContextValue extends Selection {} + +export const CustomersSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface CustomersSelectionProviderProps { + children: React.ReactNode; + customers: Customer[]; +} + +export function CustomersSelectionProvider({ + children, + customers = [], +}: CustomersSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useCustomersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/cf_categories/customers-table.tsx b/002_source/cms/src/components/dashboard/cf_categories/customers-table.tsx new file mode 100644 index 0000000..bf9b01a --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/customers-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 { useCustomersSelection } from './customers-selection-context'; + +export interface Customer { + 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 CustomersTableProps { + rows: Customer[]; +} + +export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); + + 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 customers found + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/cf_categories/helloworld.tsx b/002_source/cms/src/components/dashboard/cf_categories/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/cf_categories/notifications.tsx b/002_source/cms/src/components/dashboard/cf_categories/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/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/cf_categories/payments.tsx b/002_source/cms/src/components/dashboard/cf_categories/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/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/cf_categories/shipping-address.tsx b/002_source/cms/src/components/dashboard/cf_categories/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_categories/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/cf_questions/customer-create-form.tsx b/002_source/cms/src/components/dashboard/cf_questions/customer-create-form.tsx new file mode 100644 index 0000000..7be8fc8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/customer-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.customers.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/cf_questions/customers-filters.tsx b/002_source/cms/src/components/dashboard/cf_questions/customers-filters.tsx new file mode 100644 index 0000000..1567e3b --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/customers-filters.tsx @@ -0,0 +1,241 @@ +'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 { useCustomersSelection } from './customers-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 CustomersFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useCustomersSelection(); + + 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.customers.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/cf_questions/customers-pagination.tsx b/002_source/cms/src/components/dashboard/cf_questions/customers-pagination.tsx new file mode 100644 index 0000000..ab01272 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/customers-pagination.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface CustomersPaginationProps { + count: number; + page: number; +} + +export function CustomersPagination({ count, page }: CustomersPaginationProps): 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/cf_questions/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/cf_questions/customers-selection-context.tsx new file mode 100644 index 0000000..023dbc0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/customers-selection-context.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { Customer } from './customers-table'; + +function noop(): void { + return undefined; +} + +export interface CustomersSelectionContextValue extends Selection {} + +export const CustomersSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface CustomersSelectionProviderProps { + children: React.ReactNode; + customers: Customer[]; +} + +export function CustomersSelectionProvider({ + children, + customers = [], +}: CustomersSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useCustomersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/cf_questions/customers-table.tsx b/002_source/cms/src/components/dashboard/cf_questions/customers-table.tsx new file mode 100644 index 0000000..bf9b01a --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/customers-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 { useCustomersSelection } from './customers-selection-context'; + +export interface Customer { + 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 CustomersTableProps { + rows: Customer[]; +} + +export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); + + 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 customers found + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/cf_questions/helloworld.tsx b/002_source/cms/src/components/dashboard/cf_questions/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/cf_questions/notifications.tsx b/002_source/cms/src/components/dashboard/cf_questions/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/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/cf_questions/payments.tsx b/002_source/cms/src/components/dashboard/cf_questions/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/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/cf_questions/shipping-address.tsx b/002_source/cms/src/components/dashboard/cf_questions/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/cf_questions/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_category/lesson-category-create-form.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-category-create-form.tsx index 84073ff..7744b52 100644 --- 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 @@ -31,12 +31,11 @@ import { z as zod } from 'zod'; import { paths } from '@/paths'; import { logger } from '@/lib/default-logger'; +import { fileToBase64 } from '@/lib/file-to-base64'; import { Option } from '@/components/core/option'; import { TextEditor } from '@/components/core/text-editor/text-editor'; import { toast } from '@/components/core/toaster'; -import { fileToBase64 } from './file-to-base64'; - const schema = zod.object({ avatar: zod.string().optional(), name: zod.string().min(1, 'Name is required').max(255), diff --git a/002_source/cms/src/components/dashboard/lesson_category/lesson-category-edit-form.tsx b/002_source/cms/src/components/dashboard/lesson_category/lesson-category-edit-form.tsx index ac6cde9..8d039b4 100644 --- a/002_source/cms/src/components/dashboard/lesson_category/lesson-category-edit-form.tsx +++ b/002_source/cms/src/components/dashboard/lesson_category/lesson-category-edit-form.tsx @@ -33,6 +33,7 @@ import { z as zod } from 'zod'; import { paths } from '@/paths'; import { dayjs } from '@/lib/dayjs'; import { logger } from '@/lib/default-logger'; +import { fileToBase64 } from '@/lib/file-to-base64'; import { pb } from '@/lib/pb'; import { Option } from '@/components/core/option'; import { TextEditor } from '@/components/core/text-editor/text-editor'; @@ -41,7 +42,6 @@ import FormLoading from '@/components/loading'; import ErrorDisplay from '../error'; import { defaultLessonCategory } from './_constants'; -import { fileToBase64 } from './file-to-base64'; import { EditFormProps, LessonCategory } from './types'; // TODO: review this diff --git a/002_source/cms/src/components/dashboard/lp_categories/_PROMPT.MD b/002_source/cms/src/components/dashboard/lp_categories/_PROMPT.MD new file mode 100644 index 0000000..93ce5a6 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/_PROMPT.MD @@ -0,0 +1 @@ +please review and add translations, e.g. `{t('[word]')}` diff --git a/002_source/cms/src/components/dashboard/lp_categories/helloworld.tsx b/002_source/cms/src/components/dashboard/lp_categories/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/lp_categories/lp-categories-create-form.tsx b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-create-form.tsx new file mode 100644 index 0000000..6555889 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-create-form.tsx @@ -0,0 +1,388 @@ +'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 { useTranslation } from 'react-i18next'; +import { z as zod } from 'zod'; + +import { paths } from '@/paths'; +import { logger } from '@/lib/default-logger'; +import { fileToBase64 } from '@/lib/file-to-base64'; +import { Option } from '@/components/core/option'; +import { toast } from '@/components/core/toaster'; + +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), +}); + +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 type Values = zod.infer; + +export function LpCategoryCreateForm(): React.JSX.Element { + const router = useRouter(); + const { t } = useTranslation(); + + 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.customers.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}> + + {t('Account information')} + + + + + + + + + + {t('Avatar')} + {t('Min 400x400px, PNG or JPEG')} + + + + + + + ( + + {t('Name')} + + {errors.name ? {errors.name.message} : null} + + )} + /> + + + ( + + {t('Email address')} + + {errors.email ? {errors.email.message} : null} + + )} + /> + + + ( + + {t('Phone number')} + + {errors.phone ? {errors.phone.message} : null} + + )} + /> + + + ( + + {t('Company')} + + {errors.company ? {errors.company.message} : null} + + )} + /> + + + + + {t('Billing information')} + + + ( + + {t('Country')} + + {errors.billingAddress?.country ? ( + {errors.billingAddress?.country?.message} + ) : null} + + )} + /> + + + ( + + {t('State')} + + {errors.billingAddress?.state ? ( + {errors.billingAddress?.state?.message} + ) : null} + + )} + /> + + + ( + + {t('City')} + + {errors.billingAddress?.city ? ( + {errors.billingAddress?.city?.message} + ) : null} + + )} + /> + + + ( + + {t('Zip code')} + + {errors.billingAddress?.zipCode ? ( + {errors.billingAddress?.zipCode?.message} + ) : null} + + )} + /> + + + ( + + {t('Address')} + + {errors.billingAddress?.line1 ? ( + {errors.billingAddress?.line1?.message} + ) : null} + + )} + /> + + + ( + + {t('Tax ID')} + + {errors.taxId ? {errors.taxId.message} : null} + + )} + /> + + + + + {t('Shipping information')} + } label={t('Same as billing address')} /> + + + {t('Additional information')} + + + ( + + {t('Timezone')} + + {errors.timezone ? {errors.timezone.message} : null} + + )} + /> + + + ( + + {t('Language')} + + {errors.language ? {errors.language.message} : null} + + )} + /> + + + ( + + {t('Currency')} + + {errors.currency ? {errors.currency.message} : null} + + )} + /> + + + + + + + + + + +
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lp_categories/lp-categories-filters.tsx b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-filters.tsx new file mode 100644 index 0000000..f1add2a --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-filters.tsx @@ -0,0 +1,264 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import GetAllCount from '@/db/QuizListenings/GetAllCount'; +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 { useLpCategoriesSelection } from './lp-categories-selection-context'; + +// The tabs should be generated using API data. +const tabs1 = [ + { 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 CustomersFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function LpCategoriesFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element { + const { t } = useTranslation(); + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useLpCategoriesSelection(); + + 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.lp_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 [allCount, setAllCount] = React.useState(0); + + React.useEffect(() => { + async function fetchAllCount(): Promise { + setAllCount(await GetAllCount()); + } + + void fetchAllCount(); + }, []); + + const tabs = [ + { label: 'All', value: '', count: allCount }, + { label: 'Active', value: 'active', count: 3 }, + { label: 'Pending', value: 'pending', count: 1 }, + { label: 'Blocked', value: 'blocked', count: 1 }, + ] as const; + + 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 { 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 PhoneFilterPopover(): 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} + placeholder={t('Enter phone number')} + /> + + + + ); +} diff --git a/002_source/cms/src/components/dashboard/lp_categories/lp-categories-pagination.tsx b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-pagination.tsx new file mode 100644 index 0000000..48c4ba3 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-pagination.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface CustomersPaginationProps { + count: number; + page: number; +} + +export function LpCategoriesPagination({ count, page }: CustomersPaginationProps): 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/lp_categories/lp-categories-selection-context.tsx b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-selection-context.tsx new file mode 100644 index 0000000..c3f712e --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-selection-context.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { LpCategory } from '../../../types/LpCategory.tsx'; + +function noop(): void { + return undefined; +} + +export interface LpCategoriesSelectionContextValue extends Selection {} + +export const CustomersSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface LpCategoriesSelectionProviderProps { + children: React.ReactNode; + LpCategories: LpCategory[]; +} + +export function LpCategoriesSelectionProvider({ + children, + LpCategories: customers = [], +}: LpCategoriesSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useLpCategoriesSelection(): LpCategoriesSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/lp_categories/lp-categories-table.tsx b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-table.tsx new file mode 100644 index 0000000..ecefa51 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/lp-categories-table.tsx @@ -0,0 +1,254 @@ +'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 Button from '@mui/material/Button'; +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 { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images'; +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 type { LpCategory } from '@/types/LpCategory'; +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 type { LessonCategory } from '../lesson_category/types'; +import { useLpCategoriesSelection } from './lp-categories-selection-context'; + +const columns1 = [ + { + 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[]; + +function columns(handleDeleteClick: (testId: string) => void): ColumnDef[] { + return [ + { + formatter: (row): React.JSX.Element => ( + + + + + + {' '} +
+ {row.name} + + slug: {row.name} + +
+
+ +
+ ), + name: 'Name', + width: '200px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)} + + + ), + // NOTE: please refer to translation.json here + name: 'word-count', + width: '100px', + }, + { + 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: }, + NA: { label: 'NA', icon: }, + } as const; + const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null }; + + return ( + + ); + }, + name: 'Status', + width: '150px', + }, + { + formatter(row) { + return dayjs(row.createdAt).format('MMM D, YYYY'); + }, + name: 'Created at', + width: '100px', + }, + { + formatter: (row): React.JSX.Element => ( + + + + + { + handleDeleteClick(row.id); + }} + > + + + + ), + name: 'Actions', + hideName: true, + width: '100px', + align: 'right', + }, + ]; +} + +export interface LpCategoriesTableProps { + rows: LpCategory[]; +} + +function getCatImageFromId(row: LpCategory): string | undefined { + return `http://127.0.0.1:8090/api/files/${row.collectionId}/${row.id}/${row.cat_image}`; +} + +export function LpCategoriesTable({ rows }: LpCategoriesTableProps): React.JSX.Element { + const { t } = useTranslation(); + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpCategoriesSelection(); + + 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 customers found')} + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/lp_categories/notifications.tsx b/002_source/cms/src/components/dashboard/lp_categories/notifications.tsx new file mode 100644 index 0000000..6099a51 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/notifications.tsx @@ -0,0 +1,98 @@ +'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 { useTranslation } from 'react-i18next'; + +import type { Notification } from '@/types/notification'; +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'; + +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 { + const { t } = useTranslation(); + + return ( + + + + + } + title={t('Notifications')} + /> + + + + +
+ +
+
+ + + columns={columns} rows={notifications} /> + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lp_categories/payments.tsx b/002_source/cms/src/components/dashboard/lp_categories/payments.tsx new file mode 100644 index 0000000..b57f40c --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/payments.tsx @@ -0,0 +1,140 @@ +'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 { useTranslation } from 'react-i18next'; + +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 { + const { t } = useTranslation(); + return ( + + }> + {t('Create Payment')} + + } + avatar={ + + + + } + title={t('Payments')} + /> + + + + } + spacing={3} + sx={{ justifyContent: 'space-between', p: 2 }} + > +
+ + {t('Total orders')} + + {new Intl.NumberFormat('en-US').format(totalOrders)} +
+
+ + {t('Orders value')} + + + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)} + +
+
+ + {t('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/lp_categories/shipping-address.tsx b/002_source/cms/src/components/dashboard/lp_categories/shipping-address.tsx new file mode 100644 index 0000000..ad92281 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_categories/shipping-address.tsx @@ -0,0 +1,43 @@ +'use client'; + +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'; +import { useTranslation } from 'react-i18next'; + +import type { Address } from '@/types/Address'; + +export interface ShippingAddressProps { + address: Address; +} + +export function ShippingAddress({ address }: ShippingAddressProps): React.ReactElement { + const { t } = useTranslation(); + + return ( + + + + + {address.street}, +
+ {address.city}, {address.state}, {address.country}, +
+ {address.zipCode} +
+ + {address.primary ? : } + + +
+
+
+ ); +} diff --git a/002_source/cms/src/components/dashboard/lp_questions/customer-create-form.tsx b/002_source/cms/src/components/dashboard/lp_questions/customer-create-form.tsx new file mode 100644 index 0000000..7be8fc8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/customer-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.customers.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/lp_questions/customers-filters.tsx b/002_source/cms/src/components/dashboard/lp_questions/customers-filters.tsx new file mode 100644 index 0000000..1567e3b --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/customers-filters.tsx @@ -0,0 +1,241 @@ +'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 { useCustomersSelection } from './customers-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 CustomersFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useCustomersSelection(); + + 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.customers.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/lp_questions/customers-pagination.tsx b/002_source/cms/src/components/dashboard/lp_questions/customers-pagination.tsx new file mode 100644 index 0000000..ab01272 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/customers-pagination.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface CustomersPaginationProps { + count: number; + page: number; +} + +export function CustomersPagination({ count, page }: CustomersPaginationProps): 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/lp_questions/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/lp_questions/customers-selection-context.tsx new file mode 100644 index 0000000..023dbc0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/customers-selection-context.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { Customer } from './customers-table'; + +function noop(): void { + return undefined; +} + +export interface CustomersSelectionContextValue extends Selection {} + +export const CustomersSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface CustomersSelectionProviderProps { + children: React.ReactNode; + customers: Customer[]; +} + +export function CustomersSelectionProvider({ + children, + customers = [], +}: CustomersSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useCustomersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/lp_questions/customers-table.tsx b/002_source/cms/src/components/dashboard/lp_questions/customers-table.tsx new file mode 100644 index 0000000..bf9b01a --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/customers-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 { useCustomersSelection } from './customers-selection-context'; + +export interface Customer { + 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 CustomersTableProps { + rows: Customer[]; +} + +export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); + + 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 customers found + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/lp_questions/helloworld.tsx b/002_source/cms/src/components/dashboard/lp_questions/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/lp_questions/notifications.tsx b/002_source/cms/src/components/dashboard/lp_questions/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/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/lp_questions/payments.tsx b/002_source/cms/src/components/dashboard/lp_questions/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/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/lp_questions/shipping-address.tsx b/002_source/cms/src/components/dashboard/lp_questions/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/lp_questions/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/mf_categories/customer-create-form.tsx b/002_source/cms/src/components/dashboard/mf_categories/customer-create-form.tsx new file mode 100644 index 0000000..7be8fc8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/customer-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.customers.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/mf_categories/customers-filters.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-filters.tsx new file mode 100644 index 0000000..1567e3b --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/customers-filters.tsx @@ -0,0 +1,241 @@ +'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 { useCustomersSelection } from './customers-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 CustomersFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useCustomersSelection(); + + 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.customers.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/mf_categories/customers-pagination.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx new file mode 100644 index 0000000..ab01272 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/customers-pagination.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface CustomersPaginationProps { + count: number; + page: number; +} + +export function CustomersPagination({ count, page }: CustomersPaginationProps): 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/mf_categories/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx new file mode 100644 index 0000000..023dbc0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/customers-selection-context.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { Customer } from './customers-table'; + +function noop(): void { + return undefined; +} + +export interface CustomersSelectionContextValue extends Selection {} + +export const CustomersSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface CustomersSelectionProviderProps { + children: React.ReactNode; + customers: Customer[]; +} + +export function CustomersSelectionProvider({ + children, + customers = [], +}: CustomersSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useCustomersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx b/002_source/cms/src/components/dashboard/mf_categories/customers-table.tsx new file mode 100644 index 0000000..bf9b01a --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/customers-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 { useCustomersSelection } from './customers-selection-context'; + +export interface Customer { + 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 CustomersTableProps { + rows: Customer[]; +} + +export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); + + 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 customers found + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx b/002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/mf_categories/notifications.tsx b/002_source/cms/src/components/dashboard/mf_categories/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/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/mf_categories/payments.tsx b/002_source/cms/src/components/dashboard/mf_categories/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/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/mf_categories/shipping-address.tsx b/002_source/cms/src/components/dashboard/mf_categories/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_categories/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/mf_questions/customer-create-form.tsx b/002_source/cms/src/components/dashboard/mf_questions/customer-create-form.tsx new file mode 100644 index 0000000..7be8fc8 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/customer-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.customers.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/mf_questions/customers-filters.tsx b/002_source/cms/src/components/dashboard/mf_questions/customers-filters.tsx new file mode 100644 index 0000000..1567e3b --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/customers-filters.tsx @@ -0,0 +1,241 @@ +'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 { useCustomersSelection } from './customers-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 CustomersFiltersProps { + filters?: Filters; + sortDir?: SortDir; +} + +export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFiltersProps): React.JSX.Element { + const { email, phone, status } = filters; + + const router = useRouter(); + + const selection = useCustomersSelection(); + + 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.customers.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/mf_questions/customers-pagination.tsx b/002_source/cms/src/components/dashboard/mf_questions/customers-pagination.tsx new file mode 100644 index 0000000..ab01272 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/customers-pagination.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import TablePagination from '@mui/material/TablePagination'; + +function noop(): void { + return undefined; +} + +interface CustomersPaginationProps { + count: number; + page: number; +} + +export function CustomersPagination({ count, page }: CustomersPaginationProps): 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/mf_questions/customers-selection-context.tsx b/002_source/cms/src/components/dashboard/mf_questions/customers-selection-context.tsx new file mode 100644 index 0000000..023dbc0 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/customers-selection-context.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as React from 'react'; + +import { useSelection } from '@/hooks/use-selection'; +import type { Selection } from '@/hooks/use-selection'; + +import type { Customer } from './customers-table'; + +function noop(): void { + return undefined; +} + +export interface CustomersSelectionContextValue extends Selection {} + +export const CustomersSelectionContext = React.createContext({ + deselectAll: noop, + deselectOne: noop, + selectAll: noop, + selectOne: noop, + selected: new Set(), + selectedAny: false, + selectedAll: false, +}); + +interface CustomersSelectionProviderProps { + children: React.ReactNode; + customers: Customer[]; +} + +export function CustomersSelectionProvider({ + children, + customers = [], +}: CustomersSelectionProviderProps): React.JSX.Element { + const customerIds = React.useMemo(() => customers.map((customer) => customer.id), [customers]); + const selection = useSelection(customerIds); + + return {children}; +} + +export function useCustomersSelection(): CustomersSelectionContextValue { + return React.useContext(CustomersSelectionContext); +} diff --git a/002_source/cms/src/components/dashboard/mf_questions/customers-table.tsx b/002_source/cms/src/components/dashboard/mf_questions/customers-table.tsx new file mode 100644 index 0000000..bf9b01a --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/customers-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 { useCustomersSelection } from './customers-selection-context'; + +export interface Customer { + 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 CustomersTableProps { + rows: Customer[]; +} + +export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element { + const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); + + 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 customers found + + + ) : null} + + ); +} diff --git a/002_source/cms/src/components/dashboard/mf_questions/helloworld.tsx b/002_source/cms/src/components/dashboard/mf_questions/helloworld.tsx new file mode 100644 index 0000000..3989cb1 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/helloworld.tsx @@ -0,0 +1,3 @@ +const helloworld = 'helloworld'; + +export { helloworld }; diff --git a/002_source/cms/src/components/dashboard/mf_questions/notifications.tsx b/002_source/cms/src/components/dashboard/mf_questions/notifications.tsx new file mode 100644 index 0000000..a6c16bd --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/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/mf_questions/payments.tsx b/002_source/cms/src/components/dashboard/mf_questions/payments.tsx new file mode 100644 index 0000000..0420d32 --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/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/mf_questions/shipping-address.tsx b/002_source/cms/src/components/dashboard/mf_questions/shipping-address.tsx new file mode 100644 index 0000000..8793e5c --- /dev/null +++ b/002_source/cms/src/components/dashboard/mf_questions/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/constants.ts b/002_source/cms/src/constants.ts index 52c8e53..d9a9bd4 100644 --- a/002_source/cms/src/constants.ts +++ b/002_source/cms/src/constants.ts @@ -5,6 +5,7 @@ const NO_NUM = -Infinity; const NS_LESSON_CATEGORY = 'lesson_category'; const COL_USERS = 'users'; const COL_USER_METAS = 'UserMetas'; +const COL_QUIZ_LISTENINGS = 'QuizLPCategories'; export { COL_LESSON_TYPES, @@ -14,5 +15,6 @@ export { NS_LESSON_CATEGORY, COL_USERS, COL_USER_METAS, + COL_QUIZ_LISTENINGS, // }; diff --git a/002_source/cms/src/db/QuizListenings/GetAll.tsx b/002_source/cms/src/db/QuizListenings/GetAll.tsx new file mode 100644 index 0000000..addf770 --- /dev/null +++ b/002_source/cms/src/db/QuizListenings/GetAll.tsx @@ -0,0 +1,8 @@ +import { COL_QUIZ_LISTENINGS } from '@/constants'; +import type { RecordModel } from 'pocketbase'; + +import { pb } from '@/lib/pb'; + +export default function getAllQuizListenings(): Promise { + return pb.collection(COL_QUIZ_LISTENINGS).getFullList(); +} diff --git a/002_source/cms/src/db/QuizListenings/GetAllCount.tsx b/002_source/cms/src/db/QuizListenings/GetAllCount.tsx new file mode 100644 index 0000000..3dc035e --- /dev/null +++ b/002_source/cms/src/db/QuizListenings/GetAllCount.tsx @@ -0,0 +1,9 @@ +// REQ0006 +import { COL_QUIZ_LISTENINGS } from '@/constants'; + +import { pb } from '@/lib/pb'; + +export default async function GetAllCount(): Promise { + const { totalItems: count } = await pb.collection(COL_QUIZ_LISTENINGS).getList(1, 9999, {}); + return count; +} diff --git a/002_source/cms/src/components/dashboard/lesson_category/file-to-base64.tsx b/002_source/cms/src/lib/file-to-base64.tsx similarity index 100% rename from 002_source/cms/src/components/dashboard/lesson_category/file-to-base64.tsx rename to 002_source/cms/src/lib/file-to-base64.tsx diff --git a/002_source/cms/src/types/Address.tsx b/002_source/cms/src/types/Address.tsx new file mode 100644 index 0000000..1f43241 --- /dev/null +++ b/002_source/cms/src/types/Address.tsx @@ -0,0 +1,9 @@ +export interface Address { + id: string; + country: string; + state: string; + city: string; + zipCode: string; + street: string; + primary?: boolean; +} diff --git a/002_source/cms/src/types/LpCategory.tsx b/002_source/cms/src/types/LpCategory.tsx new file mode 100644 index 0000000..f5d4988 --- /dev/null +++ b/002_source/cms/src/types/LpCategory.tsx @@ -0,0 +1,13 @@ +export interface LpCategory { + cat_image?: string; + isEmpty?: boolean; + collectionId?: string; + id: string; + name: string; + avatar?: string; + email: string; + phone?: string; + quota: number; + status: 'pending' | 'active' | 'blocked'; + createdAt: Date; +} diff --git a/002_source/cms/src/types/Payment.tsx b/002_source/cms/src/types/Payment.tsx new file mode 100644 index 0000000..78cc277 --- /dev/null +++ b/002_source/cms/src/types/Payment.tsx @@ -0,0 +1,7 @@ +export interface Payment { + currency: string; + amount: number; + invoiceId: string; + status: 'pending' | 'completed' | 'canceled' | 'refunded'; + createdAt: Date; +} diff --git a/002_source/cms/src/types/notification.ts b/002_source/cms/src/types/notification.ts new file mode 100644 index 0000000..0dd5768 --- /dev/null +++ b/002_source/cms/src/types/notification.ts @@ -0,0 +1,6 @@ +export interface Notification { + id: string; + type: string; + status: 'delivered' | 'pending' | 'failed'; + createdAt: Date; +}