diff --git a/src/components/ImageCarousel/ImageCarousel.stories.tsx b/src/components/ImageCarousel/ImageCarousel.stories.tsx new file mode 100644 index 00000000..0c32cb01 --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.stories.tsx @@ -0,0 +1,41 @@ +import { ImageCarousel } from "./ImageCarousel"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/ImageCarousel", + component: ImageCarousel, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +/////////////////////////////////////////////////////////// +// STORIES + +type Story = StoryObj; + +export const BasicDemo = { + args: { + images: [ + "https://picsum.photos/seed/yvyiJ/640/480", + "https://picsum.photos/seed/13nxj7X7/640/480", + "https://picsum.photos/seed/BLA60wJBa/640/480", + "https://picsum.photos/seed/NmFcRNn/640/480", + "https://picsum.photos/seed/YpRUOrcLl/640/480", + ].map((imgURL, index) => ({ + label: `Foo Random Image ${index + 1}`, + src: imgURL, + })), + showImageLabels: true, + style: { + height: "30rem", + width: "40rem", + }, + }, +} satisfies Story; diff --git a/src/components/ImageCarousel/ImageCarousel.tsx b/src/components/ImageCarousel/ImageCarousel.tsx new file mode 100644 index 00000000..33abb11e --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import SwipeableViews, { type SwipeableViewsProps } from "react-swipeable-views-react-18-fix"; +import { styled } from "@mui/material/styles"; +import IconButton from "@mui/material/IconButton"; +import MobileStepper, { + mobileStepperClasses, + type MobileStepperProps, +} from "@mui/material/MobileStepper"; +import Paper, { type PaperProps } from "@mui/material/Paper"; +import Text from "@mui/material/Typography"; +import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import { imageCarouselClassNames } from "./classNames"; +import type { Simplify, SetRequired } from "type-fest"; + +/** + * This component displays images in a carousel that allows the user to navigate + * between multiple images using left/right swipes and/or the back/next buttons. + * + * This component is based on the Mui `MobileStepper` demo: + * https://mui.com/material-ui/react-stepper/#text-with-carousel-effect + * + * `react-swipeable-views` docs: https://react-swipeable-views.com/api/api/ + */ +export const ImageCarousel = ({ + images, + initialImageIndex = 0, + showImageLabels = false, + SwipeableViewsProps = {}, + MobileStepperProps = {}, + ...paperProps +}: ImageCarouselProps) => { + // If an image exists at the given index, use it. Otherwise, use zero. + const [activeImgIndex, setActiveImgIndex] = useState( + images?.[initialImageIndex] ? initialImageIndex : 0 + ); + + const numImages = images.length; + + const handleBack = () => { + setActiveImgIndex((prevActiveImgIndex) => { + const maybeNextIndex = prevActiveImgIndex - 1; + return maybeNextIndex < 0 + ? numImages - 1 // Wrap around to the last image + : maybeNextIndex; + }); + }; + + const handleNext = () => { + setActiveImgIndex((prevActiveImgIndex) => { + const maybeNextIndex = prevActiveImgIndex + 1; + return maybeNextIndex > numImages - 1 + ? 0 // Wrap around to the first image + : maybeNextIndex; + }); + }; + + const handleChangeIndex = (step: number) => setActiveImgIndex(step); + + // Destructure any provided styles from the `SwipeableViewsProps` object for merging: + const { + style: swipeableViewsStyles, + containerStyle: swipeableViewsContainerStyles, + slideStyle: swipeableViewsSlideStyles, + ...swipeableViewsProps + } = SwipeableViewsProps; + + return ( + + {showImageLabels && ( + + + {images[activeImgIndex]?.label ?? "?"} + + + )} + + (there's only 1) + flexGrow: 1, + flexShrink: 1, + ...swipeableViewsStyles, + }} + containerStyle={{ + // THE CHILD OF THE ROOT SwipeableViews
(there's only 1) + height: "100%", + ...swipeableViewsContainerStyles, + }} + slideStyle={{ + // THE DIV THAT WRAPS EACH + display: "flex", + justifyContent: "center", + alignItems: "center", + ...swipeableViewsSlideStyles, + }} + {...swipeableViewsProps} + > + {images.map(({ src, label, alt, ...imgProps }) => ( + {alt + ))} + + 5 ? "text" : "dots"} + position="static" + backButton={ + + + + } + nextButton={ + + + + } + {...MobileStepperProps} + /> + + ); +}; + +const StyledPaper = styled(Paper)(({ style = {} }) => { + const borderRadius = style?.borderRadius || "inherit"; + + return { + // THE ROOT PAPER + display: "flex", + flexDirection: "column", + justifyContent: "center", + // some default dimensions to ensure the carousel never extends beyond the viewport: + maxHeight: style?.maxHeight || "95vh", + maxWidth: style?.maxWidth || "95vw", + height: style?.height || "100%", + width: style?.width || "100%", + + borderTopLeftRadius: borderRadius, + borderTopRightRadius: borderRadius, + borderBottomLeftRadius: borderRadius, + borderBottomRightRadius: borderRadius, + + // HEADER + [`& > .${imageCarouselClassNames.headerRoot}`]: { + display: "flex", + alignItems: "center", + padding: "1rem", + borderBottom: "1px solid black", + backgroundColor: "inherit", + borderTopLeftRadius: "inherit", + borderTopRightRadius: "inherit", + borderBottomLeftRadius: "0 !important", + borderBottomRightRadius: "0 !important", + }, + + // IMAGES + [`& .${imageCarouselClassNames.image}`]: { + maxHeight: "98%", + maxWidth: "98%", + objectFit: "contain", + imageRendering: "crisp-edges", + }, + + // FOOTER/MOBILE STEPPER CONTROLS + [`& > .${mobileStepperClasses.root}`]: { + backgroundColor: "inherit", + borderTop: "1px solid black", + borderTopLeftRadius: "0 !important", + borderTopRightRadius: "0 !important", + borderBottomLeftRadius: "inherit", + borderBottomRightRadius: "inherit", + }, + }; +}); + +/** + * A `label` for an image and its `src` URL. If `alt` is not provided, the `label` is used. + */ +export type CarouselImageConfig = { label: string } & SetRequired< + React.ComponentPropsWithoutRef<"img">, + "src" +>; + +export type ImageCarouselProps = { + /** An array of {@link CarouselImageConfig|image config} objects. */ + images: Array; + /** + * The index of the image to display first. If an {@link CarouselImageConfig|image config} + * does not exist at the given index, the zero-index image is used as a fallback. + */ + initialImageIndex?: number; + showImageLabels?: boolean; + SwipeableViewsProps?: Simplify< + Pick< + SwipeableViewsProps, + | "animateHeight" + | "animateTransitions" + | "containerStyle" + | "disabled" + | "hysteresis" + | "onSwitching" + | "onTransitionEnd" + | "resistance" + | "slideClassName" + | "slideStyle" + | "springConfig" + | "style" + | "threshold" + > + >; + MobileStepperProps?: Simplify< + Omit + >; +} & Omit; diff --git a/src/components/ImageCarousel/classNames.ts b/src/components/ImageCarousel/classNames.ts new file mode 100644 index 00000000..ad7c2175 --- /dev/null +++ b/src/components/ImageCarousel/classNames.ts @@ -0,0 +1,9 @@ +/** + * Class names for `ImageCarousel` components (src/components/ImageCarousel/). + */ +export const imageCarouselClassNames = { + root: "image-carousel__root", + headerRoot: "image-carousel__header-root", + headerText: "image-carousel__header-text", + image: "image-carousel__image", +} as const; diff --git a/src/components/ImageCarousel/index.ts b/src/components/ImageCarousel/index.ts new file mode 100644 index 00000000..f74b159d --- /dev/null +++ b/src/components/ImageCarousel/index.ts @@ -0,0 +1,2 @@ +export * from "./ImageCarousel"; +export * from "./classNames"; diff --git a/src/images/demo_desktop_dashboard.webp b/src/images/demo_desktop_dashboard.webp new file mode 100644 index 00000000..1b03961a Binary files /dev/null and b/src/images/demo_desktop_dashboard.webp differ diff --git a/src/images/demo_desktop_workorders_datagrid.webp b/src/images/demo_desktop_workorders_datagrid.webp new file mode 100644 index 00000000..c115759c Binary files /dev/null and b/src/images/demo_desktop_workorders_datagrid.webp differ diff --git a/src/images/demo_mobile_create_invoice.webp b/src/images/demo_mobile_create_invoice.webp new file mode 100644 index 00000000..cb5967a1 Binary files /dev/null and b/src/images/demo_mobile_create_invoice.webp differ diff --git a/src/images/demo_mobile_workorders_list.webp b/src/images/demo_mobile_workorders_list.webp new file mode 100644 index 00000000..fca88aee Binary files /dev/null and b/src/images/demo_mobile_workorders_list.webp differ diff --git a/src/images/landing_page_bg.webp b/src/images/landing_page_bg.webp new file mode 100644 index 00000000..b554a82e Binary files /dev/null and b/src/images/landing_page_bg.webp differ diff --git a/src/pages/LandingPage/ProductImage.stories.tsx b/src/pages/LandingPage/ProductImage.stories.tsx new file mode 100644 index 00000000..bff88d8a --- /dev/null +++ b/src/pages/LandingPage/ProductImage.stories.tsx @@ -0,0 +1,43 @@ +import { ProductImage, PRODUCT_IMAGES } from "./ProductImage"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Pages/LandingPage/ProductImage", + component: ProductImage, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + ImageCarouselProps: { + showImageLabels: true, + }, + style: { maxHeight: "100%", maxWidth: "100%" }, + }, +} satisfies Meta; + +export default meta; + +/////////////////////////////////////////////////////////// +// STORIES + +type Story = StoryObj; + +export const DashboardDesktopView = { + args: { ...PRODUCT_IMAGES[0] }, +} satisfies Story; + +export const CreateInvoiceMobileView = { + args: { ...PRODUCT_IMAGES[1] }, +} satisfies Story; + +export const DataGridDemo = { + args: { ...PRODUCT_IMAGES[2] }, +} satisfies Story; + +export const ListViewMobileDemo = { + args: { ...PRODUCT_IMAGES[3] }, +} satisfies Story; diff --git a/src/pages/LandingPage/ProductImage.tsx b/src/pages/LandingPage/ProductImage.tsx new file mode 100644 index 00000000..ee9d21cc --- /dev/null +++ b/src/pages/LandingPage/ProductImage.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { styled } from "@mui/material/styles"; +import Box, { type BoxProps } from "@mui/material/Box"; +import Dialog, { dialogClasses } from "@mui/material/Dialog"; +import { globalClassNames } from "@/app/GlobalStyles/classNames"; +import { + ImageCarousel, + type ImageCarouselProps, + type CarouselImageConfig, +} from "@/components/ImageCarousel"; +import { NoMaxWidthTooltip } from "@/components/Tooltips"; +import demoDesktopDashboardImageSrc from "@/images/demo_desktop_dashboard.webp"; +import demoDesktopDataGridImageSrc from "@/images/demo_desktop_workorders_datagrid.webp"; +import demoMobileCreateInvoiceImageSrc from "@/images/demo_mobile_create_invoice.webp"; +import demoMobileListViewImageSrc from "@/images/demo_mobile_workorders_list.webp"; +import type { OverrideProperties } from "type-fest"; + +export const ProductImage = ({ + label, + src, + ImageCarouselProps = {}, + ...boxProps +}: ProductImageProps) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleOpen = () => setIsModalOpen(true); + const handleClose = () => setIsModalOpen(false); + + return ( + <> + + + + {isModalOpen && ( + + image.label === label)} + showImageLabels + {...ImageCarouselProps} + /> + + )} + + ); +}; + +export const PRODUCT_IMAGES = [ + { label: "Fixit Dashboard demo", src: demoDesktopDashboardImageSrc }, + { label: "Fixit Create-Invoice mobile demo", src: demoMobileCreateInvoiceImageSrc }, + { label: "Fixit Data-grid demo", src: demoDesktopDataGridImageSrc }, + { label: "Fixit List-view mobile demo", src: demoMobileListViewImageSrc }, +] as const satisfies Array; + +const StyledDialog = styled(Dialog)({ + // Starting from div.MuiDialog-root + [`& > .${dialogClasses.container}`]: { + // + [`& > .${dialogClasses.paper}`]: { + width: "80vw", + maxWidth: "80vw", + height: "80vh", + maxHeight: "80vh", + overflow: "hidden", + borderRadius: "0.5rem", + }, + }, +}); + +export type ProductImageProps = OverrideProperties< + CarouselImageConfig, + { + label: (typeof PRODUCT_IMAGES)[number]["label"]; + } +> & { + ImageCarouselProps?: Omit; +} & Omit;