From ef0f9b4d26e4f3fe3236b3795f0a5ca1c5f0b3c2 Mon Sep 17 00:00:00 2001 From: Zhi <neozhixuan@gmail.com> Date: Wed, 6 Dec 2023 14:59:24 +0800 Subject: [PATCH 1/3] Feature - Base Multiselect Impleentation --- client/components/pickup/ButtonRow.tsx | 5 +- .../components/pickup/ItemsAndFilterRow.tsx | 52 +++++++---- client/components/pickup/filterPopover.tsx | 7 +- client/spa-pages/components/MapPage.tsx | 87 +++++++++++-------- client/spa-pages/components/PickupPage.tsx | 62 ++++++------- 5 files changed, 126 insertions(+), 87 deletions(-) diff --git a/client/components/pickup/ButtonRow.tsx b/client/components/pickup/ButtonRow.tsx index 682f4f6..3671e97 100644 --- a/client/components/pickup/ButtonRow.tsx +++ b/client/components/pickup/ButtonRow.tsx @@ -1,7 +1,6 @@ -import { ArrowBackIcon, ArrowLeftIcon } from "@chakra-ui/icons"; -import { Button, Flex, Heading, Spacer, Box, ButtonGroup, Icon } from "@chakra-ui/react"; +import { ArrowBackIcon } from "@chakra-ui/icons"; +import { Button, Flex, Heading, Spacer, Box, ButtonGroup } from "@chakra-ui/react"; import { Dispatch, SetStateAction } from "react"; -import { BiLeftArrow } from "react-icons/bi"; import { Pages } from "spa-pages/pageEnums"; type Props = { diff --git a/client/components/pickup/ItemsAndFilterRow.tsx b/client/components/pickup/ItemsAndFilterRow.tsx index 465612a..cf52203 100644 --- a/client/components/pickup/ItemsAndFilterRow.tsx +++ b/client/components/pickup/ItemsAndFilterRow.tsx @@ -1,17 +1,49 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { Flex, theme, useDisclosure } from "@chakra-ui/react"; import { TEmptyItem, TItemSelection } from "app-context/SheetyContext/types"; import FilterButton from "./filterPopover"; -import { SelectAndFilterBar, SelectedItemChips } from "spa-pages"; +import { SelectAndFilterBar, multiselectOnChange, OptionType } from "spa-pages"; +import { ActionMeta, MultiValue } from "react-select"; +import { useSheetyData } from "hooks/useSheetyData"; +import { OrgProps } from "spa-pages"; type Props = { items: (TItemSelection | TEmptyItem)[]; + setOrgs: Dispatch<SetStateAction<OrgProps[]>>; + sortPickups: (itemEntry: (TItemSelection | TEmptyItem)[]) => OrgProps[]; }; -const ItemsAndFilterRow = ({ items }: Props) => { +const ItemsAndFilterRow = ({ items, setOrgs, sortPickups }: Props) => { const colors = theme.colors; + // Multiselect Box + const selectOptions: OptionType[] = items.map((item, index) => ({ + value: item.name, + label: item.name, + method: item.method, + idx: index, + })); + + const [itemState, setItemState] = useState<(TItemSelection | TEmptyItem)[]>(items); + const [selectedOptions, setSelectedOptions] = useState<OptionType[]>([...selectOptions]); + const { isOpen: isFilterOpen, onOpen: onFilterOpen, onClose: onFilterClose } = useDisclosure(); + const handleMultiselectOnChange = ( + newValue: MultiValue<OptionType>, + actionMeta: ActionMeta<OptionType>, + ) => { + const { updatedOptions, updatedItemState } = multiselectOnChange( + newValue, + actionMeta, + itemState, + selectedOptions, + ); + setSelectedOptions(updatedOptions); + setItemState(updatedItemState); + setOrgs(sortPickups(updatedItemState)); + }; + return ( <Flex px={4}> <Flex @@ -22,19 +54,9 @@ const ItemsAndFilterRow = ({ items }: Props) => { borderRadius="md" > <SelectAndFilterBar - selectedOptions={items.map((item, idx) => ({ - label: item.name, - value: item.name, - method: item.method, - idx, - }))} - onMultiSelectChange={() => void 0} - selectOptions={items.map((item, idx) => ({ - label: item.name, - value: item.name, - method: item.method, - idx, - }))} + selectedOptions={selectedOptions} + onMultiSelectChange={handleMultiselectOnChange} + selectOptions={selectOptions} onFilterOpen={onFilterOpen} /> </Flex> diff --git a/client/components/pickup/filterPopover.tsx b/client/components/pickup/filterPopover.tsx index 27e8b83..729d328 100644 --- a/client/components/pickup/filterPopover.tsx +++ b/client/components/pickup/filterPopover.tsx @@ -35,7 +35,12 @@ const FilterButton = ({ <FilterSection title="Your items" button={ - <Button size="sm" color="white" bgColor={COLORS.Button.primary}> + <Button + size="sm" + color="white" + bgColor={COLORS.Button.primary} + onClick={onClose} + > Apply </Button> } diff --git a/client/spa-pages/components/MapPage.tsx b/client/spa-pages/components/MapPage.tsx index 0895c56..b9f74a3 100644 --- a/client/spa-pages/components/MapPage.tsx +++ b/client/spa-pages/components/MapPage.tsx @@ -55,6 +55,7 @@ const Cluster = dynamic( ssr: false, }, ); + export type OptionType = { value: string; label: string; @@ -82,11 +83,11 @@ const MapInner = ({ setPage }: Props) => { // const [isExpanded, setIsExpanded] = useState(false); // Multiselect Box - const selectOptions: OptionType[] = items.map((item) => ({ + const selectOptions: OptionType[] = items.map((item, index) => ({ value: item.name, label: item.name, method: item.method, - idx: index++, + idx: index, })); const [selectedOptions, setSelectedOptions] = useState<OptionType[]>([...selectOptions]); // Internal tracking of user-selected items @@ -123,7 +124,6 @@ const MapInner = ({ setPage }: Props) => { ////// Variables ////// const isLoading = !map || !leafletWindow; const zoom = 15; - let index = 0; const [centerPos, setCenterPos] = useState<LatLngExpression>( address.value !== "" @@ -170,6 +170,22 @@ const MapInner = ({ setPage }: Props) => { return { cardIsOpen: cardIsOpen, cardDetails: cardDetails, distance: facility.distance }; }; + const handleMultiselectOnChange = ( + newValue: MultiValue<OptionType>, + actionMeta: ActionMeta<OptionType>, + ) => { + const { updatedOptions, updatedItemState } = multiselectOnChange( + newValue, + actionMeta, + itemState, + selectedOptions, + ); + setSelectedOptions(updatedOptions); + setItemState(updatedItemState); + setFacCardIsOpen(false); + handleChangedLocation(updatedItemState); + }; + // Handle the changing of location in this page itself const handleChangedLocation = (itemEntry: (TItemSelection | TEmptyItem)[]) => { const locations = getNearbyFacilities( @@ -208,38 +224,6 @@ const MapInner = ({ setPage }: Props) => { ] as LatLngExpression); }; - // Handle change in multi-select box (remove, add items) - const handleMultiselectOnChange = ( - newValue: MultiValue<OptionType>, - actionMeta: ActionMeta<OptionType>, - ) => { - let updatedItemState: (TItemSelection | TEmptyItem)[] = itemState; - let updatedOptions: OptionType[] = selectedOptions; - // If user adds an option - if (actionMeta.action === "select-option") { - const newItem = { - name: actionMeta.option?.label, - method: actionMeta.option?.method as Methods, - } as TItemSelection; - itemState.push(newItem); - updatedItemState = [...itemState]; - updatedOptions.push(actionMeta.option as OptionType); - // If user removes an option - } else if (actionMeta.action === "remove-value") { - const removedValue = actionMeta.removedValue; - updatedItemState = itemState.filter((item) => { - return item.name !== removedValue.label; - }); - updatedOptions = selectedOptions.filter( - (option) => option.value !== removedValue.label, - ); - } - setSelectedOptions(updatedOptions); - handleChangedLocation(updatedItemState); - setItemState(updatedItemState); - setFacCardIsOpen(false); - }; - // Handle changes in items selected in the Filter panel const handleCheckboxChange = (e: ChangeEvent<HTMLInputElement>) => { let updatedItemState: (TItemSelection | TEmptyItem)[] = itemState; @@ -272,11 +256,11 @@ const MapInner = ({ setPage }: Props) => { }; const selectAllItems = () => { - const selectOptions: OptionType[] = items.map((item) => ({ + const selectOptions: OptionType[] = items.map((item, index) => ({ value: item.name, label: item.name, method: item.method, - idx: index++, + idx: index, })); const itemState = items.map((item) => ({ name: item.name, @@ -550,4 +534,33 @@ export function SelectedItemChips({ ); } +// Handle change in multi-select box (remove, add items) +export const multiselectOnChange = ( + newValue: MultiValue<OptionType>, + actionMeta: ActionMeta<OptionType>, + itemState: (TItemSelection | TEmptyItem)[], + selectedOptions: OptionType[], +): { updatedItemState: (TItemSelection | TEmptyItem)[]; updatedOptions: OptionType[] } => { + let updatedItemState: (TItemSelection | TEmptyItem)[] = itemState; + let updatedOptions: OptionType[] = selectedOptions; + // If user adds an option + if (actionMeta.action === "select-option") { + const newItem = { + name: actionMeta.option?.label, + method: actionMeta.option?.method as Methods, + } as TItemSelection; + itemState.push(newItem); + updatedItemState = [...itemState]; + updatedOptions.push(actionMeta.option as OptionType); + // If user removes an option + } else if (actionMeta.action === "remove-value") { + const removedValue = actionMeta.removedValue; + updatedItemState = itemState.filter((item) => { + return item.name !== removedValue.label; + }); + updatedOptions = selectedOptions.filter((option) => option.value !== removedValue.label); + } + return { updatedItemState, updatedOptions }; +}; + export default MapPage; diff --git a/client/spa-pages/components/PickupPage.tsx b/client/spa-pages/components/PickupPage.tsx index c50126d..0f24ed6 100644 --- a/client/spa-pages/components/PickupPage.tsx +++ b/client/spa-pages/components/PickupPage.tsx @@ -11,6 +11,7 @@ import { useSheetyData } from "hooks/useSheetyData"; import { TSheetyPickupDetails } from "api/sheety/types"; import { TEmptyItem, TItemSelection } from "app-context/SheetyContext/types"; import NonRecyclableModal from "components/common/NonRecyclableModal"; +import { useState } from "react"; type Props = { setPage: Dispatch<SetStateAction<Pages>>; @@ -25,8 +26,6 @@ export type OrgProps = { export const PickupPage = ({ setPage }: Props) => { const { items, recyclingLocationResults } = useUserInputs(); const results = recyclingLocationResults ? recyclingLocationResults.results : {}; - console.log(recyclingLocationResults); - // Find shortest distance to facility let minDistance = 100; if (Object.keys(results).length > 0) { @@ -43,30 +42,34 @@ export const PickupPage = ({ setPage }: Props) => { // Pick up services const { pickUpServices, getItemCategory } = useSheetyData(); - const possiblePickups = pickUpServices.filter((pickUpService) => { - let picksUpAtLeastOneItem = false; - for (const item of items) { - if (pickUpService.categoriesAccepted.includes(getItemCategory(item.name))) { - picksUpAtLeastOneItem = true; - break; + const sortPickups = (itemEntry: (TItemSelection | TEmptyItem)[]): OrgProps[] => { + const possiblePickups = pickUpServices.filter((pickUpService) => { + let picksUpAtLeastOneItem = false; + for (const item of itemEntry) { + if (pickUpService.categoriesAccepted.includes(getItemCategory(item.name))) { + picksUpAtLeastOneItem = true; + break; + } } - } - return picksUpAtLeastOneItem; - }); - const orgPropsList: OrgProps[] = possiblePickups.map((pickup) => { - return { - organisation: pickup, - acceptedItems: items.filter((item) => - pickup.categoriesAccepted.includes(getItemCategory(item.name)), - ), - notAcceptedItems: items.filter( - (item) => !pickup.categoriesAccepted.includes(getItemCategory(item.name)), - ), - }; - }); - const sortedPossiblePickups = orgPropsList.sort((a, b) => - a.acceptedItems.length > b.acceptedItems.length ? -1 : 1, - ); + return picksUpAtLeastOneItem; + }); + const orgPropsList: OrgProps[] = possiblePickups.map((pickup) => { + return { + organisation: pickup, + acceptedItems: itemEntry.filter((item) => + pickup.categoriesAccepted.includes(getItemCategory(item.name)), + ), + notAcceptedItems: itemEntry.filter( + (item) => !pickup.categoriesAccepted.includes(getItemCategory(item.name)), + ), + }; + }); + const sortedPossiblePickups = orgPropsList.sort((a, b) => + a.acceptedItems.length > b.acceptedItems.length ? -1 : 1, + ); + return sortedPossiblePickups; + }; + const [orgs, setOrgs] = useState<OrgProps[]>(sortPickups(items)); return ( <BasePage title="Home Pickup" description="Singapore's first recycling planner"> @@ -80,13 +83,10 @@ export const PickupPage = ({ setPage }: Props) => { pb={5} > <VStack align="stretch" my={23} spacing={4}> - <PickupCarousel - numPickupServices={sortedPossiblePickups.length} - minDist={minDistance} - /> + <PickupCarousel numPickupServices={orgs.length} minDist={minDistance} /> <ButtonRow setPage={setPage} /> - <ItemsAndFilterRow items={items} /> - <OrgList sortedPossiblePickups={sortedPossiblePickups} /> + <ItemsAndFilterRow items={items} setOrgs={setOrgs} sortPickups={sortPickups} /> + <OrgList sortedPossiblePickups={orgs} /> </VStack> </Container> </BasePage> From 7b4fb7f934808c1b9eb2975333ef6a448ff72a6d Mon Sep 17 00:00:00 2001 From: Zhi <neozhixuan@gmail.com> Date: Sat, 9 Dec 2023 02:34:45 +0800 Subject: [PATCH 2/3] Feature - Multiselect Functions --- client/api/onemap/index.ts | 2 +- client/components/map/FacilityCard.tsx | 4 +- client/components/pickup/ButtonRow.tsx | 2 +- .../components/pickup/ItemsAndFilterRow.tsx | 3 +- client/components/pickup/OrgCard.tsx | 121 ++++++++++-------- client/components/pickup/OrgLabel.tsx | 27 ++-- client/components/pickup/OrgList.tsx | 30 +++-- client/components/pickup/PickupCarousel.tsx | 10 +- client/components/pickup/filterPopover.tsx | 29 ++++- client/spa-pages/components/MapPage.tsx | 8 +- client/spa-pages/components/PickupPage.tsx | 11 +- 11 files changed, 156 insertions(+), 91 deletions(-) diff --git a/client/api/onemap/index.ts b/client/api/onemap/index.ts index 14a4049..a5cf9b0 100644 --- a/client/api/onemap/index.ts +++ b/client/api/onemap/index.ts @@ -3,7 +3,7 @@ import { OneMapResponse } from "./types"; export const fetchAddresses = async (searchValue: string): Promise<OneMapResponse> => { try { - const url = `https://developers.onemap.sg/commonapi/search?searchVal=${searchValue}&returnGeom=Y&getAddrDetails=Y&pageNum=1`; + const url = `https://www.onemap.gov.sg/api/common/elastic/search?searchVal=${searchValue}&returnGeom=Y&getAddrDetails=Y&pageNum=1`; const res = await axios.get<OneMapResponse>(url, { headers: { Accept: "application/json", diff --git a/client/components/map/FacilityCard.tsx b/client/components/map/FacilityCard.tsx index abfcd7a..7779356 100644 --- a/client/components/map/FacilityCard.tsx +++ b/client/components/map/FacilityCard.tsx @@ -167,7 +167,7 @@ export const FacilityCard = ({ ); }; -const AcceptedTab: React.FC<{ children: ReactNode }> = ({ children }) => { +export const AcceptedTab: React.FC<{ children: ReactNode }> = ({ children }) => { return ( <Box bg={"#CCECD5"} @@ -183,7 +183,7 @@ const AcceptedTab: React.FC<{ children: ReactNode }> = ({ children }) => { ); }; -const UnacceptedTab: React.FC<{ children: ReactNode }> = ({ children }) => { +export const UnacceptedTab: React.FC<{ children: ReactNode }> = ({ children }) => { return ( <Box bg={"#E0F0EF"} borderRadius={"42px"} minWidth={"fit-content"} padding={"5px 10px"}> {children} diff --git a/client/components/pickup/ButtonRow.tsx b/client/components/pickup/ButtonRow.tsx index 3671e97..a992bb4 100644 --- a/client/components/pickup/ButtonRow.tsx +++ b/client/components/pickup/ButtonRow.tsx @@ -9,7 +9,7 @@ type Props = { const ButtonRow = ({ setPage }: Props) => { return ( - <Flex px={6}> + <Flex> <Box> <Heading size={"md"}>Your items:</Heading> </Box> diff --git a/client/components/pickup/ItemsAndFilterRow.tsx b/client/components/pickup/ItemsAndFilterRow.tsx index cf52203..9812695 100644 --- a/client/components/pickup/ItemsAndFilterRow.tsx +++ b/client/components/pickup/ItemsAndFilterRow.tsx @@ -45,7 +45,7 @@ const ItemsAndFilterRow = ({ items, setOrgs, sortPickups }: Props) => { }; return ( - <Flex px={4}> + <Flex> <Flex justifyContent="space-between" flexGrow={1} @@ -58,6 +58,7 @@ const ItemsAndFilterRow = ({ items, setOrgs, sortPickups }: Props) => { onMultiSelectChange={handleMultiselectOnChange} selectOptions={selectOptions} onFilterOpen={onFilterOpen} + enableBoxShadow={false} /> </Flex> <FilterButton items={items} isOpen={isFilterOpen} onClose={onFilterClose} /> diff --git a/client/components/pickup/OrgCard.tsx b/client/components/pickup/OrgCard.tsx index ea488f3..69fd9b7 100644 --- a/client/components/pickup/OrgCard.tsx +++ b/client/components/pickup/OrgCard.tsx @@ -1,4 +1,16 @@ -import { Text, Button, ButtonGroup, VStack, Heading, Flex, Accordion, AccordionItem, AccordionButton, Box, AccordionIcon, AccordionPanel, Spacer, theme } from "@chakra-ui/react"; +import { + Text, + Button, + ButtonGroup, + VStack, + Heading, + Flex, + Box, + theme, + Divider, +} from "@chakra-ui/react"; +import Link from "next/link"; + import { Card, CardBody } from "@chakra-ui/card"; import { TSheetyPickupDetails } from "api/sheety/types"; import { MdOutlineScale } from "react-icons/md"; @@ -6,7 +18,7 @@ import { BiTimeFive } from "react-icons/bi"; import { BsCurrencyDollar } from "react-icons/bs"; import OrgLabel from "./OrgLabel"; import { TEmptyItem, TItemSelection } from "app-context/SheetyContext/types"; -import { CheckIcon, SmallCloseIcon } from "@chakra-ui/icons"; +import { AcceptedTab, UnacceptedTab } from "components/map"; type Props = { orgDetails: TSheetyPickupDetails; @@ -24,71 +36,76 @@ const OrgCard = (props: Props) => { pricingTermsInSgd, contactMethod, contactDetail, - lastUpdated + lastUpdated, } = props.orgDetails; const { acceptedItems, notAcceptedItems } = props; const numItems = acceptedItems.length + notAcceptedItems.length; const colors = theme.colors; return ( - <Card mx={5} my={2} boxShadow="md" border="2px" borderColor={colors.gray[100]} > + <Card my={2} boxShadow="md" border="2px" borderColor={colors.gray[100]}> <CardBody> <VStack spacing={"3"} align="left"> - <Heading size={"md"}> - {organisationName} - </Heading> + <Heading size={"md"}>{organisationName}</Heading> <Flex wrap="wrap" columnGap={6} rowGap={1}> - <OrgLabel icon={MdOutlineScale} title="Min. Weight:" text={`${minimumWeightInKg} kg`} /> + <OrgLabel + icon={MdOutlineScale} + title="Min. Weight:" + text={`${minimumWeightInKg} kg`} + /> <OrgLabel icon={BiTimeFive} title="Pickup Hours:" text={time} /> - <OrgLabel icon={BsCurrencyDollar} title="Service Cost:" text={`$${pricingTermsInSgd}`} /> + <OrgLabel + icon={BsCurrencyDollar} + title="Service Cost:" + text={`$${pricingTermsInSgd}`} + /> </Flex> - <Accordion allowToggle> - <AccordionItem border="none"> - <h2> - <AccordionButton py={1} bgColor={colors.green[100]} border="1px" borderColor={colors.green[300]} borderRadius="md" _hover={{ bg: colors.green[100] }}> - <Box rowGap={2} as="b" flex="1" textAlign="left" fontSize="sm" textColor={colors.gray[700]}> - <CheckIcon boxSize="3" ml={1} mr={2} color={colors.green[400]} /> - Accepted: {acceptedItems.length}/{numItems} items - </Box> - <AccordionIcon /> - </AccordionButton> - </h2> - <AccordionPanel pb={4} fontSize="sm"> - <Box> - <Text>{acceptedItems.map((item) => item.name).join(", ")}</Text> - <Spacer mt={4} /> - <Text as="b">They also accept these items:</Text> - <Text>{categoriesAccepted.slice(0, 1)}{categoriesAccepted.slice(1).toLowerCase().replaceAll("_", " ")}.</Text> - <Text>Please check their website for more info.</Text> - </Box> - </AccordionPanel> - </AccordionItem> - </Accordion> - {notAcceptedItems.length > 0 && - <Accordion allowToggle> - <AccordionItem border="none"> - <h2> - <AccordionButton py={1} bgColor={colors.red[100]} border="1px" borderColor={colors.red[300]} borderRadius="md" _hover={{ bg: colors.red[100] }}> - <Box rowGap={2} as="b" flex="1" textAlign="left" fontSize="sm" textColor={colors.gray[700]}> - <SmallCloseIcon boxSize="4" mr={2} color={colors.red[400]} /> - Not Accepted: {notAcceptedItems.length}/{numItems} items - </Box> - <AccordionIcon /> - </AccordionButton> - </h2> - <AccordionPanel pb={4} fontSize="sm"> - {notAcceptedItems.map((item) => item.name).join(", ")} - </AccordionPanel> - </AccordionItem> - </Accordion> - } - <ButtonGroup mt={4}> + <Divider /> + <Box> + <Text color={"black"} fontWeight={500} mb={1}> + They accept {acceptedItems.length} of {numItems} items: + </Text> + <Flex gap={2} fontSize={"xs"} width={"100%"} wrap={"wrap"}> + {acceptedItems.map((item, idx) => ( + <AcceptedTab key={idx}>{item.name}</AcceptedTab> + ))} + {notAcceptedItems.map((item, idx) => ( + <UnacceptedTab key={idx}>{item.name}</UnacceptedTab> + ))} + </Flex> + </Box> + <Box> + <Text as="b">They also accept these items:</Text> + <Text> + {categoriesAccepted + .split(" ") + .map( + (category) => + category.slice(0, 1) + + category.slice(1).toLowerCase().replaceAll("_", " "), + ) + .join(" ")} + </Text> + </Box> + <ButtonGroup mt={2}> <a href={website} target="_blank" rel="noreferrer"> - <Button colorScheme={"teal"} variant={"outline"}> + <Button + colorScheme={"teal"} + variant={"outline"} + disabled={website ? false : true} + > Website </Button> </a> - <a href={Number.isNaN(Number(contactDetail)) ? contactDetail : `tel:${contactDetail}`} target="_blank" rel="noreferrer"> + <a + href={ + Number.isNaN(Number(contactDetail)) + ? contactDetail + : `tel:${contactDetail}` + } + target="_blank" + rel="noreferrer" + > <Button colorScheme={"teal"} variant={"solid"}> Arrange Pickup! </Button> diff --git a/client/components/pickup/OrgLabel.tsx b/client/components/pickup/OrgLabel.tsx index 25b173c..d83ef37 100644 --- a/client/components/pickup/OrgLabel.tsx +++ b/client/components/pickup/OrgLabel.tsx @@ -1,20 +1,25 @@ import { Text, HStack, Icon } from "@chakra-ui/react"; import { IconType } from "react-icons"; +import { COLORS } from "theme"; type Props = { - icon: IconType; - title: string; - text: string; + icon: IconType; + title: string; + text: string; }; const OrgLabel = (props: Props) => { - return ( - <HStack flexShrink={0}> - <Icon as={props.icon} boxSize={4} /> - <Text as='b' fontSize="xs" >{props.title}</Text> - <Text fontSize="xs" >{props.text}</Text> - </HStack> - ); + return ( + <HStack flexShrink={0}> + <Icon as={props.icon} boxSize={4} /> + <Text as="b" fontSize="sm" color={COLORS.teal} fontWeight={500}> + {props.title} + </Text> + <Text fontSize="sm" fontWeight={500}> + {props.text} + </Text> + </HStack> + ); }; -export default OrgLabel; \ No newline at end of file +export default OrgLabel; diff --git a/client/components/pickup/OrgList.tsx b/client/components/pickup/OrgList.tsx index 0d0c56a..6485bbf 100644 --- a/client/components/pickup/OrgList.tsx +++ b/client/components/pickup/OrgList.tsx @@ -1,4 +1,4 @@ -import { Divider, Heading, Center, theme } from "@chakra-ui/react"; +import { Box, Heading, Text } from "@chakra-ui/react"; import OrgCard from "./OrgCard"; import { OrgProps } from "spa-pages/components/PickupPage"; @@ -7,21 +7,25 @@ type Props = { }; const OrgList = (props: Props) => { - const colors = theme.colors; - return ( - <> - <Center> - <Divider borderColor={colors.gray[500]} w="85%" /> - </Center> - <Heading size="lg" textAlign="center"> + <Box> + <Heading size="md" textAlign="left" mb={4}> Pick Up Services Near You </Heading> - {props.sortedPossiblePickups.map((pickup) => ( - <OrgCard key={pickup.organisation["s/n"]} orgDetails={pickup.organisation} acceptedItems={pickup.acceptedItems} notAcceptedItems={pickup.notAcceptedItems} /> - ))} - </> + {props.sortedPossiblePickups.length > 0 ? ( + props.sortedPossiblePickups.map((pickup) => ( + <OrgCard + key={pickup.organisation["s/n"]} + orgDetails={pickup.organisation} + acceptedItems={pickup.acceptedItems} + notAcceptedItems={pickup.notAcceptedItems} + /> + )) + ) : ( + <Text textAlign={"center"}>No relevant pick up services were found. :(</Text> + )} + </Box> ); }; -export default OrgList; \ No newline at end of file +export default OrgList; diff --git a/client/components/pickup/PickupCarousel.tsx b/client/components/pickup/PickupCarousel.tsx index ee08284..e77ed1e 100644 --- a/client/components/pickup/PickupCarousel.tsx +++ b/client/components/pickup/PickupCarousel.tsx @@ -8,7 +8,13 @@ import * as csstype from "csstype"; const SLIDES_INTERVAL_TIME = 5000; -const PickupCarousel = ({ minDist, numPickupServices }: { minDist: number, numPickupServices: number }) => { +const PickupCarousel = ({ + minDist, + numPickupServices, +}: { + minDist: number; + numPickupServices: number; +}) => { const slides = [ { url: useBreakpointValue({ @@ -60,7 +66,7 @@ const PickupCarousel = ({ minDist, numPickupServices }: { minDist: number, numPi }; return ( - <Box className={styles.carouselbox} px={4} h={40}> + <Box className={styles.carouselbox} h={40}> <Carousel showThumbs={false} showStatus={false} diff --git a/client/components/pickup/filterPopover.tsx b/client/components/pickup/filterPopover.tsx index 729d328..e11101e 100644 --- a/client/components/pickup/filterPopover.tsx +++ b/client/components/pickup/filterPopover.tsx @@ -1,8 +1,6 @@ import { Button, Modal, ModalContent, ModalOverlay } from "@chakra-ui/react"; -import { useState, useRef } from "react"; +import { useState, useEffect } from "react"; import MarkedSlider from "./slider"; -import { BsFilter } from "react-icons/bs"; -import { Icon } from "@chakra-ui/icons"; import { CheckboxGroup, FilterSection } from "components/map"; import { COLORS } from "theme"; import { TEmptyItem, TItemSelection } from "app-context/SheetyContext/types"; @@ -16,7 +14,22 @@ const FilterButton = ({ onClose: () => void; items: (TItemSelection | TEmptyItem)[]; }) => { - const initRef = useRef<HTMLElement | null>(null); // Specify the correct type for initRef + const [modalTop, setModalTop] = useState(265); + + useEffect(() => { + const handleScroll = () => { + // Update the modal top position based on the scroll position + setModalTop(265 - window.scrollY); + }; + + // Attach the scroll event listener + window.addEventListener("scroll", handleScroll); + + // Cleanup function to remove the event listener when the component unmounts + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, []); const [priceValue, setPriceValue] = useState(100); const [quantityValue, setQuantityValue] = useState(100); @@ -31,7 +44,13 @@ const FilterButton = ({ return ( <Modal isOpen={isOpen} onClose={onClose}> <ModalOverlay /> - <ModalContent maxWidth="calc(768px - 32px)" marginTop="330px" marginInline="4"> + <ModalContent + position={"fixed"} + maxWidth="calc(768px - 32px)" + top={`${modalTop}px`} + marginBottom={0} + marginRight={3} + > <FilterSection title="Your items" button={ diff --git a/client/spa-pages/components/MapPage.tsx b/client/spa-pages/components/MapPage.tsx index b9f74a3..233737b 100644 --- a/client/spa-pages/components/MapPage.tsx +++ b/client/spa-pages/components/MapPage.tsx @@ -451,13 +451,17 @@ export function SelectAndFilterBar({ selectOptions, onMultiSelectChange, onFilterOpen, -}: ComponentProps<typeof SelectedItemChips> & { onFilterOpen: () => void }) { + enableBoxShadow = true, +}: ComponentProps<typeof SelectedItemChips> & { + onFilterOpen: () => void; + enableBoxShadow?: boolean; +}) { return ( <Flex w="100%" direction={"row"} background="white" - boxShadow="2px 2px 8px 0px rgba(0, 0, 0, 0.50)" + boxShadow={enableBoxShadow ? "2px 2px 8px 0px rgba(0, 0, 0, 0.50)" : "none"} borderRadius="6px" alignItems="center" > diff --git a/client/spa-pages/components/PickupPage.tsx b/client/spa-pages/components/PickupPage.tsx index 0f24ed6..e1168a6 100644 --- a/client/spa-pages/components/PickupPage.tsx +++ b/client/spa-pages/components/PickupPage.tsx @@ -42,10 +42,14 @@ export const PickupPage = ({ setPage }: Props) => { // Pick up services const { pickUpServices, getItemCategory } = useSheetyData(); + console.log(pickUpServices); const sortPickups = (itemEntry: (TItemSelection | TEmptyItem)[]): OrgProps[] => { const possiblePickups = pickUpServices.filter((pickUpService) => { let picksUpAtLeastOneItem = false; for (const item of itemEntry) { + console.log("pickUpService.categoriesAccepted:", pickUpService.categoriesAccepted); + console.log("getItemCategory(item.name):", getItemCategory(item.name)); + if (pickUpService.categoriesAccepted.includes(getItemCategory(item.name))) { picksUpAtLeastOneItem = true; break; @@ -69,6 +73,7 @@ export const PickupPage = ({ setPage }: Props) => { ); return sortedPossiblePickups; }; + const [orgs, setOrgs] = useState<OrgProps[]>(sortPickups(items)); return ( @@ -82,10 +87,14 @@ export const PickupPage = ({ setPage }: Props) => { p={0} pb={5} > - <VStack align="stretch" my={23} spacing={4}> + <VStack align="stretch" my={23} spacing={4} px={6}> + {/* Carousel */} <PickupCarousel numPickupServices={orgs.length} minDist={minDistance} /> + {/* Title + Back Button */} <ButtonRow setPage={setPage} /> + {/* Multiselect Box */} <ItemsAndFilterRow items={items} setOrgs={setOrgs} sortPickups={sortPickups} /> + {/* Title + List of all Services */} <OrgList sortedPossiblePickups={orgs} /> </VStack> </Container> From 38a25554c6d41cd5e6f19353f2c660eaa05b69a6 Mon Sep 17 00:00:00 2001 From: Zhi <neozhixuan@gmail.com> Date: Sat, 9 Dec 2023 10:32:28 +0800 Subject: [PATCH 3/3] Feature - Working Panel (not the filters) --- .../components/pickup/ItemsAndFilterRow.tsx | 40 +++++++++- client/components/pickup/filterPopover.tsx | 34 ++++---- client/spa-pages/components/MapPage.tsx | 77 ++++++++++--------- client/spa-pages/components/PickupPage.tsx | 4 - 4 files changed, 96 insertions(+), 59 deletions(-) diff --git a/client/components/pickup/ItemsAndFilterRow.tsx b/client/components/pickup/ItemsAndFilterRow.tsx index 9812695..1acdfa1 100644 --- a/client/components/pickup/ItemsAndFilterRow.tsx +++ b/client/components/pickup/ItemsAndFilterRow.tsx @@ -1,10 +1,9 @@ -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { ChangeEvent, Dispatch, SetStateAction, useState } from "react"; import { Flex, theme, useDisclosure } from "@chakra-ui/react"; import { TEmptyItem, TItemSelection } from "app-context/SheetyContext/types"; import FilterButton from "./filterPopover"; -import { SelectAndFilterBar, multiselectOnChange, OptionType } from "spa-pages"; +import { SelectAndFilterBar, multiselectOnChange, OptionType, checkboxChange } from "spa-pages"; import { ActionMeta, MultiValue } from "react-select"; -import { useSheetyData } from "hooks/useSheetyData"; import { OrgProps } from "spa-pages"; type Props = { @@ -44,6 +43,31 @@ const ItemsAndFilterRow = ({ items, setOrgs, sortPickups }: Props) => { setOrgs(sortPickups(updatedItemState)); }; + // Handle changes in items selected in the Filter panel + const handleCheckboxChange = (e: ChangeEvent<HTMLInputElement>) => { + const { updatedItemState, updatedOptions } = checkboxChange(e, itemState, selectedOptions); + setSelectedOptions(updatedOptions); + setItemState(updatedItemState); + setOrgs(sortPickups(updatedItemState)); + }; + + const selectAllItems = () => { + const selectOptions: OptionType[] = items.map((item, index) => ({ + value: item.name, + label: item.name, + method: item.method, + idx: index, + })); + const itemState = items.map((item) => ({ + name: item.name, + method: item.method, + })); + + setItemState(itemState); + setSelectedOptions(selectOptions); + setOrgs(sortPickups(itemState)); + }; + return ( <Flex> <Flex @@ -61,7 +85,15 @@ const ItemsAndFilterRow = ({ items, setOrgs, sortPickups }: Props) => { enableBoxShadow={false} /> </Flex> - <FilterButton items={items} isOpen={isFilterOpen} onClose={onFilterClose} /> + {/* Filter Panel */} + <FilterButton + isOpen={isFilterOpen} + onClose={onFilterClose} + handleCheckboxChange={handleCheckboxChange} + selectAllItems={selectAllItems} + itemState={itemState} + selectOptions={selectOptions} + /> </Flex> ); }; diff --git a/client/components/pickup/filterPopover.tsx b/client/components/pickup/filterPopover.tsx index e11101e..e239d96 100644 --- a/client/components/pickup/filterPopover.tsx +++ b/client/components/pickup/filterPopover.tsx @@ -1,18 +1,25 @@ import { Button, Modal, ModalContent, ModalOverlay } from "@chakra-ui/react"; -import { useState, useEffect } from "react"; +import { useState, useEffect, ChangeEvent } from "react"; import MarkedSlider from "./slider"; import { CheckboxGroup, FilterSection } from "components/map"; import { COLORS } from "theme"; import { TEmptyItem, TItemSelection } from "app-context/SheetyContext/types"; +import { OptionType } from "spa-pages"; const FilterButton = ({ isOpen, onClose, - items, + handleCheckboxChange, + selectAllItems, + selectOptions, + itemState, }: { isOpen: boolean; onClose: () => void; - items: (TItemSelection | TEmptyItem)[]; + handleCheckboxChange: (e: ChangeEvent<HTMLInputElement>) => void; + selectAllItems: () => void; + itemState: (TItemSelection | TEmptyItem)[]; + selectOptions: OptionType[]; }) => { const [modalTop, setModalTop] = useState(265); @@ -41,6 +48,13 @@ const FilterButton = ({ setQuantityValue(val); }; + const selectedOptionsWithCheckedState = selectOptions.map((option) => { + const isChecked = itemState.some( + (item) => item.name === option.value && item.method === option.method, + ); + return { ...option, isChecked }; + }); + return ( <Modal isOpen={isOpen} onClose={onClose}> <ModalOverlay /> @@ -65,17 +79,9 @@ const FilterButton = ({ } > <CheckboxGroup - items={items.map((item) => ({ - isChecked: true, - value: item.name, - method: item.method, - }))} - onChange={() => { - return void 0; - }} - onSelectAll={() => { - return void 0; - }} + items={selectedOptionsWithCheckedState} + onChange={handleCheckboxChange} + onSelectAll={selectAllItems} /> </FilterSection> diff --git a/client/spa-pages/components/MapPage.tsx b/client/spa-pages/components/MapPage.tsx index 233737b..5029d40 100644 --- a/client/spa-pages/components/MapPage.tsx +++ b/client/spa-pages/components/MapPage.tsx @@ -186,6 +186,14 @@ const MapInner = ({ setPage }: Props) => { handleChangedLocation(updatedItemState); }; + // Handle changes in items selected in the Filter panel + const handleCheckboxChange = (e: ChangeEvent<HTMLInputElement>) => { + const { updatedItemState, updatedOptions } = checkboxChange(e, itemState, selectedOptions); + handleChangedLocation(updatedItemState); + setSelectedOptions(updatedOptions); + setItemState(updatedItemState); + }; + // Handle the changing of location in this page itself const handleChangedLocation = (itemEntry: (TItemSelection | TEmptyItem)[]) => { const locations = getNearbyFacilities( @@ -224,44 +232,7 @@ const MapInner = ({ setPage }: Props) => { ] as LatLngExpression); }; - // Handle changes in items selected in the Filter panel - const handleCheckboxChange = (e: ChangeEvent<HTMLInputElement>) => { - let updatedItemState: (TItemSelection | TEmptyItem)[] = itemState; - let updatedOptions: OptionType[] = selectedOptions; - if (e.target.checked) { - // If add - const newItem = { - name: e.target.value, - method: e.target.name as Methods, - } as TItemSelection; - itemState.push(newItem); - updatedItemState = [...itemState]; - const newOption: OptionType = { - value: e.target.value, - label: e.target.value, - method: e.target.name as Methods, - idx: parseInt(e.target.dataset.key as string), - }; - updatedOptions.push(newOption); - } else if (!e.target.checked) { - // If remove - updatedItemState = itemState.filter((item) => { - return item.name !== e.target.value; - }); - updatedOptions = selectedOptions.filter((option) => option.value !== e.target.value); - } - handleChangedLocation(updatedItemState); - setSelectedOptions(updatedOptions); - setItemState(updatedItemState); - }; - const selectAllItems = () => { - const selectOptions: OptionType[] = items.map((item, index) => ({ - value: item.name, - label: item.name, - method: item.method, - idx: index, - })); const itemState = items.map((item) => ({ name: item.name, method: item.method, @@ -567,4 +538,36 @@ export const multiselectOnChange = ( return { updatedItemState, updatedOptions }; }; +export const checkboxChange = ( + e: ChangeEvent<HTMLInputElement>, + itemState: (TItemSelection | TEmptyItem)[], + selectedOptions: OptionType[], +): { updatedItemState: (TItemSelection | TEmptyItem)[]; updatedOptions: OptionType[] } => { + let updatedItemState: (TItemSelection | TEmptyItem)[] = itemState; + let updatedOptions: OptionType[] = selectedOptions; + if (e.target.checked) { + // If add + const newItem = { + name: e.target.value, + method: e.target.name as Methods, + } as TItemSelection; + itemState.push(newItem); + updatedItemState = [...itemState]; + const newOption: OptionType = { + value: e.target.value, + label: e.target.value, + method: e.target.name as Methods, + idx: parseInt(e.target.dataset.key as string), + }; + updatedOptions.push(newOption); + } else if (!e.target.checked) { + // If remove + updatedItemState = itemState.filter((item) => { + return item.name !== e.target.value; + }); + updatedOptions = selectedOptions.filter((option) => option.value !== e.target.value); + } + return { updatedItemState, updatedOptions }; +}; + export default MapPage; diff --git a/client/spa-pages/components/PickupPage.tsx b/client/spa-pages/components/PickupPage.tsx index e1168a6..fe77e57 100644 --- a/client/spa-pages/components/PickupPage.tsx +++ b/client/spa-pages/components/PickupPage.tsx @@ -42,14 +42,10 @@ export const PickupPage = ({ setPage }: Props) => { // Pick up services const { pickUpServices, getItemCategory } = useSheetyData(); - console.log(pickUpServices); const sortPickups = (itemEntry: (TItemSelection | TEmptyItem)[]): OrgProps[] => { const possiblePickups = pickUpServices.filter((pickUpService) => { let picksUpAtLeastOneItem = false; for (const item of itemEntry) { - console.log("pickUpService.categoriesAccepted:", pickUpService.categoriesAccepted); - console.log("getItemCategory(item.name):", getItemCategory(item.name)); - if (pickUpService.categoriesAccepted.includes(getItemCategory(item.name))) { picksUpAtLeastOneItem = true; break;