Compare commits

...

8 Commits

Author SHA1 Message Date
louiscklaw
29b074f6dd init helloworld test, 2025-04-24 10:26:30 +08:00
louiscklaw
2f8acbbcdf init test and snapshot, 2025-04-24 10:25:31 +08:00
louiscklaw
7a33549a79 init teachers, 2025-04-24 02:12:07 +08:00
louiscklaw
d81b3e9a9e update summary, 2025-04-24 02:06:05 +08:00
louiscklaw
da08798b10 update, 2025-04-24 01:08:44 +08:00
louiscklaw
41cc82d54d update customers, 2025-04-23 02:02:47 +08:00
louiscklaw
c8d184465a update customer edit form in the middle, 2025-04-23 01:01:31 +08:00
louiscklaw
adc04a1a40 update fix status filter for customer, 2025-04-22 23:16:10 +08:00
110 changed files with 69496 additions and 4974 deletions

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders homepage unchanged 1`] = `
<div>
<div
class="MuiBox-root css-0"
>
hello world!
<div
class="MuiBox-root css-0"
>
<p
class="MuiTypography-root MuiTypography-body1 css-1kivl2a-MuiTypography-root"
>
inner component
</p>
<div
class="MuiListItemIcon-root css-cveggr-MuiListItemIcon-root"
/>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 256 256"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"
/>
</svg>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,21 @@
/// <reference types="jest" />
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import Page from '@/app/_helloworld/page';
// Mock the translation hook
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('Page', () => {
it('renders a heading', () => {
render(<Page hello={'world'} />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,9 @@
import { render } from '@testing-library/react';
// CUT = Component Under Test
import CUT from '../components/_helloworld';
it('renders homepage unchanged', () => {
const { container } = render(<CUT />);
expect(container).toMatchSnapshot();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
# Connective Revision Guidelines
## Files and component highlight
1. `_GUIDELINES.md` - this document
1. categories
- list (page.tsx), also containing a button to delete record
- read/view ([cat_id]/page.tsx)
- create (create/page.tsx)
- edit/update (edit/[cat_id]/page.tsx)
- optional data for testing(lp-categories-sample-data.tsx)
1. questions
- list (page.tsx), also containing a button to delete record
- read/view ([cat_id]/page.tsx)
- create (create/page.tsx)
- edit/update (edit/[cat_id]/page.tsx)
- optional data for testing(lp-categories-sample-data.tsx)
## Prompt Documents
Each edit page contains `_PROMPT.md` file that provides guidance for editing.
## Sample Data
- `categories/lp-categories-sample-data.tsx`: Categories sample data
- `questions/cr-categories-sample-data.tsx`: Questions sample data
## Assumptions & Requirements
1. Using PocketBase to handle ConnectiveRevision records
2. Each ConnectiveRevision record has:
- `id` (autogenerated)
- `collectionId` (autogenerated)
- `collectionName` (autogenerated)
- `created` (autogenerated)
- `updated` (autogenerated)
- `title` (string)
- `description` (string)
- `category` (string)
- `status` (string)
- `priority` (number)
- `dueDate` (string)
- `assignee` (string)
- `reporter` (string)
- `comments` (array)
- `attachments` (array)
- `tags` (array)
- `related` (array)
- `history` (array)
## Assumption and Requirements
- the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
- assume `pb` is located in `@/lib/pb`
- type information defined in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Customers/type.d.tsx`
## Component Development Guidelines
### Requirements
1. **Single Responsibility Principle**:
2. **File Organization**:
- One file per component
- File name should match component name (PascalCase)
- Place components in logical directories based on their purpose
3. **Type Safety**:
- Always use TypeScript types/interfaces
- Import types from `@/db/Customers/type.d.tsx`
4. **PocketBase Integration**:
- Use `pb` instance from `@/lib/pb`
### Component Example
```typescript
'use client';
import { pb } from '@/lib/pb';
import { COL_ExampleModel } from '@/constants';
import type { ExampleType } from '@/db/ExampleModel/type';
// common reference to error display, Provide user-friendly error messages
import ErrorDisplay from '@/components/dashboard/error';
// declare `Props` explicitively
interface Props {
initialData?: ExampleType;
}
export default function ExampleForm({ initialData }: Props) {
let { t } = useTranslate();
// Render form UI
return <>helloworld</>
}
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,14 @@ import Typography from '@mui/material/Typography';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Customer } from '@/components/dashboard/customer/type.d'; import type { Customer } from '@/components/dashboard/customer/type.d';
// import type { CrCategory } from '@/components/dashboard/cr/categories/type'; // import type { CrCategory } from '@/components/dashboard/cr/categories/type';
function getImageUrlFrRecord(record: Customer): string { function getImageUrlFrRecord(record: Customer): string {
return `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${record.cat_image}`; // TODO: fix this
// `http://127.0.0.1:8090/api/files/${'record.collectionId'}/${'record.id'}/${'record.cat_image'}`;
return 'getImageUrlFrRecord(helloworld)';
} }
export default function SampleTitleCard({ lpModel }: { lpModel: Customer }): React.JSX.Element { export default function SampleTitleCard({ lpModel }: { lpModel: Customer }): React.JSX.Element {

View File

@@ -0,0 +1,49 @@
# GUIDELINES
this folder is part of nextjs typescript project and containing page definition for `Customer` / `Customers` record:
- list (./page.tsx)
- view (./[customerId]/page.tsx)
- create (./create/page.tsx)
- edit (./[customerId]/page.tsx)
- translation provided by react-i18next
the `@` sign refer to `<base_dir>/002_source/002_source/cms/src`
## Assumption and Requirements
- let one file contains one component only.
- type information defined in `<base_dir>/002_source/cms/src/db/Customers/type.d.tsx`
- it mainly consume the db drivers `Customres` in `<base_dir>/002_source/cms/src/db/Customers`
simple template:
```typescript
// src/app/dashboard/customers/page.tsx
'use client';
// RULES:
// contains list page for customers (Customers)
// contain definition to collection only
//
import statements here ...
...
...
...
export default function Page({ searchParams }: PageProps): React.JSX.Element {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
return (
<>
{* page content *}
</>
)
}
interface PageProps {
searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
}
```

View File

@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form'; import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { CustomerEditForm } from '@/components/dashboard/customer/customer-edit-form';
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['lp_categories']);

View File

@@ -25,7 +25,7 @@ import { CustomersPagination } from '@/components/dashboard/customer/customers-p
import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context';
import { CustomersTable } from '@/components/dashboard/customer/customers-table'; import { CustomersTable } from '@/components/dashboard/customer/customers-table';
import type { Customer, Filters } from '@/components/dashboard/customer/type.d'; import type { Customer, Filters } from '@/components/dashboard/customer/type.d';
import { SampleCustomers } from './customers'; import { SampleCustomers } from './SampleCustomers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { paths } from '@/paths'; import { paths } from '@/paths';
@@ -96,8 +96,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
React.useEffect(() => { React.useEffect(() => {
if (!isFirstRun.current) { if (!isFirstRun.current) {
isFirstRun.current = true; isFirstRun.current = true;
} else { } else if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) {
if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) {
// reset page number as tab changes // reset page number as tab changes
setLastListOption(listOption); setLastListOption(listOption);
setCurrentPage(0); setCurrentPage(0);
@@ -105,13 +104,16 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
} else { } else {
void reloadRows(); void reloadRows();
} }
}
}, [currentPage, rowsPerPage, listOption]); }, [currentPage, rowsPerPage, listOption]);
React.useEffect(() => { React.useEffect(() => {
let tempFilter = [], let tempFilter = [],
tempSortDir = ''; tempSortDir = '';
if (status) {
tempFilter.push(`status = "${status}"`);
}
if (sortDir) { if (sortDir) {
tempSortDir = `-created`; tempSortDir = `-created`;
} }
@@ -136,7 +138,7 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
// sort: tempSortDir, // sort: tempSortDir,
// // // //
// }); // });
}, [sortDir, email, phone]); }, [sortDir, email, phone, status]);
if (showLoading) return <FormLoading />; if (showLoading) return <FormLoading />;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,308 +0,0 @@
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 (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack spacing={3}>
<div>
<Link
color="text.primary"
component={RouterLink}
href={paths.dashboard.customers.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2"
>
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Customers
</Link>
</div>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
<Avatar src="/assets/avatar-1.png" sx={{ '--Avatar-size': '64px' }}>
MV
</Avatar>
<div>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h4">Miron Vitold</Typography>
<Chip
icon={<CheckCircleIcon color="var(--mui-palette-success-main)" weight="fill" />}
label="Active"
size="small"
variant="outlined"
/>
</Stack>
<Typography color="text.secondary" variant="body1">
miron.vitold@domain.com
</Typography>
</div>
</Stack>
<div>
<Button endIcon={<CaretDownIcon />} variant="contained">
Action
</Button>
</div>
</Stack>
</Stack>
<Grid container spacing={4}>
<Grid lg={4} xs={12}>
<Stack spacing={4}>
<Card>
<CardHeader
action={
<IconButton>
<PencilSimpleIcon />
</IconButton>
}
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Basic details"
/>
<PropertyList
divider={<Divider />}
orientation="vertical"
sx={{ '--PropertyItem-padding': '12px 24px' }}
>
{(
[
{ key: 'Customer ID', value: <Chip label="USR-001" size="small" variant="soft" /> },
{ 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: (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<LinearProgress sx={{ flex: '1 1 auto' }} value={50} variant="determinate" />
<Typography color="text.secondary" variant="body2">
50%
</Typography>
</Stack>
),
},
] satisfies { key: string; value: React.ReactNode }[]
).map(
(item): React.JSX.Element => (
<PropertyItem key={item.key} name={item.key} value={item.value} />
)
)}
</PropertyList>
</Card>
<Card>
<CardHeader
avatar={
<Avatar>
<ShieldWarningIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Security"
/>
<CardContent>
<Stack spacing={1}>
<div>
<Button color="error" variant="contained">
Delete account
</Button>
</div>
<Typography color="text.secondary" variant="body2">
A deleted customer cannot be restored. All data will be permanently removed.
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
</Grid>
<Grid lg={8} xs={12}>
<Stack spacing={4}>
<Payments
ordersValue={2069.48}
payments={[
{
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(),
},
]}
refundsValue={324.5}
totalOrders={5}
/>
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
}
avatar={
<Avatar>
<CreditCardIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Billing details"
/>
<CardContent>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<PropertyList divider={<Divider />} 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 => (
<PropertyItem key={item.key} name={item.key} value={item.value} />
)
)}
</PropertyList>
</Card>
</CardContent>
</Card>
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Add
</Button>
}
avatar={
<Avatar>
<HouseIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Shipping addresses"
/>
<CardContent>
<Grid container spacing={3}>
{(
[
{
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) => (
<Grid key={address.id} md={6} xs={12}>
<ShippingAddress address={address} />
</Grid>
))}
</Grid>
</CardContent>
</Card>
<Notifications
notifications={[
{
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(),
},
]}
/>
</Stack>
</Grid>
</Grid>
</Stack>
</Box>
);
}

View File

@@ -1,25 +1,9 @@
import * as React from 'react'; // src/app/dashboard/customers/page.tsx
import type { Metadata } from 'next'; 'use client';
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/type.d'; import type { Customer } from '@/components/dashboard/customer/type.d';
import { dayjs } from '@/lib/dayjs';
export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; export const SampleCustomers = [
const customers = [
{ {
id: 'USR-005', id: 'USR-005',
name: 'Fran Perez', name: 'Fran Perez',
@@ -171,98 +155,3 @@ const customers = [
createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(), createdAt: dayjs().subtract(2, 'hour').subtract(2, 'day').toDate(),
}, },
] satisfies Customer[]; ] 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 (
<Box
sx={{
maxWidth: 'var(--Content-maxWidth)',
m: 'var(--Content-margin)',
p: 'var(--Content-padding)',
width: 'var(--Content-width)',
}}
>
<Stack spacing={4}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{ alignItems: 'flex-start' }}
>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">Customers</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
startIcon={<PlusIcon />}
variant="contained"
>
Add
</Button>
</Box>
</Stack>
<CustomersSelectionProvider customers={filteredCustomers}>
<Card>
<CustomersFilters
filters={{ email, phone, status }}
sortDir={sortDir}
/>
<Divider />
<Box sx={{ overflowX: 'auto' }}>
<CustomersTable rows={filteredCustomers} />
</Box>
<Divider />
<CustomersPagination
count={filteredCustomers.length + 100}
page={0}
/>
</Card>
</CustomersSelectionProvider>
</Stack>
</Box>
);
}
// 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;
});
}

View File

@@ -0,0 +1,80 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Card from '@mui/material/Card';
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 { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { useTranslation } from 'react-i18next';
import { PropertyItem } from '@/components/core/property-item';
import { PropertyList } from '@/components/core/property-list';
// import { CrCategory } from '@/components/dashboard/cr/categories/type';
import type { Customer } from '@/components/dashboard/customer/type.d';
export default function BasicDetailCard({
lpModel: model,
handleEditClick,
}: {
lpModel: Customer;
handleEditClick: () => void;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<Card>
<CardHeader
action={
<IconButton
onClick={() => {
handleEditClick();
}}
>
<PencilSimpleIcon />
</IconButton>
}
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title={t('list.basic-details')}
/>
<PropertyList
divider={<Divider />}
orientation="vertical"
sx={{ '--PropertyItem-padding': '12px 24px' }}
>
{(
[
{
key: 'Customer ID',
value: (
<Chip
label={model.id}
size="small"
variant="soft"
/>
),
},
{ key: 'Email', value: model.email },
{ key: 'Quota', value: model.quota },
{ key: 'Status', value: model.status },
] satisfies { key: string; value: React.ReactNode }[]
).map(
(item): React.JSX.Element => (
<PropertyItem
key={item.key}
name={item.key}
value={item.value}
/>
)
)}
</PropertyList>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import { Button } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { useTranslation } from 'react-i18next';
import type { Customer } from '@/components/dashboard/customer/type.d';
// import type { CrCategory } from '@/components/dashboard/cr/categories/type';
function getImageUrlFrRecord(record: Customer): string {
// TODO: fix this
// `http://127.0.0.1:8090/api/files/${'record.collectionId'}/${'record.id'}/${'record.cat_image'}`;
return 'getImageUrlFrRecord(helloworld)';
}
export default function SampleTitleCard({ lpModel }: { lpModel: Customer }): React.JSX.Element {
const { t } = useTranslation();
return (
<>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto' }}
>
<Avatar
variant="rounded"
src={getImageUrlFrRecord(lpModel)}
sx={{ '--Avatar-size': '64px' }}
>
{t('empty')}
</Avatar>
<div>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="h4">{lpModel.email}</Typography>
<Chip
icon={
<CheckCircleIcon
color="var(--mui-palette-success-main)"
weight="fill"
/>
}
label={lpModel.quota}
size="small"
variant="outlined"
/>
</Stack>
<Typography
color="text.secondary"
variant="body1"
>
{lpModel.status}
</Typography>
</div>
</Stack>
<div>
<Button
endIcon={<CaretDownIcon />}
variant="contained"
>
{t('list.action')}
</Button>
</div>
</>
);
}

View File

@@ -1,43 +1,83 @@
'use client';
import * as React from 'react'; import * as React from 'react';
import type { Metadata } from 'next';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import Avatar from '@mui/material/Avatar'; import { useParams, useRouter } from 'next/navigation';
import SampleAddressCard from '@/app/dashboard/Sample/AddressCard';
import { SampleNotifications } from '@/app/dashboard/Sample/Notifications';
import SamplePaymentCard from '@/app/dashboard/Sample/PaymentCard';
import SampleSecurityCard from '@/app/dashboard/Sample/SecurityCard';
import Box from '@mui/material/Box'; 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 Link from '@mui/material/Link';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2'; import Grid from '@mui/material/Unstable_Grid2';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown'; import type { RecordModel } from 'pocketbase';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; import { useTranslation } from 'react-i18next';
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 { config } from '@/config';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs'; import { logger } from '@/lib/default-logger';
import { PropertyItem } from '@/components/core/property-item'; import { pb } from '@/lib/pb';
import { PropertyList } from '@/components/core/property-list'; import { toast } from '@/components/core/toaster';
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; import ErrorDisplay from '@/components/dashboard/error';
import { Notifications } from '@/components/dashboard/customer/notifications';
import FormLoading from '@/components/loading';
import BasicDetailCard from './BasicDetailCard';
import TitleCard from './TitleCard';
import { defaultCustomer } from '@/components/dashboard/customer/_constants';
import type { Customer } from '@/components/dashboard/customer/type.d';
import { COL_CUSTOMERS } from '@/constants';
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation();
const router = useRouter();
//
const { customerId } = useParams<{ customerId: string }>();
//
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [showLessonCategory, setShowLessonCategory] = React.useState<Customer>(defaultCustomer);
function handleEditClick(): void {
router.push(paths.dashboard.customers.edit(showLessonCategory.id));
}
React.useEffect(() => {
if (customerId) {
pb.collection(COL_CUSTOMERS)
.getOne(customerId)
.then((model: RecordModel) => {
setShowLessonCategory({ ...defaultCustomer, ...model });
})
.catch((err) => {
logger.error(err);
toast(t('list.error'));
setShowError({ show: true, detail: JSON.stringify(err) });
})
.finally(() => {
setShowLoading(false);
});
}
}, [customerId]);
// return <>{JSON.stringify({ showError, showLessonCategory }, null, 2)}</>;
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return ( return (
<Box <Box
sx={{ sx={{
@@ -61,244 +101,38 @@ export default function Page(): React.JSX.Element {
Customers Customers
</Link> </Link>
</div> </div>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}> <Stack
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}> direction={{ xs: 'column', sm: 'row' }}
<Avatar src="/assets/avatar-1.png" sx={{ '--Avatar-size': '64px' }}> spacing={3}
MV sx={{ alignItems: 'flex-start' }}
</Avatar>
<div>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h4">Miron Vitold</Typography>
<Chip
icon={<CheckCircleIcon color="var(--mui-palette-success-main)" weight="fill" />}
label="Active"
size="small"
variant="outlined"
/>
</Stack>
<Typography color="text.secondary" variant="body1">
miron.vitold@domain.com
</Typography>
</div>
</Stack>
<div>
<Button endIcon={<CaretDownIcon />} variant="contained">
Action
</Button>
</div>
</Stack>
</Stack>
<Grid container spacing={4}>
<Grid lg={4} xs={12}>
<Stack spacing={4}>
<Card>
<CardHeader
action={
<IconButton>
<PencilSimpleIcon />
</IconButton>
}
avatar={
<Avatar>
<UserIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Basic details"
/>
<PropertyList
divider={<Divider />}
orientation="vertical"
sx={{ '--PropertyItem-padding': '12px 24px' }}
> >
{( <TitleCard lpModel={showLessonCategory} />
[
{ key: 'Customer ID', value: <Chip label="USR-001" size="small" variant="soft" /> },
{ 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: (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<LinearProgress sx={{ flex: '1 1 auto' }} value={50} variant="determinate" />
<Typography color="text.secondary" variant="body2">
50%
</Typography>
</Stack> </Stack>
),
},
] satisfies { key: string; value: React.ReactNode }[]
).map(
(item): React.JSX.Element => (
<PropertyItem key={item.key} name={item.key} value={item.value} />
)
)}
</PropertyList>
</Card>
<Card>
<CardHeader
avatar={
<Avatar>
<ShieldWarningIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Security"
/>
<CardContent>
<Stack spacing={1}>
<div>
<Button color="error" variant="contained">
Delete account
</Button>
</div>
<Typography color="text.secondary" variant="body2">
A deleted customer cannot be restored. All data will be permanently removed.
</Typography>
</Stack> </Stack>
</CardContent> <Grid
</Card> container
</Stack> spacing={4}
</Grid> >
<Grid lg={8} xs={12}> <Grid
lg={4}
xs={12}
>
<Stack spacing={4}> <Stack spacing={4}>
<Payments <BasicDetailCard
ordersValue={2069.48} lpModel={showLessonCategory}
payments={[ handleEditClick={handleEditClick}
{
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(),
},
]}
refundsValue={324.5}
totalOrders={5}
/> />
<Card> <SampleSecurityCard />
<CardHeader </Stack>
action={
<Button color="secondary" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
}
avatar={
<Avatar>
<CreditCardIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Billing details"
/>
<CardContent>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<PropertyList divider={<Divider />} 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 => (
<PropertyItem key={item.key} name={item.key} value={item.value} />
)
)}
</PropertyList>
</Card>
</CardContent>
</Card>
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Add
</Button>
}
avatar={
<Avatar>
<HouseIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Shipping addresses"
/>
<CardContent>
<Grid container spacing={3}>
{(
[
{
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) => (
<Grid key={address.id} md={6} xs={12}>
<ShippingAddress address={address} />
</Grid> </Grid>
))} <Grid
</Grid> lg={8}
</CardContent> xs={12}
</Card> >
<Notifications <Stack spacing={4}>
notifications={[ <SamplePaymentCard />
{ <SampleAddressCard />
id: 'EV-002', <Notifications notifications={SampleNotifications} />
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(),
},
]}
/>
</Stack> </Stack>
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -0,0 +1,49 @@
# GUIDELINES
this folder is part of nextjs typescript project and containing page definition for `Customer` / `Customers` record:
- list (./page.tsx)
- view (./[customerId]/page.tsx)
- create (./create/page.tsx)
- edit (./[customerId]/page.tsx)
- translation provided by react-i18next
the `@` sign refer to `<base_dir>/002_source/002_source/cms/src`
## Assumption and Requirements
- let one file contains one component only.
- type information defined in `<base_dir>/002_source/cms/src/db/Customers/type.d.tsx`
- it mainly consume the db drivers `Customres` in `<base_dir>/002_source/cms/src/db/Customers`
simple template:
```typescript
// src/app/dashboard/customers/page.tsx
'use client';
// RULES:
// contains list page for customers (Customers)
// contain definition to collection only
//
import statements here ...
...
...
...
export default function Page({ searchParams }: PageProps): React.JSX.Element {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
return (
<>
{* page content *}
</>
)
}
interface PageProps {
searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
}
```

View File

@@ -0,0 +1,11 @@
# task
## instruction
with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/_helloworld/page.tsx`
with reference to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_types/edit/[typeId]/page.tsx`
please modify `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/app/dashboard/lesson_categories/edit/page.tsx`
please draft a tsx for showing error to user thanks,

View File

@@ -1,19 +1,25 @@
'use client';
import * as React from 'react'; import * as React from 'react';
import type { Metadata } from 'next';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft'; import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { useTranslation } from 'react-i18next';
import { config } from '@/config';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { CustomerCreateForm } from '@/components/dashboard/customer/customer-create-form'; import { CrCategoryEditForm } from '@/components/dashboard/cr/categories/cr-category-edit-form';
import { CustomerEditForm } from '@/components/dashboard/customer/customer-edit-form';
export const metadata = { title: `Create | Customers | Dashboard | ${config.site.name}` } satisfies Metadata;
export default function Page(): React.JSX.Element { export default function Page(): React.JSX.Element {
const { t } = useTranslation(['lp_categories']);
React.useEffect(() => {
// console.log('helloworld');
}, []);
return ( return (
<Box <Box
sx={{ sx={{
@@ -29,19 +35,19 @@ export default function Page(): React.JSX.Element {
<Link <Link
color="text.primary" color="text.primary"
component={RouterLink} component={RouterLink}
href={paths.dashboard.customers.list} href={paths.dashboard.cr_categories.list}
sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }} sx={{ alignItems: 'center', display: 'inline-flex', gap: 1 }}
variant="subtitle2" variant="subtitle2"
> >
<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" /> <ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />
Customers {t('edit.title')}
</Link> </Link>
</div> </div>
<div> <div>
<Typography variant="h4">Create customer</Typography> <Typography variant="h4">{t('edit.title')}</Typography>
</div> </div>
</Stack> </Stack>
<CustomerCreateForm /> <CustomerEditForm />
</Stack> </Stack>
</Box> </Box>
); );

View File

@@ -1,5 +1,14 @@
// src/app/dashboard/customers/page.tsx
'use client';
// RULES:
// contains list page for customers (Customers)
// contain definition to collection only
//
import * as React from 'react'; import * as React from 'react';
import type { Metadata } from 'next'; import { useRouter } from 'next/navigation';
import { COL_CUSTOMERS } from '@/constants';
import { LoadingButton } from '@mui/lab';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Card from '@mui/material/Card'; import Card from '@mui/material/Card';
@@ -7,180 +16,140 @@ import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus'; import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import type { ListResult, RecordModel } from 'pocketbase';
import { config } from '@/config'; import { config } from '@/config';
import { dayjs } from '@/lib/dayjs';
import { CustomersFilters } from '@/components/dashboard/customer/customers-filters'; import { CustomersFilters } from '@/components/dashboard/customer/customers-filters';
import type { Filters } from '@/components/dashboard/customer/customers-filters'; // import type { Filters } from '@/components/dashboard/customer/customers-filters';
import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination'; import { CustomersPagination } from '@/components/dashboard/customer/customers-pagination';
import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context'; import { CustomersSelectionProvider } from '@/components/dashboard/customer/customers-selection-context';
import { CustomersTable } from '@/components/dashboard/customer/customers-table'; import { CustomersTable } from '@/components/dashboard/customer/customers-table';
import type { Customer } from '@/components/dashboard/customer/type.d'; import type { Customer, Filters } from '@/components/dashboard/customer/type.d';
import { SampleCustomers } from './SampleCustomers';
import { useTranslation } from 'react-i18next';
export const metadata = { title: `List | Customers | Dashboard | ${config.site.name}` } satisfies Metadata; import { paths } from '@/paths';
import isDevelopment from '@/lib/check-is-development';
const customers = [ import { logger } from '@/lib/default-logger';
{ import { pb } from '@/lib/pb';
id: 'USR-005', import { toast } from '@/components/core/toaster';
name: 'Fran Perez', import ErrorDisplay from '@/components/dashboard/error';
avatar: '/assets/avatar-5.png', import { defaultCustomer } from '@/components/dashboard/customer/_constants';
email: 'fran.perez@domain.com', import FormLoading from '@/components/loading';
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 { export default function Page({ searchParams }: PageProps): React.JSX.Element {
const { t } = useTranslation(['customers']);
const router = useRouter();
const { email, phone, sortDir, status } = searchParams; const { email, phone, sortDir, status } = searchParams;
const sortedCustomers = applySort(customers, sortDir); const [lessonCategoriesData, setLessonCategoriesData] = React.useState<Customer[]>([]);
const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status }); //
const [isLoadingAddPage, setIsLoadingAddPage] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(true);
const [showError, setShowError] = React.useState({ show: false, detail: '' });
//
const [rowsPerPage, setRowsPerPage] = React.useState<number>(5);
//
const [f, setF] = React.useState<Customer[]>([]);
const [currentPage, setCurrentPage] = React.useState<number>(0);
//
const [recordCount, setRecordCount] = React.useState<number>(0);
const [listOption, setListOption] = React.useState({});
const [listSort, setListSort] = React.useState({});
//
// const sortedCustomers = applySort(SampleCustomers, sortDir);
// const filteredCustomers = applyFilters(sortedCustomers, { email, phone, status });
//
const reloadRows = async (): Promise<void> => {
try {
const models: ListResult<RecordModel> = await pb
.collection(COL_CUSTOMERS)
.getList(currentPage + 1, rowsPerPage, listOption);
const { items, totalItems } = models;
const tempLessonTypes: Customer[] = items.map((lt) => {
return { ...defaultCustomer, ...lt };
});
setLessonCategoriesData(tempLessonTypes);
setRecordCount(totalItems);
setF(tempLessonTypes);
// console.log({ currentPage, f });
} catch (error) {
//
logger.error(error);
setShowError({
//
show: true,
detail: JSON.stringify(error, null, 2),
});
} finally {
setShowLoading(false);
}
};
const [lastListOption, setLastListOption] = React.useState({});
const isFirstRun = React.useRef(false);
React.useEffect(() => {
if (!isFirstRun.current) {
isFirstRun.current = true;
} else if (JSON.stringify(listOption) !== JSON.stringify(lastListOption)) {
// reset page number as tab changes
setLastListOption(listOption);
setCurrentPage(0);
void reloadRows();
} else {
void reloadRows();
}
}, [currentPage, rowsPerPage, listOption]);
React.useEffect(() => {
let tempFilter = [],
tempSortDir = '';
if (status) {
tempFilter.push(`status = "${status}"`);
}
if (sortDir) {
tempSortDir = `-created`;
}
if (email) {
tempFilter.push(`email ~ "%${email}%"`);
}
if (phone) {
tempFilter.push(`phone ~ "%${phone}%"`);
}
let preFinalListOption = {};
if (tempFilter.length > 0) {
preFinalListOption = { filter: tempFilter.join(' && ') };
}
if (tempSortDir.length > 0) {
preFinalListOption = { ...preFinalListOption, sort: tempSortDir };
}
setListOption(preFinalListOption);
// setListOption({
// filter: tempFilter.join(' && '),
// sort: tempSortDir,
// //
// });
}, [sortDir, email, phone, status]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code={-1}
details={showError.detail}
/>
);
return ( return (
<Box <Box
@@ -198,35 +167,50 @@ export default function Page({ searchParams }: PageProps): React.JSX.Element {
sx={{ alignItems: 'flex-start' }} sx={{ alignItems: 'flex-start' }}
> >
<Box sx={{ flex: '1 1 auto' }}> <Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">Customers</Typography> <Typography variant="h4">{t('list.title')}</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button <LoadingButton
loading={isLoadingAddPage}
onClick={(): void => {
setIsLoadingAddPage(true);
router.push(paths.dashboard.customers.create);
}}
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
variant="contained" variant="contained"
> >
Add {t('list.add')}
</Button> </LoadingButton>
</Box> </Box>
</Stack> </Stack>
<CustomersSelectionProvider customers={filteredCustomers}> <CustomersSelectionProvider customers={f}>
<Card> <Card>
<CustomersFilters <CustomersFilters
filters={{ email, phone, status }} filters={{ email, phone, status }}
fullData={lessonCategoriesData}
sortDir={sortDir} sortDir={sortDir}
/> />
<Divider /> <Divider />
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<CustomersTable rows={filteredCustomers} /> <CustomersTable
reloadRows={reloadRows}
rows={f}
/>
</Box> </Box>
<Divider /> <Divider />
<CustomersPagination <CustomersPagination
count={filteredCustomers.length + 100} count={recordCount}
page={0} page={currentPage}
rowsPerPage={rowsPerPage}
setPage={setCurrentPage}
setRowsPerPage={setRowsPerPage}
/> />
</Card> </Card>
</CustomersSelectionProvider> </CustomersSelectionProvider>
</Stack> </Stack>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(f, null, 2)}</pre>
</Box>
</Box> </Box>
); );
} }
@@ -266,3 +250,7 @@ function applyFilters(row: Customer[], { email, phone, status }: Filters): Custo
return true; return true;
}); });
} }
interface PageProps {
searchParams: { email?: string; phone?: string; sortDir?: 'asc' | 'desc'; status?: string };
}

View File

@@ -1,398 +0,0 @@
'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<string> {
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<typeof schema>;
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<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (_: Values): Promise<void> => {
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<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack divider={<Divider />} spacing={4}>
<Stack spacing={3}>
<Typography variant="h6">Account information</Typography>
<Grid container spacing={3}>
<Grid xs={12}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack spacing={1} sx={{ alignItems: 'flex-start' }}>
<Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
Select
</Button>
<input hidden onChange={handleAvatarChange} ref={avatarInputRef} type="file" />
</Stack>
</Stack>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl error={Boolean(errors.name)} fullWidth>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl error={Boolean(errors.email)} fullWidth>
<InputLabel required>Email address</InputLabel>
<OutlinedInput {...field} type="email" />
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl error={Boolean(errors.phone)} fullWidth>
<InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl error={Boolean(errors.company)} fullWidth>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.country)} fullWidth>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<Option value="">Choose a country</Option>
<Option value="us">United States</Option>
<Option value="de">Germany</Option>
<Option value="es">Spain</Option>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.state)} fullWidth>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress?.state?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.city)} fullWidth>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress?.city?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.zipCode)} fullWidth>
<InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.line1)} fullWidth>
<InputLabel required>Address</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl error={Boolean(errors.taxId)} fullWidth>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput {...field} placeholder="e.g EU372054390" />
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography>
<FormControlLabel control={<Checkbox defaultChecked />} label="Same as billing address" />
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl error={Boolean(errors.timezone)} fullWidth>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<Option value="">Select a timezone</Option>
<Option value="new_york">US - New York</Option>
<Option value="california">US - California</Option>
<Option value="london">UK - London</Option>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl error={Boolean(errors.language)} fullWidth>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<Option value="">Select a language</Option>
<Option value="en">English</Option>
<Option value="es">Spanish</Option>
<Option value="de">German</Option>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl error={Boolean(errors.currency)} fullWidth>
<InputLabel>Currency</InputLabel>
<Select {...field}>
<Option value="">Select a currency</Option>
<Option value="USD">USD</Option>
<Option value="EUR">EUR</Option>
<Option value="RON">RON</Option>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" component={RouterLink} href={paths.dashboard.customers.list}>
Cancel
</Button>
<Button type="submit" variant="contained">
Create customer
</Button>
</CardActions>
</Card>
</form>
);
}

