"add PartyEvent frontend module with mock data, API actions, UI components and routing configuration"

This commit is contained in:
louiscklaw
2025-06-15 16:13:09 +08:00
parent d987b0fe36
commit a88de2f17f
43 changed files with 4480 additions and 0 deletions

View 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'] },
];

View 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
);
}

View File

@@ -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,

View File

@@ -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}
/>
</>
);
}

View 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} />
</>
);
}

View 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 />
</>
);
}

View 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 />
</>
);
}

View 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 />
</>
);
}

View 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} />
</>
);
}

View File

@@ -0,0 +1,5 @@
function Helloworld() {
return <>helloworld</>;
}
export default Helloworld;

View 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} />
</>
);
}

View File

@@ -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`,
},
},
},
};

View File

@@ -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 /> },
],
},
],
},
];

View File

@@ -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: [

View 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>
);
}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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]),
]}
/>
);
}

View File

@@ -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} />
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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()}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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' },
}}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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' },
}}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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()}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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()}
</>
);
}

View File

@@ -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>
</>
);
}

View 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';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View 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;
};