"add PartyEvent frontend module with mock data, API actions, UI components and routing configuration"
This commit is contained in:
70
03_source/frontend/src/_mock/_party-event.ts
Normal file
70
03_source/frontend/src/_mock/_party-event.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export const PRODUCT_GENDER_OPTIONS = [
|
||||
{ label: 'Men', value: 'Men' },
|
||||
{ label: 'Women', value: 'Women' },
|
||||
{ label: 'Kids', value: 'Kids' },
|
||||
];
|
||||
|
||||
export const PRODUCT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories'];
|
||||
|
||||
export const PRODUCT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star'];
|
||||
|
||||
export const PRODUCT_COLOR_OPTIONS = [
|
||||
'#FF4842',
|
||||
'#1890FF',
|
||||
'#FFC0CB',
|
||||
'#00AB55',
|
||||
'#FFC107',
|
||||
'#7F00FF',
|
||||
'#000000',
|
||||
'#FFFFFF',
|
||||
];
|
||||
|
||||
export const PRODUCT_COLOR_NAME_OPTIONS = [
|
||||
{ value: '#FF4842', label: 'Red' },
|
||||
{ value: '#1890FF', label: 'Blue' },
|
||||
{ value: '#FFC0CB', label: 'Pink' },
|
||||
{ value: '#00AB55', label: 'Green' },
|
||||
{ value: '#FFC107', label: 'Yellow' },
|
||||
{ value: '#7F00FF', label: 'Violet' },
|
||||
{ value: '#000000', label: 'Black' },
|
||||
{ value: '#FFFFFF', label: 'White' },
|
||||
];
|
||||
|
||||
export const PRODUCT_SIZE_OPTIONS = [
|
||||
{ value: '7', label: '7' },
|
||||
{ value: '8', label: '8' },
|
||||
{ value: '8.5', label: '8.5' },
|
||||
{ value: '9', label: '9' },
|
||||
{ value: '9.5', label: '9.5' },
|
||||
{ value: '10', label: '10' },
|
||||
{ value: '10.5', label: '10.5' },
|
||||
{ value: '11', label: '11' },
|
||||
{ value: '11.5', label: '11.5' },
|
||||
{ value: '12', label: '12' },
|
||||
{ value: '13', label: '13' },
|
||||
];
|
||||
|
||||
export const PRODUCT_STOCK_OPTIONS = [
|
||||
{ value: 'in stock', label: 'In stock' },
|
||||
{ value: 'low stock', label: 'Low stock' },
|
||||
{ value: 'out of stock', label: 'Out of stock' },
|
||||
];
|
||||
|
||||
// not used due to i18n
|
||||
export const PRODUCT_PUBLISH_OPTIONS = [
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
];
|
||||
|
||||
export const PRODUCT_SORT_OPTIONS = [
|
||||
{ value: 'featured', label: 'Featured' },
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'priceDesc', label: 'Price: High - Low' },
|
||||
{ value: 'priceAsc', label: 'Price: Low - High' },
|
||||
];
|
||||
|
||||
export const PRODUCT_CATEGORY_GROUP_OPTIONS = [
|
||||
{ group: 'Clothing', classify: ['Shirts', 'T-shirts', 'Jeans', 'Leather', 'Accessories'] },
|
||||
{ group: 'Tailored', classify: ['Suits', 'Blazers', 'Trousers', 'Waistcoats', 'Apparel'] },
|
||||
{ group: 'Accessories', classify: ['Shoes', 'Backpacks and bags', 'Bracelets', 'Face masks'] },
|
||||
];
|
175
03_source/frontend/src/actions/party-event.ts
Normal file
175
03_source/frontend/src/actions/party-event.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// src/actions/product.ts
|
||||
//
|
||||
import { useMemo } from 'react';
|
||||
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import type { SWRConfiguration } from 'swr';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const swrOptions: SWRConfiguration = {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ProductsData = {
|
||||
products: IProductItem[];
|
||||
};
|
||||
|
||||
export function useGetProducts() {
|
||||
const url = endpoints.product.list;
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<ProductsData>(url, fetcher, swrOptions);
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
products: data?.products || [],
|
||||
productsLoading: isLoading,
|
||||
productsError: error,
|
||||
productsValidating: isValidating,
|
||||
productsEmpty: !isLoading && !isValidating && !data?.products.length,
|
||||
}),
|
||||
[data?.products, error, isLoading, isValidating]
|
||||
);
|
||||
|
||||
return memoizedValue;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ProductData = {
|
||||
product: IProductItem;
|
||||
};
|
||||
|
||||
export function useGetPartyEvent(productId: string) {
|
||||
const url = productId ? [endpoints.product.details, { params: { productId } }] : '';
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<ProductData>(url, fetcher, swrOptions);
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
partyEvent: data?.product,
|
||||
partyEventLoading: isLoading,
|
||||
partyEventError: error,
|
||||
partyEventValidating: isValidating,
|
||||
mutate,
|
||||
}),
|
||||
[data?.product, error, isLoading, isValidating]
|
||||
);
|
||||
|
||||
return memoizedValue;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type SearchResultsData = {
|
||||
results: IProductItem[];
|
||||
};
|
||||
|
||||
export function useSearchProducts(query: string) {
|
||||
const url = query ? [endpoints.product.search, { params: { query } }] : '';
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<SearchResultsData>(url, fetcher, {
|
||||
...swrOptions,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
searchResults: data?.results || [],
|
||||
searchLoading: isLoading,
|
||||
searchError: error,
|
||||
searchValidating: isValidating,
|
||||
searchEmpty: !isLoading && !isValidating && !data?.results.length,
|
||||
}),
|
||||
[data?.results, error, isLoading, isValidating]
|
||||
);
|
||||
|
||||
return memoizedValue;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function createProduct(productData: IProductItem) {
|
||||
/**
|
||||
* Work on server
|
||||
*/
|
||||
const data = { productData };
|
||||
const {
|
||||
data: { id },
|
||||
} = await axiosInstance.post(endpoints.product.create, data);
|
||||
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
mutate(
|
||||
endpoints.product.list,
|
||||
(currentData: any) => {
|
||||
const currentProducts: IProductItem[] = currentData?.products;
|
||||
|
||||
const products = [...currentProducts, { ...productData, id }];
|
||||
|
||||
return { ...currentData, products };
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function updateProduct(productData: Partial<IProductItem>) {
|
||||
/**
|
||||
* Work on server
|
||||
*/
|
||||
const data = { productData };
|
||||
await axiosInstance.put(endpoints.product.update, data);
|
||||
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
|
||||
mutate(
|
||||
endpoints.product.list,
|
||||
(currentData: any) => {
|
||||
const currentProducts: IProductItem[] = currentData?.products;
|
||||
|
||||
const products = currentProducts.map((product) =>
|
||||
product.id === productData.id ? { ...product, ...productData } : product
|
||||
);
|
||||
|
||||
return { ...currentData, products };
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function deleteProduct(productId: string) {
|
||||
/**
|
||||
* Work on server
|
||||
*/
|
||||
const data = { productId };
|
||||
await axiosInstance.patch(endpoints.product.delete, data);
|
||||
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
|
||||
mutate(
|
||||
endpoints.product.list,
|
||||
(currentData: any) => {
|
||||
console.log({ currentData });
|
||||
const currentProducts: IProductItem[] = currentData?.products;
|
||||
|
||||
const products = currentProducts.filter((product) => product.id !== productId);
|
||||
|
||||
return { ...currentData, products };
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
@@ -91,6 +91,17 @@ export const navData: NavSectionProps['data'] = [
|
||||
{ title: 'Account', path: paths.dashboard.user.account },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'party-event',
|
||||
path: paths.dashboard.partyEvent.root,
|
||||
icon: ICONS.product,
|
||||
children: [
|
||||
{ title: 'List', path: paths.dashboard.partyEvent.root },
|
||||
{ title: 'Details', path: paths.dashboard.partyEvent.demo.details },
|
||||
{ title: 'Create', path: paths.dashboard.partyEvent.new },
|
||||
{ title: 'Edit', path: paths.dashboard.partyEvent.demo.edit },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Product',
|
||||
path: paths.dashboard.product.root,
|
||||
|
@@ -0,0 +1,28 @@
|
||||
// src/pages/dashboard/party-event/details.tsx
|
||||
|
||||
import { useGetPartyEvent } from 'src/actions/party-event';
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { useParams } from 'src/routes/hooks';
|
||||
import { PartyEventDetailsView } from 'src/sections/party-event/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `PartyEvent details | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
const { partyEvent, partyEventLoading, partyEventError } = useGetPartyEvent(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<PartyEventDetailsView
|
||||
product={partyEvent}
|
||||
loading={partyEventLoading}
|
||||
error={partyEventError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
22
03_source/frontend/src/pages/dashboard/party-event/edit.tsx
Normal file
22
03_source/frontend/src/pages/dashboard/party-event/edit.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useGetPartyEvent } from 'src/actions/party-event';
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { useParams } from 'src/routes/hooks';
|
||||
import { PartyEventEditView } from 'src/sections/party-event/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `PartyEvent edit | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
const { partyEvent } = useGetPartyEvent(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<PartyEventEditView product={partyEvent} />
|
||||
</>
|
||||
);
|
||||
}
|
18
03_source/frontend/src/pages/dashboard/party-event/list.tsx
Normal file
18
03_source/frontend/src/pages/dashboard/party-event/list.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/pages/dashboard/party-event/list.tsx
|
||||
//
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { PartyEventListView } from 'src/sections/party-event/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `PartyEvent list | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<PartyEventListView />
|
||||
</>
|
||||
);
|
||||
}
|
16
03_source/frontend/src/pages/dashboard/party-event/new.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-event/new.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { PartyEventCreateView } from 'src/sections/party-event/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Create a new party-event | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<PartyEventCreateView />
|
||||
</>
|
||||
);
|
||||
}
|
16
03_source/frontend/src/pages/party-event/checkout.tsx
Normal file
16
03_source/frontend/src/pages/party-event/checkout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { CheckoutView } from 'src/sections/checkout/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Checkout - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<CheckoutView />
|
||||
</>
|
||||
);
|
||||
}
|
22
03_source/frontend/src/pages/party-event/details.tsx
Normal file
22
03_source/frontend/src/pages/party-event/details.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useGetProduct } from 'src/actions/product';
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { useParams } from 'src/routes/hooks';
|
||||
import { ProductShopDetailsView } from 'src/sections/product/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Product details - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
const { product, productLoading, productError } = useGetProduct(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<ProductShopDetailsView product={product} loading={productLoading} error={productError} />
|
||||
</>
|
||||
);
|
||||
}
|
5
03_source/frontend/src/pages/party-event/helloworld.tsx
Normal file
5
03_source/frontend/src/pages/party-event/helloworld.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
function Helloworld() {
|
||||
return <>helloworld</>;
|
||||
}
|
||||
|
||||
export default Helloworld;
|
19
03_source/frontend/src/pages/party-event/list.tsx
Normal file
19
03_source/frontend/src/pages/party-event/list.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useGetProducts } from 'src/actions/product';
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { ProductShopView } from 'src/sections/product/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Product shop - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
const { products, productsLoading } = useGetProducts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<ProductShopView products={products} loading={productsLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -171,5 +171,16 @@ export const paths = {
|
||||
edit: `${ROOTS.DASHBOARD}/tour/${MOCK_ID}/edit`,
|
||||
},
|
||||
},
|
||||
//
|
||||
partyEvent: {
|
||||
root: `${ROOTS.DASHBOARD}/party-event`,
|
||||
new: `${ROOTS.DASHBOARD}/party-event/new`,
|
||||
details: (id: string) => `${ROOTS.DASHBOARD}/party-event/${id}`,
|
||||
edit: (id: string) => `${ROOTS.DASHBOARD}/party-event/${id}/edit`,
|
||||
demo: {
|
||||
details: `${ROOTS.DASHBOARD}/party-event/${MOCK_ID}`,
|
||||
edit: `${ROOTS.DASHBOARD}/party-event/${MOCK_ID}/edit`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -1,3 +1,5 @@
|
||||
// src/routes/sections/dashboard.tsx
|
||||
//
|
||||
import { lazy, Suspense } from 'react';
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { Outlet } from 'react-router';
|
||||
@@ -75,6 +77,12 @@ const PermissionDeniedPage = lazy(() => import('src/pages/dashboard/permission')
|
||||
const ParamsPage = lazy(() => import('src/pages/dashboard/params'));
|
||||
const BlankPage = lazy(() => import('src/pages/dashboard/blank'));
|
||||
|
||||
// PartyEvent
|
||||
const PartyEventDetailsPage = lazy(() => import('src/pages/dashboard/party-event/details'));
|
||||
const PartyEventListPage = lazy(() => import('src/pages/dashboard/party-event/list'));
|
||||
const PartyEventCreatePage = lazy(() => import('src/pages/dashboard/party-event/new'));
|
||||
const PartyEventEditPage = lazy(() => import('src/pages/dashboard/party-event/edit'));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function SuspenseOutlet() {
|
||||
@@ -198,6 +206,17 @@ export const dashboardRoutes: RouteObject[] = [
|
||||
{ path: 'permission', element: <PermissionDeniedPage /> },
|
||||
{ path: 'params', element: <ParamsPage /> },
|
||||
{ path: 'blank', element: <BlankPage /> },
|
||||
//
|
||||
{
|
||||
path: 'party-event',
|
||||
children: [
|
||||
{ index: true, element: <PartyEventListPage /> },
|
||||
{ path: 'list', element: <PartyEventListPage /> },
|
||||
{ path: ':id', element: <PartyEventDetailsPage /> },
|
||||
{ path: 'new', element: <PartyEventCreatePage /> },
|
||||
{ path: ':id/edit', element: <PartyEventEditPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@@ -27,6 +27,10 @@ const Page403 = lazy(() => import('src/pages/error/403'));
|
||||
const Page404 = lazy(() => import('src/pages/error/404'));
|
||||
// Blank
|
||||
const BlankPage = lazy(() => import('src/pages/blank'));
|
||||
// PartyEvent
|
||||
const PartyEventDetailsPage = lazy(() => import('src/pages/dashboard/party-event/details'));
|
||||
const PartyEventListPage = lazy(() => import('src/pages/dashboard/party-event/list'));
|
||||
const PartyEventCheckoutPage = lazy(() => import('src/pages/party-event/checkout'));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -58,6 +62,15 @@ export const mainRoutes: RouteObject[] = [
|
||||
{ path: 'checkout', element: <ProductCheckoutPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'party-event',
|
||||
children: [
|
||||
{ index: true, element: <PartyEventListPage /> },
|
||||
{ path: 'list', element: <PartyEventListPage /> },
|
||||
{ path: ':id', element: <PartyEventDetailsPage /> },
|
||||
{ path: 'checkout', element: <PartyEventCheckoutPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'post',
|
||||
children: [
|
||||
|
45
03_source/frontend/src/sections/party-event/cart-icon.tsx
Normal file
45
03_source/frontend/src/sections/party-event/cart-icon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Badge from '@mui/material/Badge';
|
||||
import type { BoxProps } from '@mui/material/Box';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps<'a'> & {
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
export function CartIcon({ totalItems, sx, ...other }: Props) {
|
||||
return (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
href={paths.product.checkout}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
right: 0,
|
||||
top: 112,
|
||||
zIndex: 999,
|
||||
display: 'flex',
|
||||
cursor: 'pointer',
|
||||
position: 'fixed',
|
||||
color: 'text.primary',
|
||||
borderTopLeftRadius: 16,
|
||||
borderBottomLeftRadius: 16,
|
||||
bgcolor: 'background.paper',
|
||||
padding: theme.spacing(1, 3, 1, 2),
|
||||
boxShadow: theme.vars.customShadows.dropdown,
|
||||
transition: theme.transitions.create(['opacity']),
|
||||
'&:hover': { opacity: 0.72 },
|
||||
}),
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
>
|
||||
<Badge showZero badgeContent={totalItems} color="error" max={99}>
|
||||
<Iconify icon="solar:cart-3-bold" width={24} />
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,86 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselArrowNumberButtons,
|
||||
CarouselThumb,
|
||||
CarouselThumbs,
|
||||
useCarousel,
|
||||
} from 'src/components/carousel';
|
||||
import { Image } from 'src/components/image';
|
||||
import { Lightbox, useLightBox } from 'src/components/lightbox';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
images?: IProductItem['images'];
|
||||
};
|
||||
|
||||
export function ProductDetailsCarousel({ images }: Props) {
|
||||
const carousel = useCarousel({ thumbs: { slidesToShow: 'auto' } });
|
||||
|
||||
const slides = images?.map((img) => ({ src: img })) || [];
|
||||
|
||||
const lightbox = useLightBox(slides);
|
||||
|
||||
useEffect(() => {
|
||||
if (lightbox.open) {
|
||||
carousel.mainApi?.scrollTo(lightbox.selected, true);
|
||||
}
|
||||
}, [carousel.mainApi, lightbox.open, lightbox.selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Box sx={{ mb: 2.5, position: 'relative' }}>
|
||||
<CarouselArrowNumberButtons
|
||||
{...carousel.arrows}
|
||||
options={carousel.options}
|
||||
totalSlides={carousel.dots.dotCount}
|
||||
selectedIndex={carousel.dots.selectedIndex + 1}
|
||||
sx={{ right: 16, bottom: 16, position: 'absolute' }}
|
||||
/>
|
||||
|
||||
<Carousel carousel={carousel} sx={{ borderRadius: 2 }}>
|
||||
{slides.map((slide) => (
|
||||
<Image
|
||||
key={slide.src}
|
||||
alt={slide.src}
|
||||
src={slide.src}
|
||||
ratio="1/1"
|
||||
onClick={() => lightbox.onOpen(slide.src)}
|
||||
sx={{ cursor: 'zoom-in', minWidth: 320 }}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</Box>
|
||||
|
||||
<CarouselThumbs
|
||||
ref={carousel.thumbs.thumbsRef}
|
||||
options={carousel.options?.thumbs}
|
||||
slotProps={{ disableMask: true }}
|
||||
sx={{ width: 360 }}
|
||||
>
|
||||
{slides.map((item, index) => (
|
||||
<CarouselThumb
|
||||
key={item.src}
|
||||
index={index}
|
||||
src={item.src}
|
||||
selected={index === carousel.thumbs.selectedIndex}
|
||||
onClick={() => carousel.thumbs.onClickThumb(index)}
|
||||
/>
|
||||
))}
|
||||
</CarouselThumbs>
|
||||
</div>
|
||||
|
||||
<Lightbox
|
||||
index={lightbox.selected}
|
||||
slides={slides}
|
||||
open={lightbox.open}
|
||||
close={lightbox.onClose}
|
||||
onGetCurrentIndex={(index) => lightbox.setSelected(index)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Markdown } from 'src/components/markdown';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
description?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
};
|
||||
|
||||
export function ProductDetailsDescription({ description, sx }: Props) {
|
||||
return (
|
||||
<Markdown
|
||||
children={description}
|
||||
sx={[
|
||||
() => ({
|
||||
p: 3,
|
||||
'& p, li, ol, table': { typography: 'body2' },
|
||||
'& table': {
|
||||
mt: 2,
|
||||
maxWidth: 640,
|
||||
'& td': { px: 2 },
|
||||
'& td:first-of-type': { color: 'text.secondary' },
|
||||
'tbody tr:nth-of-type(odd)': { bgcolor: 'transparent' },
|
||||
},
|
||||
}),
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -0,0 +1,125 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { sumBy } from 'es-toolkit';
|
||||
import { useBoolean } from 'minimal-shared/hooks';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import type { IProductReview } from 'src/types/party-event';
|
||||
import { fShortenNumber } from 'src/utils/format-number';
|
||||
import { ProductReviewList } from './party-event-review-list';
|
||||
import { ProductReviewNewForm } from './party-event-review-new-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
totalRatings?: number;
|
||||
totalReviews?: number;
|
||||
reviews?: IProductReview[];
|
||||
ratings?: { name: string; starCount: number; reviewCount: number }[];
|
||||
};
|
||||
|
||||
export function ProductDetailsReview({
|
||||
totalRatings,
|
||||
totalReviews,
|
||||
ratings = [],
|
||||
reviews = [],
|
||||
}: Props) {
|
||||
const review = useBoolean();
|
||||
|
||||
const total = sumBy(ratings, (star) => star.starCount);
|
||||
|
||||
const renderSummary = () => (
|
||||
<Stack spacing={1} sx={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="subtitle2">Average rating</Typography>
|
||||
|
||||
<Typography variant="h2">
|
||||
{totalRatings}
|
||||
/5
|
||||
</Typography>
|
||||
|
||||
<Rating readOnly value={totalRatings} precision={0.1} />
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
({fShortenNumber(totalReviews)} reviews)
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderProgress = () => (
|
||||
<Stack
|
||||
spacing={1.5}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
py: 5,
|
||||
px: { xs: 3, md: 5 },
|
||||
borderLeft: { md: `dashed 1px ${theme.vars.palette.divider}` },
|
||||
borderRight: { md: `dashed 1px ${theme.vars.palette.divider}` },
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{ratings
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((rating) => (
|
||||
<Box key={rating.name} sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2" component="span" sx={{ width: 42 }}>
|
||||
{rating.name}
|
||||
</Typography>
|
||||
|
||||
<LinearProgress
|
||||
color="inherit"
|
||||
variant="determinate"
|
||||
value={(rating.starCount / total) * 100}
|
||||
sx={{ mx: 2, flexGrow: 1 }}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
sx={{ minWidth: 48, color: 'text.secondary' }}
|
||||
>
|
||||
{fShortenNumber(rating.reviewCount)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderReviewButton = () => (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Button
|
||||
size="large"
|
||||
variant="soft"
|
||||
color="inherit"
|
||||
onClick={review.onTrue}
|
||||
startIcon={<Iconify icon="solar:pen-bold" />}
|
||||
>
|
||||
Write your review
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
py: { xs: 5, md: 0 },
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{renderSummary()}
|
||||
{renderProgress()}
|
||||
{renderReviewButton()}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
<ProductReviewList reviews={reviews} />
|
||||
<ProductReviewNewForm open={review.value} onClose={review.onFalse} />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,321 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import { formHelperTextClasses } from '@mui/material/FormHelperText';
|
||||
import Link, { linkClasses } from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCallback } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { ColorPicker } from 'src/components/color-utils';
|
||||
import { Field, Form } from 'src/components/hook-form';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Label } from 'src/components/label';
|
||||
import { NumberInput } from 'src/components/number-input';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { CheckoutContextValue } from 'src/types/checkout';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { fCurrency, fShortenNumber } from 'src/utils/format-number';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
product: IProductItem;
|
||||
disableActions?: boolean;
|
||||
items?: CheckoutContextValue['state']['items'];
|
||||
onAddToCart?: CheckoutContextValue['onAddToCart'];
|
||||
};
|
||||
|
||||
export function ProductDetailsSummary({
|
||||
items,
|
||||
product,
|
||||
onAddToCart,
|
||||
disableActions,
|
||||
...other
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
sizes,
|
||||
price,
|
||||
colors,
|
||||
coverUrl,
|
||||
newLabel,
|
||||
available,
|
||||
priceSale,
|
||||
saleLabel,
|
||||
totalRatings,
|
||||
totalReviews,
|
||||
inventoryType,
|
||||
subDescription,
|
||||
} = product;
|
||||
|
||||
const existProduct = !!items?.length && items.map((item) => item.id).includes(id);
|
||||
|
||||
const isMaxQuantity =
|
||||
!!items?.length &&
|
||||
items.filter((item) => item.id === id).map((item) => item.quantity)[0] >= available;
|
||||
|
||||
const defaultValues = {
|
||||
id,
|
||||
name,
|
||||
coverUrl,
|
||||
available,
|
||||
price,
|
||||
colors: colors[0],
|
||||
size: sizes[4],
|
||||
quantity: available < 1 ? 0 : 1,
|
||||
};
|
||||
|
||||
const methods = useForm<typeof defaultValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { watch, control, setValue, handleSubmit } = methods;
|
||||
|
||||
const values = watch();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
console.info('DATA', JSON.stringify(data, null, 2));
|
||||
|
||||
try {
|
||||
if (!existProduct) {
|
||||
onAddToCart?.({ ...data, colors: [values.colors] });
|
||||
}
|
||||
router.push(paths.product.checkout);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleAddCart = useCallback(() => {
|
||||
try {
|
||||
onAddToCart?.({
|
||||
...values,
|
||||
colors: [values.colors],
|
||||
subtotal: values.price * values.quantity,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [onAddToCart, values]);
|
||||
|
||||
const renderPrice = () => (
|
||||
<Box sx={{ typography: 'h5' }}>
|
||||
{priceSale && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: 'text.disabled', textDecoration: 'line-through', mr: 0.5 }}
|
||||
>
|
||||
{fCurrency(priceSale)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{fCurrency(price)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderShare = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
[`& .${linkClasses.root}`]: {
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
color: 'text.secondary',
|
||||
typography: 'subtitle2',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link>
|
||||
<Iconify icon="mingcute:add-line" width={16} />
|
||||
Compare
|
||||
</Link>
|
||||
|
||||
<Link>
|
||||
<Iconify icon="solar:heart-bold" width={16} />
|
||||
Favorite
|
||||
</Link>
|
||||
|
||||
<Link>
|
||||
<Iconify icon="solar:share-bold" width={16} />
|
||||
Share
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderColorOptions = () => (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
|
||||
Color
|
||||
</Typography>
|
||||
|
||||
<Controller
|
||||
name="colors"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorPicker
|
||||
options={colors}
|
||||
value={field.value}
|
||||
onChange={(color) => field.onChange(color as string)}
|
||||
limit={4}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderSizeOptions = () => (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
|
||||
Size
|
||||
</Typography>
|
||||
|
||||
<Field.Select
|
||||
name="size"
|
||||
size="small"
|
||||
helperText={
|
||||
<Link underline="always" color="text.primary">
|
||||
Size chart
|
||||
</Link>
|
||||
}
|
||||
sx={{
|
||||
maxWidth: 88,
|
||||
[`& .${formHelperTextClasses.root}`]: { mx: 0, mt: 1, textAlign: 'right' },
|
||||
}}
|
||||
>
|
||||
{sizes.map((size) => (
|
||||
<MenuItem key={size} value={size}>
|
||||
{size}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Field.Select>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderQuantity = () => (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
|
||||
Quantity
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<NumberInput
|
||||
hideDivider
|
||||
value={values.quantity}
|
||||
onChange={(event, quantity: number) => setValue('quantity', quantity)}
|
||||
max={available}
|
||||
sx={{ maxWidth: 112 }}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{ textAlign: 'right', color: 'text.secondary' }}
|
||||
>
|
||||
Available: {available}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderActions = () => (
|
||||
<Box sx={{ gap: 2, display: 'flex' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
disabled={isMaxQuantity || disableActions}
|
||||
size="large"
|
||||
color="warning"
|
||||
variant="contained"
|
||||
startIcon={<Iconify icon="solar:cart-plus-bold" width={24} />}
|
||||
onClick={handleAddCart}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Add to cart
|
||||
</Button>
|
||||
|
||||
<Button fullWidth size="large" type="submit" variant="contained" disabled={disableActions}>
|
||||
Buy now
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderSubDescription = () => (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{subDescription}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const renderRating = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
typography: 'body2',
|
||||
alignItems: 'center',
|
||||
color: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
<Rating size="small" value={totalRatings} precision={0.1} readOnly sx={{ mr: 1 }} />
|
||||
{`(${fShortenNumber(totalReviews)} reviews)`}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderLabels = () =>
|
||||
(newLabel.enabled || saleLabel.enabled) && (
|
||||
<Box sx={{ gap: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{newLabel.enabled && <Label color="info">{newLabel.content}</Label>}
|
||||
{saleLabel.enabled && <Label color="error">{saleLabel.content}</Label>}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderInventoryType = () => (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
typography: 'overline',
|
||||
color:
|
||||
(inventoryType === 'out of stock' && 'error.main') ||
|
||||
(inventoryType === 'low stock' && 'warning.main') ||
|
||||
'success.main',
|
||||
}}
|
||||
>
|
||||
{inventoryType}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form methods={methods} onSubmit={onSubmit}>
|
||||
<Stack spacing={3} sx={{ pt: 3 }} {...other}>
|
||||
<Stack spacing={2} alignItems="flex-start">
|
||||
{renderLabels()}
|
||||
{renderInventoryType()}
|
||||
|
||||
<Typography variant="h5">{name}</Typography>
|
||||
|
||||
{renderRating()}
|
||||
{renderPrice()}
|
||||
{renderSubDescription()}
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
|
||||
{renderColorOptions()}
|
||||
{renderSizeOptions()}
|
||||
{renderQuantity()}
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
|
||||
{renderActions()}
|
||||
{renderShare()}
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
@@ -0,0 +1,113 @@
|
||||
import type { BoxProps } from '@mui/material/Box';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps & {
|
||||
backHref: string;
|
||||
editHref: string;
|
||||
liveHref: string;
|
||||
publish: string;
|
||||
onChangePublish: (newValue: string) => void;
|
||||
publishOptions: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
export function ProductDetailsToolbar({
|
||||
sx,
|
||||
publish,
|
||||
backHref,
|
||||
editHref,
|
||||
liveHref,
|
||||
publishOptions,
|
||||
onChangePublish,
|
||||
...other
|
||||
}: Props) {
|
||||
const menuActions = usePopover();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'top-right' } }}
|
||||
>
|
||||
<MenuList>
|
||||
{publishOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
selected={option.value === publish}
|
||||
onClick={() => {
|
||||
menuActions.onClose();
|
||||
onChangePublish(option.value);
|
||||
}}
|
||||
>
|
||||
{option.value === 'published' && <Iconify icon="eva:cloud-upload-fill" />}
|
||||
{option.value === 'draft' && <Iconify icon="solar:file-text-bold" />}
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={[
|
||||
{ gap: 1.5, display: 'flex', mb: { xs: 3, md: 5 } },
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={backHref}
|
||||
startIcon={<Iconify icon="eva:arrow-ios-back-fill" width={16} />}
|
||||
>
|
||||
{t('back')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{publish === 'published' && (
|
||||
<Tooltip title="Go Live">
|
||||
<IconButton component={RouterLink} href={liveHref}>
|
||||
<Iconify icon="eva:external-link-fill" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Edit">
|
||||
<IconButton component={RouterLink} href={editHref}>
|
||||
<Iconify icon="solar:pen-bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="contained"
|
||||
loading={!publish}
|
||||
loadingIndicator="Loading…"
|
||||
endIcon={<Iconify icon="eva:arrow-ios-downward-fill" />}
|
||||
onClick={menuActions.onOpen}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{publish}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,334 @@
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { inputBaseClasses } from '@mui/material/InputBase';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import { ColorPicker } from 'src/components/color-utils';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { NumberInput } from 'src/components/number-input';
|
||||
import { Scrollbar } from 'src/components/scrollbar';
|
||||
import type { IProductFilters } from 'src/types/party-event';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
canReset: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
filters: UseSetStateReturn<IProductFilters>;
|
||||
options: {
|
||||
colors: string[];
|
||||
ratings: string[];
|
||||
categories: string[];
|
||||
genders: { value: string; label: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
const MAX_AMOUNT = 200;
|
||||
|
||||
const marksLabel = Array.from({ length: 21 }, (_, index) => {
|
||||
const value = index * 10;
|
||||
const firstValue = index === 0 ? `$${value}` : `${value}`;
|
||||
|
||||
return {
|
||||
value,
|
||||
label: index % 4 ? '' : firstValue,
|
||||
};
|
||||
});
|
||||
|
||||
export function ProductFiltersDrawer({ open, onOpen, onClose, canReset, filters, options }: Props) {
|
||||
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||
|
||||
const handleFilterGender = useCallback(
|
||||
(newValue: string) => {
|
||||
const checked = currentFilters.gender.includes(newValue)
|
||||
? currentFilters.gender.filter((value) => value !== newValue)
|
||||
: [...currentFilters.gender, newValue];
|
||||
|
||||
updateFilters({ gender: checked });
|
||||
},
|
||||
[updateFilters, currentFilters.gender]
|
||||
);
|
||||
|
||||
const handleFilterCategory = useCallback(
|
||||
(newValue: string) => {
|
||||
updateFilters({ category: newValue });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterColors = useCallback(
|
||||
(newValue: string[]) => {
|
||||
updateFilters({ colors: newValue });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterPriceRange = useCallback(
|
||||
(event: Event, newValue: number | number[]) => {
|
||||
updateFilters({ priceRange: newValue as number[] });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterRating = useCallback(
|
||||
(newValue: string) => {
|
||||
updateFilters({ rating: newValue });
|
||||
},
|
||||
[updateFilters]
|
||||
);
|
||||
|
||||
const renderHead = () => (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
py: 2,
|
||||
pr: 1,
|
||||
pl: 2.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Filters
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="Reset">
|
||||
<IconButton onClick={() => resetFilters()}>
|
||||
<Badge color="error" variant="dot" invisible={!canReset}>
|
||||
<Iconify icon="solar:restart-bold" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton onClick={onClose}>
|
||||
<Iconify icon="mingcute:close-line" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
</>
|
||||
);
|
||||
|
||||
const renderGender = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Gender
|
||||
</Typography>
|
||||
{options.genders.map((option) => (
|
||||
<FormControlLabel
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={currentFilters.gender.includes(option.label)}
|
||||
onClick={() => handleFilterGender(option.label)}
|
||||
slotProps={{ input: { id: `${option.value}-checkbox` } }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderCategory = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Category
|
||||
</Typography>
|
||||
{options.categories.map((option) => (
|
||||
<FormControlLabel
|
||||
key={option}
|
||||
label={option}
|
||||
control={
|
||||
<Radio
|
||||
checked={option === currentFilters.category}
|
||||
onClick={() => handleFilterCategory(option)}
|
||||
slotProps={{ input: { id: `${option}-radio` } }}
|
||||
/>
|
||||
}
|
||||
sx={{ ...(option === 'all' && { textTransform: 'capitalize' }) }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderColor = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Color
|
||||
</Typography>
|
||||
|
||||
<ColorPicker
|
||||
options={options.colors}
|
||||
value={currentFilters.colors}
|
||||
onChange={(colors) => handleFilterColors(colors as string[])}
|
||||
limit={6}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderPrice = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2">Price</Typography>
|
||||
|
||||
<Box sx={{ my: 2, gap: 5, display: 'flex' }}>
|
||||
<InputRange type="min" value={currentFilters.priceRange} onChange={updateFilters} />
|
||||
<InputRange type="max" value={currentFilters.priceRange} onChange={updateFilters} />
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={currentFilters.priceRange}
|
||||
onChange={handleFilterPriceRange}
|
||||
step={10}
|
||||
min={0}
|
||||
max={MAX_AMOUNT}
|
||||
marks={marksLabel}
|
||||
getAriaValueText={(value) => `$${value}`}
|
||||
valueLabelFormat={(value) => `$${value}`}
|
||||
sx={{ alignSelf: 'center', width: `calc(100% - 24px)` }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderRating = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||
Rating
|
||||
</Typography>
|
||||
|
||||
{options.ratings.map((item, index) => (
|
||||
<Box
|
||||
key={item}
|
||||
onClick={() => handleFilterRating(item)}
|
||||
sx={{
|
||||
mb: 1,
|
||||
gap: 1,
|
||||
ml: -1,
|
||||
p: 0.5,
|
||||
display: 'flex',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
typography: 'body2',
|
||||
alignItems: 'center',
|
||||
'&:hover': { opacity: 0.48 },
|
||||
...(currentFilters.rating === item && { bgcolor: 'action.selected' }),
|
||||
}}
|
||||
>
|
||||
<Rating readOnly value={4 - index} /> & Up
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disableRipple
|
||||
color="inherit"
|
||||
endIcon={
|
||||
<Badge color="error" variant="dot" invisible={!canReset}>
|
||||
<Iconify icon="ic:round-filter-list" />
|
||||
</Badge>
|
||||
}
|
||||
onClick={onOpen}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slotProps={{
|
||||
backdrop: { invisible: true },
|
||||
paper: { sx: { width: 320 } },
|
||||
}}
|
||||
>
|
||||
{renderHead()}
|
||||
|
||||
<Scrollbar sx={{ px: 2.5, py: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
{renderGender()}
|
||||
{renderCategory()}
|
||||
{renderColor()}
|
||||
{renderPrice()}
|
||||
{renderRating()}
|
||||
</Stack>
|
||||
</Scrollbar>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type InputRangeProps = {
|
||||
value: number[];
|
||||
type: 'min' | 'max';
|
||||
onChange: UseSetStateReturn<IProductFilters>['setState'];
|
||||
};
|
||||
|
||||
function InputRange({ type, value, onChange: onFilters }: InputRangeProps) {
|
||||
const minValue = value[0];
|
||||
const maxValue = value[1];
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const newMin = Math.max(0, Math.min(minValue, MAX_AMOUNT));
|
||||
const newMax = Math.max(0, Math.min(maxValue, MAX_AMOUNT));
|
||||
|
||||
if (newMin !== minValue || newMax !== maxValue) {
|
||||
onFilters({ priceRange: [newMin, newMax] });
|
||||
}
|
||||
}, [minValue, maxValue, onFilters]);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
color: 'text.disabled',
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: 'fontWeightSemiBold',
|
||||
}}
|
||||
>
|
||||
{`${type} ($)`}
|
||||
</Typography>
|
||||
|
||||
<NumberInput
|
||||
hideButtons
|
||||
max={MAX_AMOUNT}
|
||||
value={type === 'min' ? minValue : maxValue}
|
||||
onChange={(event, newValue) =>
|
||||
onFilters({ priceRange: type === 'min' ? [newValue, maxValue] : [minValue, newValue] })
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
sx={{ maxWidth: 64 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
sx: {
|
||||
[`& .${inputBaseClasses.input}`]: {
|
||||
pr: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,101 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useCallback } from 'react';
|
||||
import type { FiltersResultProps } from 'src/components/filters-result';
|
||||
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
|
||||
import type { IProductFilters } from 'src/types/party-event';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = FiltersResultProps & {
|
||||
filters: UseSetStateReturn<IProductFilters>;
|
||||
};
|
||||
|
||||
export function ProductFiltersResult({ filters, totalResults, sx }: Props) {
|
||||
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||
|
||||
const handleRemoveGender = useCallback(
|
||||
(inputValue: string) => {
|
||||
const newValue = currentFilters.gender.filter((item) => item !== inputValue);
|
||||
|
||||
updateFilters({ gender: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.gender]
|
||||
);
|
||||
|
||||
const handleRemoveCategory = useCallback(() => {
|
||||
updateFilters({ category: 'all' });
|
||||
}, [updateFilters]);
|
||||
|
||||
const handleRemoveColor = useCallback(
|
||||
(inputValue: string | string[]) => {
|
||||
const newValue = currentFilters.colors.filter((item: string) => item !== inputValue);
|
||||
|
||||
updateFilters({ colors: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.colors]
|
||||
);
|
||||
|
||||
const handleRemovePrice = useCallback(() => {
|
||||
updateFilters({ priceRange: [0, 200] });
|
||||
}, [updateFilters]);
|
||||
|
||||
const handleRemoveRating = useCallback(() => {
|
||||
updateFilters({ rating: '' });
|
||||
}, [updateFilters]);
|
||||
|
||||
return (
|
||||
<FiltersResult totalResults={totalResults} onReset={() => resetFilters()} sx={sx}>
|
||||
<FiltersBlock label="Gender:" isShow={!!currentFilters.gender.length}>
|
||||
{currentFilters.gender.map((item) => (
|
||||
<Chip {...chipProps} key={item} label={item} onDelete={() => handleRemoveGender(item)} />
|
||||
))}
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Category:" isShow={currentFilters.category !== 'all'}>
|
||||
<Chip {...chipProps} label={currentFilters.category} onDelete={handleRemoveCategory} />
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Colors:" isShow={!!currentFilters.colors.length}>
|
||||
{currentFilters.colors.map((item) => (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={item}
|
||||
label={
|
||||
<Box
|
||||
sx={[
|
||||
(theme) => ({
|
||||
ml: -0.5,
|
||||
width: 18,
|
||||
height: 18,
|
||||
bgcolor: item,
|
||||
borderRadius: '50%',
|
||||
border: `solid 1px ${varAlpha(theme.vars.palette.common.whiteChannel, 0.24)}`,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
}
|
||||
onDelete={() => handleRemoveColor(item)}
|
||||
/>
|
||||
))}
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock
|
||||
label="Price:"
|
||||
isShow={currentFilters.priceRange[0] !== 0 || currentFilters.priceRange[1] !== 200}
|
||||
>
|
||||
<Chip
|
||||
{...chipProps}
|
||||
label={`$${currentFilters.priceRange[0]} - ${currentFilters.priceRange[1]}`}
|
||||
onDelete={handleRemovePrice}
|
||||
/>
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Rating:" isShow={!!currentFilters.rating}>
|
||||
<Chip {...chipProps} label={currentFilters.rating} onDelete={handleRemoveRating} />
|
||||
</FiltersBlock>
|
||||
</FiltersResult>
|
||||
);
|
||||
}
|
147
03_source/frontend/src/sections/party-event/party-event-item.tsx
Normal file
147
03_source/frontend/src/sections/party-event/party-event-item.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Fab, { fabClasses } from '@mui/material/Fab';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { ColorPreview } from 'src/components/color-utils';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Image } from 'src/components/image';
|
||||
import { Label } from 'src/components/label';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { fCurrency } from 'src/utils/format-number';
|
||||
import { useCheckoutContext } from '../checkout/context';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
product: IProductItem;
|
||||
detailsHref: string;
|
||||
};
|
||||
|
||||
export function ProductItem({ product, detailsHref }: Props) {
|
||||
const { onAddToCart } = useCheckoutContext();
|
||||
|
||||
const { id, name, coverUrl, price, colors, available, sizes, priceSale, newLabel, saleLabel } =
|
||||
product;
|
||||
|
||||
const handleAddCart = async () => {
|
||||
const newProduct = {
|
||||
id,
|
||||
name,
|
||||
coverUrl,
|
||||
available,
|
||||
price,
|
||||
colors: [colors[0]],
|
||||
size: sizes[0],
|
||||
quantity: 1,
|
||||
};
|
||||
try {
|
||||
onAddToCart(newProduct);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderLabels = () =>
|
||||
(newLabel.enabled || saleLabel.enabled) && (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 1,
|
||||
top: 16,
|
||||
zIndex: 9,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{newLabel.enabled && (
|
||||
<Label variant="filled" color="info">
|
||||
{newLabel.content}
|
||||
</Label>
|
||||
)}
|
||||
{saleLabel.enabled && (
|
||||
<Label variant="filled" color="error">
|
||||
{saleLabel.content}
|
||||
</Label>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderImage = () => (
|
||||
<Box sx={{ position: 'relative', p: 1 }}>
|
||||
{!!available && (
|
||||
<Fab
|
||||
size="medium"
|
||||
color="warning"
|
||||
onClick={handleAddCart}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
right: 16,
|
||||
zIndex: 9,
|
||||
bottom: 16,
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
transform: 'scale(0)',
|
||||
transition: theme.transitions.create(['opacity', 'transform'], {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Iconify icon="solar:cart-plus-bold" width={24} />
|
||||
</Fab>
|
||||
)}
|
||||
|
||||
<Tooltip title={!available && 'Out of stock'} placement="bottom-end">
|
||||
<Image
|
||||
alt={name}
|
||||
src={coverUrl}
|
||||
ratio="1/1"
|
||||
sx={{ borderRadius: 1.5, ...(!available && { opacity: 0.48, filter: 'grayscale(1)' }) }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<Stack spacing={2.5} sx={{ p: 3, pt: 2 }}>
|
||||
<Link component={RouterLink} href={detailsHref} color="inherit" variant="subtitle2" noWrap>
|
||||
{name}
|
||||
</Link>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tooltip title="Color">
|
||||
<ColorPreview colors={colors} />
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ gap: 0.5, display: 'flex', typography: 'subtitle1' }}>
|
||||
{priceSale && (
|
||||
<Box component="span" sx={{ color: 'text.disabled', textDecoration: 'line-through' }}>
|
||||
{fCurrency(priceSale)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box component="span">{fCurrency(price)}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
'&:hover': {
|
||||
[`& .${fabClasses.root}`]: { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderLabels()}
|
||||
{renderImage()}
|
||||
{renderContent()}
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
import type { BoxProps } from '@mui/material/Box';
|
||||
import Box from '@mui/material/Box';
|
||||
import Pagination, { paginationClasses } from '@mui/material/Pagination';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { ProductItem } from './party-event-item';
|
||||
import { ProductItemSkeleton } from './party-event-skeleton';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = BoxProps & {
|
||||
loading?: boolean;
|
||||
products: IProductItem[];
|
||||
};
|
||||
|
||||
export function ProductList({ products, loading, sx, ...other }: Props) {
|
||||
const renderLoading = () => <ProductItemSkeleton />;
|
||||
|
||||
const renderList = () =>
|
||||
products.map((product) => (
|
||||
<ProductItem
|
||||
key={product.id}
|
||||
product={product}
|
||||
detailsHref={paths.product.details(product.id)}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={[
|
||||
() => ({
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: 'repeat(1, 1fr)',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
lg: 'repeat(4, 1fr)',
|
||||
},
|
||||
}),
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
>
|
||||
{loading ? renderLoading() : renderList()}
|
||||
</Box>
|
||||
|
||||
{products.length > 8 && (
|
||||
<Pagination
|
||||
count={8}
|
||||
sx={{
|
||||
mt: { xs: 5, md: 8 },
|
||||
[`& .${paginationClasses.ul}`]: { justifyContent: 'center' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,560 @@
|
||||
// src/sections/product/product-new-edit-form.tsx
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useBoolean } from 'minimal-shared/hooks';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
PRODUCT_CATEGORY_GROUP_OPTIONS,
|
||||
PRODUCT_COLOR_NAME_OPTIONS,
|
||||
PRODUCT_SIZE_OPTIONS,
|
||||
} from 'src/_mock';
|
||||
import { createProduct, updateProduct } from 'src/actions/party-event';
|
||||
import { Field, Form, schemaHelper } from 'src/components/hook-form';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { toast } from 'src/components/snackbar';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { fileToBase64 } from 'src/utils/file-to-base64';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
const PRODUCT_PUBLISH_OPTIONS = [
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
];
|
||||
|
||||
const PRODUCT_COLOR_OPTIONS = [
|
||||
'#FF4842',
|
||||
'#1890FF',
|
||||
'#FFC0CB',
|
||||
'#00AB55',
|
||||
'#FFC107',
|
||||
'#7F00FF',
|
||||
'#000000',
|
||||
'#FFFFFF',
|
||||
];
|
||||
|
||||
const _tags = [
|
||||
`Technology`,
|
||||
`Health and Wellness`,
|
||||
`Travel`,
|
||||
`Finance`,
|
||||
`Education`,
|
||||
`Food and Beverage`,
|
||||
`Fashion`,
|
||||
`Home and Garden`,
|
||||
`Sports`,
|
||||
`Entertainment`,
|
||||
`Business`,
|
||||
`Science`,
|
||||
`Automotive`,
|
||||
`Beauty`,
|
||||
`Fitness`,
|
||||
`Lifestyle`,
|
||||
`Real Estate`,
|
||||
`Parenting`,
|
||||
`Pet Care`,
|
||||
`Environmental`,
|
||||
`DIY and Crafts`,
|
||||
`Gaming`,
|
||||
`Photography`,
|
||||
`Music`,
|
||||
];
|
||||
|
||||
const PRODUCT_GENDER_OPTIONS = [
|
||||
{ label: 'Men', value: 'Men' },
|
||||
{ label: 'Women', value: 'Women' },
|
||||
{ label: 'Kids', value: 'Kids' },
|
||||
];
|
||||
|
||||
export type NewProductSchemaType = zod.infer<typeof NewProductSchema>;
|
||||
|
||||
export const NewProductSchema = zod.object({
|
||||
sku: zod.string().min(1, { message: 'Product sku is required!' }),
|
||||
name: zod.string().min(1, { message: 'Name is required!' }),
|
||||
code: zod.string().min(1, { message: 'Product code is required!' }),
|
||||
price: schemaHelper.nullableInput(
|
||||
zod.number({ coerce: true }).min(1, { message: 'Price is required!' }),
|
||||
{
|
||||
// message for null value
|
||||
message: 'Price is required!',
|
||||
}
|
||||
),
|
||||
taxes: zod.number({ coerce: true }).nullable(),
|
||||
tags: zod.string().array().min(2, { message: 'Must have at least 2 items!' }),
|
||||
sizes: zod.string().array().min(1, { message: 'Choose at least one option!' }),
|
||||
publish: zod.string(),
|
||||
gender: zod.array(zod.string()).min(1, { message: 'Choose at least one option!' }),
|
||||
coverUrl: zod.string(),
|
||||
images: schemaHelper.files({ message: 'Images is required!' }),
|
||||
colors: zod.string().array().min(1, { message: 'Choose at least one option!' }),
|
||||
quantity: schemaHelper.nullableInput(
|
||||
zod.number({ coerce: true }).min(1, { message: 'Quantity is required!' }),
|
||||
{
|
||||
// message for null value
|
||||
message: 'Quantity is required!',
|
||||
}
|
||||
),
|
||||
category: zod.string(),
|
||||
available: zod.number(),
|
||||
totalSold: zod.number(),
|
||||
description: schemaHelper
|
||||
.editor({ message: 'Description is required!' })
|
||||
.min(10, { message: 'Description must be at least 10 characters' })
|
||||
.max(50000, { message: 'Description must be less than 50000 characters' }),
|
||||
totalRatings: zod.number(),
|
||||
totalReviews: zod.number(),
|
||||
inventoryType: zod.string(),
|
||||
subDescription: zod.string(),
|
||||
priceSale: zod.number({ coerce: true }).nullable(),
|
||||
newLabel: zod.object({ enabled: zod.boolean(), content: zod.string() }),
|
||||
saleLabel: zod.object({ enabled: zod.boolean(), content: zod.string() }),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
currentProduct?: IProductItem;
|
||||
};
|
||||
|
||||
export function ProductNewEditForm({ currentProduct }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const openDetails = useBoolean(true);
|
||||
const openProperties = useBoolean(true);
|
||||
const openPricing = useBoolean(true);
|
||||
|
||||
const [includeTaxes, setIncludeTaxes] = useState(false);
|
||||
|
||||
const defaultValues: NewProductSchemaType = {
|
||||
sku: '321',
|
||||
name: 'hello product',
|
||||
code: '123',
|
||||
price: 1.1,
|
||||
taxes: 1.1,
|
||||
tags: [_tags[0], _tags[1]],
|
||||
sizes: ['9'],
|
||||
publish: PRODUCT_PUBLISH_OPTIONS[0].value,
|
||||
gender: [
|
||||
PRODUCT_GENDER_OPTIONS[0].value,
|
||||
PRODUCT_GENDER_OPTIONS[1].value,
|
||||
PRODUCT_GENDER_OPTIONS[2].value,
|
||||
],
|
||||
coverUrl: '',
|
||||
images: [],
|
||||
colors: [PRODUCT_COLOR_OPTIONS[0], PRODUCT_COLOR_OPTIONS[1]],
|
||||
quantity: 3,
|
||||
category: PRODUCT_CATEGORY_GROUP_OPTIONS[0].classify[1],
|
||||
available: 0,
|
||||
totalSold: 0,
|
||||
description: 'hello description',
|
||||
totalRatings: 0,
|
||||
totalReviews: 0,
|
||||
inventoryType: '',
|
||||
subDescription: '',
|
||||
priceSale: 0.9,
|
||||
newLabel: { enabled: false, content: '' },
|
||||
saleLabel: { enabled: false, content: '' },
|
||||
};
|
||||
|
||||
const methods = useForm<NewProductSchemaType>({
|
||||
resolver: zodResolver(NewProductSchema),
|
||||
defaultValues,
|
||||
values: currentProduct,
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = methods;
|
||||
|
||||
const values = watch();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const updatedData = {
|
||||
...data,
|
||||
taxes: includeTaxes ? defaultValues.taxes : data.taxes,
|
||||
};
|
||||
|
||||
try {
|
||||
// sanitize file field
|
||||
for (let i = 0; i < values.images.length; i++) {
|
||||
const temp: any = values.images[i];
|
||||
if (temp instanceof File) {
|
||||
values.images[i] = await fileToBase64(temp);
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedValues: IProductItem = values as unknown as IProductItem;
|
||||
|
||||
if (currentProduct) {
|
||||
// perform save
|
||||
updateProduct(sanitizedValues);
|
||||
} else {
|
||||
// perform create
|
||||
createProduct(sanitizedValues);
|
||||
}
|
||||
|
||||
toast.success(currentProduct ? 'Update success!' : 'Create success!');
|
||||
|
||||
router.push(paths.dashboard.product.root);
|
||||
|
||||
// console.info('DATA', updatedData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveFile = useCallback(
|
||||
(inputFile: File | string) => {
|
||||
const filtered = values.images && values.images?.filter((file) => file !== inputFile);
|
||||
setValue('images', filtered);
|
||||
},
|
||||
[setValue, values.images]
|
||||
);
|
||||
|
||||
const handleRemoveAllFiles = useCallback(() => {
|
||||
setValue('images', [], { shouldValidate: true });
|
||||
}, [setValue]);
|
||||
|
||||
const handleChangeIncludeTaxes = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIncludeTaxes(event.target.checked);
|
||||
}, []);
|
||||
|
||||
const renderCollapseButton = (value: boolean, onToggle: () => void) => (
|
||||
<IconButton onClick={onToggle}>
|
||||
<Iconify icon={value ? 'eva:arrow-ios-downward-fill' : 'eva:arrow-ios-forward-fill'} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
function handleProductImageUpload() {
|
||||
console.log(values);
|
||||
}
|
||||
|
||||
const [disableUserInput, setDisableUserInput] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDisableUserInput(isSubmitting);
|
||||
}, [isSubmitting]);
|
||||
|
||||
const renderDetails = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Details"
|
||||
subheader="Title, short description, image..."
|
||||
action={renderCollapseButton(openDetails.value, openDetails.onToggle)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Collapse in={openDetails.value}>
|
||||
<Divider />
|
||||
|
||||
<Stack spacing={3} sx={{ p: 3 }}>
|
||||
<Field.Text disabled={disableUserInput} name="name" label="產品名稱 / Product name" />
|
||||
|
||||
<Field.Text
|
||||
disabled={disableUserInput}
|
||||
name="subDescription"
|
||||
label="Sub description"
|
||||
multiline
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Content</Typography>
|
||||
<Field.Editor name="description" sx={{ maxHeight: 480 }} />
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Images</Typography>
|
||||
<Field.Upload
|
||||
multiple
|
||||
thumbnail
|
||||
name="images"
|
||||
maxSize={3145728}
|
||||
onRemove={handleRemoveFile}
|
||||
onRemoveAll={handleRemoveAllFiles}
|
||||
onUpload={handleProductImageUpload}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderProperties = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Properties"
|
||||
subheader="Additional functions and attributes..."
|
||||
action={renderCollapseButton(openProperties.value, openProperties.onToggle)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Collapse in={openProperties.value}>
|
||||
<Divider />
|
||||
|
||||
<Stack spacing={3} sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
rowGap: 3,
|
||||
columnGap: 2,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' },
|
||||
}}
|
||||
>
|
||||
<Field.Text disabled={disableUserInput} name="code" label="Product code" />
|
||||
|
||||
<Field.Text disabled={disableUserInput} name="sku" label="Product SKU" />
|
||||
|
||||
<Field.Text
|
||||
disabled={disableUserInput}
|
||||
name="quantity"
|
||||
label="Quantity"
|
||||
placeholder="0"
|
||||
type="number"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
|
||||
<Field.Select
|
||||
disabled={disableUserInput}
|
||||
name="category"
|
||||
label="Category"
|
||||
slotProps={{
|
||||
select: { native: true },
|
||||
inputLabel: { shrink: true },
|
||||
}}
|
||||
>
|
||||
{PRODUCT_CATEGORY_GROUP_OPTIONS.map((category) => (
|
||||
<optgroup key={category.group} label={category.group}>
|
||||
{category.classify.map((classify) => (
|
||||
<option key={classify} value={classify}>
|
||||
{classify}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</Field.Select>
|
||||
|
||||
<Field.MultiSelect
|
||||
checkbox
|
||||
name="colors"
|
||||
label="Colors"
|
||||
options={PRODUCT_COLOR_NAME_OPTIONS}
|
||||
/>
|
||||
|
||||
<Field.MultiSelect checkbox name="sizes" label="Sizes" options={PRODUCT_SIZE_OPTIONS} />
|
||||
</Box>
|
||||
|
||||
<Field.Autocomplete
|
||||
disabled={disableUserInput}
|
||||
name="tags"
|
||||
label="Tags"
|
||||
placeholder="+ Tags"
|
||||
multiple
|
||||
freeSolo
|
||||
disableCloseOnSelect
|
||||
options={_tags.map((option) => option)}
|
||||
getOptionLabel={(option) => option}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option}>
|
||||
{option}
|
||||
</li>
|
||||
)}
|
||||
renderTags={(selected, getTagProps) =>
|
||||
selected.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option}
|
||||
label={option}
|
||||
size="small"
|
||||
color="info"
|
||||
variant="soft"
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Gender</Typography>
|
||||
<Field.MultiCheckbox
|
||||
row
|
||||
name="gender"
|
||||
options={PRODUCT_GENDER_OPTIONS}
|
||||
sx={{ gap: 2 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
|
||||
<Box sx={{ gap: 3, display: 'flex', alignItems: 'center' }}>
|
||||
<Field.Switch name="saleLabel.enabled" label={null} sx={{ m: 0 }} />
|
||||
<Field.Text
|
||||
name="saleLabel.content"
|
||||
label="Sale label"
|
||||
fullWidth
|
||||
disabled={!values.saleLabel.enabled}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gap: 3, display: 'flex', alignItems: 'center' }}>
|
||||
<Field.Switch name="newLabel.enabled" label={null} sx={{ m: 0 }} />
|
||||
<Field.Text
|
||||
name="newLabel.content"
|
||||
label="New label"
|
||||
fullWidth
|
||||
disabled={!values.newLabel.enabled}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderPricing = () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pricing"
|
||||
subheader="Price related inputs"
|
||||
action={renderCollapseButton(openPricing.value, openPricing.onToggle)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Collapse in={openPricing.value}>
|
||||
<Divider />
|
||||
|
||||
<Stack spacing={3} sx={{ p: 3 }}>
|
||||
<Field.Text
|
||||
disabled={disableUserInput}
|
||||
name="price"
|
||||
label="Regular price"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start" sx={{ mr: 0.75 }}>
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
$
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field.Text
|
||||
disabled={disableUserInput}
|
||||
name="priceSale"
|
||||
label="Sale price"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start" sx={{ mr: 0.75 }}>
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
$
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
disabled={disableUserInput}
|
||||
id="toggle-taxes"
|
||||
checked={includeTaxes}
|
||||
onChange={handleChangeIncludeTaxes}
|
||||
/>
|
||||
}
|
||||
label="Price includes taxes"
|
||||
/>
|
||||
|
||||
{!includeTaxes && (
|
||||
<Field.Text
|
||||
disabled={disableUserInput}
|
||||
name="taxes"
|
||||
label="Tax (%)"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start" sx={{ mr: 0.75 }}>
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
%
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderActions = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>{JSON.stringify({ errors })}</div>
|
||||
<FormControlLabel
|
||||
label="Publish"
|
||||
control={
|
||||
<Switch
|
||||
disabled={disableUserInput}
|
||||
defaultChecked
|
||||
slotProps={{ input: { id: 'publish-switch' } }}
|
||||
/>
|
||||
}
|
||||
sx={{ pl: 3, flexGrow: 1 }}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="contained" size="large" loading={isSubmitting}>
|
||||
{!currentProduct ? 'Create product' : 'Save changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form methods={methods} onSubmit={onSubmit}>
|
||||
<Stack spacing={{ xs: 3, md: 5 }} sx={{ mx: 'auto', maxWidth: { xs: 720, xl: 880 } }}>
|
||||
{renderDetails()}
|
||||
{renderProperties()}
|
||||
{renderPricing()}
|
||||
{renderActions()}
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
@@ -0,0 +1,124 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import type { IProductReview } from 'src/types/party-event';
|
||||
import { fDate } from 'src/utils/format-time';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
review: IProductReview;
|
||||
};
|
||||
|
||||
export function ProductReviewItem({ review }: Props) {
|
||||
const renderInfo = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
width: { md: 240 },
|
||||
alignItems: 'center',
|
||||
textAlign: { md: 'center' },
|
||||
flexDirection: { xs: 'row', md: 'column' },
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={review.avatarUrl}
|
||||
sx={{ width: { xs: 48, md: 64 }, height: { xs: 48, md: 64 } }}
|
||||
/>
|
||||
|
||||
<ListItemText
|
||||
primary={review.name}
|
||||
secondary={fDate(review.postedAt)}
|
||||
slotProps={{
|
||||
primary: { noWrap: true },
|
||||
secondary: {
|
||||
noWrap: true,
|
||||
sx: { mt: 0.5, typography: 'caption' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Rating size="small" value={review.rating} precision={0.1} readOnly />
|
||||
|
||||
{review.isPurchased && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'success.main',
|
||||
typography: 'caption',
|
||||
}}
|
||||
>
|
||||
<Iconify icon="solar:verified-check-bold" width={16} sx={{ mr: 0.5 }} />
|
||||
Verified purchase
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="body2">{review.comment}</Typography>
|
||||
|
||||
{!!review.attachments?.length && (
|
||||
<Box
|
||||
sx={{
|
||||
pt: 1,
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{review.attachments.map((attachment) => (
|
||||
<Box
|
||||
key={attachment}
|
||||
component="img"
|
||||
alt={attachment}
|
||||
src={attachment}
|
||||
sx={{ width: 64, height: 64, borderRadius: 1.5 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ gap: 2, pt: 1.5, display: 'flex' }}>
|
||||
<ButtonBase disableRipple sx={{ gap: 0.5, typography: 'caption' }}>
|
||||
<Iconify icon="solar:like-outline" width={16} />
|
||||
123
|
||||
</ButtonBase>
|
||||
|
||||
<ButtonBase disableRipple sx={{ gap: 0.5, typography: 'caption' }}>
|
||||
<Iconify icon="solar:dislike-outline" width={16} />
|
||||
34
|
||||
</ButtonBase>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 5,
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
px: { xs: 2.5, md: 0 },
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
}}
|
||||
>
|
||||
{renderInfo()}
|
||||
{renderContent()}
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import Pagination, { paginationClasses } from '@mui/material/Pagination';
|
||||
import type { IProductReview } from 'src/types/party-event';
|
||||
import { ProductReviewItem } from './party-event-review-item';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
reviews: IProductReview[];
|
||||
};
|
||||
|
||||
export function ProductReviewList({ reviews }: Props) {
|
||||
return (
|
||||
<>
|
||||
{reviews.map((review) => (
|
||||
<ProductReviewItem key={review.id} review={review} />
|
||||
))}
|
||||
|
||||
<Pagination
|
||||
count={10}
|
||||
sx={{
|
||||
mx: 'auto',
|
||||
[`& .${paginationClasses.ul}`]: { my: 5, mx: 'auto', justifyContent: 'center' },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,102 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Button from '@mui/material/Button';
|
||||
import type { DialogProps } from '@mui/material/Dialog';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Field, Form } from 'src/components/hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type ReviewSchemaType = zod.infer<typeof ReviewSchema>;
|
||||
|
||||
export const ReviewSchema = zod.object({
|
||||
rating: zod.number().min(1, 'Rating must be greater than or equal to 1!'),
|
||||
name: zod.string().min(1, { message: 'Name is required!' }),
|
||||
review: zod.string().min(1, { message: 'Review is required!' }),
|
||||
email: zod
|
||||
.string()
|
||||
.min(1, { message: 'Email is required!' })
|
||||
.email({ message: 'Email must be a valid email address!' }),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = DialogProps & {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ProductReviewNewForm({ onClose, ...other }: Props) {
|
||||
const defaultValues: ReviewSchemaType = {
|
||||
rating: 0,
|
||||
review: '',
|
||||
name: '',
|
||||
email: '',
|
||||
};
|
||||
|
||||
const methods = useForm<ReviewSchemaType>({
|
||||
mode: 'all',
|
||||
resolver: zodResolver(ReviewSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = methods;
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
reset();
|
||||
onClose();
|
||||
console.info('DATA', data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
onClose();
|
||||
reset();
|
||||
}, [onClose, reset]);
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} {...other}>
|
||||
<Form methods={methods} onSubmit={onSubmit}>
|
||||
<DialogTitle> Add Review </DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Your review about this product:
|
||||
</Typography>
|
||||
<Field.Rating name="rating" />
|
||||
</div>
|
||||
|
||||
<Field.Text name="review" label="Review *" multiline rows={3} sx={{ mt: 3 }} />
|
||||
|
||||
<Field.Text name="name" label="Name *" sx={{ mt: 3 }} />
|
||||
|
||||
<Field.Text name="email" label="Email *" sx={{ mt: 3 }} />
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button color="inherit" variant="outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" variant="contained" loading={isSubmitting}>
|
||||
Post
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -0,0 +1,150 @@
|
||||
import Autocomplete, { autocompleteClasses, createFilterOptions } from '@mui/material/Autocomplete';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Link, { linkClasses } from '@mui/material/Link';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import match from 'autosuggest-highlight/match';
|
||||
import parse from 'autosuggest-highlight/parse';
|
||||
import { useDebounce } from 'minimal-shared/hooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useSearchProducts } from 'src/actions/party-event';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { SearchNotFound } from 'src/components/search-not-found';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
sx?: SxProps<Theme>;
|
||||
redirectPath: (id: string) => string;
|
||||
};
|
||||
|
||||
export function ProductSearch({ redirectPath, sx }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<IProductItem | null>(null);
|
||||
|
||||
const debouncedQuery = useDebounce(searchQuery);
|
||||
const { searchResults: options, searchLoading: loading } = useSearchProducts(debouncedQuery);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(item: IProductItem | null) => {
|
||||
setSelectedItem(item);
|
||||
if (item) {
|
||||
router.push(redirectPath(item.id));
|
||||
}
|
||||
},
|
||||
[redirectPath, router]
|
||||
);
|
||||
|
||||
const filterOptions = createFilterOptions({
|
||||
matchFrom: 'any',
|
||||
stringify: (option: IProductItem) => `${option.name} ${option.sku}`,
|
||||
});
|
||||
|
||||
const paperStyles: SxProps<Theme> = {
|
||||
width: 320,
|
||||
[`& .${autocompleteClasses.listbox}`]: {
|
||||
[`& .${autocompleteClasses.option}`]: {
|
||||
p: 0,
|
||||
[`& .${linkClasses.root}`]: {
|
||||
p: 0.75,
|
||||
gap: 1.5,
|
||||
width: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
autoHighlight
|
||||
popupIcon={null}
|
||||
loading={loading}
|
||||
options={options}
|
||||
value={selectedItem}
|
||||
filterOptions={filterOptions}
|
||||
onChange={(event, newValue) => handleChange(newValue)}
|
||||
onInputChange={(event, newValue) => setSearchQuery(newValue)}
|
||||
getOptionLabel={(option) => option.name}
|
||||
noOptionsText={<SearchNotFound query={debouncedQuery} />}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
slotProps={{ paper: { sx: paperStyles } }}
|
||||
sx={[{ width: { xs: 1, sm: 260 } }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder="Search..."
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Iconify icon="eva:search-fill" sx={{ ml: 1, color: 'text.disabled' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading ? <CircularProgress size={18} color="inherit" sx={{ mr: -3 }} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, product, { inputValue }) => {
|
||||
const matches = match(product.name, inputValue);
|
||||
const parts = parse(product.name, matches);
|
||||
|
||||
return (
|
||||
<li {...props} key={product.id}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
href={redirectPath(product.id)}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
>
|
||||
<Avatar
|
||||
key={product.id}
|
||||
alt={product.name}
|
||||
src={product.coverUrl}
|
||||
variant="rounded"
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
flexShrink: 0,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div key={inputValue}>
|
||||
{parts.map((part, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
component="span"
|
||||
color={part.highlight ? 'primary' : 'textPrimary'}
|
||||
sx={{
|
||||
typography: 'body2',
|
||||
fontWeight: part.highlight ? 'fontWeightSemiBold' : 'fontWeightMedium',
|
||||
}}
|
||||
>
|
||||
{part.text}
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -0,0 +1,85 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import type { GridProps } from '@mui/material/Grid';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import type { PaperProps } from '@mui/material/Paper';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ProductItemSkeletonProps = PaperProps & {
|
||||
itemCount?: number;
|
||||
};
|
||||
|
||||
export function ProductItemSkeleton({ sx, itemCount = 16, ...other }: ProductItemSkeletonProps) {
|
||||
return Array.from({ length: itemCount }, (_, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
variant="outlined"
|
||||
sx={[{ borderRadius: 2 }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
{...other}
|
||||
>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Skeleton sx={{ pt: '100%' }} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2} sx={{ p: 3, pt: 2 }}>
|
||||
<Skeleton sx={{ width: 0.5, height: 16 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Skeleton variant="circular" sx={{ width: 16, height: 16 }} />
|
||||
<Skeleton variant="circular" sx={{ width: 16, height: 16 }} />
|
||||
<Skeleton variant="circular" sx={{ width: 16, height: 16 }} />
|
||||
</Box>
|
||||
|
||||
<Skeleton sx={{ width: 40, height: 16 }} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function ProductDetailsSkeleton({ ...other }: GridProps) {
|
||||
return (
|
||||
<Grid container spacing={8} {...other}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 7 }}>
|
||||
<Skeleton sx={{ pt: '100%' }} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6, lg: 5 }}>
|
||||
<Stack spacing={3}>
|
||||
<Skeleton sx={{ height: 16, width: 48 }} />
|
||||
<Skeleton sx={{ height: 16, width: 80 }} />
|
||||
<Skeleton sx={{ height: 16, width: 0.5 }} />
|
||||
<Skeleton sx={{ height: 16, width: 0.75 }} />
|
||||
<Skeleton sx={{ height: 120 }} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
gap: 2,
|
||||
width: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="circular" sx={{ width: 80, height: 80 }} />
|
||||
<Skeleton sx={{ height: 16, width: 160 }} />
|
||||
<Skeleton sx={{ height: 16, width: 80 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
@@ -0,0 +1,70 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
sort: string;
|
||||
onSort: (newValue: string) => void;
|
||||
sortOptions: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function ProductSort({ sort, onSort, sortOptions }: Props) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const sortLabel = sortOptions.find((option) => option.value === sort)?.label;
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
>
|
||||
<MenuList>
|
||||
{sortOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
selected={option.value === sort}
|
||||
onClick={() => {
|
||||
menuActions.onClose();
|
||||
onSort(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disableRipple
|
||||
color="inherit"
|
||||
onClick={menuActions.onOpen}
|
||||
endIcon={
|
||||
<Iconify
|
||||
icon={menuActions.open ? 'eva:arrow-ios-upward-fill' : 'eva:arrow-ios-downward-fill'}
|
||||
/>
|
||||
}
|
||||
sx={{ fontWeight: 'fontWeightSemiBold' }}
|
||||
>
|
||||
Sort by:
|
||||
<Box component="span" sx={{ ml: 0.5, fontWeight: 'fontWeightBold' }}>
|
||||
{sortLabel}
|
||||
</Box>
|
||||
</Button>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
import Chip from '@mui/material/Chip';
|
||||
import { upperFirst } from 'es-toolkit';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import type { FiltersResultProps } from 'src/components/filters-result';
|
||||
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
|
||||
import type { IProductTableFilters } from 'src/types/party-event';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = FiltersResultProps & {
|
||||
filters: UseSetStateReturn<IProductTableFilters>;
|
||||
};
|
||||
|
||||
export function ProductTableFiltersResult({ filters, totalResults, sx }: Props) {
|
||||
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||
|
||||
const handleRemoveStock = useCallback(
|
||||
(inputValue: string) => {
|
||||
const newValue = currentFilters.stock.filter((item) => item !== inputValue);
|
||||
|
||||
updateFilters({ stock: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.stock]
|
||||
);
|
||||
|
||||
const handleRemovePublish = useCallback(
|
||||
(inputValue: string) => {
|
||||
const newValue = currentFilters.publish.filter((item) => item !== inputValue);
|
||||
|
||||
updateFilters({ publish: newValue });
|
||||
},
|
||||
[updateFilters, currentFilters.publish]
|
||||
);
|
||||
|
||||
return (
|
||||
<FiltersResult totalResults={totalResults} onReset={() => resetFilters()} sx={sx}>
|
||||
<FiltersBlock label="Stock:" isShow={!!currentFilters.stock.length}>
|
||||
{currentFilters.stock.map((item) => (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={item}
|
||||
label={upperFirst(item)}
|
||||
onDelete={() => handleRemoveStock(item)}
|
||||
/>
|
||||
))}
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Publish:" isShow={!!currentFilters.publish.length}>
|
||||
{currentFilters.publish.map((item) => (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={item}
|
||||
label={upperFirst(item)}
|
||||
onDelete={() => handleRemovePublish(item)}
|
||||
/>
|
||||
))}
|
||||
</FiltersBlock>
|
||||
</FiltersResult>
|
||||
);
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
// src/sections/product/product-table-row.tsx
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Link from '@mui/material/Link';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import type { GridCellParams } from '@mui/x-data-grid';
|
||||
import { Label } from 'src/components/label';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { fCurrency } from 'src/utils/format-number';
|
||||
import { fDate, fTime } from 'src/utils/format-time';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ParamsProps = {
|
||||
params: GridCellParams;
|
||||
};
|
||||
|
||||
export function RenderCellPrice({ params }: ParamsProps) {
|
||||
return fCurrency(params.row.price);
|
||||
}
|
||||
|
||||
export function RenderCellPublish({ params }: ParamsProps) {
|
||||
return (
|
||||
<Label variant="soft" color={params.row.publish === 'published' ? 'info' : 'default'}>
|
||||
{params.row.publish}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderCellCreatedAt({ params }: ParamsProps) {
|
||||
return (
|
||||
<Box sx={{ gap: 0.5, display: 'flex', flexDirection: 'column' }}>
|
||||
<span>{fDate(params.row.createdAt)}</span>
|
||||
<Box component="span" sx={{ typography: 'caption', color: 'text.secondary' }}>
|
||||
{fTime(params.row.createdAt)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderCellStock({ params }: ParamsProps) {
|
||||
return (
|
||||
<Box sx={{ width: 1, typography: 'caption', color: 'text.secondary' }}>
|
||||
<LinearProgress
|
||||
value={(params.row.available * 100) / params.row.quantity}
|
||||
variant="determinate"
|
||||
color={
|
||||
(params.row.inventoryType === 'out of stock' && 'error') ||
|
||||
(params.row.inventoryType === 'low stock' && 'warning') ||
|
||||
'success'
|
||||
}
|
||||
sx={{ mb: 1, height: 6, width: 80 }}
|
||||
/>
|
||||
{!!params.row.available && params.row.available} {params.row.inventoryType}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderCellProduct({ params, href }: ParamsProps & { href: string }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
py: 2,
|
||||
gap: 2,
|
||||
width: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
alt={params.row.name}
|
||||
src={params.row.coverUrl}
|
||||
variant="rounded"
|
||||
sx={{ width: 64, height: 64 }}
|
||||
/>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Link component={RouterLink} href={href} color="inherit">
|
||||
{params.row.name}
|
||||
</Link>
|
||||
}
|
||||
secondary={params.row.category}
|
||||
slotProps={{
|
||||
primary: { noWrap: true },
|
||||
secondary: { sx: { color: 'text.disabled' } },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,184 @@
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import type { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Select from '@mui/material/Select';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import type { IProductTableFilters } from 'src/types/party-event';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
filters: UseSetStateReturn<IProductTableFilters>;
|
||||
options: {
|
||||
stocks: { value: string; label: string }[];
|
||||
publishs: { value: string; label: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
export function ProductTableToolbar({ filters, options }: Props) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const { state: currentFilters, setState: updateFilters } = filters;
|
||||
|
||||
const [stock, setStock] = useState(currentFilters.stock);
|
||||
const [publish, setPublish] = useState(currentFilters.publish);
|
||||
|
||||
const handleChangeStock = useCallback((event: SelectChangeEvent<string[]>) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
|
||||
setStock(typeof value === 'string' ? value.split(',') : value);
|
||||
}, []);
|
||||
|
||||
const handleChangePublish = useCallback((event: SelectChangeEvent<string[]>) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
|
||||
setPublish(typeof value === 'string' ? value.split(',') : value);
|
||||
}, []);
|
||||
|
||||
const handleFilterStock = useCallback(() => {
|
||||
updateFilters({ stock });
|
||||
}, [updateFilters, stock]);
|
||||
|
||||
const handleFilterPublish = useCallback(() => {
|
||||
updateFilters({ publish });
|
||||
}, [publish, updateFilters]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:printer-minimalistic-bold" />
|
||||
Print
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:import-bold" />
|
||||
Import
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:export-bold" />
|
||||
Export
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl sx={{ flexShrink: 0, width: { xs: 1, md: 200 } }}>
|
||||
<InputLabel htmlFor="filter-stock-select">{t('Stock')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={stock}
|
||||
onChange={handleChangeStock}
|
||||
onClose={handleFilterStock}
|
||||
input={<OutlinedInput label="Stock" />}
|
||||
renderValue={(selected) => selected.map((value) => t(value)).join(', ')}
|
||||
inputProps={{ id: 'filter-stock-select' }}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{options.stocks.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
<Checkbox
|
||||
disableRipple
|
||||
size="small"
|
||||
checked={stock.includes(option.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
id: `${option.value}-checkbox`,
|
||||
'aria-label': `${option.label} checkbox`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem
|
||||
onClick={handleFilterStock}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
justifyContent: 'center',
|
||||
fontWeight: theme.typography.button,
|
||||
bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
|
||||
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{t('Apply')}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ flexShrink: 0, width: { xs: 1, md: 200 } }}>
|
||||
<InputLabel htmlFor="filter-publish-select">{t('Publish')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={publish}
|
||||
onChange={handleChangePublish}
|
||||
onClose={handleFilterPublish}
|
||||
input={<OutlinedInput label={t('Publish')} />}
|
||||
renderValue={(selected) => selected.map((value) => t(value)).join(', ')}
|
||||
inputProps={{ id: 'filter-publish-select' }}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{options.publishs.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
<Checkbox
|
||||
disableRipple
|
||||
size="small"
|
||||
checked={publish.includes(option.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
id: `${option.value}-checkbox`,
|
||||
'aria-label': `${option.label} checkbox`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<MenuItem
|
||||
disableGutters
|
||||
disableTouchRipple
|
||||
onClick={handleFilterPublish}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
justifyContent: 'center',
|
||||
fontWeight: theme.typography.button,
|
||||
bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
|
||||
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{t('Apply')}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,38 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { useBoolean } from 'minimal-shared/hooks';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function ConfirmDeleteProductDialog() {
|
||||
const openDialog = useBoolean();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="info" variant="outlined" onClick={openDialog.onTrue}>
|
||||
Open alert dialog
|
||||
</Button>
|
||||
|
||||
<Dialog open onClose={openDialog.onFalse}>
|
||||
<DialogTitle>Are you sure delete product ?</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ color: 'text.secondary' }}>
|
||||
Are you sure delete product ?
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button variant="outlined" onClick={openDialog.onFalse}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button loading variant="contained" onClick={openDialog.onFalse} autoFocus>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
11
03_source/frontend/src/sections/party-event/view/index.ts
Normal file
11
03_source/frontend/src/sections/party-event/view/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from './party-event-edit-view';
|
||||
|
||||
export * from './party-event-shop-view';
|
||||
|
||||
export * from './party-event-list-view';
|
||||
|
||||
export * from './party-event-create-view';
|
||||
|
||||
export * from './party-event-details-view';
|
||||
|
||||
export * from './party-event-shop-details-view';
|
@@ -0,0 +1,24 @@
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { ProductNewEditForm } from '../party-event-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function PartyEventCreateView() {
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Create a new product"
|
||||
links={[
|
||||
{ name: 'Dashboard', href: paths.dashboard.root },
|
||||
{ name: 'Product', href: paths.dashboard.product.root },
|
||||
{ name: 'New product' },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<ProductNewEditForm />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,186 @@
|
||||
// src/sections/product/view/product-details-view.tsx
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useTabs } from 'minimal-shared/hooks';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// import { PRODUCT_PUBLISH_OPTIONS } from 'src/_mock';
|
||||
import { EmptyContent } from 'src/components/empty-content';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { ProductDetailsCarousel } from '../party-event-details-carousel';
|
||||
import { ProductDetailsDescription } from '../party-event-details-description';
|
||||
import { ProductDetailsReview } from '../party-event-details-review';
|
||||
import { ProductDetailsSummary } from '../party-event-details-summary';
|
||||
import { ProductDetailsToolbar } from '../party-event-details-toolbar';
|
||||
import { ProductDetailsSkeleton } from '../party-event-skeleton';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SUMMARY = [
|
||||
{
|
||||
title: '100% original',
|
||||
description: 'Chocolate bar candy canes ice cream toffee cookie halvah.',
|
||||
icon: 'solar:verified-check-bold',
|
||||
},
|
||||
{
|
||||
title: '10 days replacement',
|
||||
description: 'Marshmallow biscuit donut dragée fruitcake wafer.',
|
||||
icon: 'solar:clock-circle-bold',
|
||||
},
|
||||
{
|
||||
title: 'Year warranty',
|
||||
description: 'Cotton candy gingerbread cake I love sugar sweet.',
|
||||
icon: 'solar:shield-check-bold',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
product?: IProductItem;
|
||||
loading?: boolean;
|
||||
error?: any;
|
||||
};
|
||||
|
||||
export function PartyEventDetailsView({ product, error, loading }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tabs = useTabs('description');
|
||||
|
||||
const [publish, setPublish] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setPublish(product?.publish);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const handleChangePublish = useCallback((newValue: string) => {
|
||||
setPublish(newValue);
|
||||
}, []);
|
||||
|
||||
const PRODUCT_PUBLISH_OPTIONS = [
|
||||
{ value: 'published', label: t('Published') },
|
||||
{ value: 'draft', label: t('Draft') },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardContent sx={{ pt: 5 }}>
|
||||
<ProductDetailsSkeleton />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<DashboardContent sx={{ pt: 5 }}>
|
||||
<EmptyContent
|
||||
filled
|
||||
title={t('Product not found!')}
|
||||
action={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.product.root}
|
||||
startIcon={<Iconify width={16} icon="eva:arrow-ios-back-fill" />}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{t('Back to list')}
|
||||
</Button>
|
||||
}
|
||||
sx={{ py: 10, height: 'auto', flexGrow: 'unset' }}
|
||||
/>
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<ProductDetailsToolbar
|
||||
backHref={paths.dashboard.product.root}
|
||||
liveHref={paths.product.details(`${product?.id}`)}
|
||||
editHref={paths.dashboard.product.edit(`${product?.id}`)}
|
||||
publish={publish}
|
||||
onChangePublish={handleChangePublish}
|
||||
publishOptions={PRODUCT_PUBLISH_OPTIONS}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 3, md: 5, lg: 8 }}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 7 }}>
|
||||
<ProductDetailsCarousel images={product?.images ?? []} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6, lg: 5 }}>
|
||||
{product && <ProductDetailsSummary disableActions product={product} />}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 5,
|
||||
my: 10,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{SUMMARY.map((item) => (
|
||||
<Box key={item.title} sx={{ textAlign: 'center', px: 5 }}>
|
||||
<Iconify icon={item.icon} width={32} sx={{ color: 'primary.main' }} />
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, mt: 2 }}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
value={tabs.value}
|
||||
onChange={tabs.onChange}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
px: 3,
|
||||
boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{[
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'reviews', label: `Reviews (${product?.reviews.length})` },
|
||||
].map((tab) => (
|
||||
<Tab key={tab.value} value={tab.value} label={tab.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{tabs.value === 'description' && (
|
||||
<ProductDetailsDescription description={product?.description ?? ''} />
|
||||
)}
|
||||
|
||||
{tabs.value === 'reviews' && (
|
||||
<ProductDetailsReview
|
||||
ratings={product?.ratings ?? []}
|
||||
reviews={product?.reviews ?? []}
|
||||
totalRatings={product?.totalRatings ?? 0}
|
||||
totalReviews={product?.totalReviews ?? 0}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { ProductNewEditForm } from '../party-event-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
product?: IProductItem;
|
||||
};
|
||||
|
||||
export function PartyEventEditView({ product }: Props) {
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Edit"
|
||||
backHref={paths.dashboard.product.root}
|
||||
links={[
|
||||
{ name: 'Dashboard', href: paths.dashboard.root },
|
||||
{ name: 'Product', href: paths.dashboard.product.root },
|
||||
{ name: product?.name },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<ProductNewEditForm currentProduct={product} />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,493 @@
|
||||
// src/sections/product/view/product-list-view.tsx
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import Link from '@mui/material/Link';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import type {
|
||||
GridActionsCellItemProps,
|
||||
GridColDef,
|
||||
GridColumnVisibilityModel,
|
||||
GridRowSelectionModel,
|
||||
GridSlotProps,
|
||||
} from '@mui/x-data-grid';
|
||||
import {
|
||||
DataGrid,
|
||||
GridActionsCellItem,
|
||||
gridClasses,
|
||||
GridToolbarColumnsButton,
|
||||
GridToolbarContainer,
|
||||
GridToolbarExport,
|
||||
GridToolbarFilterButton,
|
||||
GridToolbarQuickFilter,
|
||||
} from '@mui/x-data-grid';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { useBoolean, useSetState } from 'minimal-shared/hooks';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// import { PRODUCT_STOCK_OPTIONS } from 'src/_mock';
|
||||
import { deleteProduct, useGetProducts } from 'src/actions/party-event';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { ConfirmDialog } from 'src/components/custom-dialog';
|
||||
import { EmptyContent } from 'src/components/empty-content';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { toast } from 'src/components/snackbar';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { endpoints } from 'src/lib/axios';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductItem, IProductTableFilters } from 'src/types/party-event';
|
||||
import { mutate } from 'swr';
|
||||
import { ProductTableFiltersResult } from '../party-event-table-filters-result';
|
||||
import {
|
||||
RenderCellCreatedAt,
|
||||
RenderCellPrice,
|
||||
RenderCellProduct,
|
||||
RenderCellPublish,
|
||||
RenderCellStock,
|
||||
} from '../party-event-table-row';
|
||||
import { ProductTableToolbar } from '../party-event-table-toolbar';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const HIDE_COLUMNS = { category: false };
|
||||
|
||||
const HIDE_COLUMNS_TOGGLABLE = ['category', 'actions'];
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function PartyEventListView() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const confirmDialog = useBoolean();
|
||||
|
||||
const PRODUCT_STOCK_OPTIONS = [
|
||||
{ value: 'in stock', label: t('In stock') },
|
||||
{ value: 'low stock', label: t('Low stock') },
|
||||
{ value: 'out of stock', label: t('Out of stock') },
|
||||
];
|
||||
|
||||
const PUBLISH_OPTIONS = [
|
||||
{ value: 'published', label: t('Published') },
|
||||
{ value: 'draft', label: t('Draft') },
|
||||
];
|
||||
|
||||
const confirmDeleteMultiItemsDialog = useBoolean();
|
||||
|
||||
const confirmDeleteSingleItemDialog = useBoolean();
|
||||
const [idToDelete, setIdToDelete] = useState<string | null>(null);
|
||||
|
||||
const { products, productsLoading } = useGetProducts();
|
||||
|
||||
const [tableData, setTableData] = useState<IProductItem[]>(products);
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<GridRowSelectionModel>([]);
|
||||
const [filterButtonEl, setFilterButtonEl] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const filters = useSetState<IProductTableFilters>({ publish: [], stock: [] });
|
||||
const { state: currentFilters } = filters;
|
||||
|
||||
const [columnVisibilityModel, setColumnVisibilityModel] =
|
||||
useState<GridColumnVisibilityModel>(HIDE_COLUMNS);
|
||||
|
||||
useEffect(() => {
|
||||
if (products.length) {
|
||||
setTableData(products);
|
||||
}
|
||||
}, [products]);
|
||||
|
||||
const canReset = currentFilters.publish.length > 0 || currentFilters.stock.length > 0;
|
||||
|
||||
const dataFiltered = applyFilter({
|
||||
inputData: tableData,
|
||||
filters: currentFilters,
|
||||
});
|
||||
|
||||
const handleDeleteSingleRow = useCallback(async () => {
|
||||
// const deleteRow = tableData.filter((row) => row.id !== id);
|
||||
|
||||
try {
|
||||
if (idToDelete) {
|
||||
await deleteProduct(idToDelete);
|
||||
toast.success('Delete success!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Delete failed!');
|
||||
}
|
||||
|
||||
// setTableData(deleteRow);
|
||||
setDeleteInProgress(false);
|
||||
}, [idToDelete, mutate]);
|
||||
|
||||
// NOTE: this is working using example from calendar
|
||||
const handleDeleteRow = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
|
||||
// invalidate cache to reload list
|
||||
await mutate(endpoints.product.list);
|
||||
|
||||
toast.success('Delete success!');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast.error('Delete error!');
|
||||
}
|
||||
},
|
||||
[tableData]
|
||||
);
|
||||
|
||||
const handleDeleteRows = useCallback(() => {
|
||||
const deleteRows = tableData.filter((row) => !selectedRowIds.includes(row.id));
|
||||
|
||||
toast.success('Delete success!');
|
||||
|
||||
setTableData(deleteRows);
|
||||
}, [selectedRowIds, tableData]);
|
||||
|
||||
const CustomToolbarCallback = useCallback(
|
||||
() => (
|
||||
<CustomToolbar
|
||||
filters={filters}
|
||||
canReset={canReset}
|
||||
selectedRowIds={selectedRowIds}
|
||||
setFilterButtonEl={setFilterButtonEl}
|
||||
filteredResults={dataFiltered.length}
|
||||
onOpenConfirmDeleteRows={confirmDeleteMultiItemsDialog.onTrue}
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[currentFilters, selectedRowIds]
|
||||
);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: 'category', headerName: t('Category'), filterable: false },
|
||||
{
|
||||
field: 'name',
|
||||
headerName: t('Product'),
|
||||
flex: 1,
|
||||
minWidth: 360,
|
||||
hideable: false,
|
||||
renderCell: (params) => (
|
||||
<RenderCellProduct params={params} href={paths.dashboard.product.details(params.row.id)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
headerName: t('Create-at'),
|
||||
width: 160,
|
||||
renderCell: (params) => <RenderCellCreatedAt params={params} />,
|
||||
},
|
||||
{
|
||||
field: 'inventoryType',
|
||||
headerName: t('Stock'),
|
||||
width: 160,
|
||||
type: 'singleSelect',
|
||||
valueOptions: PRODUCT_STOCK_OPTIONS,
|
||||
renderCell: (params) => <RenderCellStock params={params} />,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
headerName: t('Price'),
|
||||
width: 140,
|
||||
editable: true,
|
||||
renderCell: (params) => <RenderCellPrice params={params} />,
|
||||
},
|
||||
{
|
||||
field: 'publish',
|
||||
headerName: t('Publish'),
|
||||
width: 110,
|
||||
type: 'singleSelect',
|
||||
editable: true,
|
||||
valueOptions: PUBLISH_OPTIONS,
|
||||
renderCell: (params) => <RenderCellPublish params={params} />,
|
||||
},
|
||||
{
|
||||
type: 'actions',
|
||||
field: 'actions',
|
||||
headerName: ' ',
|
||||
align: 'right',
|
||||
headerAlign: 'right',
|
||||
width: 80,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
getActions: (params) => [
|
||||
<GridActionsLinkItem
|
||||
showInMenu
|
||||
icon={<Iconify icon="solar:eye-bold" />}
|
||||
label="View"
|
||||
href={paths.dashboard.product.details(params.row.id)}
|
||||
/>,
|
||||
<GridActionsLinkItem
|
||||
showInMenu
|
||||
icon={<Iconify icon="solar:pen-bold" />}
|
||||
label="Edit"
|
||||
href={paths.dashboard.product.edit(params.row.id)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
showInMenu
|
||||
icon={<Iconify icon="solar:trash-bin-trash-bold" />}
|
||||
label="Delete"
|
||||
onClick={() => {
|
||||
setIdToDelete(params.row.id);
|
||||
confirmDeleteSingleItemDialog.onTrue();
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
/>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getTogglableColumns = () =>
|
||||
columns
|
||||
.filter((column) => !HIDE_COLUMNS_TOGGLABLE.includes(column.field))
|
||||
.map((column) => column.field);
|
||||
|
||||
const renderDeleteMultipleItemsConfirmDialog = () => (
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteMultiItemsDialog.value}
|
||||
onClose={confirmDeleteMultiItemsDialog.onFalse}
|
||||
title="Delete multiple products"
|
||||
content={
|
||||
<>
|
||||
Are you sure want to delete <strong> {selectedRowIds.length} </strong> items?
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
handleDeleteRows();
|
||||
confirmDeleteMultiItemsDialog.onFalse();
|
||||
}}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const [deleteInProgress, setDeleteInProgress] = useState<boolean>(false);
|
||||
const renderDeleteSingleItemConfirmDialog = () => (
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteSingleItemDialog.value}
|
||||
onClose={confirmDeleteSingleItemDialog.onFalse}
|
||||
title="Delete product"
|
||||
content={<>Are you sure want to delete item?</>}
|
||||
action={
|
||||
<Button
|
||||
loading={deleteInProgress}
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
setDeleteInProgress(true);
|
||||
handleDeleteSingleRow();
|
||||
confirmDeleteSingleItemDialog.onFalse();
|
||||
}}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<CustomBreadcrumbs
|
||||
heading={t('Product List')}
|
||||
links={[
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('Product'), href: paths.dashboard.product.root },
|
||||
{ name: t('List') },
|
||||
]}
|
||||
action={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.product.new}
|
||||
variant="contained"
|
||||
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||
>
|
||||
{t('new-product')}
|
||||
</Button>
|
||||
}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
minHeight: 640,
|
||||
flexGrow: { md: 1 },
|
||||
display: { md: 'flex' },
|
||||
height: { xs: 800, md: '1px' },
|
||||
flexDirection: { md: 'column' },
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
checkboxSelection
|
||||
disableRowSelectionOnClick
|
||||
rows={dataFiltered}
|
||||
columns={columns}
|
||||
loading={productsLoading}
|
||||
getRowHeight={() => 'auto'}
|
||||
pageSizeOptions={[5, 10, 20, { value: -1, label: 'All' }]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
|
||||
onRowSelectionModelChange={(newSelectionModel) => setSelectedRowIds(newSelectionModel)}
|
||||
columnVisibilityModel={columnVisibilityModel}
|
||||
onColumnVisibilityModelChange={(newModel) => setColumnVisibilityModel(newModel)}
|
||||
slots={{
|
||||
toolbar: CustomToolbarCallback,
|
||||
noRowsOverlay: () => <EmptyContent />,
|
||||
noResultsOverlay: () => <EmptyContent title="No results found" />,
|
||||
}}
|
||||
slotProps={{
|
||||
toolbar: { setFilterButtonEl },
|
||||
panel: { anchorEl: filterButtonEl },
|
||||
columnsManagement: { getTogglableColumns },
|
||||
}}
|
||||
sx={{ [`& .${gridClasses.cell}`]: { alignItems: 'center', display: 'inline-flex' } }}
|
||||
/>
|
||||
</Card>
|
||||
</DashboardContent>
|
||||
|
||||
{renderDeleteMultipleItemsConfirmDialog()}
|
||||
{renderDeleteSingleItemConfirmDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
declare module '@mui/x-data-grid' {
|
||||
interface ToolbarPropsOverrides {
|
||||
setFilterButtonEl: React.Dispatch<React.SetStateAction<HTMLButtonElement | null>>;
|
||||
}
|
||||
}
|
||||
|
||||
type CustomToolbarProps = GridSlotProps['toolbar'] & {
|
||||
canReset: boolean;
|
||||
filteredResults: number;
|
||||
selectedRowIds: GridRowSelectionModel;
|
||||
filters: UseSetStateReturn<IProductTableFilters>;
|
||||
|
||||
onOpenConfirmDeleteRows: () => void;
|
||||
};
|
||||
|
||||
function CustomToolbar({
|
||||
filters,
|
||||
canReset,
|
||||
selectedRowIds,
|
||||
filteredResults,
|
||||
setFilterButtonEl,
|
||||
onOpenConfirmDeleteRows,
|
||||
}: CustomToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const PRODUCT_STOCK_OPTIONS = [
|
||||
{ value: 'in stock', label: t('In stock') },
|
||||
{ value: 'low stock', label: t('Low stock') },
|
||||
{ value: 'out of stock', label: t('Out of stock') },
|
||||
];
|
||||
|
||||
const PUBLISH_OPTIONS = [
|
||||
{ value: 'published', label: t('Published') },
|
||||
{ value: 'draft', label: t('Draft') },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridToolbarContainer>
|
||||
<ProductTableToolbar
|
||||
filters={filters}
|
||||
options={{ stocks: PRODUCT_STOCK_OPTIONS, publishs: PUBLISH_OPTIONS }}
|
||||
/>
|
||||
|
||||
<GridToolbarQuickFilter />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 1,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
{!!selectedRowIds.length && (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<Iconify icon="solar:trash-bin-trash-bold" />}
|
||||
onClick={onOpenConfirmDeleteRows}
|
||||
>
|
||||
Delete ({selectedRowIds.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<GridToolbarColumnsButton />
|
||||
<GridToolbarFilterButton ref={setFilterButtonEl} />
|
||||
<GridToolbarExport />
|
||||
</Box>
|
||||
</GridToolbarContainer>
|
||||
|
||||
{canReset && (
|
||||
<ProductTableFiltersResult
|
||||
filters={filters}
|
||||
totalResults={filteredResults}
|
||||
sx={{ p: 2.5, pt: 0 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type GridActionsLinkItemProps = Pick<GridActionsCellItemProps, 'icon' | 'label' | 'showInMenu'> & {
|
||||
href: string;
|
||||
sx?: SxProps<Theme>;
|
||||
ref?: React.RefObject<HTMLLIElement | null>;
|
||||
};
|
||||
|
||||
export function GridActionsLinkItem({ ref, href, label, icon, sx }: GridActionsLinkItemProps) {
|
||||
return (
|
||||
<MenuItem ref={ref} sx={sx}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
href={href}
|
||||
underline="none"
|
||||
color="inherit"
|
||||
sx={{ width: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{icon && <ListItemIcon>{icon}</ListItemIcon>}
|
||||
{label}
|
||||
</Link>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ApplyFilterProps = {
|
||||
inputData: IProductItem[];
|
||||
filters: IProductTableFilters;
|
||||
};
|
||||
|
||||
function applyFilter({ inputData, filters }: ApplyFilterProps) {
|
||||
const { stock, publish } = filters;
|
||||
|
||||
if (stock.length) {
|
||||
inputData = inputData.filter((product) => stock.includes(product.inventoryType));
|
||||
}
|
||||
|
||||
if (publish.length) {
|
||||
inputData = inputData.filter((product) => publish.includes(product.publish));
|
||||
}
|
||||
|
||||
return inputData;
|
||||
}
|
@@ -0,0 +1,182 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import Container from '@mui/material/Container';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useTabs } from 'minimal-shared/hooks';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { EmptyContent } from 'src/components/empty-content';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductItem } from 'src/types/party-event';
|
||||
import { useCheckoutContext } from '../../checkout/context';
|
||||
import { CartIcon } from '../cart-icon';
|
||||
import { ProductDetailsCarousel } from '../party-event-details-carousel';
|
||||
import { ProductDetailsDescription } from '../party-event-details-description';
|
||||
import { ProductDetailsReview } from '../party-event-details-review';
|
||||
import { ProductDetailsSummary } from '../party-event-details-summary';
|
||||
import { ProductDetailsSkeleton } from '../party-event-skeleton';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SUMMARY = [
|
||||
{
|
||||
title: '100% original',
|
||||
description: 'Chocolate bar candy canes ice cream toffee cookie halvah.',
|
||||
icon: 'solar:verified-check-bold',
|
||||
},
|
||||
{
|
||||
title: '10 days replacement',
|
||||
description: 'Marshmallow biscuit donut dragée fruitcake wafer.',
|
||||
icon: 'solar:clock-circle-bold',
|
||||
},
|
||||
{
|
||||
title: 'Year warranty',
|
||||
description: 'Cotton candy gingerbread cake I love sugar sweet.',
|
||||
icon: 'solar:shield-check-bold',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
product?: IProductItem;
|
||||
loading?: boolean;
|
||||
error?: any;
|
||||
};
|
||||
|
||||
export function ProductShopDetailsView({ product, error, loading }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { state: checkoutState, onAddToCart } = useCheckoutContext();
|
||||
|
||||
const containerStyles: SxProps<Theme> = {
|
||||
mt: 5,
|
||||
mb: 10,
|
||||
};
|
||||
|
||||
const tabs = useTabs('description');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container sx={containerStyles}>
|
||||
<ProductDetailsSkeleton />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container sx={containerStyles}>
|
||||
<EmptyContent
|
||||
filled
|
||||
title={t('Product not found!')}
|
||||
action={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.product.root}
|
||||
startIcon={<Iconify width={16} icon="eva:arrow-ios-back-fill" />}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
}
|
||||
sx={{ py: 10 }}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container sx={containerStyles}>
|
||||
<CartIcon totalItems={checkoutState.totalItems} />
|
||||
|
||||
<CustomBreadcrumbs
|
||||
links={[
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Shop', href: paths.product.root },
|
||||
{ name: product?.name },
|
||||
]}
|
||||
sx={{ mb: 5 }}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 3, md: 5, lg: 8 }}>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 7 }}>
|
||||
<ProductDetailsCarousel images={product?.images} />
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6, lg: 5 }}>
|
||||
{product && (
|
||||
<ProductDetailsSummary
|
||||
product={product}
|
||||
items={checkoutState.items}
|
||||
onAddToCart={onAddToCart}
|
||||
disableActions={!product?.available}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box
|
||||
sx={{
|
||||
gap: 5,
|
||||
my: 10,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{SUMMARY.map((item) => (
|
||||
<Box key={item.title} sx={{ textAlign: 'center', px: 5 }}>
|
||||
<Iconify icon={item.icon} width={32} sx={{ color: 'primary.main' }} />
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, mt: 2 }}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
value={tabs.value}
|
||||
onChange={tabs.onChange}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
px: 3,
|
||||
boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{[
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'reviews', label: `Reviews (${product?.reviews.length})` },
|
||||
].map((tab) => (
|
||||
<Tab key={tab.value} value={tab.value} label={tab.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{tabs.value === 'description' && (
|
||||
<ProductDetailsDescription description={product?.description} />
|
||||
)}
|
||||
|
||||
{tabs.value === 'reviews' && (
|
||||
<ProductDetailsReview
|
||||
ratings={product?.ratings}
|
||||
reviews={product?.reviews}
|
||||
totalRatings={product?.totalRatings}
|
||||
totalReviews={product?.totalReviews}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
@@ -0,0 +1,193 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { orderBy } from 'es-toolkit';
|
||||
import { useBoolean, useSetState } from 'minimal-shared/hooks';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
PRODUCT_CATEGORY_OPTIONS,
|
||||
PRODUCT_COLOR_OPTIONS,
|
||||
PRODUCT_GENDER_OPTIONS,
|
||||
PRODUCT_RATING_OPTIONS,
|
||||
PRODUCT_SORT_OPTIONS,
|
||||
} from 'src/_mock';
|
||||
import { EmptyContent } from 'src/components/empty-content';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IProductFilters, IProductItem } from 'src/types/party-event';
|
||||
import { useCheckoutContext } from '../../checkout/context';
|
||||
import { CartIcon } from '../cart-icon';
|
||||
import { ProductFiltersDrawer } from '../party-event-filters-drawer';
|
||||
import { ProductFiltersResult } from '../party-event-filters-result';
|
||||
import { ProductList } from '../party-event-list';
|
||||
import { ProductSearch } from '../party-event-search';
|
||||
import { ProductSort } from '../party-event-sort';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
products: IProductItem[];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function ProductShopView({ products, loading }: Props) {
|
||||
const { state: checkoutState } = useCheckoutContext();
|
||||
|
||||
const openFilters = useBoolean();
|
||||
|
||||
const [sortBy, setSortBy] = useState('featured');
|
||||
|
||||
const filters = useSetState<IProductFilters>({
|
||||
gender: [],
|
||||
colors: [],
|
||||
rating: '',
|
||||
category: 'all',
|
||||
priceRange: [0, 200],
|
||||
});
|
||||
const { state: currentFilters } = filters;
|
||||
|
||||
const dataFiltered = applyFilter({
|
||||
inputData: products,
|
||||
filters: currentFilters,
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const canReset =
|
||||
currentFilters.gender.length > 0 ||
|
||||
currentFilters.colors.length > 0 ||
|
||||
currentFilters.rating !== '' ||
|
||||
currentFilters.category !== 'all' ||
|
||||
currentFilters.priceRange[0] !== 0 ||
|
||||
currentFilters.priceRange[1] !== 200;
|
||||
|
||||
const notFound = !dataFiltered.length && canReset;
|
||||
const isEmpty = !loading && !products.length;
|
||||
|
||||
const renderFilters = () => (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
alignItems: { xs: 'flex-end', sm: 'center' },
|
||||
}}
|
||||
>
|
||||
<ProductSearch redirectPath={(id: string) => paths.product.details(id)} />
|
||||
|
||||
<Box sx={{ gap: 1, flexShrink: 0, display: 'flex' }}>
|
||||
<ProductFiltersDrawer
|
||||
filters={filters}
|
||||
canReset={canReset}
|
||||
open={openFilters.value}
|
||||
onOpen={openFilters.onTrue}
|
||||
onClose={openFilters.onFalse}
|
||||
options={{
|
||||
colors: PRODUCT_COLOR_OPTIONS,
|
||||
ratings: PRODUCT_RATING_OPTIONS,
|
||||
genders: PRODUCT_GENDER_OPTIONS,
|
||||
categories: ['all', ...PRODUCT_CATEGORY_OPTIONS],
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProductSort
|
||||
sort={sortBy}
|
||||
onSort={(newValue: string) => setSortBy(newValue)}
|
||||
sortOptions={PRODUCT_SORT_OPTIONS}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderResults = () => (
|
||||
<ProductFiltersResult filters={filters} totalResults={dataFiltered.length} />
|
||||
);
|
||||
|
||||
const renderNotFound = () => <EmptyContent filled sx={{ py: 10 }} />;
|
||||
|
||||
return (
|
||||
<Container sx={{ mb: 10 }}>
|
||||
<CartIcon totalItems={checkoutState.totalItems} />
|
||||
|
||||
<Typography variant="h4" sx={{ my: { xs: 3, md: 5 } }}>
|
||||
Shop
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2.5} sx={{ mb: { xs: 3, md: 5 } }}>
|
||||
{renderFilters()}
|
||||
{canReset && renderResults()}
|
||||
</Stack>
|
||||
|
||||
{notFound || isEmpty ? (
|
||||
renderNotFound()
|
||||
) : (
|
||||
<ProductList products={dataFiltered} loading={loading} />
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ApplyFilterProps = {
|
||||
sortBy: string;
|
||||
filters: IProductFilters;
|
||||
inputData: IProductItem[];
|
||||
};
|
||||
|
||||
function applyFilter({ inputData, filters, sortBy }: ApplyFilterProps) {
|
||||
const { gender, category, colors, priceRange, rating } = filters;
|
||||
|
||||
const min = priceRange[0];
|
||||
const max = priceRange[1];
|
||||
|
||||
// Sort by
|
||||
if (sortBy === 'featured') {
|
||||
inputData = orderBy(inputData, ['totalSold'], ['desc']);
|
||||
}
|
||||
|
||||
if (sortBy === 'newest') {
|
||||
inputData = orderBy(inputData, ['createdAt'], ['desc']);
|
||||
}
|
||||
|
||||
if (sortBy === 'priceDesc') {
|
||||
inputData = orderBy(inputData, ['price'], ['desc']);
|
||||
}
|
||||
|
||||
if (sortBy === 'priceAsc') {
|
||||
inputData = orderBy(inputData, ['price'], ['asc']);
|
||||
}
|
||||
|
||||
// filters
|
||||
if (gender.length) {
|
||||
inputData = inputData.filter((product) => product.gender.some((i) => gender.includes(i)));
|
||||
}
|
||||
|
||||
if (category !== 'all') {
|
||||
inputData = inputData.filter((product) => product.category === category);
|
||||
}
|
||||
|
||||
if (colors.length) {
|
||||
inputData = inputData.filter((product) =>
|
||||
product.colors.some((color) => colors.includes(color))
|
||||
);
|
||||
}
|
||||
|
||||
if (min !== 0 || max !== 200) {
|
||||
inputData = inputData.filter((product) => product.price >= min && product.price <= max);
|
||||
}
|
||||
|
||||
if (rating) {
|
||||
inputData = inputData.filter((product) => {
|
||||
const convertRating = (value: string) => {
|
||||
if (value === 'up4Star') return 4;
|
||||
if (value === 'up3Star') return 3;
|
||||
if (value === 'up2Star') return 2;
|
||||
return 1;
|
||||
};
|
||||
return product.totalRatings > convertRating(rating);
|
||||
});
|
||||
}
|
||||
|
||||
return inputData;
|
||||
}
|
60
03_source/frontend/src/types/party-event.ts
Normal file
60
03_source/frontend/src/types/party-event.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { IDateValue } from './common';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type IProductFilters = {
|
||||
rating: string;
|
||||
gender: string[];
|
||||
category: string;
|
||||
colors: string[];
|
||||
priceRange: number[];
|
||||
};
|
||||
|
||||
export type IProductTableFilters = {
|
||||
stock: string[];
|
||||
publish: string[];
|
||||
};
|
||||
|
||||
export type IProductReview = {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
helpful: number;
|
||||
avatarUrl: string;
|
||||
postedAt: IDateValue;
|
||||
isPurchased: boolean;
|
||||
attachments?: string[];
|
||||
};
|
||||
|
||||
export type IProductItem = {
|
||||
id: string;
|
||||
createdAt: IDateValue;
|
||||
//
|
||||
available: number;
|
||||
category: string;
|
||||
code: string;
|
||||
colors: string[];
|
||||
coverUrl: string;
|
||||
description: string;
|
||||
gender: string[];
|
||||
images: string[];
|
||||
inventoryType: string;
|
||||
name: string;
|
||||
newLabel: { content: string; enabled: boolean };
|
||||
price: number;
|
||||
priceSale: number | null;
|
||||
publish: string;
|
||||
quantity: number;
|
||||
ratings: { name: string; starCount: number; reviewCount: number }[];
|
||||
reviews: IProductReview[];
|
||||
saleLabel: { content: string; enabled: boolean };
|
||||
sizes: string[];
|
||||
sku: string;
|
||||
subDescription: string;
|
||||
tags: string[];
|
||||
taxes: number;
|
||||
totalRatings: number;
|
||||
totalReviews: number;
|
||||
totalSold: number;
|
||||
};
|
Reference in New Issue
Block a user