View File

@@ -1,241 +0,0 @@
'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 (
<div>
<Tabs onChange={handleStatusChange} sx={{ px: 3 }} value={status ?? ''} variant="scrollable">
{tabs.map((tab) => (
<Tab
icon={<Chip label={tab.count} size="small" variant="soft" />}
iconPosition="end"
key={tab.value}
label={tab.label}
sx={{ minHeight: 'auto' }}
tabIndex={0}
value={tab.value}
/>
))}
</Tabs>
<Divider />
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}>
<FilterButton
displayValue={email}
label="Email"
onFilterApply={(value) => {
handleEmailChange(value as string);
}}
onFilterDelete={() => {
handleEmailChange();
}}
popover={<EmailFilterPopover />}
value={email}
/>
<FilterButton
displayValue={phone}
label="Phone number"
onFilterApply={(value) => {
handlePhoneChange(value as string);
}}
onFilterDelete={() => {
handlePhoneChange();
}}
popover={<PhoneFilterPopover />}
value={phone}
/>
{hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null}
</Stack>
{selection.selectedAny ? (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Typography color="text.secondary" variant="body2">
{selection.selected.size} selected
</Typography>
<Button color="error" variant="contained">
Delete
</Button>
</Stack>
) : null}
<Select name="sort" onChange={handleSortChange} sx={{ maxWidth: '100%', width: '120px' }} value={sortDir}>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
</Stack>
</div>
);
}
function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by email">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}
function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by phone number">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -1,31 +0,0 @@
'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 (
<TablePagination
component="div"
count={count}
onPageChange={noop}
onRowsPerPageChange={noop}
page={page}
rowsPerPage={5}
rowsPerPageOptions={[5, 10, 25]}
//
/>
);
}

View File

@@ -1,43 +0,0 @@
'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<CustomersSelectionContextValue>({
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 <CustomersSelectionContext.Provider value={{ ...selection }}>{children}</CustomersSelectionContext.Provider>;
}
export function useCustomersSelection(): CustomersSelectionContextValue {
return React.useContext(CustomersSelectionContext);
}

View File

@@ -1,139 +0,0 @@
'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 => (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Avatar src={row.avatar} />{' '}
<div>
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.customers.details('1')}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
{row.name}
</Link>
<Typography color="text.secondary" variant="body2">
{row.email}
</Typography>
</div>
</Stack>
),
name: 'Name',
width: '250px',
},
{
formatter: (row): React.JSX.Element => (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<LinearProgress sx={{ flex: '1 1 auto' }} value={row.quota} variant="determinate" />
<Typography color="text.secondary" variant="body2">
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
</Typography>
</Stack>
),
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: <CheckCircleIcon color="var(--mui-palette-success-main)" weight="fill" /> },
blocked: { label: 'Blocked', icon: <MinusIcon color="var(--mui-palette-error-main)" /> },
pending: { label: 'Pending', icon: <ClockIcon color="var(--mui-palette-warning-main)" weight="fill" /> },
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return <Chip icon={icon} label={label} size="small" variant="outlined" />;
},
name: 'Status',
width: '150px',
},
{
formatter: (): React.JSX.Element => (
<IconButton component={RouterLink} href={paths.dashboard.customers.details('1')}>
<PencilSimpleIcon />
</IconButton>
),
name: 'Actions',
hideName: true,
width: '100px',
align: 'right',
},
] satisfies ColumnDef<Customer>[];
export interface CustomersTableProps {
rows: Customer[];
}
export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element {
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
return (
<React.Fragment>
<DataTable<Customer>
columns={columns}
onDeselectAll={deselectAll}
onDeselectOne={(_, row) => {
deselectOne(row.id);
}}
onSelectAll={selectAll}
onSelectOne={(_, row) => {
selectOne(row.id);
}}
rows={rows}
selectable
selected={selected}
/>
{!rows.length ? (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="body2">
No customers found
</Typography>
</Box>
) : null}
</React.Fragment>
);
}

View File

@@ -1,3 +0,0 @@
const helloworld = 'helloworld';
export { helloworld };

