diff --git a/03_source/frontend/src/_mock/_party-event.ts b/03_source/frontend/src/_mock/_party-event.ts new file mode 100644 index 0000000..1b8cdab --- /dev/null +++ b/03_source/frontend/src/_mock/_party-event.ts @@ -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'] }, +]; diff --git a/03_source/frontend/src/actions/party-event.ts b/03_source/frontend/src/actions/party-event.ts new file mode 100644 index 0000000..06fdd46 --- /dev/null +++ b/03_source/frontend/src/actions/party-event.ts @@ -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(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(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(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) { + /** + * 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 + ); +} diff --git a/03_source/frontend/src/layouts/nav-config-dashboard.tsx b/03_source/frontend/src/layouts/nav-config-dashboard.tsx index 6eab1ab..caad4e9 100644 --- a/03_source/frontend/src/layouts/nav-config-dashboard.tsx +++ b/03_source/frontend/src/layouts/nav-config-dashboard.tsx @@ -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, diff --git a/03_source/frontend/src/pages/dashboard/party-event/details.tsx b/03_source/frontend/src/pages/dashboard/party-event/details.tsx new file mode 100644 index 0000000..160c1af --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-event/details.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-event/edit.tsx b/03_source/frontend/src/pages/dashboard/party-event/edit.tsx new file mode 100644 index 0000000..7cbc729 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-event/edit.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-event/list.tsx b/03_source/frontend/src/pages/dashboard/party-event/list.tsx new file mode 100644 index 0000000..629a7a6 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-event/list.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-event/new.tsx b/03_source/frontend/src/pages/dashboard/party-event/new.tsx new file mode 100644 index 0000000..65651c2 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-event/new.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/party-event/checkout.tsx b/03_source/frontend/src/pages/party-event/checkout.tsx new file mode 100644 index 0000000..663edf6 --- /dev/null +++ b/03_source/frontend/src/pages/party-event/checkout.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/party-event/details.tsx b/03_source/frontend/src/pages/party-event/details.tsx new file mode 100644 index 0000000..1329516 --- /dev/null +++ b/03_source/frontend/src/pages/party-event/details.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/party-event/helloworld.tsx b/03_source/frontend/src/pages/party-event/helloworld.tsx new file mode 100644 index 0000000..61d7eb4 --- /dev/null +++ b/03_source/frontend/src/pages/party-event/helloworld.tsx @@ -0,0 +1,5 @@ +function Helloworld() { + return <>helloworld; +} + +export default Helloworld; diff --git a/03_source/frontend/src/pages/party-event/list.tsx b/03_source/frontend/src/pages/party-event/list.tsx new file mode 100644 index 0000000..afdd483 --- /dev/null +++ b/03_source/frontend/src/pages/party-event/list.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/routes/paths.ts b/03_source/frontend/src/routes/paths.ts index 032ba37..999fbe8 100644 --- a/03_source/frontend/src/routes/paths.ts +++ b/03_source/frontend/src/routes/paths.ts @@ -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`, + }, + }, }, }; diff --git a/03_source/frontend/src/routes/sections/dashboard.tsx b/03_source/frontend/src/routes/sections/dashboard.tsx index 6d245db..988c89b 100644 --- a/03_source/frontend/src/routes/sections/dashboard.tsx +++ b/03_source/frontend/src/routes/sections/dashboard.tsx @@ -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: }, { path: 'params', element: }, { path: 'blank', element: }, + // + { + path: 'party-event', + children: [ + { index: true, element: }, + { path: 'list', element: }, + { path: ':id', element: }, + { path: 'new', element: }, + { path: ':id/edit', element: }, + ], + }, ], }, ]; diff --git a/03_source/frontend/src/routes/sections/main.tsx b/03_source/frontend/src/routes/sections/main.tsx index 3cdcf11..e5ca41c 100644 --- a/03_source/frontend/src/routes/sections/main.tsx +++ b/03_source/frontend/src/routes/sections/main.tsx @@ -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: }, ], }, + { + path: 'party-event', + children: [ + { index: true, element: }, + { path: 'list', element: }, + { path: ':id', element: }, + { path: 'checkout', element: }, + ], + }, { path: 'post', children: [ diff --git a/03_source/frontend/src/sections/party-event/cart-icon.tsx b/03_source/frontend/src/sections/party-event/cart-icon.tsx new file mode 100644 index 0000000..d707e43 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/cart-icon.tsx @@ -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 ( + ({ + 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} + > + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-details-carousel.tsx b/03_source/frontend/src/sections/party-event/party-event-details-carousel.tsx new file mode 100644 index 0000000..ae372af --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-details-carousel.tsx @@ -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 ( + <> +
+ + + + + {slides.map((slide) => ( + {slide.src} lightbox.onOpen(slide.src)} + sx={{ cursor: 'zoom-in', minWidth: 320 }} + /> + ))} + + + + + {slides.map((item, index) => ( + carousel.thumbs.onClickThumb(index)} + /> + ))} + +
+ + lightbox.setSelected(index)} + /> + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-details-description.tsx b/03_source/frontend/src/sections/party-event/party-event-details-description.tsx new file mode 100644 index 0000000..7b1a748 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-details-description.tsx @@ -0,0 +1,31 @@ +import type { SxProps, Theme } from '@mui/material/styles'; +import { Markdown } from 'src/components/markdown'; + +// ---------------------------------------------------------------------- + +type Props = { + description?: string; + sx?: SxProps; +}; + +export function ProductDetailsDescription({ description, sx }: Props) { + return ( + ({ + 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]), + ]} + /> + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-details-review.tsx b/03_source/frontend/src/sections/party-event/party-event-details-review.tsx new file mode 100644 index 0000000..ba43add --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-details-review.tsx @@ -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 = () => ( + + Average rating + + + {totalRatings} + /5 + + + + + + ({fShortenNumber(totalReviews)} reviews) + + + ); + + const renderProgress = () => ( + ({ + 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) => ( + + + {rating.name} + + + + + + {fShortenNumber(rating.reviewCount)} + + + ))} + + ); + + const renderReviewButton = () => ( + + + + ); + + return ( + <> + + {renderSummary()} + {renderProgress()} + {renderReviewButton()} + + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-details-summary.tsx b/03_source/frontend/src/sections/party-event/party-event-details-summary.tsx new file mode 100644 index 0000000..61dbec2 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-details-summary.tsx @@ -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({ + 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 = () => ( + + {priceSale && ( + + {fCurrency(priceSale)} + + )} + + {fCurrency(price)} + + ); + + const renderShare = () => ( + + + + Compare + + + + + Favorite + + + + + Share + + + ); + + const renderColorOptions = () => ( + + + Color + + + ( + field.onChange(color as string)} + limit={4} + /> + )} + /> + + ); + + const renderSizeOptions = () => ( + + + Size + + + + Size chart + + } + sx={{ + maxWidth: 88, + [`& .${formHelperTextClasses.root}`]: { mx: 0, mt: 1, textAlign: 'right' }, + }} + > + {sizes.map((size) => ( + + {size} + + ))} + + + ); + + const renderQuantity = () => ( + + + Quantity + + + + setValue('quantity', quantity)} + max={available} + sx={{ maxWidth: 112 }} + /> + + + Available: {available} + + + + ); + + const renderActions = () => ( + + + + + + ); + + const renderSubDescription = () => ( + + {subDescription} + + ); + + const renderRating = () => ( + + + {`(${fShortenNumber(totalReviews)} reviews)`} + + ); + + const renderLabels = () => + (newLabel.enabled || saleLabel.enabled) && ( + + {newLabel.enabled && } + {saleLabel.enabled && } + + ); + + const renderInventoryType = () => ( + + {inventoryType} + + ); + + return ( +
+ + + {renderLabels()} + {renderInventoryType()} + + {name} + + {renderRating()} + {renderPrice()} + {renderSubDescription()} + + + + + {renderColorOptions()} + {renderSizeOptions()} + {renderQuantity()} + + + + {renderActions()} + {renderShare()} + +
+ ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-details-toolbar.tsx b/03_source/frontend/src/sections/party-event/party-event-details-toolbar.tsx new file mode 100644 index 0000000..7252c64 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-details-toolbar.tsx @@ -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 = () => ( + + + {publishOptions.map((option) => ( + { + menuActions.onClose(); + onChangePublish(option.value); + }} + > + {option.value === 'published' && } + {option.value === 'draft' && } + {option.label} + + ))} + + + ); + + return ( + <> + + + + + + {publish === 'published' && ( + + + + + + )} + + + + + + + + + + + {renderMenuActions()} + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-filters-drawer.tsx b/03_source/frontend/src/sections/party-event/party-event-filters-drawer.tsx new file mode 100644 index 0000000..4507208 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-filters-drawer.tsx @@ -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; + 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 = () => ( + <> + + + Filters + + + + resetFilters()}> + + + + + + + + + + + + + + ); + + const renderGender = () => ( + + + Gender + + {options.genders.map((option) => ( + handleFilterGender(option.label)} + slotProps={{ input: { id: `${option.value}-checkbox` } }} + /> + } + /> + ))} + + ); + + const renderCategory = () => ( + + + Category + + {options.categories.map((option) => ( + handleFilterCategory(option)} + slotProps={{ input: { id: `${option}-radio` } }} + /> + } + sx={{ ...(option === 'all' && { textTransform: 'capitalize' }) }} + /> + ))} + + ); + + const renderColor = () => ( + + + Color + + + handleFilterColors(colors as string[])} + limit={6} + /> + + ); + + const renderPrice = () => ( + + Price + + + + + + + `$${value}`} + valueLabelFormat={(value) => `$${value}`} + sx={{ alignSelf: 'center', width: `calc(100% - 24px)` }} + /> + + ); + + const renderRating = () => ( + + + Rating + + + {options.ratings.map((item, index) => ( + 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' }), + }} + > + & Up + + ))} + + ); + + return ( + <> + + + + {renderHead()} + + + + {renderGender()} + {renderCategory()} + {renderColor()} + {renderPrice()} + {renderRating()} + + + + + ); +} + +// ---------------------------------------------------------------------- + +type InputRangeProps = { + value: number[]; + type: 'min' | 'max'; + onChange: UseSetStateReturn['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 ( + + + {`${type} ($)`} + + + + onFilters({ priceRange: type === 'min' ? [newValue, maxValue] : [minValue, newValue] }) + } + onBlur={handleBlur} + sx={{ maxWidth: 64 }} + slotProps={{ + input: { + sx: { + [`& .${inputBaseClasses.input}`]: { + pr: 1, + textAlign: 'right', + }, + }, + }, + }} + /> + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-filters-result.tsx b/03_source/frontend/src/sections/party-event/party-event-filters-result.tsx new file mode 100644 index 0000000..98833e0 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-filters-result.tsx @@ -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; +}; + +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 ( + resetFilters()} sx={sx}> + + {currentFilters.gender.map((item) => ( + handleRemoveGender(item)} /> + ))} + + + + + + + + {currentFilters.colors.map((item) => ( + ({ + 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)} + /> + ))} + + + + + + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-item.tsx b/03_source/frontend/src/sections/party-event/party-event-item.tsx new file mode 100644 index 0000000..62ef8f9 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-item.tsx @@ -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) && ( + + {newLabel.enabled && ( + + )} + {saleLabel.enabled && ( + + )} + + ); + + const renderImage = () => ( + + {!!available && ( + ({ + 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, + }), + }), + ]} + > + + + )} + + + {name} + + + ); + + const renderContent = () => ( + + + {name} + + + + + + + + + {priceSale && ( + + {fCurrency(priceSale)} + + )} + + {fCurrency(price)} + + + + ); + + return ( + + {renderLabels()} + {renderImage()} + {renderContent()} + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-list.tsx b/03_source/frontend/src/sections/party-event/party-event-list.tsx new file mode 100644 index 0000000..c56793a --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-list.tsx @@ -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 = () => ; + + const renderList = () => + products.map((product) => ( + + )); + + return ( + <> + ({ + 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()} + + + {products.length > 8 && ( + + )} + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-new-edit-form.tsx b/03_source/frontend/src/sections/party-event/party-event-new-edit-form.tsx new file mode 100644 index 0000000..05aa056 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-new-edit-form.tsx @@ -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; + +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({ + 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) => { + setIncludeTaxes(event.target.checked); + }, []); + + const renderCollapseButton = (value: boolean, onToggle: () => void) => ( + + + + ); + + function handleProductImageUpload() { + console.log(values); + } + + const [disableUserInput, setDisableUserInput] = useState(false); + + useEffect(() => { + setDisableUserInput(isSubmitting); + }, [isSubmitting]); + + const renderDetails = () => ( + + + + + + + + + + + + + Content + + + + + Images + + + + + + ); + + const renderProperties = () => ( + + + + + + + + + + + + + + + + {PRODUCT_CATEGORY_GROUP_OPTIONS.map((category) => ( + + {category.classify.map((classify) => ( + + ))} + + ))} + + + + + + + + option)} + getOptionLabel={(option) => option} + renderOption={(props, option) => ( +
  • + {option} +
  • + )} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + + )) + } + /> + + + Gender + + + + + + + + + + + + + + +
    +
    +
    + ); + + const renderPricing = () => ( + + + + + + + + + + $ + + + ), + }, + }} + /> + + + + $ + + + ), + }, + }} + /> + + + } + label="Price includes taxes" + /> + + {!includeTaxes && ( + + + % + + + ), + }, + }} + /> + )} + + + + ); + + const renderActions = () => ( + +
    {JSON.stringify({ errors })}
    + + } + sx={{ pl: 3, flexGrow: 1 }} + /> + + +
    + ); + + return ( +
    + + {renderDetails()} + {renderProperties()} + {renderPricing()} + {renderActions()} + +
    + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-review-item.tsx b/03_source/frontend/src/sections/party-event/party-event-review-item.tsx new file mode 100644 index 0000000..05d10d6 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-review-item.tsx @@ -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 = () => ( + + + + + + ); + + const renderContent = () => ( + + + + {review.isPurchased && ( + + + Verified purchase + + )} + + {review.comment} + + {!!review.attachments?.length && ( + + {review.attachments.map((attachment) => ( + + ))} + + )} + + + + + 123 + + + + + 34 + + + + ); + + return ( + + {renderInfo()} + {renderContent()} + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-review-list.tsx b/03_source/frontend/src/sections/party-event/party-event-review-list.tsx new file mode 100644 index 0000000..faf5098 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-review-list.tsx @@ -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) => ( + + ))} + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-review-new-form.tsx b/03_source/frontend/src/sections/party-event/party-event-review-new-form.tsx new file mode 100644 index 0000000..8ab59f9 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-review-new-form.tsx @@ -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; + +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({ + 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 ( + +
    + Add Review + + +
    + + Your review about this product: + + +
    + + + + + + +
    + + + + + + +
    +
    + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-search.tsx b/03_source/frontend/src/sections/party-event/party-event-search.tsx new file mode 100644 index 0000000..e835a75 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-search.tsx @@ -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; + redirectPath: (id: string) => string; +}; + +export function ProductSearch({ redirectPath, sx }: Props) { + const router = useRouter(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedItem, setSelectedItem] = useState(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 = { + width: 320, + [`& .${autocompleteClasses.listbox}`]: { + [`& .${autocompleteClasses.option}`]: { + p: 0, + [`& .${linkClasses.root}`]: { + p: 0.75, + gap: 1.5, + width: 1, + display: 'flex', + alignItems: 'center', + }, + }, + }, + }; + + return ( + handleChange(newValue)} + onInputChange={(event, newValue) => setSearchQuery(newValue)} + getOptionLabel={(option) => option.name} + noOptionsText={} + isOptionEqualToValue={(option, value) => option.id === value.id} + slotProps={{ paper: { sx: paperStyles } }} + sx={[{ width: { xs: 1, sm: 260 } }, ...(Array.isArray(sx) ? sx : [sx])]} + renderInput={(params) => ( + + + + ), + endAdornment: ( + <> + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + renderOption={(props, product, { inputValue }) => { + const matches = match(product.name, inputValue); + const parts = parse(product.name, matches); + + return ( +
  • + + + +
    + {parts.map((part, index) => ( + + {part.text} + + ))} +
    + +
  • + ); + }} + /> + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-skeleton.tsx b/03_source/frontend/src/sections/party-event/party-event-skeleton.tsx new file mode 100644 index 0000000..cac0eaf --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-skeleton.tsx @@ -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) => ( + + + + + + + + + + + + + + + + + + + )); +} + +// ---------------------------------------------------------------------- + +export function ProductDetailsSkeleton({ ...other }: GridProps) { + return ( + + + + + + + + + + + + + + + + + + {Array.from({ length: 3 }, (_, index) => ( + + + + + + ))} + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-sort.tsx b/03_source/frontend/src/sections/party-event/party-event-sort.tsx new file mode 100644 index 0000000..7926a78 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-sort.tsx @@ -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 = () => ( + + + {sortOptions.map((option) => ( + { + menuActions.onClose(); + onSort(option.value); + }} + > + {option.label} + + ))} + + + ); + + return ( + <> + + + {renderMenuActions()} + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-table-filters-result.tsx b/03_source/frontend/src/sections/party-event/party-event-table-filters-result.tsx new file mode 100644 index 0000000..0edd9f6 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-table-filters-result.tsx @@ -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; +}; + +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 ( + resetFilters()} sx={sx}> + + {currentFilters.stock.map((item) => ( + handleRemoveStock(item)} + /> + ))} + + + + {currentFilters.publish.map((item) => ( + handleRemovePublish(item)} + /> + ))} + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-table-row.tsx b/03_source/frontend/src/sections/party-event/party-event-table-row.tsx new file mode 100644 index 0000000..580a97d --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-table-row.tsx @@ -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 ( + + ); +} + +export function RenderCellCreatedAt({ params }: ParamsProps) { + return ( + + {fDate(params.row.createdAt)} + + {fTime(params.row.createdAt)} + + + ); +} + +export function RenderCellStock({ params }: ParamsProps) { + return ( + + + {!!params.row.available && params.row.available} {params.row.inventoryType} + + ); +} + +export function RenderCellProduct({ params, href }: ParamsProps & { href: string }) { + return ( + + + + + {params.row.name} + + } + secondary={params.row.category} + slotProps={{ + primary: { noWrap: true }, + secondary: { sx: { color: 'text.disabled' } }, + }} + /> + + ); +} diff --git a/03_source/frontend/src/sections/party-event/party-event-table-toolbar.tsx b/03_source/frontend/src/sections/party-event/party-event-table-toolbar.tsx new file mode 100644 index 0000000..b04047f --- /dev/null +++ b/03_source/frontend/src/sections/party-event/party-event-table-toolbar.tsx @@ -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; + 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) => { + const { + target: { value }, + } = event; + + setStock(typeof value === 'string' ? value.split(',') : value); + }, []); + + const handleChangePublish = useCallback((event: SelectChangeEvent) => { + 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 = () => ( + + + menuActions.onClose()}> + + Print + + + menuActions.onClose()}> + + Import + + + menuActions.onClose()}> + + Export + + + + ); + + return ( + <> + + {t('Stock')} + + + + + {t('Publish')} + + + + {renderMenuActions()} + + ); +} diff --git a/03_source/frontend/src/sections/party-event/view/confirm-delete-product-dialog.tsx b/03_source/frontend/src/sections/party-event/view/confirm-delete-product-dialog.tsx new file mode 100644 index 0000000..9edf2b0 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/confirm-delete-product-dialog.tsx @@ -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 ( + <> + + + + Are you sure delete product ? + + + Are you sure delete product ? + + + + + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/view/index.ts b/03_source/frontend/src/sections/party-event/view/index.ts new file mode 100644 index 0000000..9ba7b48 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/index.ts @@ -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'; diff --git a/03_source/frontend/src/sections/party-event/view/party-event-create-view.tsx b/03_source/frontend/src/sections/party-event/view/party-event-create-view.tsx new file mode 100644 index 0000000..0d77d8f --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/party-event-create-view.tsx @@ -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 ( + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/view/party-event-details-view.tsx b/03_source/frontend/src/sections/party-event/view/party-event-details-view.tsx new file mode 100644 index 0000000..91f0c79 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/party-event-details-view.tsx @@ -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 ( + + + + ); + } + + if (error) { + return ( + + } + sx={{ mt: 3 }} + > + {t('Back to list')} + + } + sx={{ py: 10, height: 'auto', flexGrow: 'unset' }} + /> + + ); + } + + return ( + + + + + + + + + + {product && } + + + + + {SUMMARY.map((item) => ( + + + + + {item.title} + + + + {item.description} + + + ))} + + + + ({ + 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) => ( + + ))} + + + {tabs.value === 'description' && ( + + )} + + {tabs.value === 'reviews' && ( + + )} + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/view/party-event-edit-view.tsx b/03_source/frontend/src/sections/party-event/view/party-event-edit-view.tsx new file mode 100644 index 0000000..d2439e9 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/party-event-edit-view.tsx @@ -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 ( + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/view/party-event-list-view.tsx b/03_source/frontend/src/sections/party-event/view/party-event-list-view.tsx new file mode 100644 index 0000000..b617fca --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/party-event-list-view.tsx @@ -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(null); + + const { products, productsLoading } = useGetProducts(); + + const [tableData, setTableData] = useState(products); + const [selectedRowIds, setSelectedRowIds] = useState([]); + const [filterButtonEl, setFilterButtonEl] = useState(null); + + const filters = useSetState({ publish: [], stock: [] }); + const { state: currentFilters } = filters; + + const [columnVisibilityModel, setColumnVisibilityModel] = + useState(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( + () => ( + + ), + // 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) => ( + + ), + }, + { + field: 'createdAt', + headerName: t('Create-at'), + width: 160, + renderCell: (params) => , + }, + { + field: 'inventoryType', + headerName: t('Stock'), + width: 160, + type: 'singleSelect', + valueOptions: PRODUCT_STOCK_OPTIONS, + renderCell: (params) => , + }, + { + field: 'price', + headerName: t('Price'), + width: 140, + editable: true, + renderCell: (params) => , + }, + { + field: 'publish', + headerName: t('Publish'), + width: 110, + type: 'singleSelect', + editable: true, + valueOptions: PUBLISH_OPTIONS, + renderCell: (params) => , + }, + { + type: 'actions', + field: 'actions', + headerName: ' ', + align: 'right', + headerAlign: 'right', + width: 80, + sortable: false, + filterable: false, + disableColumnMenu: true, + getActions: (params) => [ + } + label="View" + href={paths.dashboard.product.details(params.row.id)} + />, + } + label="Edit" + href={paths.dashboard.product.edit(params.row.id)} + />, + } + 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 = () => ( + + Are you sure want to delete {selectedRowIds.length} items? + + } + action={ + + } + /> + ); + + const [deleteInProgress, setDeleteInProgress] = useState(false); + const renderDeleteSingleItemConfirmDialog = () => ( + Are you sure want to delete item?} + action={ + + } + /> + ); + + return ( + <> + + } + > + {t('new-product')} + + } + sx={{ mb: { xs: 3, md: 5 } }} + /> + + + '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: () => , + noResultsOverlay: () => , + }} + slotProps={{ + toolbar: { setFilterButtonEl }, + panel: { anchorEl: filterButtonEl }, + columnsManagement: { getTogglableColumns }, + }} + sx={{ [`& .${gridClasses.cell}`]: { alignItems: 'center', display: 'inline-flex' } }} + /> + + + + {renderDeleteMultipleItemsConfirmDialog()} + {renderDeleteSingleItemConfirmDialog()} + + ); +} + +// ---------------------------------------------------------------------- + +declare module '@mui/x-data-grid' { + interface ToolbarPropsOverrides { + setFilterButtonEl: React.Dispatch>; + } +} + +type CustomToolbarProps = GridSlotProps['toolbar'] & { + canReset: boolean; + filteredResults: number; + selectedRowIds: GridRowSelectionModel; + filters: UseSetStateReturn; + + 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 ( + <> + + + + + + + {!!selectedRowIds.length && ( + + )} + + + + + + + + {canReset && ( + + )} + + ); +} + +// ---------------------------------------------------------------------- + +type GridActionsLinkItemProps = Pick & { + href: string; + sx?: SxProps; + ref?: React.RefObject; +}; + +export function GridActionsLinkItem({ ref, href, label, icon, sx }: GridActionsLinkItemProps) { + return ( + + + {icon && {icon}} + {label} + + + ); +} + +// ---------------------------------------------------------------------- + +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; +} diff --git a/03_source/frontend/src/sections/party-event/view/party-event-shop-details-view.tsx b/03_source/frontend/src/sections/party-event/view/party-event-shop-details-view.tsx new file mode 100644 index 0000000..25956c0 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/party-event-shop-details-view.tsx @@ -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 = { + mt: 5, + mb: 10, + }; + + const tabs = useTabs('description'); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + } + sx={{ mt: 3 }} + > + Back to list + + } + sx={{ py: 10 }} + /> + + ); + } + + return ( + + + + + + + + + + + + {product && ( + + )} + + + + {SUMMARY.map((item) => ( + + + + + {item.title} + + + + {item.description} + + + ))} + + + + ({ + 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) => ( + + ))} + + + {tabs.value === 'description' && ( + + )} + + {tabs.value === 'reviews' && ( + + )} + + + ); +} diff --git a/03_source/frontend/src/sections/party-event/view/party-event-shop-view.tsx b/03_source/frontend/src/sections/party-event/view/party-event-shop-view.tsx new file mode 100644 index 0000000..0ac3ec0 --- /dev/null +++ b/03_source/frontend/src/sections/party-event/view/party-event-shop-view.tsx @@ -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({ + 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 = () => ( + + paths.product.details(id)} /> + + + + + setSortBy(newValue)} + sortOptions={PRODUCT_SORT_OPTIONS} + /> + + + ); + + const renderResults = () => ( + + ); + + const renderNotFound = () => ; + + return ( + + + + + Shop + + + + {renderFilters()} + {canReset && renderResults()} + + + {notFound || isEmpty ? ( + renderNotFound() + ) : ( + + )} + + ); +} + +// ---------------------------------------------------------------------- + +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; +} diff --git a/03_source/frontend/src/types/party-event.ts b/03_source/frontend/src/types/party-event.ts new file mode 100644 index 0000000..60c3569 --- /dev/null +++ b/03_source/frontend/src/types/party-event.ts @@ -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; +};