View File

@@ -1,101 +0,0 @@
'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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{row.type}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Notification>[];
export interface NotificationsProps {
notifications: Notification[];
}
export function Notifications({ notifications }: NotificationsProps): React.JSX.Element {
return (
<Card>
<CardHeader
avatar={
<Avatar>
<EnvelopeSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Notifications"
/>
<CardContent>
<Stack spacing={3}>
<Stack spacing={2}>
<Select defaultValue="last_invoice" name="type" sx={{ maxWidth: '100%', width: '320px' }}>
<Option value="last_invoice">Resend last invoice</Option>
<Option value="password_reset">Send password reset</Option>
<Option value="verification">Send verification</Option>
</Select>
<div>
<Button startIcon={<EnvelopeSimpleIcon />} variant="contained">
Send email
</Button>
</div>
</Stack>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Notification> columns={columns} rows={notifications} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,138 +0,0 @@
'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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="subtitle2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => {
return <Link variant="inherit">{row.invoiceId}</Link>;
},
name: 'Invoice ID',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Payment>[];
export interface PaymentsProps {
ordersValue: number;
payments: Payment[];
refundsValue: number;
totalOrders: number;
}
export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element {
return (
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Create Payment
</Button>
}
avatar={
<Avatar>
<ShoppingCartSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Payments"
/>
<CardContent>
<Stack spacing={3}>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Stack
direction="row"
divider={<Divider flexItem orientation="vertical" />}
spacing={3}
sx={{ justifyContent: 'space-between', p: 2 }}
>
<div>
<Typography color="text.secondary" variant="overline">
Total orders
</Typography>
<Typography variant="h6">{new Intl.NumberFormat('en-US').format(totalOrders)}</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Orders value
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)}
</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Refunds
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)}
</Typography>
</div>
</Stack>
</Card>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Payment> columns={columns} rows={payments} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,46 +0,0 @@
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 (
<Card sx={{ borderRadius: 1, height: '100%' }} variant="outlined">
<CardContent>
<Stack spacing={2}>
<Typography>
{address.street},
<br />
{address.city}, {address.state}, {address.country},
<br />
{address.zipCode}
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
{address.primary ? <Chip color="warning" label="Primary" variant="soft" /> : <span />}
<Button color="secondary" size="small" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,398 +0,0 @@
'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<string> {
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<typeof schema>;
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<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (_: Values): Promise<void> => {
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<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack divider={<Divider />} spacing={4}>
<Stack spacing={3}>
<Typography variant="h6">Account information</Typography>
<Grid container spacing={3}>
<Grid xs={12}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack spacing={1} sx={{ alignItems: 'flex-start' }}>
<Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
Select
</Button>
<input hidden onChange={handleAvatarChange} ref={avatarInputRef} type="file" />
</Stack>
</Stack>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl error={Boolean(errors.name)} fullWidth>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl error={Boolean(errors.email)} fullWidth>
<InputLabel required>Email address</InputLabel>
<OutlinedInput {...field} type="email" />
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl error={Boolean(errors.phone)} fullWidth>
<InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl error={Boolean(errors.company)} fullWidth>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.country)} fullWidth>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<Option value="">Choose a country</Option>
<Option value="us">United States</Option>
<Option value="de">Germany</Option>
<Option value="es">Spain</Option>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.state)} fullWidth>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress?.state?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.city)} fullWidth>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress?.city?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.zipCode)} fullWidth>
<InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.line1)} fullWidth>
<InputLabel required>Address</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl error={Boolean(errors.taxId)} fullWidth>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput {...field} placeholder="e.g EU372054390" />
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography>
<FormControlLabel control={<Checkbox defaultChecked />} label="Same as billing address" />
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl error={Boolean(errors.timezone)} fullWidth>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<Option value="">Select a timezone</Option>
<Option value="new_york">US - New York</Option>
<Option value="california">US - California</Option>
<Option value="london">UK - London</Option>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl error={Boolean(errors.language)} fullWidth>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<Option value="">Select a language</Option>
<Option value="en">English</Option>
<Option value="es">Spanish</Option>
<Option value="de">German</Option>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl error={Boolean(errors.currency)} fullWidth>
<InputLabel>Currency</InputLabel>
<Select {...field}>
<Option value="">Select a currency</Option>
<Option value="USD">USD</Option>
<Option value="EUR">EUR</Option>
<Option value="RON">RON</Option>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" component={RouterLink} href={paths.dashboard.customers.list}>
Cancel
</Button>
<Button type="submit" variant="contained">
Create customer
</Button>
</CardActions>
</Card>
</form>
);
}

View File

@@ -1,241 +0,0 @@
'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 (
<div>
<Tabs onChange={handleStatusChange} sx={{ px: 3 }} value={status ?? ''} variant="scrollable">
{tabs.map((tab) => (
<Tab
icon={<Chip label={tab.count} size="small" variant="soft" />}
iconPosition="end"
key={tab.value}
label={tab.label}
sx={{ minHeight: 'auto' }}
tabIndex={0}
value={tab.value}
/>
))}
</Tabs>
<Divider />
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}>
<FilterButton
displayValue={email}
label="Email"
onFilterApply={(value) => {
handleEmailChange(value as string);
}}
onFilterDelete={() => {
handleEmailChange();
}}
popover={<EmailFilterPopover />}
value={email}
/>
<FilterButton
displayValue={phone}
label="Phone number"
onFilterApply={(value) => {
handlePhoneChange(value as string);
}}
onFilterDelete={() => {
handlePhoneChange();
}}
popover={<PhoneFilterPopover />}
value={phone}
/>
{hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null}
</Stack>
{selection.selectedAny ? (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Typography color="text.secondary" variant="body2">
{selection.selected.size} selected
</Typography>
<Button color="error" variant="contained">
Delete
</Button>
</Stack>
) : null}
<Select name="sort" onChange={handleSortChange} sx={{ maxWidth: '100%', width: '120px' }} value={sortDir}>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
</Stack>
</div>
);
}
function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by email">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}
function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by phone number">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -1,31 +0,0 @@
'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 (
<TablePagination
component="div"
count={count}
onPageChange={noop}
onRowsPerPageChange={noop}
page={page}
rowsPerPage={5}
rowsPerPageOptions={[5, 10, 25]}
//
/>
);
}

View File

@@ -1,43 +0,0 @@
'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<CustomersSelectionContextValue>({
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 <CustomersSelectionContext.Provider value={{ ...selection }}>{children}</CustomersSelectionContext.Provider>;
}
export function useCustomersSelection(): CustomersSelectionContextValue {
return React.useContext(CustomersSelectionContext);
}

View File

@@ -1,139 +0,0 @@
'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 => (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Avatar src={row.avatar} />{' '}
<div>
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.customers.details('1')}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
{row.name}
</Link>
<Typography color="text.secondary" variant="body2">
{row.email}
</Typography>
</div>
</Stack>
),
name: 'Name',
width: '250px',
},
{
formatter: (row): React.JSX.Element => (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<LinearProgress sx={{ flex: '1 1 auto' }} value={row.quota} variant="determinate" />
<Typography color="text.secondary" variant="body2">
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
</Typography>
</Stack>
),
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: <CheckCircleIcon color="var(--mui-palette-success-main)" weight="fill" /> },
blocked: { label: 'Blocked', icon: <MinusIcon color="var(--mui-palette-error-main)" /> },
pending: { label: 'Pending', icon: <ClockIcon color="var(--mui-palette-warning-main)" weight="fill" /> },
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return <Chip icon={icon} label={label} size="small" variant="outlined" />;
},
name: 'Status',
width: '150px',
},
{
formatter: (): React.JSX.Element => (
<IconButton component={RouterLink} href={paths.dashboard.customers.details('1')}>
<PencilSimpleIcon />
</IconButton>
),
name: 'Actions',
hideName: true,
width: '100px',
align: 'right',
},
] satisfies ColumnDef<Customer>[];
export interface CustomersTableProps {
rows: Customer[];
}
export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element {
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
return (
<React.Fragment>
<DataTable<Customer>
columns={columns}
onDeselectAll={deselectAll}
onDeselectOne={(_, row) => {
deselectOne(row.id);
}}
onSelectAll={selectAll}
onSelectOne={(_, row) => {
selectOne(row.id);
}}
rows={rows}
selectable
selected={selected}
/>
{!rows.length ? (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="body2">
No customers found
</Typography>
</Box>
) : null}
</React.Fragment>
);
}

View File

@@ -1,3 +0,0 @@
const helloworld = 'helloworld';
export { helloworld };

View File

@@ -1,101 +0,0 @@
'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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{row.type}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Notification>[];
export interface NotificationsProps {
notifications: Notification[];
}
export function Notifications({ notifications }: NotificationsProps): React.JSX.Element {
return (
<Card>
<CardHeader
avatar={
<Avatar>
<EnvelopeSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Notifications"
/>
<CardContent>
<Stack spacing={3}>
<Stack spacing={2}>
<Select defaultValue="last_invoice" name="type" sx={{ maxWidth: '100%', width: '320px' }}>
<Option value="last_invoice">Resend last invoice</Option>
<Option value="password_reset">Send password reset</Option>
<Option value="verification">Send verification</Option>
</Select>
<div>
<Button startIcon={<EnvelopeSimpleIcon />} variant="contained">
Send email
</Button>
</div>
</Stack>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Notification> columns={columns} rows={notifications} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,138 +0,0 @@
'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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="subtitle2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => {
return <Link variant="inherit">{row.invoiceId}</Link>;
},
name: 'Invoice ID',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Payment>[];
export interface PaymentsProps {
ordersValue: number;
payments: Payment[];
refundsValue: number;
totalOrders: number;
}
export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element {
return (
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Create Payment
</Button>
}
avatar={
<Avatar>
<ShoppingCartSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Payments"
/>
<CardContent>
<Stack spacing={3}>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Stack
direction="row"
divider={<Divider flexItem orientation="vertical" />}
spacing={3}
sx={{ justifyContent: 'space-between', p: 2 }}
>
<div>
<Typography color="text.secondary" variant="overline">
Total orders
</Typography>
<Typography variant="h6">{new Intl.NumberFormat('en-US').format(totalOrders)}</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Orders value
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)}
</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Refunds
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)}
</Typography>
</div>
</Stack>
</Card>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Payment> columns={columns} rows={payments} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,46 +0,0 @@
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 (
<Card sx={{ borderRadius: 1, height: '100%' }} variant="outlined">
<CardContent>
<Stack spacing={2}>
<Typography>
{address.street},
<br />
{address.city}, {address.state}, {address.country},
<br />
{address.zipCode}
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
{address.primary ? <Chip color="warning" label="Primary" variant="soft" /> : <span />}
<Button color="secondary" size="small" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@ The content is organized as follows:
<directory_structure> <directory_structure>
categories/ categories/
_constants.ts _constants.ts
_PROMPT.MD _SUMMARY.md
confirm-delete-modal.tsx confirm-delete-modal.tsx
cr-categories-filters.tsx cr-categories-filters.tsx
cr-categories-pagination.tsx cr-categories-pagination.tsx
@@ -59,7 +59,6 @@ categories/
type.d.ts type.d.ts
questions/ questions/
_constants.ts _constants.ts
_PROMPT.MD
confirm-delete-modal.tsx confirm-delete-modal.tsx
cr-question-create-form.tsx cr-question-create-form.tsx
cr-question-edit-form.tsx cr-question-edit-form.tsx
@@ -123,8 +122,101 @@ export const emptyCrCategory: CrCategory = {
}; };
</file> </file>
<file path="categories/_PROMPT.MD"> <file path="categories/_SUMMARY.md">
please review and update translations # CR Categories Components Summary
## Main Components
### `cr-categories-table.tsx`
- Displays categories in a table format with columns for Name, Status, Created At, etc.
- Features:
- Row selection functionality
- Status indicators (Active/Blocked/Pending)
- Progress bars for quota/word count
- Edit/delete actions
- Image and name display with slugs
### `cr-category-create-form.tsx`
- Form for creating new categories
- Fields:
- Name, image, position, visibility
- Slug, description, remarks
- Initial answer (JSON)
- Uses Zod validation and React Hook Form
- Material UI components
- Internationalization support
### `cr-category-edit-form.tsx`
- Similar to create form but for editing
- Pre-fills existing data
- Handles image updates
- More strict validation for init_answer
## Supporting Components
### `confirm-delete-modal.tsx`
- Confirmation dialog for category deletion
- Loading states and toast notifications
- Internationalization support
### `cr-categories-filters.tsx`
- Filtering functionality:
- Visibility status tabs
- Text search filters
- Sorting options
- Shows selected items count
### `cr-categories-pagination.tsx`
- Basic pagination controls
- Page number and rows per page selection
- Default options: [5, 10, 25]
### `cr-categories-selection-context.tsx`
- Manages selection state
- Provides hooks for:
- Selecting/deselecting items
- Checking selection state
- Bulk operations
## Types & Constants
### `type.d.ts`
- Interfaces:
- `CrCategory`: Main category type
- `CreateFormProps`: Create form data
- `EditFormProps`: Edit form data
### `_constants.ts`
- Default values:
- `defaultCrCategory`
- `emptyCrCategory`
## Component Relationships
```mermaid
graph TD
A[cr-categories-table] --> B[cr-category-create-form]
A --> C[cr-category-edit-form]
A --> D[confirm-delete-modal]
A --> E[cr-categories-filters]
A --> F[cr-categories-pagination]
A --> G[cr-categories-selection-context]
H[type.d.ts] --> A
H --> B
H --> C
I[_constants.ts] --> A
I --> B
I --> C
```
</file> </file>
<file path="categories/confirm-delete-modal.tsx"> <file path="categories/confirm-delete-modal.tsx">
@@ -265,10 +357,9 @@ import { useRouter } from 'next/navigation';
// import { COL_LESSON_CATEGORIES } from '@/constants'; // import { COL_LESSON_CATEGORIES } from '@/constants';
// RULES: Quiz // RULES: Quiz
import GetAllCount from '@/db/QuizListenings/GetAllCount'; import GetAllCount from '@/db/QuizMFCategories/GetAllCount';
import GetHiddenCount from '@/db/QuizListenings/GetHiddenCount'; import GetHiddenCount from '@/db/QuizMFCategories/GetHiddenCount';
// import GetVisibleCount from '@/db/QuizListenings/GetVisibleCount'; import GetVisibleCount from '@/db/QuizMFCategories/GetVisibleCount';
// import GetVisibleCount from '@/db/Q';
// //
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
@@ -288,7 +379,7 @@ import { FilterButton, FilterPopover, useFilterContext } from '@/components/core
import { Option } from '@/components/core/option'; import { Option } from '@/components/core/option';
import { useCrCategoriesSelection } from './cr-categories-selection-context'; import { useCrCategoriesSelection } from './cr-categories-selection-context';
import { CrCategory } from './type'; import type { CrCategory } from './type';
export interface Filters { export interface Filters {
email?: string; email?: string;
@@ -2355,9 +2446,9 @@ export interface Helloworld {
<file path="questions/_constants.ts"> <file path="questions/_constants.ts">
import { dayjs } from '@/lib/dayjs'; import { dayjs } from '@/lib/dayjs';
import { CreateFormProps, LpQuestion } from './type'; import { CreateFormProps, CrQuestion } from './type';
export const defaultLpQuestion: LpQuestion = { export const defaultCrQuestion: CrQuestion = {
isEmpty: false, isEmpty: false,
id: 'default-id', id: 'default-id',
cat_name: 'default-question-name', cat_name: 'default-question-name',
@@ -2393,36 +2484,12 @@ export const defaultLpQuestion: LpQuestion = {
// imageUrl: '', // imageUrl: '',
// }; // };
export const emptyLpQuestion: LpQuestion = { export const emptyLpQuestion: CrQuestion = {
...defaultLpQuestion, ...defaultCrQuestion,
isEmpty: true, isEmpty: true,
}; };
</file> </file>
<file path="questions/_PROMPT.MD">
please review and add translations, e.g. `{t('[word]')}`
---
please help to review the `tsx` file in this folder
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/components/dashboard/lp/questions`
it was clone from
`category`/`categories`, `lp_category`/`lp_categories`
please help to modify to `question`/`questions`, `lp_question`/`lp_questions`
please also help to modify the name of
`variables`, `constants`, `functions`, `classes`, components's name, paths
the db fields structures are the same
do not move the files
do not create directories
keep current folder structure is important
thanks
</file>
<file path="questions/confirm-delete-modal.tsx"> <file path="questions/confirm-delete-modal.tsx">
'use client'; 'use client';
@@ -2620,7 +2687,7 @@ export const defaultValues = {
description: '', description: '',
} satisfies Values; } satisfies Values;
export function LpQuestionCreateForm(): React.JSX.Element { export function CrQuestionCreateForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['lp_categories']);
@@ -3064,7 +3131,7 @@ const defaultValues = {
description: '', description: '',
} satisfies Values; } satisfies Values;
export function LpQuestionEditForm(): React.JSX.Element { export function CrQuestionEditForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['lp_categories']);
@@ -3502,8 +3569,8 @@ import { paths } from '@/paths';
import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button';
import { Option } from '@/components/core/option'; import { Option } from '@/components/core/option';
import { useLpQuestionsSelection } from './cr-questions-selection-context'; import { useCrQuestionsSelection } from './cr-questions-selection-context';
import { LpQuestion } from './type'; import { CrQuestion } from './type';
export interface Filters { export interface Filters {
email?: string; email?: string;
@@ -3519,10 +3586,10 @@ export type SortDir = 'asc' | 'desc';
export interface LpQuestionsFiltersProps { export interface LpQuestionsFiltersProps {
filters?: Filters; filters?: Filters;
sortDir?: SortDir; sortDir?: SortDir;
fullData: LpQuestion[]; fullData: CrQuestion[];
} }
export function LpQuestionsFilters({ export function CrQuestionsFilters({
filters = {}, filters = {},
sortDir = 'desc', sortDir = 'desc',
fullData, fullData,
@@ -3536,16 +3603,16 @@ export function LpQuestionsFilters({
const router = useRouter(); const router = useRouter();
const selection = useLpQuestionsSelection(); const selection = useCrQuestionsSelection();
function getVisible(): number { function getVisible(): number {
return fullData.reduce((count, item: LpQuestion) => { return fullData.reduce((count, item: CrQuestion) => {
return item.visible === 'visible' ? count + 1 : count; return item.visible === 'visible' ? count + 1 : count;
}, 0); }, 0);
} }
function getHidden(): number { function getHidden(): number {
return fullData.reduce((count, item: LpQuestion) => { return fullData.reduce((count, item: CrQuestion) => {
return item.visible === 'hidden' ? count + 1 : count; return item.visible === 'hidden' ? count + 1 : count;
}, 0); }, 0);
} }
@@ -3954,7 +4021,7 @@ interface LessonQuestionsPaginationProps {
rowsPerPage: number; rowsPerPage: number;
} }
export function LpQuestionsPagination({ export function CrQuestionsPagination({
count, count,
page, page,
// //
@@ -3982,6 +4049,7 @@ export function LpQuestionsPagination({
page={page} page={page}
rowsPerPage={rowsPerPage} rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 25]} rowsPerPageOptions={[5, 10, 25]}
//
/> />
); );
} }
@@ -3996,15 +4064,15 @@ import * as React from 'react';
import { useSelection } from '@/hooks/use-selection'; import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection'; import type { Selection } from '@/hooks/use-selection';
import type { LpQuestion } from './type'; import type { CrQuestion } from './type';
function noop(): void { function noop(): void {
return undefined; return undefined;
} }
export interface LpQuestionsSelectionContextValue extends Selection {} export interface CrQuestionsSelectionContextValue extends Selection {}
export const LpQuestionsSelectionContext = React.createContext<LpQuestionsSelectionContextValue>({ export const CrQuestionsSelectionContext = React.createContext<CrQuestionsSelectionContextValue>({
deselectAll: noop, deselectAll: noop,
deselectOne: noop, deselectOne: noop,
selectAll: noop, selectAll: noop,
@@ -4014,25 +4082,25 @@ export const LpQuestionsSelectionContext = React.createContext<LpQuestionsSelect
selectedAll: false, selectedAll: false,
}); });
interface LpQuestionsSelectionProviderProps { interface CrQuestionsSelectionProviderProps {
children: React.ReactNode; children: React.ReactNode;
lessonQuestions: LpQuestion[]; lessonQuestions: CrQuestion[];
} }
export function LpQuestionsSelectionProvider({ export function CrQuestionsSelectionProvider({
children, children,
lessonQuestions = [], lessonQuestions = [],
}: LpQuestionsSelectionProviderProps): React.JSX.Element { }: CrQuestionsSelectionProviderProps): React.JSX.Element {
const customerIds = React.useMemo(() => lessonQuestions.map((customer) => customer.id), [lessonQuestions]); const customerIds = React.useMemo(() => lessonQuestions.map((customer) => customer.id), [lessonQuestions]);
const selection = useSelection(customerIds); const selection = useSelection(customerIds);
return ( return (
<LpQuestionsSelectionContext.Provider value={{ ...selection }}>{children}</LpQuestionsSelectionContext.Provider> <CrQuestionsSelectionContext.Provider value={{ ...selection }}>{children}</CrQuestionsSelectionContext.Provider>
); );
} }
export function useLpQuestionsSelection(): LpQuestionsSelectionContextValue { export function useCrQuestionsSelection(): CrQuestionsSelectionContextValue {
return React.useContext(LpQuestionsSelectionContext); return React.useContext(CrQuestionsSelectionContext);
} }
</file> </file>
@@ -4064,10 +4132,10 @@ import { DataTable } from '@/components/core/data-table';
import type { ColumnDef } from '@/components/core/data-table'; import type { ColumnDef } from '@/components/core/data-table';
import ConfirmDeleteModal from './confirm-delete-modal'; import ConfirmDeleteModal from './confirm-delete-modal';
import { useLpQuestionsSelection } from './cr-questions-selection-context'; import { useCrQuestionsSelection } from './cr-questions-selection-context';
import type { LpQuestion } from './type'; import type { CrQuestion } from './type';
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpQuestion>[] { function columns(handleDeleteClick: (testId: string) => void): ColumnDef<CrQuestion>[] {
return [ return [
{ {
formatter: (row): React.JSX.Element => ( formatter: (row): React.JSX.Element => (
@@ -4079,7 +4147,7 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpQuest
<Link <Link
color="inherit" color="inherit"
component={RouterLink} component={RouterLink}
href={paths.dashboard.lp_categories.details(row.id)} href={paths.dashboard.cr_questions.details(row.id)}
sx={{ whiteSpace: 'nowrap' }} sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2" variant="subtitle2"
> >
@@ -4202,7 +4270,7 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpQuest
// //
color="secondary" color="secondary"
component={RouterLink} component={RouterLink}
href={paths.dashboard.lp_categories.details(row.id)} href={paths.dashboard.cr_questions.details(row.id)}
> >
<PencilSimpleIcon size={24} /> <PencilSimpleIcon size={24} />
</LoadingButton> </LoadingButton>
@@ -4226,13 +4294,13 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<LpQuest
} }
export interface LessonQuestionsTableProps { export interface LessonQuestionsTableProps {
rows: LpQuestion[]; rows: CrQuestion[];
reloadRows: () => void; reloadRows: () => void;
} }
export function LpQuestionsTable({ rows, reloadRows }: LessonQuestionsTableProps): React.JSX.Element { export function CrQuestionsTable({ rows, reloadRows }: LessonQuestionsTableProps): React.JSX.Element {
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['lp_categories']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useLpQuestionsSelection(); const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCrQuestionsSelection();
const [idToDelete, setIdToDelete] = React.useState(''); const [idToDelete, setIdToDelete] = React.useState('');
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -4250,7 +4318,7 @@ export function LpQuestionsTable({ rows, reloadRows }: LessonQuestionsTableProps
reloadRows={reloadRows} reloadRows={reloadRows}
setOpen={setOpen} setOpen={setOpen}
/> />
<DataTable<LpQuestion> <DataTable<CrQuestion>
columns={columns(handleDeleteClick)} columns={columns(handleDeleteClick)}
onDeselectAll={deselectAll} onDeselectAll={deselectAll}
onDeselectOne={(_, row) => { onDeselectOne={(_, row) => {
@@ -4576,7 +4644,7 @@ export function ShippingAddress({ address }: ShippingAddressProps): React.ReactE
</file> </file>
<file path="questions/type.d.ts"> <file path="questions/type.d.ts">
export interface LpQuestion { export interface CrQuestion {
isEmpty?: boolean; isEmpty?: boolean;
// //
id: string; id: string;

View File

@@ -0,0 +1,25 @@
# GUIDELINES & KEY COMPONENTS
- `_constants.ts` contains the constant for
- default value (defaultValue)
- empty value (emptyValue)
- `customers-table.tsx`
- `confirm-delete-modal.tsx` - delete modal component when click delete button on list
- `customers-filters.tsx`
- `customers-pagination.tsx`
- `email-filter-popover.tsx`
- `phone-filter-popover.tsx`
- `customers-selection-context.tsx`
- `customer-create-form.tsx` - form to create a new customer
- `customer-edit-form.tsx` - form to edit a existing customer
- `type.d.tsx` - contains type definition
- `notifications.tsx` - constants used for demonstration
- `payments.tsx` - constants used for demonstration
- `shipping-address.tsx` - constants used for demonstration

View File

@@ -1,9 +0,0 @@
# task
Create a customer edit form
## steps
- read other `tsx` files in same directory,
- draft `customer-edit-form.tsx`.
- the `customer-edit-form.tsx` is already there with content, you can modify it freely thanks.

View File

@@ -1,3 +1,7 @@
// RULES:
// default variable value for customer
// empty valur for customer
import { dayjs } from '@/lib/dayjs'; import { dayjs } from '@/lib/dayjs';
import type { Customer } from './type.d'; import type { Customer } from './type.d';

View File

@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import { deleteCustomer } from '@/db/Customers/Delete';
export default function ConfirmDeleteModal({ export default function ConfirmDeleteModal({
open, open,
@@ -46,7 +47,9 @@ export default function ConfirmDeleteModal({
function handleUserConfirmDelete(): void { function handleUserConfirmDelete(): void {
if (idToDelete) { if (idToDelete) {
setIsDeleteing(true); setIsDeleteing(true);
deleteQuizLPCategories(idToDelete)
// RULES: delete<CollectionName>
deleteCustomer(idToDelete)
.then(() => { .then(() => {
reloadRows(); reloadRows();
handleClose(); handleClose();

View File

@@ -29,6 +29,8 @@ import { paths } from '@/paths';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { Option } from '@/components/core/option'; import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import { createCustomer } from '@/db/Customers/Create';
import isDevelopment from '@/lib/check-is-development';
function fileToBase64(file: Blob): Promise<string> { function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -67,12 +69,19 @@ type Values = zod.infer<typeof schema>;
const defaultValues = { const defaultValues = {
avatar: '', avatar: '',
name: '', name: 'new name',
email: '', email: '123@123.com',
phone: '', phone: '91234567',
company: '', company: '',
billingAddress: { country: '', state: '', city: '', zipCode: '', line1: '', line2: '' }, billingAddress: {
taxId: '', country: 'US',
state: '00000',
city: 'NY',
zipCode: '00000',
line1: 'test line 1',
line2: 'test line 2',
},
taxId: '12345',
timezone: 'new_york', timezone: 'new_york',
language: 'en', language: 'en',
currency: 'USD', currency: 'USD',
@@ -90,14 +99,15 @@ export function CustomerCreateForm(): React.JSX.Element {
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) }); } = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback( const onSubmit = React.useCallback(
async (_: Values): Promise<void> => { async (values: Values): Promise<void> => {
try { try {
// Make API request // Use standard create method from db/Customers/Create
toast.success('Customer updated'); const record = await createCustomer(values);
router.push(paths.dashboard.customers.details('1')); toast.success('Customer created');
router.push(paths.dashboard.customers.details(record.id));
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
toast.error('Something went wrong!'); toast.error('Failed to create customer');
} }
}, },
[router] [router]
@@ -122,12 +132,22 @@ export function CustomerCreateForm(): React.JSX.Element {
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Card> <Card>
<CardContent> <CardContent>
<Stack divider={<Divider />} spacing={4}> <Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Account information</Typography> <Typography variant="h6">Account information</Typography>
<Grid container spacing={3}> <Grid
container
spacing={3}
>
<Grid xs={12}> <Grid xs={12}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}> <Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box <Box
sx={{ sx={{
border: '1px dashed var(--mui-palette-divider)', border: '1px dashed var(--mui-palette-divider)',
@@ -151,7 +171,10 @@ export function CustomerCreateForm(): React.JSX.Element {
<CameraIcon fontSize="var(--Icon-fontSize)" /> <CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar> </Avatar>
</Box> </Box>
<Stack spacing={1} sx={{ alignItems: 'flex-start' }}> <Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">Avatar</Typography> <Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography> <Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button <Button
@@ -163,16 +186,27 @@ export function CustomerCreateForm(): React.JSX.Element {
> >
Select Select
</Button> </Button>
<input hidden onChange={handleAvatarChange} ref={avatarInputRef} type="file" /> <input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack> </Stack>
</Stack> </Stack>
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.name)} fullWidth> <FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel> <InputLabel required>Name</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null} {errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
@@ -180,25 +214,40 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.email)} fullWidth> <FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email address</InputLabel> <InputLabel required>Email address</InputLabel>
<OutlinedInput {...field} type="email" /> <OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null} {errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="phone" name="phone"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.phone)} fullWidth> <FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel> <InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null} {errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
@@ -206,12 +255,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="company" name="company"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.company)} fullWidth> <FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel> <InputLabel>Company</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null} {errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
@@ -223,13 +278,22 @@ export function CustomerCreateForm(): React.JSX.Element {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Billing information</Typography> <Typography variant="h6">Billing information</Typography>
<Grid container spacing={3}> <Grid
<Grid md={6} xs={12}> container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.country" name="billingAddress.country"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.country)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<InputLabel required>Country</InputLabel> <InputLabel required>Country</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Choose a country</Option> <Option value="">Choose a country</Option>
@@ -244,12 +308,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.state" name="billingAddress.state"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.state)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel> <InputLabel required>State</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.state ? ( {errors.billingAddress?.state ? (
@@ -259,12 +329,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.city" name="billingAddress.city"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.city)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel> <InputLabel required>City</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.city ? ( {errors.billingAddress?.city ? (
@@ -274,12 +350,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.zipCode" name="billingAddress.zipCode"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.zipCode)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip code</InputLabel> <InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? ( {errors.billingAddress?.zipCode ? (
@@ -289,12 +371,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.line1" name="billingAddress.line1"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.line1)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address</InputLabel> <InputLabel required>Address</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.line1 ? ( {errors.billingAddress?.line1 ? (
@@ -304,14 +392,23 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="taxId" name="taxId"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.taxId)} fullWidth> <FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel> <InputLabel>Tax ID</InputLabel>
<OutlinedInput {...field} placeholder="e.g EU372054390" /> <OutlinedInput
{...field}
placeholder="e.g EU372054390"
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null} {errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
@@ -321,17 +418,29 @@ export function CustomerCreateForm(): React.JSX.Element {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography> <Typography variant="h6">Shipping information</Typography>
<FormControlLabel control={<Checkbox defaultChecked />} label="Same as billing address" /> <FormControlLabel
control={<Checkbox defaultChecked />}
label="Same as billing address"
/>
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Additional information</Typography> <Typography variant="h6">Additional information</Typography>
<Grid container spacing={3}> <Grid
<Grid md={6} xs={12}> container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="timezone" name="timezone"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.timezone)} fullWidth> <FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel> <InputLabel required>Timezone</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a timezone</Option> <Option value="">Select a timezone</Option>
@@ -344,12 +453,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="language" name="language"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.language)} fullWidth> <FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel> <InputLabel required>Language</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a language</Option> <Option value="">Select a language</Option>
@@ -362,12 +477,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="currency" name="currency"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.currency)} fullWidth> <FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel>Currency</InputLabel> <InputLabel>Currency</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a currency</Option> <Option value="">Select a currency</Option>
@@ -385,14 +506,24 @@ export function CustomerCreateForm(): React.JSX.Element {
</Stack> </Stack>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}> <CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" component={RouterLink} href={paths.dashboard.customers.list}> <Button
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.list}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" variant="contained"> <Button
type="submit"
variant="contained"
>
Create customer Create customer
</Button> </Button>
</CardActions> </CardActions>
</Card> </Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form> </form>
); );
} }

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
// //
import { COL_QUIZ_LP_CATEGORIES } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab'; import { LoadingButton } from '@mui/lab';
// //
@@ -35,64 +35,61 @@ import { paths } from '@/paths';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64'; import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { TextEditor } from '@/components/core/text-editor/text-editor';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading'; import FormLoading from '@/components/loading';
import ErrorDisplay from '../../error'; // import ErrorDisplay from '../../error';
import type { EditFormProps } from './type'; import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this // TODO: review this
const schema = zod.object({ const schema = zod.object({
cat_name: zod.string().min(1, 'name-is-required').max(255), name: zod.string().min(1, 'Name is required').max(255),
// accept file object when user change image email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
// accept http string when user not changing image phone: zod.string().min(1, 'Phone is required').max(25),
cat_image: zod.union([zod.array(zod.any()), zod.string()]).optional(), company: zod.string().max(255).optional(),
billingAddress: zod.object({
// position country: zod.string().min(1, 'Country is required').max(255),
pos: zod.number().min(1, 'position is required').max(99), state: zod.string().min(1, 'State is required').max(255),
city: zod.string().min(1, 'City is required').max(255),
// it should be a valid JSON zipCode: zod.string().min(1, 'Zip code is required').max(255),
init_answer: zod line1: zod.string().min(1, 'Street line 1 is required').max(255),
.string() line2: zod.string().max(255).optional(),
.refine( }),
(value) => { taxId: zod.string().max(255).optional(),
try { timezone: zod.string().min(1, 'Timezone is required').max(255),
JSON.parse(value); language: zod.string().min(1, 'Language is required').max(255),
return true; currency: zod.string().min(1, 'Currency is required').max(255),
} catch (error) {
return false;
}
},
{ message: 'init_answer must be a valid JSON' }
)
.optional(),
visible: zod.string(),
slug: zod.string().min(0, 'slug-is-required').max(255).optional(),
remarks: zod.string().optional(),
description: zod.string().optional(),
// NOTE: for image handling
avatar: zod.string().optional(), avatar: zod.string().optional(),
}); });
type Values = zod.infer<typeof schema>; type Values = zod.infer<typeof schema>;
const defaultValues = { const defaultValues = {
cat_name: '', name: '',
cat_image: undefined, email: '',
pos: 1, phone: '',
init_answer: JSON.stringify({}), company: '',
visible: 'hidden', billingAddress: {
slug: '', country: '',
remarks: '', state: '',
description: '', city: '',
zipCode: '',
line1: '',
line2: '',
},
taxId: '',
timezone: '',
language: '',
currency: '',
avatar: '',
} satisfies Values; } satisfies Values;
export function CrQuestionEditForm(): React.JSX.Element { export function CustomerEditForm(): React.JSX.Element {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(['lp_categories']); const { t } = useTranslation(['lp_categories']);
const { cat_id: catId } = useParams<{ cat_id: string }>(); const { customerId } = useParams<{ customerId: string }>();
// //
const [isUpdating, setIsUpdating] = React.useState<boolean>(false); const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false); const [showLoading, setShowLoading] = React.useState<boolean>(false);
@@ -112,37 +109,31 @@ export function CrQuestionEditForm(): React.JSX.Element {
async (values: Values): Promise<void> => { async (values: Values): Promise<void> => {
setIsUpdating(true); setIsUpdating(true);
const tempUpdate: EditFormProps = { const updateData = {
cat_name: values.cat_name, name: values.name,
cat_image: values.avatar ? [await base64ToFile(values.avatar)] : null, email: values.email,
pos: values.pos, phone: values.phone,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment company: values.company,
init_answer: JSON.parse(values.init_answer || '{}'), billingAddress: values.billingAddress,
taxId: values.taxId,
visible: values.visible, timezone: values.timezone,
slug: values.slug || 'not-defined', language: values.language,
remarks: values.remarks, currency: values.currency,
description: values.description, avatar: values.avatar ? await base64ToFile(values.avatar) : null,
//
// TODO: remove below
type: '',
}; };
try { try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).update(catId, tempUpdate); await pb.collection(COL_CUSTOMERS).update(customerId, updateData);
logger.debug(result); toast.success('Customer updated successfully');
toast.success(t('edit.success')); router.push(paths.dashboard.customers.list);
router.push(paths.dashboard.lp_categories.list);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
toast.error(t('update.failed')); toast.error('Failed to update customer');
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}, },
// t is not necessary here [customerId, router]
// eslint-disable-next-line react-hooks/exhaustive-deps
[router]
); );
const avatarInputRef = React.useRef<HTMLInputElement>(null); const avatarInputRef = React.useRef<HTMLInputElement>(null);
@@ -171,41 +162,33 @@ export function CrQuestionEditForm(): React.JSX.Element {
setShowLoading(true); setShowLoading(true);
try { try {
const result = await pb.collection(COL_QUIZ_LP_CATEGORIES).getOne(id); const result = await pb.collection(COL_CUSTOMERS).getOne(id);
reset({ ...defaultValues, ...result });
console.log({ result });
reset({ ...defaultValues, ...result, init_answer: JSON.stringify(result.init_answer) }); if (result.avatar_file) {
setTextDescription(result.description);
setTextRemarks(result.remarks);
if (result.cat_image !== '') {
const fetchResult = await fetch( const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.cat_image}` `http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar_file}`
); );
const blob = await fetchResult.blob(); const blob = await fetchResult.blob();
const url = await fileToBase64(blob); const url = await fileToBase64(blob);
setValue('avatar', url); setValue('avatar', url);
} else {
setValue('avatar', '');
} }
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
toast(t('list.error')); toast.error('Failed to load customer data');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) }); setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally { } finally {
setShowLoading(false); setShowLoading(false);
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [reset, setValue]
[catId]
); );
React.useEffect(() => { React.useEffect(() => {
void loadExistingData(catId); void loadExistingData(customerId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [catId]); }, [customerId]);
if (showLoading) return <FormLoading />; if (showLoading) return <FormLoading />;
if (showError.show) if (showError.show)
@@ -290,112 +273,79 @@ export function CrQuestionEditForm(): React.JSX.Element {
xs={12} xs={12}
> >
<Controller <Controller
disabled={isUpdating}
control={control} control={control}
name="cat_name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
disabled={isUpdating} error={Boolean(errors.name)}
error={Boolean(errors.cat_name)}
fullWidth fullWidth
> >
<InputLabel required>{t('edit.cat_name')}</InputLabel> <InputLabel required>Name</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.cat_name ? <FormHelperText>{errors.cat_name.message}</FormHelperText> : null} {errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
/> />
</Grid> </Grid>
{/* */}
<Grid <Grid
md={6} md={6}
xs={12} xs={12}
> >
<Controller <Controller
disabled={isUpdating}
control={control} control={control}
name="pos" name="email"
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
error={Boolean(errors.pos)} error={Boolean(errors.email)}
fullWidth fullWidth
> >
<InputLabel required>{t('edit.pos')}</InputLabel> <InputLabel required>Email</InputLabel>
<OutlinedInput <OutlinedInput
{...field} {...field}
onChange={(e) => { type="email"
field.onChange(parseInt(e.target.value));
}}
type="number"
/> />
{errors.pos ? <FormHelperText>{errors.pos.message}</FormHelperText> : null} {errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
/> />
</Grid> </Grid>
{/* */}
<Grid <Grid
md={6} md={6}
xs={12} xs={12}
> >
<Controller <Controller
disabled={isUpdating}
control={control} control={control}
name="slug" name="phone"
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
error={Boolean(errors.slug)} error={Boolean(errors.phone)}
fullWidth fullWidth
> >
<InputLabel required>{t('edit.slug')}</InputLabel> <InputLabel required>Phone</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.slug ? <FormHelperText>{errors.slug.message}</FormHelperText> : null} {errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
/> />
</Grid> </Grid>
{/* */}
<Grid <Grid
md={6} md={6}
xs={12} xs={12}
> >
<Controller <Controller
disabled={isUpdating}
control={control} control={control}
name="visible" name="company"
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
error={Boolean(errors.visible)} error={Boolean(errors.company)}
fullWidth fullWidth
> >
<InputLabel>{t('edit.visible')}</InputLabel> <InputLabel>Company</InputLabel>
<Select {...field}> <OutlinedInput
<MenuItem value="visible">{t('edit.visible')}</MenuItem> {...field}
<MenuItem value="hidden">{t('edit.hidden')}</MenuItem> placeholder="no company name"
</Select>
{errors.visible ? <FormHelperText>{errors.visible.message}</FormHelperText> : null}
</FormControl>
)}
/> />
</Grid> {errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isUpdating}
control={control}
name="init_answer"
render={({ field }) => (
<FormControl
error={Boolean(errors.init_answer)}
fullWidth
>
<InputLabel required>{t('edit.init_answer')}</InputLabel>
<OutlinedInput {...field} />
{errors.init_answer ? <FormHelperText>{errors.init_answer.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
/> />
@@ -404,7 +354,7 @@ export function CrQuestionEditForm(): React.JSX.Element {
</Stack> </Stack>
{/* */} {/* */}
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">{t('edit.detail-information')}</Typography> <Typography variant="h6">Billing Information</Typography>
<Grid <Grid
container container
spacing={3} spacing={3}
@@ -414,31 +364,24 @@ export function CrQuestionEditForm(): React.JSX.Element {
xs={12} xs={12}
> >
<Controller <Controller
disabled={isUpdating}
control={control} control={control}
name="description" name="billingAddress.country"
render={({ field }) => { render={({ field }) => (
return ( <FormControl
<Box> error={Boolean(errors.billingAddress?.country)}
<Typography fullWidth
variant="subtitle1"
color="text-secondary"
> >
{t('edit.description')} <InputLabel required>Country</InputLabel>
</Typography> <Select {...field}>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '200px' } }}> <MenuItem value="US">United States</MenuItem>
<TextEditor <MenuItem value="UK">United Kingdom</MenuItem>
{...field} <MenuItem value="CA">Canada</MenuItem>
content={textDescription} </Select>
onUpdate={({ editor }) => { {errors.billingAddress?.country ? (
field.onChange({ target: { value: editor.getHTML() } }); <FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
}} ) : null}
placeholder={t('edit.description.default')} </FormControl>
/> )}
</Box>
</Box>
);
}}
/> />
</Grid> </Grid>
<Grid <Grid
@@ -446,41 +389,199 @@ export function CrQuestionEditForm(): React.JSX.Element {
xs={12} xs={12}
> >
<Controller <Controller
disabled={isUpdating}
control={control} control={control}
name="remarks" name="billingAddress.state"
render={({ field }) => ( render={({ field }) => (
<Box> <FormControl
<Typography error={Boolean(errors.billingAddress?.state)}
variant="subtitle1" fullWidth
color="text.secondary"
> >
{t('edit.remarks')} <InputLabel required>State</InputLabel>
</Typography> <OutlinedInput {...field} />
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '200px' } }}> {errors.billingAddress?.state ? (
<TextEditor <FormHelperText>{errors.billingAddress.state.message}</FormHelperText>
content={textRemarks} ) : null}
onUpdate={({ editor }) => { </FormControl>
field.onChange({ target: { value: editor.getText() } }); )}
}}
hideToolbar
placeholder={t('edit.remarks.default')}
/> />
</Box> </Grid>
</Box> <Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress.city.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput
{...field}
placeholder="no tax id..."
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<MenuItem value="America/New_York">New York</MenuItem>
<MenuItem value="Europe/London">London</MenuItem>
<MenuItem value="Asia/Tokyo">Tokyo</MenuItem>
<MenuItem value="America/Boa_Vista">Boa Vista</MenuItem>
<MenuItem value="America/Grand_Turk">Grand Turk</MenuItem>
<MenuItem value="Asia/Manila">Manila</MenuItem>
<MenuItem value="Asia/Urumqi">Urumqi</MenuItem>
<MenuItem value="Africa/Tunis">Tunis</MenuItem>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<Select {...field}>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)} )}
/> />
</Grid> </Grid>
</Grid> </Grid>
</Stack> </Stack>
{/* */}
</Stack> </Stack>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}> <CardActions sx={{ justifyContent: 'flex-end' }}>
<Button <Button
color="secondary" color="secondary"
component={RouterLink} component={RouterLink}
href={paths.dashboard.lp_categories.list} href={paths.dashboard.customers.list}
> >
{t('edit.cancelButton')} {t('edit.cancelButton')}
</Button> </Button>
@@ -495,6 +596,9 @@ export function CrQuestionEditForm(): React.JSX.Element {
</LoadingButton> </LoadingButton>
</CardActions> </CardActions>
</Card> </Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form> </form>
); );
} }

View File

@@ -47,17 +47,17 @@ export function CustomersFilters({
const selection = useCustomersSelection(); const selection = useCustomersSelection();
function getVisible(): number { // function getVisible(): number {
return fullData.reduce((count, item: CrQuestion) => { // return fullData.reduce((count, item: CrQuestion) => {
return item.visible === 'visible' ? count + 1 : count; // return item.visible === 'visible' ? count + 1 : count;
}, 0); // }, 0);
} // }
function getHidden(): number { // function getHidden(): number {
return fullData.reduce((count, item: CrQuestion) => { // return fullData.reduce((count, item: CrQuestion) => {
return item.visible === 'hidden' ? count + 1 : count; // return item.visible === 'hidden' ? count + 1 : count;
}, 0); // }, 0);
} // }
// The tabs should be generated using API data. // The tabs should be generated using API data.
const tabs = [ const tabs = [

View File

@@ -143,13 +143,14 @@ function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Custome
// //
color="secondary" color="secondary"
component={RouterLink} component={RouterLink}
href={paths.dashboard.cr_questions.details(row.id)} href={paths.dashboard.customers.details(row.id)}
> >
<PencilSimpleIcon size={24} /> <PencilSimpleIcon size={24} />
</LoadingButton> </LoadingButton>
<LoadingButton <LoadingButton
color="error" color="error"
disabled={row.isEmpty} // TODO: originally it is row.isEmpty
disabled={false}
onClick={() => { onClick={() => {
handleDeleteClick(row.id); handleDeleteClick(row.id);
}} }}

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { SortDir } from './phone-filter-popover'; export type SortDir = 'asc' | 'desc';
import { Customer } from './type.d';
export interface Customer { export interface Customer {
id: string; id: string;
@@ -17,20 +16,46 @@ export interface Customer {
export interface CreateFormProps { export interface CreateFormProps {
name: string; name: string;
avatar?: string;
email: string; email: string;
phone?: string; phone?: string;
quota: number; company?: string;
status: 'pending' | 'active' | 'blocked'; billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
} }
export interface EditFormProps { export interface EditFormProps {
name: string; name: string;
avatar?: string;
email: string; email: string;
phone?: string; phone?: string;
quota: number; company?: string;
status: 'pending' | 'active' | 'blocked'; billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
} }
export interface CustomersFiltersProps { export interface CustomersFiltersProps {
filters?: Filters; filters?: Filters;
@@ -42,5 +67,3 @@ export interface Filters {
phone?: string; phone?: string;
status?: string; status?: string;
} }
export type SortDir = 'asc' | 'desc';

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@ The content is organized as follows:
categories/ categories/
_constants.ts _constants.ts
_PROMPT.MD _PROMPT.MD
_SUMMARY.md
confirm-delete-modal.tsx confirm-delete-modal.tsx
lp-categories-filters.tsx lp-categories-filters.tsx
lp-categories-pagination.tsx lp-categories-pagination.tsx
@@ -66,6 +67,7 @@ categories/
questions/ questions/
_constants.ts _constants.ts
_PROMPT.MD _PROMPT.MD
_SUMMARY.md
confirm-delete-modal.tsx confirm-delete-modal.tsx
lp-question-create-form.tsx lp-question-create-form.tsx
lp-question-edit-form.tsx lp-question-edit-form.tsx
@@ -133,6 +135,120 @@ export const emptyLpCategory: LpCategory = {
please review and add translations, e.g. `{t('[word]')}` please review and add translations, e.g. `{t('[word]')}`
</file> </file>
<file path="categories/_SUMMARY.md">
# LP Categories Components Summary
## Main Components
### `lp-categories-table.tsx`
- Displays LP categories in a table format with columns for:
- Name with image
- Slug
- Status indicators
- Created date
- Action buttons
- Features:
- Single and multiple row selection
- Status indicators (Active/Inactive)
- Edit/view/delete actions
- Integration with other LP components
### `lp-category-create-form.tsx`
- Form for creating new LP categories
- Key fields:
- Name (required)
- Image upload
- Slug (auto-generated)
- Status toggle
- Description (rich text)
- Additional metadata
- Features:
- Form validation
- Image handling
- Auto-slug generation
- Internationalization support
### `lp-category-edit-form.tsx`
- Form for editing existing LP categories
- Similar to create form but:
- Pre-populates existing data
- Handles image updates differently
- May have additional edit-specific validation
## Supporting Components
### `confirm-delete-modal.tsx`
- Confirmation dialog for LP category deletion
- Shows:
- Delete confirmation message
- Loading state during deletion
- Success/error notifications
### `lp-categories-filters.tsx`
- Filtering controls for LP categories table
- Includes:
- Status filter tabs
- Text search
- Sort options
- Selected items count
- Bulk actions
### `lp-categories-pagination.tsx`
- Pagination controls for LP categories table
- Standard features:
- Page navigation
- Rows per page selection
- Current/total page display
### `lp-categories-selection-context.tsx`
- Manages selection state for LP categories
- Provides:
- Selection/deselection functions
- Current selection state
- Bulk operation support
## Types & Constants
### `type.d.ts`
- Type definitions including:
- `LpCategory`: Main LP category type
- Form props for create/edit
- Component-specific prop types
### `_constants.ts`
- Contains:
- Default LP category values
- Empty category template
- Other shared constants
## Component Relationships
```mermaid
graph TD
A[lp-categories-table] --> B[lp-category-create-form]
A --> C[lp-category-edit-form]
A --> D[confirm-delete-modal]
A --> E[lp-categories-filters]
A --> F[lp-categories-pagination]
A --> G[lp-categories-selection-context]
H[type.d.ts] --> A
H --> B
H --> C
I[_constants.ts] --> A
I --> B
I --> C
```
</file>
<file path="categories/confirm-delete-modal.tsx"> <file path="categories/confirm-delete-modal.tsx">
'use client'; 'use client';
@@ -4151,6 +4267,95 @@ keep current folder structure is important
thanks thanks
</file> </file>
<file path="questions/_SUMMARY.md">
# LP Questions Components Summary
## Main Components
### LP Questions Table
- Primary data display component using Material UI DataTable
- Features:
- Column configurations with custom formatters
- Status indicators with visual icons
- Progress bars for quota visualization
- Row selection and bulk operations
- Internationalization support
- Integration with filters and pagination
### Question Forms
- **Create Form**:
- React Hook Form with Zod schema validation
- Image upload with preview functionality
- Rich text editors for descriptions
- Internationalized labels and error messages
- Form submission to PocketBase
- **Edit Form**:
- Pre-fills existing data from PocketBase
- Conditional image handling
- Loading states and error handling
- JSON validation for complex fields
- Pre-filled rich text editors
## Supporting Components
### Selection Management
- Context-based selection state
- Supports:
- Individual selection/deselection
- Bulk select/deselect operations
- Selection state tracking (selectedAny, selectedAll)
- Integration with question IDs
### Filters & Pagination
- **Filters**:
- Tab-based filtering (All/Visible/Hidden) with counts
- Name and type search functionality
- Sort controls (Newest/Oldest)
- URL parameter synchronization
- **Pagination**:
- Material UI TablePagination implementation
- Standard page size options
- Callback-based integration with parent
### Delete Confirmation
- Modal dialog with:
- Loading state during deletion
- Success/error toast notifications
- Internationalized messages
- PocketBase integration
- Custom error logging
## Types & Constants
- Type definitions for LP question data (LpQuestion)
- Shared constants for:
- Column configurations
- Form defaults
- Validation rules
## Component Relationships
```mermaid
graph TD
A[LP Questions Table] --> B[Question Forms]
A --> C[Selection Context]
A --> D[Filters]
A --> E[Pagination]
A --> F[Delete Modal]
B --> G[PocketBase]
F --> G
```
</file>
<file path="questions/confirm-delete-modal.tsx"> <file path="questions/confirm-delete-modal.tsx">
'use client'; 'use client';

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
'use client'; 'use client';
/* /*
# PROMPT RULES:
this is a subset of a typescript project show loading when fetching record from db
show error when fetch record failed
clone `LessonTypeCount`, `LessonCategoriesCount` to `UserCount` and do modifiy to get the count of users, thanks.
*/ */
import * as React from 'react'; import * as React from 'react';
import getAllUserMetasCount from '@/db/UserMetas/GetAllCount'; import getAllUserMetasCount from '@/db/UserMetas/GetAllCount';
@@ -15,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { Summary } from '@/components/dashboard/overview/summary'; import { Summary } from '@/components/dashboard/overview/summary';
import { LoadingSummary } from '../LoadingSummary'; import { LoadingSummary } from '../LoadingSummary';
import { ErrorSummary } from '../ErrorSummary';
function ActiveUserCount(): React.JSX.Element { function ActiveUserCount(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,7 +50,15 @@ function ActiveUserCount(): React.JSX.Element {
); );
} }
if (showError) return <div>{errorDetail}</div>; if (showError)
return (
<ErrorSummary
diff={10}
icon={UsersIcon}
title={t('用戶數量')}
trend="up"
/>
);
return ( return (
<Summary <Summary

View File

@@ -0,0 +1,99 @@
'use client';
/*
# NOTES:
Show error to user when loading error,
use together with:
- src/components/dashboard/overview/summary/ActiveUserCount/index.tsx
*/
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import type { Icon } from '@phosphor-icons/react/dist/lib/types';
import { TrendDown as TrendDownIcon } from '@phosphor-icons/react/dist/ssr/TrendDown';
import { TrendUp as TrendUpIcon } from '@phosphor-icons/react/dist/ssr/TrendUp';
import { useTranslation } from 'react-i18next';
export interface SummaryProps {
diff: number;
icon: Icon;
title: string;
trend: 'up' | 'down';
}
export function ErrorSummary({ diff, icon: Icon, title, trend }: SummaryProps): React.JSX.Element {
const { t } = useTranslation();
return (
<Card>
<CardContent>
<Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Avatar
sx={{
'--Avatar-size': '48px',
bgcolor: 'var(--mui-palette-background-paper)',
boxShadow: 'var(--mui-shadows-8)',
color: 'var(--mui-palette-text-primary)',
}}
>
<Icon fontSize="var(--icon-fontSize-lg)" />
</Avatar>
<div>
<Typography
color="text.secondary"
variant="body1"
>
{title}
</Typography>
<Typography variant="h3">Error</Typography>
</div>
</Stack>
</CardContent>
<Divider />
<Box sx={{ p: '16px' }}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
alignItems: 'center',
color: trend === 'up' ? 'var(--mui-palette-success-main)' : 'var(--mui-palette-error-main)',
display: 'flex',
justifyContent: 'center',
}}
>
{trend === 'up' ? (
<TrendUpIcon fontSize="var(--icon-fontSize-md)" />
) : (
<TrendDownIcon fontSize="var(--icon-fontSize-md)" />
)}
</Box>
<Typography
color="text.secondary"
variant="body2"
>
<Typography
color={trend === 'up' ? 'success.main' : 'error.main'}
component="span"
variant="subtitle2"
>
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(diff / 100)}
</Typography>{' '}
{trend === 'up' ? t('increase') : t('decrease')} {t('vs last month')}
</Typography>
</Stack>
</Box>
</Card>
);
}

View File

@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
import { Summary } from '@/components/dashboard/overview/summary'; import { Summary } from '@/components/dashboard/overview/summary';
import { LoadingSummary } from '../LoadingSummary'; import { LoadingSummary } from '../LoadingSummary';
import { ErrorSummary } from '../ErrorSummary';
function LessonCategoriesCount(): React.JSX.Element { function LessonCategoriesCount(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -40,10 +41,26 @@ function LessonCategoriesCount(): React.JSX.Element {
}, []); }, []);
if (showLoading) { if (showLoading) {
return <LoadingSummary diff={10} icon={ListChecksIcon} title={t('用戶數量')} trend="up" />; return (
<LoadingSummary
diff={10}
icon={ListChecksIcon}
title={t('用戶數量')}
trend="up"
/>
);
} }
if (showError) return <div>{errorDetail}</div>; if (showError) {
return (
<ErrorSummary
icon={ListChecksIcon}
title={t('Error')}
diff={0}
trend="down"
/>
);
}
return ( return (
<Summary <Summary

View File

@@ -1,5 +1,11 @@
'use client'; 'use client';
/*
RULES:
show loading when fetching record from db
show error when fetch record failed
*/
import * as React from 'react'; import * as React from 'react';
import GetAllCount from '@/db/LessonTypes/GetAllCount.tsx'; import GetAllCount from '@/db/LessonTypes/GetAllCount.tsx';
import { ListChecks as ListChecksIcon } from '@phosphor-icons/react/dist/ssr/ListChecks'; import { ListChecks as ListChecksIcon } from '@phosphor-icons/react/dist/ssr/ListChecks';
@@ -7,6 +13,7 @@ import { useTranslation } from 'react-i18next';
import { Summary } from '@/components/dashboard/overview/summary'; import { Summary } from '@/components/dashboard/overview/summary';
import { LoadingSummary } from '@/components/dashboard/overview/summary/LoadingSummary'; import { LoadingSummary } from '@/components/dashboard/overview/summary/LoadingSummary';
import { ErrorSummary } from '@/components/dashboard/overview/summary/ErrorSummary';
function LessonTypeCount(): React.JSX.Element { function LessonTypeCount(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -42,11 +49,10 @@ function LessonTypeCount(): React.JSX.Element {
if (error) { if (error) {
return ( return (
<Summary <ErrorSummary
amount={0}
diff={0} diff={0}
icon={ListChecksIcon} icon={ListChecksIcon}
title={t('Error')} title={t('課程類型')}
trend="down" trend="down"
/> />
); );

View File

@@ -1,5 +1,11 @@
'use client'; 'use client';
/*
NOTES:
show loading when loading summary
use with:
- src/components/dashboard/overview/summary/ActiveUserCount/index.tsx
*/
import * as React from 'react'; import * as React from 'react';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@@ -26,7 +32,11 @@ export function Summary({ amount, diff, icon: Icon, title, trend }: SummaryProps
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}> <Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Avatar <Avatar
sx={{ sx={{
'--Avatar-size': '48px', '--Avatar-size': '48px',
@@ -38,7 +48,10 @@ export function Summary({ amount, diff, icon: Icon, title, trend }: SummaryProps
<Icon fontSize="var(--icon-fontSize-lg)" /> <Icon fontSize="var(--icon-fontSize-lg)" />
</Avatar> </Avatar>
<div> <div>
<Typography color="text.secondary" variant="body1"> <Typography
color="text.secondary"
variant="body1"
>
{title} {title}
</Typography> </Typography>
<Typography variant="h3">{new Intl.NumberFormat('en-US').format(amount)}</Typography> <Typography variant="h3">{new Intl.NumberFormat('en-US').format(amount)}</Typography>
@@ -47,7 +60,11 @@ export function Summary({ amount, diff, icon: Icon, title, trend }: SummaryProps
</CardContent> </CardContent>
<Divider /> <Divider />
<Box sx={{ p: '16px' }}> <Box sx={{ p: '16px' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}> <Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center' }}
>
<Box <Box
sx={{ sx={{
alignItems: 'center', alignItems: 'center',
@@ -62,8 +79,15 @@ export function Summary({ amount, diff, icon: Icon, title, trend }: SummaryProps
<TrendDownIcon fontSize="var(--icon-fontSize-md)" /> <TrendDownIcon fontSize="var(--icon-fontSize-md)" />
)} )}
</Box> </Box>
<Typography color="text.secondary" variant="body2"> <Typography
<Typography color={trend === 'up' ? 'success.main' : 'error.main'} component="span" variant="subtitle2"> color="text.secondary"
variant="body2"
>
<Typography
color={trend === 'up' ? 'success.main' : 'error.main'}
component="span"
variant="subtitle2"
>
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(diff / 100)} {new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(diff / 100)}
</Typography>{' '} </Typography>{' '}
{trend === 'up' ? t('increase') : t('decrease')} {t('vs last month')} {trend === 'up' ? t('increase') : t('decrease')} {t('vs last month')}

View File

@@ -1,398 +0,0 @@
'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<string> {
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<typeof schema>;
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<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (_: Values): Promise<void> => {
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<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack divider={<Divider />} spacing={4}>
<Stack spacing={3}>
<Typography variant="h6">Account information</Typography>
<Grid container spacing={3}>
<Grid xs={12}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '50%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack spacing={1} sx={{ alignItems: 'flex-start' }}>
<Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
Select
</Button>
<input hidden onChange={handleAvatarChange} ref={avatarInputRef} type="file" />
</Stack>
</Stack>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl error={Boolean(errors.name)} fullWidth>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl error={Boolean(errors.email)} fullWidth>
<InputLabel required>Email address</InputLabel>
<OutlinedInput {...field} type="email" />
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl error={Boolean(errors.phone)} fullWidth>
<InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl error={Boolean(errors.company)} fullWidth>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Billing information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.country)} fullWidth>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<Option value="">Choose a country</Option>
<Option value="us">United States</Option>
<Option value="de">Germany</Option>
<Option value="es">Spain</Option>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress?.country?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.state)} fullWidth>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress?.state?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.city)} fullWidth>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress?.city?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.zipCode)} fullWidth>
<InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress?.zipCode?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.line1)} fullWidth>
<InputLabel required>Address</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress?.line1?.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl error={Boolean(errors.taxId)} fullWidth>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput {...field} placeholder="e.g EU372054390" />
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography>
<FormControlLabel control={<Checkbox defaultChecked />} label="Same as billing address" />
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional information</Typography>
<Grid container spacing={3}>
<Grid md={6} xs={12}>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl error={Boolean(errors.timezone)} fullWidth>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<Option value="">Select a timezone</Option>
<Option value="new_york">US - New York</Option>
<Option value="california">US - California</Option>
<Option value="london">UK - London</Option>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl error={Boolean(errors.language)} fullWidth>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<Option value="">Select a language</Option>
<Option value="en">English</Option>
<Option value="es">Spanish</Option>
<Option value="de">German</Option>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid md={6} xs={12}>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl error={Boolean(errors.currency)} fullWidth>
<InputLabel>Currency</InputLabel>
<Select {...field}>
<Option value="">Select a currency</Option>
<Option value="USD">USD</Option>
<Option value="EUR">EUR</Option>
<Option value="RON">RON</Option>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" component={RouterLink} href={paths.dashboard.customers.list}>
Cancel
</Button>
<Button type="submit" variant="contained">
Create customer
</Button>
</CardActions>
</Card>
</form>
);
}

View File

@@ -1,241 +0,0 @@
'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 (
<div>
<Tabs onChange={handleStatusChange} sx={{ px: 3 }} value={status ?? ''} variant="scrollable">
{tabs.map((tab) => (
<Tab
icon={<Chip label={tab.count} size="small" variant="soft" />}
iconPosition="end"
key={tab.value}
label={tab.label}
sx={{ minHeight: 'auto' }}
tabIndex={0}
value={tab.value}
/>
))}
</Tabs>
<Divider />
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}>
<FilterButton
displayValue={email}
label="Email"
onFilterApply={(value) => {
handleEmailChange(value as string);
}}
onFilterDelete={() => {
handleEmailChange();
}}
popover={<EmailFilterPopover />}
value={email}
/>
<FilterButton
displayValue={phone}
label="Phone number"
onFilterApply={(value) => {
handlePhoneChange(value as string);
}}
onFilterDelete={() => {
handlePhoneChange();
}}
popover={<PhoneFilterPopover />}
value={phone}
/>
{hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null}
</Stack>
{selection.selectedAny ? (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Typography color="text.secondary" variant="body2">
{selection.selected.size} selected
</Typography>
<Button color="error" variant="contained">
Delete
</Button>
</Stack>
) : null}
<Select name="sort" onChange={handleSortChange} sx={{ maxWidth: '100%', width: '120px' }} value={sortDir}>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
</Stack>
</div>
);
}
function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by email">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}
function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by phone number">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -1,31 +0,0 @@
'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 (
<TablePagination
component="div"
count={count}
onPageChange={noop}
onRowsPerPageChange={noop}
page={page}
rowsPerPage={5}
rowsPerPageOptions={[5, 10, 25]}
//
/>
);
}

View File

@@ -1,43 +0,0 @@
'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<CustomersSelectionContextValue>({
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 <CustomersSelectionContext.Provider value={{ ...selection }}>{children}</CustomersSelectionContext.Provider>;
}
export function useCustomersSelection(): CustomersSelectionContextValue {
return React.useContext(CustomersSelectionContext);
}

View File

@@ -1,139 +0,0 @@
'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 => (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Avatar src={row.avatar} />{' '}
<div>
<Link
color="inherit"
component={RouterLink}
href={paths.dashboard.customers.details('1')}
sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2"
>
{row.name}
</Link>
<Typography color="text.secondary" variant="body2">
{row.email}
</Typography>
</div>
</Stack>
),
name: 'Name',
width: '250px',
},
{
formatter: (row): React.JSX.Element => (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<LinearProgress sx={{ flex: '1 1 auto' }} value={row.quota} variant="determinate" />
<Typography color="text.secondary" variant="body2">
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(row.quota / 100)}
</Typography>
</Stack>
),
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: <CheckCircleIcon color="var(--mui-palette-success-main)" weight="fill" /> },
blocked: { label: 'Blocked', icon: <MinusIcon color="var(--mui-palette-error-main)" /> },
pending: { label: 'Pending', icon: <ClockIcon color="var(--mui-palette-warning-main)" weight="fill" /> },
} as const;
const { label, icon } = mapping[row.status] ?? { label: 'Unknown', icon: null };
return <Chip icon={icon} label={label} size="small" variant="outlined" />;
},
name: 'Status',
width: '150px',
},
{
formatter: (): React.JSX.Element => (
<IconButton component={RouterLink} href={paths.dashboard.customers.details('1')}>
<PencilSimpleIcon />
</IconButton>
),
name: 'Actions',
hideName: true,
width: '100px',
align: 'right',
},
] satisfies ColumnDef<Customer>[];
export interface CustomersTableProps {
rows: Customer[];
}
export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element {
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
return (
<React.Fragment>
<DataTable<Customer>
columns={columns}
onDeselectAll={deselectAll}
onDeselectOne={(_, row) => {
deselectOne(row.id);
}}
onSelectAll={selectAll}
onSelectOne={(_, row) => {
selectOne(row.id);
}}
rows={rows}
selectable
selected={selected}
/>
{!rows.length ? (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="body2">
No customers found
</Typography>
</Box>
) : null}
</React.Fragment>
);
}

View File

@@ -1,3 +0,0 @@
const helloworld = 'helloworld';
export { helloworld };

View File

@@ -1,101 +0,0 @@
'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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{row.type}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Notification>[];
export interface NotificationsProps {
notifications: Notification[];
}
export function Notifications({ notifications }: NotificationsProps): React.JSX.Element {
return (
<Card>
<CardHeader
avatar={
<Avatar>
<EnvelopeSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Notifications"
/>
<CardContent>
<Stack spacing={3}>
<Stack spacing={2}>
<Select defaultValue="last_invoice" name="type" sx={{ maxWidth: '100%', width: '320px' }}>
<Option value="last_invoice">Resend last invoice</Option>
<Option value="password_reset">Send password reset</Option>
<Option value="verification">Send verification</Option>
</Select>
<div>
<Button startIcon={<EnvelopeSimpleIcon />} variant="contained">
Send email
</Button>
</div>
</Stack>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Notification> columns={columns} rows={notifications} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,138 +0,0 @@
'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 => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="subtitle2">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: row.currency }).format(row.amount)}
</Typography>
),
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 <Chip color={color} label={label} size="small" variant="soft" />;
},
name: 'Status',
width: '200px',
},
{
formatter: (row): React.JSX.Element => {
return <Link variant="inherit">{row.invoiceId}</Link>;
},
name: 'Invoice ID',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="inherit">
{dayjs(row.createdAt).format('MMM D, YYYY hh:mm A')}
</Typography>
),
name: 'Date',
align: 'right',
},
] satisfies ColumnDef<Payment>[];
export interface PaymentsProps {
ordersValue: number;
payments: Payment[];
refundsValue: number;
totalOrders: number;
}
export function Payments({ ordersValue, payments = [], refundsValue, totalOrders }: PaymentsProps): React.JSX.Element {
return (
<Card>
<CardHeader
action={
<Button color="secondary" startIcon={<PlusIcon />}>
Create Payment
</Button>
}
avatar={
<Avatar>
<ShoppingCartSimpleIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
}
title="Payments"
/>
<CardContent>
<Stack spacing={3}>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Stack
direction="row"
divider={<Divider flexItem orientation="vertical" />}
spacing={3}
sx={{ justifyContent: 'space-between', p: 2 }}
>
<div>
<Typography color="text.secondary" variant="overline">
Total orders
</Typography>
<Typography variant="h6">{new Intl.NumberFormat('en-US').format(totalOrders)}</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Orders value
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(ordersValue)}
</Typography>
</div>
<div>
<Typography color="text.secondary" variant="overline">
Refunds
</Typography>
<Typography variant="h6">
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(refundsValue)}
</Typography>
</div>
</Stack>
</Card>
<Card sx={{ borderRadius: 1 }} variant="outlined">
<Box sx={{ overflowX: 'auto' }}>
<DataTable<Payment> columns={columns} rows={payments} />
</Box>
</Card>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -1,46 +0,0 @@
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 (
<Card sx={{ borderRadius: 1, height: '100%' }} variant="outlined">
<CardContent>
<Stack spacing={2}>
<Typography>
{address.street},
<br />
{address.city}, {address.state}, {address.country},
<br />
{address.zipCode}
</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
{address.primary ? <Chip color="warning" label="Primary" variant="soft" /> : <span />}
<Button color="secondary" size="small" startIcon={<PencilSimpleIcon />}>
Edit
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,25 @@
# GUIDELINES & KEY COMPONENTS
- `_constants.ts` contains the constant for
- default value (defaultValue)
- empty value (emptyValue)
- `customers-table.tsx`
- `confirm-delete-modal.tsx` - delete modal component when click delete button on list
- `customers-filters.tsx`
- `customers-pagination.tsx`
- `email-filter-popover.tsx`
- `phone-filter-popover.tsx`
- `customers-selection-context.tsx`
- `customer-create-form.tsx` - form to create a new customer
- `customer-edit-form.tsx` - form to edit a existing customer
- `type.d.tsx` - contains type definition
- `notifications.tsx` - constants used for demonstration
- `payments.tsx` - constants used for demonstration
- `shipping-address.tsx` - constants used for demonstration

View File

@@ -0,0 +1,21 @@
// RULES:
// default variable value for customer
// empty valur for customer
import { dayjs } from '@/lib/dayjs';
import type { Customer } from './type.d';
export const defaultCustomer: Customer = {
id: '',
name: '',
avatar: undefined,
email: '',
phone: undefined,
quota: 0,
status: 'pending',
createdAt: dayjs().toDate(),
};
export const emptyLpCategory: Customer = {
...defaultCustomer,
};

View File

@@ -0,0 +1,128 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { COL_LESSON_TYPES } from '@/constants';
import deleteQuizLPCategories from '@/db/QuizListenings/Delete';
import { LoadingButton } from '@mui/lab';
import { Button, Container, Modal, Paper } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Note as NoteIcon } from '@phosphor-icons/react/dist/ssr/Note';
import PocketBase from 'pocketbase';
import { useTranslation } from 'react-i18next';
import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster';
import { deleteCustomer } from '@/db/Customers/Delete';
export default function ConfirmDeleteModal({
open,
setOpen,
idToDelete,
reloadRows,
}: {
open: boolean;
setOpen: (b: boolean) => void;
idToDelete: string;
reloadRows: () => void;
}): React.JSX.Element {
const { t } = useTranslation();
// const handleClose = () => setOpen(false);
function handleClose(): void {
setOpen(false);
}
const [isDeleteing, setIsDeleteing] = React.useState(false);
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
function handleUserConfirmDelete(): void {
if (idToDelete) {
setIsDeleteing(true);
// RULES: delete<CollectionName>
deleteCustomer(idToDelete)
.then(() => {
reloadRows();
handleClose();
toast(t('delete.success'));
})
.catch((err) => {
// console.error(err)
logger.error(err);
toast(t('delete.error'));
})
.finally(() => {
setIsDeleteing(false);
});
}
}
return (
<div>
<Modal
open={open}
onClose={handleClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Container maxWidth="sm">
<Paper sx={{ border: '1px solid var(--mui-palette-divider)', boxShadow: 'var(--mui-shadows-16)' }}>
<Stack
direction="row"
spacing={2}
sx={{ display: 'flex', p: 3 }}
>
<Avatar sx={{ bgcolor: 'var(--mui-palette-error-50)', color: 'var(--mui-palette-error-main)' }}>
<NoteIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h5">{t('Delete Lesson Type ?')}</Typography>
<Typography
color="text.secondary"
variant="body2"
>
{t('Are you sure you want to delete lesson type ?')}
</Typography>
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ justifyContent: 'flex-end' }}
>
<Button
color="secondary"
onClick={handleClose}
>
{t('Cancel')}
</Button>
<LoadingButton
color="error"
variant="contained"
onClick={(e) => {
handleUserConfirmDelete();
}}
loading={isDeleteing}
>
{t('Delete')}
</LoadingButton>
</Stack>
</Stack>
</Stack>
</Paper>
</Container>
</Box>
</Modal>
</div>
);
}

View File

@@ -29,6 +29,8 @@ import { paths } from '@/paths';
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { Option } from '@/components/core/option'; import { Option } from '@/components/core/option';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import { createCustomer } from '@/db/Customers/Create';
import isDevelopment from '@/lib/check-is-development';
function fileToBase64(file: Blob): Promise<string> { function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -67,12 +69,19 @@ type Values = zod.infer<typeof schema>;
const defaultValues = { const defaultValues = {
avatar: '', avatar: '',
name: '', name: 'new name',
email: '', email: '123@123.com',
phone: '', phone: '91234567',
company: '', company: '',
billingAddress: { country: '', state: '', city: '', zipCode: '', line1: '', line2: '' }, billingAddress: {
taxId: '', country: 'US',
state: '00000',
city: 'NY',
zipCode: '00000',
line1: 'test line 1',
line2: 'test line 2',
},
taxId: '12345',
timezone: 'new_york', timezone: 'new_york',
language: 'en', language: 'en',
currency: 'USD', currency: 'USD',
@@ -90,14 +99,15 @@ export function CustomerCreateForm(): React.JSX.Element {
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) }); } = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback( const onSubmit = React.useCallback(
async (_: Values): Promise<void> => { async (values: Values): Promise<void> => {
try { try {
// Make API request // Use standard create method from db/Customers/Create
toast.success('Customer updated'); const record = await createCustomer(values);
router.push(paths.dashboard.customers.details('1')); toast.success('Customer created');
router.push(paths.dashboard.customers.details(record.id));
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
toast.error('Something went wrong!'); toast.error('Failed to create customer');
} }
}, },
[router] [router]
@@ -122,12 +132,22 @@ export function CustomerCreateForm(): React.JSX.Element {
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Card> <Card>
<CardContent> <CardContent>
<Stack divider={<Divider />} spacing={4}> <Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Account information</Typography> <Typography variant="h6">Account information</Typography>
<Grid container spacing={3}> <Grid
container
spacing={3}
>
<Grid xs={12}> <Grid xs={12}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}> <Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box <Box
sx={{ sx={{
border: '1px dashed var(--mui-palette-divider)', border: '1px dashed var(--mui-palette-divider)',
@@ -151,7 +171,10 @@ export function CustomerCreateForm(): React.JSX.Element {
<CameraIcon fontSize="var(--Icon-fontSize)" /> <CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar> </Avatar>
</Box> </Box>
<Stack spacing={1} sx={{ alignItems: 'flex-start' }}> <Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">Avatar</Typography> <Typography variant="subtitle1">Avatar</Typography>
<Typography variant="caption">Min 400x400px, PNG or JPEG</Typography> <Typography variant="caption">Min 400x400px, PNG or JPEG</Typography>
<Button <Button
@@ -163,16 +186,27 @@ export function CustomerCreateForm(): React.JSX.Element {
> >
Select Select
</Button> </Button>
<input hidden onChange={handleAvatarChange} ref={avatarInputRef} type="file" /> <input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack> </Stack>
</Stack> </Stack>
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.name)} fullWidth> <FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel> <InputLabel required>Name</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null} {errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
@@ -180,25 +214,40 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.email)} fullWidth> <FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email address</InputLabel> <InputLabel required>Email address</InputLabel>
<OutlinedInput {...field} type="email" /> <OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null} {errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="phone" name="phone"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.phone)} fullWidth> <FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel> <InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null} {errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
@@ -206,12 +255,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="company" name="company"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.company)} fullWidth> <FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel> <InputLabel>Company</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null} {errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
@@ -223,13 +278,22 @@ export function CustomerCreateForm(): React.JSX.Element {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Billing information</Typography> <Typography variant="h6">Billing information</Typography>
<Grid container spacing={3}> <Grid
<Grid md={6} xs={12}> container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.country" name="billingAddress.country"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.country)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<InputLabel required>Country</InputLabel> <InputLabel required>Country</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Choose a country</Option> <Option value="">Choose a country</Option>
@@ -244,12 +308,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.state" name="billingAddress.state"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.state)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel> <InputLabel required>State</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.state ? ( {errors.billingAddress?.state ? (
@@ -259,12 +329,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.city" name="billingAddress.city"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.city)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel> <InputLabel required>City</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.city ? ( {errors.billingAddress?.city ? (
@@ -274,12 +350,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.zipCode" name="billingAddress.zipCode"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.zipCode)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip code</InputLabel> <InputLabel required>Zip code</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? ( {errors.billingAddress?.zipCode ? (
@@ -289,12 +371,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="billingAddress.line1" name="billingAddress.line1"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.billingAddress?.line1)} fullWidth> <FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address</InputLabel> <InputLabel required>Address</InputLabel>
<OutlinedInput {...field} /> <OutlinedInput {...field} />
{errors.billingAddress?.line1 ? ( {errors.billingAddress?.line1 ? (
@@ -304,14 +392,23 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="taxId" name="taxId"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.taxId)} fullWidth> <FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel> <InputLabel>Tax ID</InputLabel>
<OutlinedInput {...field} placeholder="e.g EU372054390" /> <OutlinedInput
{...field}
placeholder="e.g EU372054390"
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null} {errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl> </FormControl>
)} )}
@@ -321,17 +418,29 @@ export function CustomerCreateForm(): React.JSX.Element {
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Shipping information</Typography> <Typography variant="h6">Shipping information</Typography>
<FormControlLabel control={<Checkbox defaultChecked />} label="Same as billing address" /> <FormControlLabel
control={<Checkbox defaultChecked />}
label="Same as billing address"
/>
</Stack> </Stack>
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h6">Additional information</Typography> <Typography variant="h6">Additional information</Typography>
<Grid container spacing={3}> <Grid
<Grid md={6} xs={12}> container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="timezone" name="timezone"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.timezone)} fullWidth> <FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel> <InputLabel required>Timezone</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a timezone</Option> <Option value="">Select a timezone</Option>
@@ -344,12 +453,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="language" name="language"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.language)} fullWidth> <FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel> <InputLabel required>Language</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a language</Option> <Option value="">Select a language</Option>
@@ -362,12 +477,18 @@ export function CustomerCreateForm(): React.JSX.Element {
)} )}
/> />
</Grid> </Grid>
<Grid md={6} xs={12}> <Grid
md={6}
xs={12}
>
<Controller <Controller
control={control} control={control}
name="currency" name="currency"
render={({ field }) => ( render={({ field }) => (
<FormControl error={Boolean(errors.currency)} fullWidth> <FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel>Currency</InputLabel> <InputLabel>Currency</InputLabel>
<Select {...field}> <Select {...field}>
<Option value="">Select a currency</Option> <Option value="">Select a currency</Option>
@@ -385,14 +506,24 @@ export function CustomerCreateForm(): React.JSX.Element {
</Stack> </Stack>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}> <CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" component={RouterLink} href={paths.dashboard.customers.list}> <Button
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.list}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" variant="contained"> <Button
type="submit"
variant="contained"
>
Create customer Create customer
</Button> </Button>
</CardActions> </CardActions>
</Card> </Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form> </form>
); );
} }

View File

@@ -0,0 +1,604 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { useParams, useRouter } from 'next/navigation';
//
import { COL_CUSTOMERS } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
//
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 Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
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 { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
// import ErrorDisplay from '../../error';
import ErrorDisplay from '../error';
import isDevelopment from '@/lib/check-is-development';
// TODO: review this
const schema = zod.object({
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(25),
company: zod.string().max(255).optional(),
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),
avatar: zod.string().optional(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
name: '',
email: '',
phone: '',
company: '',
billingAddress: {
country: '',
state: '',
city: '',
zipCode: '',
line1: '',
line2: '',
},
taxId: '',
timezone: '',
language: '',
currency: '',
avatar: '',
} satisfies Values;
export function CustomerEditForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['lp_categories']);
const { customerId } = useParams<{ customerId: string }>();
//
const [isUpdating, setIsUpdating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const {
control,
handleSubmit,
formState: { errors },
setValue,
reset,
watch,
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsUpdating(true);
const updateData = {
name: values.name,
email: values.email,
phone: values.phone,
company: values.company,
billingAddress: values.billingAddress,
taxId: values.taxId,
timezone: values.timezone,
language: values.language,
currency: values.currency,
avatar: values.avatar ? await base64ToFile(values.avatar) : null,
};
try {
await pb.collection(COL_CUSTOMERS).update(customerId, updateData);
toast.success('Customer updated successfully');
router.push(paths.dashboard.customers.list);
} catch (error) {
logger.error(error);
toast.error('Failed to update customer');
} finally {
setIsUpdating(false);
}
},
[customerId, router]
);
const avatarInputRef = React.useRef<HTMLInputElement>(null);
const avatar = watch('avatar');
const handleAvatarChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = await fileToBase64(file);
setValue('avatar', url);
}
},
[setValue]
);
// TODO: need to align with save form
// use trycatch
const [textDescription, setTextDescription] = React.useState<string>('');
const [textRemarks, setTextRemarks] = React.useState<string>('');
// load existing data when user arrive
const loadExistingData = React.useCallback(
async (id: string) => {
setShowLoading(true);
try {
const result = await pb.collection(COL_CUSTOMERS).getOne(id);
reset({ ...defaultValues, ...result });
console.log({ result });
if (result.avatar_file) {
const fetchResult = await fetch(
`http://127.0.0.1:8090/api/files/${result.collectionId}/${result.id}/${result.avatar_file}`
);
const blob = await fetchResult.blob();
const url = await fileToBase64(blob);
setValue('avatar', url);
}
} catch (error) {
logger.error(error);
toast.error('Failed to load customer data');
setShowError({ show: true, detail: JSON.stringify(error, null, 2) });
} finally {
setShowLoading(false);
}
},
[reset, setValue]
);
React.useEffect(() => {
void loadExistingData(customerId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customerId]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent>
<Stack
divider={<Divider />}
spacing={4}
>
<Stack spacing={3}>
<Typography variant="h6">{t('edit.basic-info')}</Typography>
<Grid
container
spacing={3}
>
<Grid xs={12}>
<Stack
direction="row"
spacing={3}
sx={{ alignItems: 'center' }}
>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: '5%',
display: 'inline-flex',
p: '4px',
}}
>
<Avatar
variant="rounded"
src={avatar}
sx={{
'--Avatar-size': '100px',
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
alignItems: 'center',
bgcolor: 'var(--mui-palette-background-level1)',
color: 'var(--mui-palette-text-primary)',
display: 'flex',
justifyContent: 'center',
}}
>
<CameraIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Box>
<Stack
spacing={1}
sx={{ alignItems: 'flex-start' }}
>
<Typography variant="subtitle1">{t('edit.avatar')}</Typography>
<Typography variant="caption">{t('edit.avatarRequirements')}</Typography>
<Button
color="secondary"
onClick={() => {
avatarInputRef.current?.click();
}}
variant="outlined"
>
{t('edit.avatar_select')}
</Button>
<input
hidden
onChange={handleAvatarChange}
ref={avatarInputRef}
type="file"
/>
</Stack>
</Stack>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="name"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
fullWidth
>
<InputLabel required>Name</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="email"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
fullWidth
>
<InputLabel required>Email</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput
{...field}
placeholder="no company name"
/>
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">Billing Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.country"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.country)}
fullWidth
>
<InputLabel required>Country</InputLabel>
<Select {...field}>
<MenuItem value="US">United States</MenuItem>
<MenuItem value="UK">United Kingdom</MenuItem>
<MenuItem value="CA">Canada</MenuItem>
</Select>
{errors.billingAddress?.country ? (
<FormHelperText>{errors.billingAddress.country.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.state"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.state)}
fullWidth
>
<InputLabel required>State</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.state ? (
<FormHelperText>{errors.billingAddress.state.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.city"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.city)}
fullWidth
>
<InputLabel required>City</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.city ? (
<FormHelperText>{errors.billingAddress.city.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.zipCode"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.zipCode)}
fullWidth
>
<InputLabel required>Zip Code</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.zipCode ? (
<FormHelperText>{errors.billingAddress.zipCode.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="billingAddress.line1"
render={({ field }) => (
<FormControl
error={Boolean(errors.billingAddress?.line1)}
fullWidth
>
<InputLabel required>Address Line 1</InputLabel>
<OutlinedInput {...field} />
{errors.billingAddress?.line1 ? (
<FormHelperText>{errors.billingAddress.line1.message}</FormHelperText>
) : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="taxId"
render={({ field }) => (
<FormControl
error={Boolean(errors.taxId)}
fullWidth
>
<InputLabel>Tax ID</InputLabel>
<OutlinedInput
{...field}
placeholder="no tax id..."
/>
{errors.taxId ? <FormHelperText>{errors.taxId.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
<Stack spacing={3}>
<Typography variant="h6">Additional Information</Typography>
<Grid
container
spacing={3}
>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="timezone"
render={({ field }) => (
<FormControl
error={Boolean(errors.timezone)}
fullWidth
>
<InputLabel required>Timezone</InputLabel>
<Select {...field}>
<MenuItem value="America/New_York">New York</MenuItem>
<MenuItem value="Europe/London">London</MenuItem>
<MenuItem value="Asia/Tokyo">Tokyo</MenuItem>
<MenuItem value="America/Boa_Vista">Boa Vista</MenuItem>
<MenuItem value="America/Grand_Turk">Grand Turk</MenuItem>
<MenuItem value="Asia/Manila">Manila</MenuItem>
<MenuItem value="Asia/Urumqi">Urumqi</MenuItem>
<MenuItem value="Africa/Tunis">Tunis</MenuItem>
</Select>
{errors.timezone ? <FormHelperText>{errors.timezone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="language"
render={({ field }) => (
<FormControl
error={Boolean(errors.language)}
fullWidth
>
<InputLabel required>Language</InputLabel>
<Select {...field}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="es">Spanish</MenuItem>
<MenuItem value="fr">French</MenuItem>
</Select>
{errors.language ? <FormHelperText>{errors.language.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="currency"
render={({ field }) => (
<FormControl
error={Boolean(errors.currency)}
fullWidth
>
<InputLabel required>Currency</InputLabel>
<Select {...field}>
<MenuItem value="USD">USD</MenuItem>
<MenuItem value="EUR">EUR</MenuItem>
<MenuItem value="GBP">GBP</MenuItem>
</Select>
{errors.currency ? <FormHelperText>{errors.currency.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.list}
>
{t('edit.cancelButton')}
</Button>
<LoadingButton
disabled={isUpdating}
loading={isUpdating}
type="submit"
variant="contained"
>
{t('edit.updateButton')}
</LoadingButton>
</CardActions>
</Card>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify({ errors }, null, 2)}</pre>
</Box>
</form>
);
}

View File

@@ -1,53 +1,72 @@
'use client'; 'use client';
// RULES:
// T.B.A.
//
import * as React from 'react'; import * as React from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { getAllCustomersCount } from '@/db/Customers/GetAllCount';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider'; 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 Select from '@mui/material/Select';
import type { SelectChangeEvent } from '@mui/material/Select'; import type { SelectChangeEvent } from '@mui/material/Select';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs'; import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { FilterButton, FilterPopover, useFilterContext } from '@/components/core/filter-button'; import { FilterButton } from '@/components/core/filter-button';
import { Option } from '@/components/core/option'; import { Option } from '@/components/core/option';
import { useCustomersSelection } from './customers-selection-context'; import { useCustomersSelection } from './customers-selection-context';
import GetBlockedCount from '@/db/Customers/GetBlockedCount';
import GetPendingCount from '@/db/Customers/GetPendingCount';
import GetActiveCount from '@/db/Customers/GetActiveCount';
import PhoneFilterPopover from './phone-filter-popover';
import EmailFilterPopover from './email-filter-popover';
import type { CustomersFiltersProps, Filters, SortDir } from './type.d';
// The tabs should be generated using API data. export function CustomersFilters({
const tabs = [ filters = {},
{ label: 'All', value: '', count: 5 }, sortDir = 'desc',
{ label: 'Active', value: 'active', count: 3 }, fullData,
{ label: 'Pending', value: 'pending', count: 1 }, }: CustomersFiltersProps): React.JSX.Element {
{ label: 'Blocked', value: 'blocked', count: 1 }, const { t } = useTranslation();
] 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 { email, phone, status } = filters;
const [totalCount, setTotalCount] = React.useState<number>(0);
const [activeCount, setActiveCount] = React.useState<number>(0);
const [pendingCount, setPendingCount] = React.useState<number>(0);
const [blockedCount, setBlockedCount] = React.useState<number>(0);
const router = useRouter(); const router = useRouter();
const selection = useCustomersSelection(); const selection = useCustomersSelection();
// function getVisible(): number {
// return fullData.reduce((count, item: CrQuestion) => {
// return item.visible === 'visible' ? count + 1 : count;
// }, 0);
// }
// function getHidden(): number {
// return fullData.reduce((count, item: CrQuestion) => {
// return item.visible === 'hidden' ? count + 1 : count;
// }, 0);
// }
// The tabs should be generated using API data.
const tabs = [
{ label: 'All', value: '', count: totalCount },
{ label: 'Active', value: 'active', count: activeCount },
{ label: 'Pending', value: 'pending', count: pendingCount },
{ label: 'Blocked', value: 'blocked', count: blockedCount },
] as const;
const updateSearchParams = React.useCallback( const updateSearchParams = React.useCallback(
(newFilters: Filters, newSortDir: SortDir): void => { (newFilters: Filters, newSortDir: SortDir): void => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@@ -107,12 +126,43 @@ export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFi
const hasFilters = status || email || phone; const hasFilters = status || email || phone;
React.useEffect(() => {
const fetchCount = async (): Promise<void> => {
try {
const tc = await getAllCustomersCount();
setTotalCount(tc);
const bc = await GetBlockedCount();
setBlockedCount(bc);
const pc = await GetPendingCount();
setPendingCount(pc);
const ac = await GetActiveCount();
setActiveCount(ac);
} catch (error) {
//
}
};
void fetchCount();
}, []);
return ( return (
<div> <div>
<Tabs onChange={handleStatusChange} sx={{ px: 3 }} value={status ?? ''} variant="scrollable"> <Tabs
onChange={handleStatusChange}
sx={{ px: 3 }}
value={status ?? ''}
variant="scrollable"
//
>
{tabs.map((tab) => ( {tabs.map((tab) => (
<Tab <Tab
icon={<Chip label={tab.count} size="small" variant="soft" />} icon={
<Chip
label={tab.count}
size="small"
variant="soft"
/>
}
iconPosition="end" iconPosition="end"
key={tab.value} key={tab.value}
label={tab.label} label={tab.label}
@@ -123,8 +173,16 @@ export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFi
))} ))}
</Tabs> </Tabs>
<Divider /> <Divider />
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}> <Stack
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}> direction="row"
spacing={2}
sx={{ alignItems: 'center', flexWrap: 'wrap', px: 3, py: 2 }}
>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}
>
<FilterButton <FilterButton
displayValue={email} displayValue={email}
label="Email" label="Email"
@@ -137,6 +195,7 @@ export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFi
popover={<EmailFilterPopover />} popover={<EmailFilterPopover />}
value={email} value={email}
/> />
<FilterButton <FilterButton
displayValue={phone} displayValue={phone}
label="Phone number" label="Phone number"
@@ -149,19 +208,35 @@ export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFi
popover={<PhoneFilterPopover />} popover={<PhoneFilterPopover />}
value={phone} value={phone}
/> />
{hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null} {hasFilters ? <Button onClick={handleClearFilters}>Clear filters</Button> : null}
</Stack> </Stack>
{selection.selectedAny ? ( {selection.selectedAny ? (
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}> <Stack
<Typography color="text.secondary" variant="body2"> direction="row"
spacing={2}
sx={{ alignItems: 'center' }}
>
<Typography
color="text.secondary"
variant="body2"
>
{selection.selected.size} selected {selection.selected.size} selected
</Typography> </Typography>
<Button color="error" variant="contained"> <Button
color="error"
variant="contained"
>
Delete Delete
</Button> </Button>
</Stack> </Stack>
) : null} ) : null}
<Select name="sort" onChange={handleSortChange} sx={{ maxWidth: '100%', width: '120px' }} value={sortDir}> <Select
name="sort"
onChange={handleSortChange}
sx={{ maxWidth: '100%', width: '120px' }}
value={sortDir}
>
<Option value="desc">Newest</Option> <Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option> <Option value="asc">Oldest</Option>
</Select> </Select>
@@ -169,73 +244,3 @@ export function CustomersFilters({ filters = {}, sortDir = 'desc' }: CustomersFi
</div> </div>
); );
} }
function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by email">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}
function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover anchorEl={anchorEl} onClose={onClose} open={open} title="Filter by phone number">
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -10,20 +10,39 @@ function noop(): void {
interface CustomersPaginationProps { interface CustomersPaginationProps {
count: number; count: number;
page: number; page: number;
//
setPage: (page: number) => void;
setRowsPerPage: (page: number) => void;
rowsPerPage: number;
} }
export function CustomersPagination({ count, page }: CustomersPaginationProps): React.JSX.Element { export function CustomersPagination({
count,
page,
//
setPage,
setRowsPerPage,
rowsPerPage,
}: CustomersPaginationProps): React.JSX.Element {
// You should implement the pagination using a similar logic as the filters. // You should implement the pagination using a similar logic as the filters.
// Note that when page change, you should keep the filter search params. // Note that when page change, you should keep the filter search params.
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value));
// console.log(parseInt(event.target.value));
};
return ( return (
<TablePagination <TablePagination
component="div" component="div"
count={count} count={count}
onPageChange={noop} onPageChange={handleChangePage}
onRowsPerPageChange={noop} onRowsPerPageChange={handleChangeRowsPerPage}
page={page} page={page}
rowsPerPage={5} rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 25]} rowsPerPageOptions={[5, 10, 25]}
// //
/> />

View File

@@ -5,7 +5,7 @@ import * as React from 'react';
import { useSelection } from '@/hooks/use-selection'; import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection'; import type { Selection } from '@/hooks/use-selection';
import type { Customer } from './customers-table'; import type { Customer } from './type.d';
function noop(): void { function noop(): void {
return undefined; return undefined;

View File

@@ -2,8 +2,10 @@
import * as React from 'react'; import * as React from 'react';
import RouterLink from 'next/link'; import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress'; import LinearProgress from '@mui/material/LinearProgress';
@@ -12,28 +14,24 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle'; import { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock'; 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 { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple'; import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { paths } from '@/paths'; import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs'; import { dayjs } from '@/lib/dayjs';
import { DataTable } from '@/components/core/data-table'; import { DataTable } from '@/components/core/data-table';
import type { ColumnDef } from '@/components/core/data-table'; import type { ColumnDef } from '@/components/core/data-table';
import ConfirmDeleteModal from './confirm-delete-modal';
import { useCustomersSelection } from './customers-selection-context'; import { useCustomersSelection } from './customers-selection-context';
import type { Customer } from './type.d';
export interface Customer { function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Customer>[] {
id: string; return [
name: string;
avatar?: string;
email: string;
phone?: string;
quota: number;
status: 'pending' | 'active' | 'blocked';
createdAt: Date;
}
const columns = [
{ {
formatter: (row): React.JSX.Element => ( formatter: (row): React.JSX.Element => (
<Stack <Stack
@@ -46,7 +44,7 @@ const columns = [
<Link <Link
color="inherit" color="inherit"
component={RouterLink} component={RouterLink}
href={paths.dashboard.customers.details('1')} href={paths.dashboard.customers.details(row.id)}
sx={{ whiteSpace: 'nowrap' }} sx={{ whiteSpace: 'nowrap' }}
variant="subtitle2" variant="subtitle2"
> >
@@ -85,18 +83,14 @@ const columns = [
</Stack> </Stack>
), ),
name: 'Quota', name: 'Quota',
width: '250px', width: '150px',
}, },
{ field: 'phone', name: 'Phone number', width: '150px' }, { 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 => { formatter: (row): React.JSX.Element => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const mapping = { const mapping = {
active: { active: {
label: 'Active', label: 'Active',
@@ -133,32 +127,72 @@ const columns = [
width: '150px', width: '150px',
}, },
{ {
formatter: (): React.JSX.Element => ( formatter(row) {
<IconButton return dayjs(row.createdAt).format('MMM D, YYYY');
component={RouterLink} },
href={paths.dashboard.customers.details('1')} name: 'Created at',
width: '150px',
},
{
formatter: (row): React.JSX.Element => (
<Stack
direction="row"
spacing={1}
> >
<PencilSimpleIcon /> <LoadingButton
</IconButton> //
color="secondary"
component={RouterLink}
href={paths.dashboard.customers.details(row.id)}
>
<PencilSimpleIcon size={24} />
</LoadingButton>
<LoadingButton
color="error"
// TODO: originally it is row.isEmpty
disabled={false}
onClick={() => {
handleDeleteClick(row.id);
}}
>
<TrashSimpleIcon size={24} />
</LoadingButton>
</Stack>
), ),
name: 'Actions', name: 'Actions',
hideName: true, hideName: true,
width: '100px',
align: 'right', align: 'right',
}, },
] satisfies ColumnDef<Customer>[]; ];
}
export interface CustomersTableProps { export interface CustomersTableProps {
rows: Customer[]; rows: Customer[];
reloadRows: () => void;
} }
export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element { export function CustomersTable({ rows, reloadRows }: CustomersTableProps): React.JSX.Element {
const { t } = useTranslation(['customers']);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection(); const { deselectAll, deselectOne, selectAll, selectOne, selected } = useCustomersSelection();
const [idToDelete, setIdToDelete] = React.useState('');
const [open, setOpen] = React.useState(false);
function handleDeleteClick(testId: string): void {
setOpen(true);
setIdToDelete(testId);
}
return ( return (
<React.Fragment> <React.Fragment>
<ConfirmDeleteModal
idToDelete={idToDelete}
open={open}
reloadRows={reloadRows}
setOpen={setOpen}
/>
<DataTable<Customer> <DataTable<Customer>
columns={columns} columns={columns(handleDeleteClick)}
onDeselectAll={deselectAll} onDeselectAll={deselectAll}
onDeselectOne={(_, row) => { onDeselectOne={(_, row) => {
deselectOne(row.id); deselectOne(row.id);
@@ -178,7 +212,8 @@ export function CustomersTable({ rows }: CustomersTableProps): React.JSX.Element
sx={{ textAlign: 'center' }} sx={{ textAlign: 'center' }}
variant="body2" variant="body2"
> >
No customers found {/* TODO: update this */}
{t('no-record-found')}
</Typography> </Typography>
</Box> </Box>
) : null} ) : null}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import OutlinedInput from '@mui/material/OutlinedInput';
import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
// EmailFilterPopover -> email-filter-popover.tsx
export default function EmailFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title="Filter by email"
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import OutlinedInput from '@mui/material/OutlinedInput';
import { FilterPopover, useFilterContext } from '@/components/core/filter-button';
// phone-filter-popover.tsx
export default function PhoneFilterPopover(): React.JSX.Element {
const { anchorEl, onApply, onClose, open, value: initialValue } = useFilterContext();
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
setValue((initialValue as string | undefined) ?? '');
}, [initialValue]);
return (
<FilterPopover
anchorEl={anchorEl}
onClose={onClose}
open={open}
title="Filter by phone number"
>
<FormControl>
<OutlinedInput
onChange={(event) => {
setValue(event.target.value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onApply(value);
}
}}
value={value}
/>
</FormControl>
<Button
onClick={() => {
onApply(value);
}}
variant="contained"
>
Apply
</Button>
</FilterPopover>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
export type SortDir = 'asc' | 'desc';
export interface Customer {
id: string;
name: string;
avatar?: string;
email: string;
phone?: string;
quota: number;
status: 'pending' | 'active' | 'blocked';
createdAt: Date;
updatedAt?: Date;
}
export interface CreateFormProps {
name: string;
email: string;
phone?: string;
company?: string;
billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
export interface EditFormProps {
name: string;
email: string;
phone?: string;
company?: string;
billingAddress?: {
country: string;
state: string;
city: string;
zipCode: string;
line1: string;
line2?: string;
};
taxId?: string;
timezone: string;
language: string;
currency: string;
avatar?: string;
// quota?: number;
// status?: 'pending' | 'active' | 'blocked';
}
export interface CustomersFiltersProps {
filters?: Filters;
sortDir?: SortDir;
fullData: Customer[];
}
export interface Filters {
email?: string;
phone?: string;
status?: string;
}

View File

@@ -3,27 +3,30 @@
// e.g. COL_APPLE = "Apple" table in dbml // e.g. COL_APPLE = "Apple" table in dbml
const COL_LESSON_TYPES = 'LessonsTypes'; const COL_LESSON_TYPES = 'LessonsTypes';
const COL_LESSON_CATEGORIES = 'LessonsCategories'; const COL_LESSON_CATEGORIES = 'LessonsCategories';
const NO_VALUE = 'NO_VALUE';
const NO_NUM = -Infinity;
const NS_LESSON_CATEGORY = 'lesson_category';
const COL_USERS = 'users'; const COL_USERS = 'users';
const COL_USER_METAS = 'UserMetas'; const COL_USER_METAS = 'UserMetas';
//
const COL_CUSTOMERS = 'Customers';
// RULES: // RULES:
// do not use LP_CATEGORIES anymore // do not use LP_CATEGORIES anymore
// LP, listening practice
const COL_QUIZ_LP_CATEGORIES = 'QuizLPCategories'; const COL_QUIZ_LP_CATEGORIES = 'QuizLPCategories';
const COL_QUIZ_LP_QUESTIONS = 'QuizLPQuestions'; const COL_QUIZ_LP_QUESTIONS = 'QuizLPQuestions';
// // MF, matching frenzy
const COL_QUIZ_MF_CATEGORIES = 'QuizMFCategories'; const COL_QUIZ_MF_CATEGORIES = 'QuizMFCategories';
const COL_QUIZ_MF_QUESTIONS = 'QuizMFQuestions'; const COL_QUIZ_MF_QUESTIONS = 'QuizMFQuestions';
// CR, connective revision
// New CR versions
const COL_QUIZ_CR_CATEGORIES = 'QuizCRCategories'; const COL_QUIZ_CR_CATEGORIES = 'QuizCRCategories';
const COL_QUIZ_CR_QUESTIONS = 'QuizCRQuestions'; const COL_QUIZ_CR_QUESTIONS = 'QuizCRQuestions';
//
// ROLES:
const COL_CUSTOMERS = 'Customers';
const COL_TEACHERS = 'Teachers';
const COL_STUDENTS = 'Students';
// FOR page display
const NO_VALUE = 'NO_VALUE';
const NO_NUM = -Infinity;
const NS_LESSON_CATEGORY = 'lesson_category';
export { export {
COL_LESSON_TYPES, COL_LESSON_TYPES,
@@ -44,5 +47,7 @@ export {
COL_QUIZ_CR_QUESTIONS, COL_QUIZ_CR_QUESTIONS,
// //
COL_CUSTOMERS, COL_CUSTOMERS,
COL_TEACHERS,
COL_STUDENTS,
// //
}; };

View File

@@ -1,6 +1,11 @@
// api method for crate customer record
// RULES:
// TBA
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
import type { CreateFormProps } from '@/components/dashboard/customer/type.d';
import type { RecordModel } from 'pocketbase';
export async function createCustomer(data) { export async function createCustomer(data: CreateFormProps): Promise<RecordModel> {
return pb.collection(COL_CUSTOMERS).create(data); return pb.collection(COL_CUSTOMERS).create(data);
} }

View File

@@ -1,6 +1,6 @@
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
export async function deleteCustomer(id) { export async function deleteCustomer(id: string): Promise<boolean> {
return pb.collection(COL_CUSTOMERS).delete(id); return pb.collection(COL_CUSTOMERS).delete(id);
} }

View File

@@ -1,6 +1,7 @@
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getAllCustomers(options = {}) { export async function getAllCustomers(options = {}): Promise<RecordModel[]> {
return pb.collection(COL_CUSTOMERS).getFullList(options); return pb.collection(COL_CUSTOMERS).getFullList(options);
} }

View File

@@ -1,7 +1,7 @@
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
export async function getAllCustomersCount() { export async function getAllCustomersCount(): Promise<number> {
const result = await pb.collection(COL_CUSTOMERS).getList(1, 1); const result = await pb.collection(COL_CUSTOMERS).getList(1, 1);
return result.totalItems; return result.totalItems;
} }

View File

@@ -1,6 +1,7 @@
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
import { RecordModel } from 'pocketbase';
export async function getCustomerById(id) { export async function getCustomerById(id: string): Promise<RecordModel> {
return pb.collection(COL_CUSTOMERS).getOne(id); return pb.collection(COL_CUSTOMERS).getOne(id);
} }

View File

@@ -1,9 +0,0 @@
import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants';
export async function getHiddenCustomersCount() {
const result = await pb.collection(COL_CUSTOMERS).getList(1, 1, {
filter: 'hidden = true',
});
return result.totalItems;
}

View File

@@ -1,9 +0,0 @@
import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants';
export async function getVisibleCustomersCount() {
const result = await pb.collection(COL_CUSTOMERS).getList(1, 1, {
filter: 'hidden = false',
});
return result.totalItems;
}

View File

@@ -1,8 +1,8 @@
import { pb } from '@/lib/pb'; import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants'; import { COL_CUSTOMERS } from '@/constants';
import type { RecordModel } from 'pocketbase'; import type { RecordModel } from 'pocketbase';
import type { CreateForm } from '@/components/dashboard/customer/type.d'; import type { EditFormProps } from '@/components/dashboard/customer/type.d';
export async function updateCustomer(id: string, data: Partial<CustomerUpdateData>): Promise<RecordModel> { export async function updateCustomer(id: string, data: Partial<EditFormProps>): Promise<RecordModel> {
return pb.collection(COL_CUSTOMERS).update(id, data); return pb.collection(COL_CUSTOMERS).update(id, data);
} }

View File

@@ -0,0 +1,31 @@
# GUIDELINES
This folder contains drivers for `Customer`/`Customers` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx, GetActiveCount.tsx, GetBlockedCount.tsx, GetPendingCount.tsx)
- misc (Helloworld.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/Customers/type.d.tsx`
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_CUSTOMERS } from '@/constants';
export async function createCustomer(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}
```

View File

@@ -0,0 +1,33 @@
# GUIDELINES
This folder contains drivers for `Event`/`Events` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/Events/type.d.tsx`
- Event records require special handling for:
- Date/time validation
- Location data
- Attendee management
simple template:
```typescript
import { pb } from '@/lib/pb';
export async function createEvent(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection('Events'))
}
```

View File

@@ -0,0 +1,30 @@
# GUIDELINES
This folder contains test drivers for `Helloworld` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function
- type information defined in `@/db/Helloworlds/type.d.tsx`
- This is a test collection - keep implementations simple
simple template:
```typescript
import { pb } from '@/lib/pb';
export async function createHelloworld(data: CreateFormProps) {
// Simple test implementation
return pb.collection('Helloworlds').create(data);
}
```

View File

@@ -0,0 +1,30 @@
# GUIDELINES
This folder contains drivers for `LessonCategory`/`LessonCategories` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/LessonCategories/type.d.tsx`
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_LESSON_CATEGORIES } from '@/constants';
export async function createLessonCategory(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}
```

View File

@@ -0,0 +1,30 @@
# GUIDELINES
This folder contains drivers for `LessonType`/`LessonTypes` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/LessonTypes/type.d.tsx`
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_LESSON_TYPES } from '@/constants';
export async function createLessonType(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}
```

View File

@@ -0,0 +1,33 @@
# GUIDELINES
This folder contains drivers for `Message`/`Messages` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/Messages/type.d.tsx`
- Message records require special handling for:
- Sender/receiver validation
- Timestamp management
- Read status tracking
simple template:
```typescript
import { pb } from '@/lib/pb';
export async function createMessage(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection('Messages'))
}
```

View File

@@ -0,0 +1,31 @@
# GUIDELINES
This folder contains drivers for `QuizCRCategory`/`QuizCRCategories` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/QuizCRCategories/type.d.tsx`
- Quiz categories may require additional validation logic
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_QUIZ_CR_CATEGORIES } from '@/constants';
export async function createQuizCRCategory(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(xxx))
}
```

View File

@@ -0,0 +1,31 @@
# GUIDELINES
This folder contains drivers for `QuizLPCategory` records using PocketBase:
- create (Create.tsx)
- read (GetById.tsx)
- write (Update.tsx)
- count (GetAllCount.tsx)
- delete (Delete.tsx)
- list (GetAll.tsx)
the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src`
## Assumption and Requirements
- assume `pb` is located in `@/lib/pb`
- no need to handle error in this function, i'll handle it in the caller
- type information defined in `@/db/QuizLPCategories/type.d.tsx`
- Quiz LP categories may require additional validation logic
simple template:
```typescript
import { pb } from '@/lib/pb';
import { COL_QUIZ_LP_CATEGORIES } from '@/constants';
export async function createQuizLPCategory(data: CreateFormProps) {
// ...content
// use direct return of pb.collection (e.g. return pb.collection(COL_QUIZ_LP_CATEGORIES))
}
```

View File

@@ -1,18 +0,0 @@
please help to review the `tsx` file in this folder
`/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/QuizLPCategories`
it was clone from
`MFCategories`
please help to modify to
`LPCategories`
please also help to modify the name of
`variables`, `constants`, `functions`, `classes`, components's name, paths
the db fields structures between them are the same
do not move the files
do not create directories
keep current folder structure is important
thanks

Some files were not shown because too many files have changed in this diff Show More